Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/mock-flow-fixtures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# End-to-End Mock Flow Fixtures

`src/fixtures/flows.ts` provides named, cross-feature "journeys" for local
development, tests, and manual QA in mock mode. Where the other files in
`src/fixtures/` each cover a single domain in isolation, `flows.ts` links
those existing fixtures together by investor address and asset, so a
contributor (or a test) can reason about *one investor's* experience across
the whole app rather than assembling that picture by hand from five
unrelated files.

This is **not** a new source of mock data. Every value referenced by a flow
scenario already exists in `compliance.ts`, `issuer.ts`,
`src/features/minting/fixtures.ts`, `portfolio.ts`, or `transactions.ts`.
`flows.ts` only assembles references to that data — if you need to change
what a scenario looks like, edit the underlying fixture file, not `flows.ts`.

---

## Why this exists

Mock mode (see [mock-mode.md](mock-mode.md)) already lets you run any single
page against fixture data. But the individual fixture files don't agree with
one another by design — `compliance.ts`'s five subjects, `portfolio.ts`'s
four assets, and `transactions.ts`'s six records aren't guaranteed to
describe the same investor or the same asset. That's fine for exercising one
component in isolation, but it makes it hard to answer questions like:

> "What does the *entire* app look like for an investor whose compliance
> status is 'restricted'?"

`flows.ts` answers that by picking out one investor, one asset, and one
outcome, and pulling the matching entry from every relevant fixture file.

---

## Available scenarios

| Scenario id | Outcome | What it demonstrates |
|---|---|---|
| `compliant-investor-journey` | `compliant` | Approved compliance, minted asset, eligible/compliant portfolio holding, successful transfer history. |
| `restricted-investor-journey` | `restricted` | Accreditation flagged for renewal, issuer-paused asset, ineligible/restricted portfolio holding, a failed transfer in history. |
| `pending-review-investor-journey` | `pending_review` | Sanctions screen still in flight, pending issuance request, portfolio holding with unavailable data, an in-flight admin action. |

Each scenario has this shape:

```ts
interface MockFlowScenario {
id: string;
title: string;
description: string;
outcome: 'compliant' | 'restricted' | 'pending_review';
investorAddress: string;
stages: {
compliance: ComplianceSubject;
assetIssuance: IssuanceRequest;
mintableAsset: MintableAsset;
portfolio: PortfolioAsset;
transactions: NormalizedTransaction[];
diagnostics: typeof mockDiagnosticsFixture;
};
}
```

## Usage

```ts
import { mockFlowScenarios, findFlowScenario } from '@/fixtures/flows';
// or: import { mockFlowScenarios } from '@/fixtures';

// Iterate every scenario, e.g. for a parameterised test or a story list:
for (const scenario of mockFlowScenarios) {
// scenario.stages.compliance, .portfolio, .transactions, etc.
}

// Look up one scenario by id:
const restricted = findFlowScenario('restricted-investor-journey');
```

See `src/__tests__/fixtures/flows.test.ts` for referential-integrity tests
that confirm every scenario resolves to real entries in the underlying
fixture files, and that all three reviewable outcomes are represented.

---

## Adding a new scenario

1. Make sure the investor address, asset id, and ticker you want already
exist (or add them) in the relevant per-domain fixture file
(`compliance.ts`, `issuer.ts`, `minting/fixtures.ts`, `portfolio.ts`,
`transactions.ts`).
2. Add a new `MockFlowScenario` object in `flows.ts` that references those
entries via the `require*`/`transactionsFor` helpers already in the file
— don't inline new literal data into `flows.ts` itself.
3. Push it into the `mockFlowScenarios` array.
4. Add or extend a test case in `flows.test.ts` covering the new scenario.

---

## Related

- [mock-mode.md](mock-mode.md) — how the mock SDK provider itself is wired up
- `src/fixtures/flows.ts` — implementation
- `src/__tests__/fixtures/flows.test.ts` — referential-integrity tests
3 changes: 2 additions & 1 deletion docs/mock-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ All fixture files live in `src/fixtures/`. They are imported only by
| `transactions.ts` | Re-exports the canonical transaction history fixtures from `src/features/transactions/fixtures.ts` |
| `diagnostics.ts` | Mock diagnostics report with `[MOCK]` labels |
| `index.ts` | Barrel that re-exports all of the above |
| `flows.ts` | Named end-to-end investor journeys that link the files above together by address/asset — see [mock-flow-fixtures.md](mock-flow-fixtures.md) |

To add or change fixture data, edit the relevant file. Changes take effect
on the next hot reload — no server restart required.
Expand Down Expand Up @@ -150,4 +151,4 @@ real env values, and all SDK calls go through `LiveAegisProvider`.
- `src/config/mockMode.ts` — `isMockModeEnabled()` and `assertMockModeSafe()`
- `src/hooks/useFeatureFlags.ts` — `mockMode` feature flag (UI toggle only; does not activate the SDK mock provider)
- `docs/feature-flags.md` — general feature flags documentation
- `docs/diagnostics.md` — diagnostics page reference
- `docs/diagnostics.md` — diagnostics page reference
85 changes: 85 additions & 0 deletions src/__tests__/fixtures/flows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Tests for src/fixtures/flows.ts — the end-to-end mock flow fixtures
* (Issue #184).
*
* These tests are deliberately about referential integrity and coverage,
* not about UI rendering: they confirm that each named journey resolves to
* real entries in the underlying domain fixtures, that the three reviewable
* outcomes are all represented, and that every domain named in the issue's
* acceptance criteria (compliance, asset registration/minting, investor
* portfolio, transactions, diagnostics) is present in every scenario.
*/

import { findFlowScenario, mockFlowScenarios } from '@/fixtures/flows';
import { mockComplianceSubjects } from '@/fixtures/compliance';
import { mockIssuanceRequests } from '@/fixtures/issuer';
import { mintableAssetsFixture } from '@/features/minting/fixtures';
import { mockPortfolioFixture } from '@/fixtures/portfolio';
import { transactionHistoryFixtures } from '@/fixtures/transactions';

describe('mockFlowScenarios', () => {
it('exposes at least one scenario per reviewable outcome', () => {
const outcomes = new Set(mockFlowScenarios.map((s) => s.outcome));
expect(outcomes.has('compliant')).toBe(true);
expect(outcomes.has('restricted')).toBe(true);
expect(outcomes.has('pending_review')).toBe(true);
});

it('has unique, kebab-case scenario ids', () => {
const ids = mockFlowScenarios.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
for (const id of ids) {
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
}
});

it.each(mockFlowScenarios)(
'scenario "$id" covers every acceptance-criteria domain',
(scenario) => {
expect(scenario.stages.compliance).toBeDefined();
expect(scenario.stages.assetIssuance).toBeDefined();
expect(scenario.stages.mintableAsset).toBeDefined();
expect(scenario.stages.portfolio).toBeDefined();
expect(scenario.stages.diagnostics).toBeDefined();
expect(Array.isArray(scenario.stages.transactions)).toBe(true);
expect(scenario.stages.transactions.length).toBeGreaterThan(0);
},
);

it.each(mockFlowScenarios)(
'scenario "$id" resolves to entries that exist in the underlying fixtures',
(scenario) => {
expect(mockComplianceSubjects).toContainEqual(scenario.stages.compliance);
expect(mockIssuanceRequests).toContainEqual(scenario.stages.assetIssuance);
expect(mintableAssetsFixture).toContainEqual(scenario.stages.mintableAsset);
expect(mockPortfolioFixture.assets).toContainEqual(scenario.stages.portfolio);
for (const tx of scenario.stages.transactions) {
expect(transactionHistoryFixtures).toContainEqual(tx);
}
},
);

it.each(mockFlowScenarios)(
'scenario "$id" uses the same investor address across compliance and metadata',
(scenario) => {
expect(scenario.stages.compliance.id).toBe(scenario.investorAddress);
},
);

it('every fixture address is synthetic (GCFXMOCK-prefixed) and no real-looking addresses leak in', () => {
for (const scenario of mockFlowScenarios) {
expect(scenario.investorAddress.startsWith('GCFXMOCK')).toBe(true);
}
});
});

describe('findFlowScenario', () => {
it('returns the matching scenario by id', () => {
const scenario = findFlowScenario('compliant-investor-journey');
expect(scenario?.outcome).toBe('compliant');
});

it('returns undefined for an unknown id', () => {
expect(findFlowScenario('does-not-exist')).toBeUndefined();
});
});
182 changes: 182 additions & 0 deletions src/fixtures/flows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* src/fixtures/flows.ts
*
* End-to-end mock flow fixtures (Issue #184).
*
* The other files in src/fixtures/ each cover one domain in isolation
* (compliance subjects, issuance requests, mintable assets, portfolio
* holdings, transaction history, diagnostics). This file does not duplicate
* that data — it assembles references to it into a small set of named
* "journeys", each representing one investor moving through the full
* lifecycle: compliance review -> asset registration -> minting ->
* portfolio holding -> transaction history -> diagnostics visibility.
*
* Use these when a test or story needs a *coherent* cross-feature scenario
* (e.g. "show what a restricted investor looks like everywhere in the app")
* rather than independent per-domain fixtures that don't necessarily agree
* with one another on address, asset, or outcome.
*
* All identifiers are synthetic. See docs/mock-flow-fixtures.md.
*
* Consumed by: tests, Storybook stories, manual QA in mock mode.
* Not consumed by MockAegisProvider directly — see mock-mode.md for the
* provider's own fixture wiring.
*/

import type { ComplianceSubject } from '@/lib/complianceReview';
import type { PortfolioAsset } from '@/lib/aegis/types';
import type { NormalizedTransaction } from '@/features/transactions/types';
import type { MintableAsset } from '@/features/minting/fixtures';
import type { IssuanceRequest } from '@/fixtures/issuer';

import { mockComplianceSubjects } from '@/fixtures/compliance';
import { mockIssuanceRequests } from '@/fixtures/issuer';
import { mockPortfolioFixture } from '@/fixtures/portfolio';
import { transactionHistoryFixtures } from '@/fixtures/transactions';
import { mockDiagnosticsFixture } from '@/fixtures/diagnostics';
import { mintableAssetsFixture } from '@/features/minting/fixtures';

/** Outcome this journey is designed to demonstrate end-to-end. */
export type MockFlowOutcome = 'compliant' | 'restricted' | 'pending_review';

export interface MockFlowScenario {
/** Stable, kebab-case identifier for lookup in tests. */
id: string;
/** Human-readable name shown in test output / story titles. */
title: string;
/** One-line summary of what the scenario demonstrates. */
description: string;
/** The outcome exercised across every stage of the journey. */
outcome: MockFlowOutcome;
/** Synthetic investor address, consistent across every stage below. */
investorAddress: string;
stages: {
compliance: ComplianceSubject;
assetIssuance: IssuanceRequest;
mintableAsset: MintableAsset;
portfolio: PortfolioAsset;
transactions: NormalizedTransaction[];
diagnostics: typeof mockDiagnosticsFixture;
};
}

function requireCompliance(id: string): ComplianceSubject {
const subject = mockComplianceSubjects.find((s) => s.id === id);
if (!subject) {
throw new Error(`mock flow fixture: no compliance subject with id ${id}`);
}
return subject;
}

function requireIssuance(id: string): IssuanceRequest {
const request = mockIssuanceRequests.find((r) => r.id === id);
if (!request) {
throw new Error(`mock flow fixture: no issuance request with id ${id}`);
}
return request;
}

function requireMintableAsset(id: string): MintableAsset {
const asset = mintableAssetsFixture.find((a) => a.id === id);
if (!asset) {
throw new Error(`mock flow fixture: no mintable asset with id ${id}`);
}
return asset;
}

function requirePortfolioAsset(id: string): PortfolioAsset {
const asset = mockPortfolioFixture.assets.find((a) => a.id === id);
if (!asset) {
throw new Error(`mock flow fixture: no portfolio asset with id ${id}`);
}
return asset;
}

function transactionsFor(ticker: string): NormalizedTransaction[] {
const matches = transactionHistoryFixtures.filter((tx) => tx.assetTicker === ticker);
if (matches.length === 0) {
throw new Error(`mock flow fixture: no transactions with assetTicker ${ticker}`);
}
return matches;
}

/**
* Alice: fully compliant investor.
* Compliance approved -> asset minted -> portfolio compliant/eligible ->
* successful transfer history -> diagnostics green across the board.
*/
const compliantInvestorJourney: MockFlowScenario = {
id: 'compliant-investor-journey',
title: 'Compliant investor, full lifecycle',
description:
'An investor whose KYC/accreditation checks pass, whose asset issuance was approved and minted, and whose portfolio and transaction history reflect a clean, eligible state.',
outcome: 'compliant',
investorAddress: 'GCFXMOCKBOB0000000000000000000000000000000000000000000000000',
stages: {
compliance: requireCompliance('GCFXMOCKBOB0000000000000000000000000000000000000000000000000'),
assetIssuance: requireIssuance('ISS-001'),
mintableAsset: requireMintableAsset('ny-cre'),
portfolio: requirePortfolioAsset('ny-cre'),
transactions: transactionsFor('NY-CRE'),
diagnostics: mockDiagnosticsFixture,
},
};

/**
* Charlie: escalated / restricted investor.
* Compliance flagged for review -> asset paused -> portfolio restricted/
* ineligible -> a failed transfer in the transaction history.
*/
const restrictedInvestorJourney: MockFlowScenario = {
id: 'restricted-investor-journey',
title: 'Restricted investor, compliance escalation',
description:
'An investor whose accreditation is flagged for renewal, whose asset holding has been paused by the issuer pending compliance review, and whose transfer history shows a policy rejection.',
outcome: 'restricted',
investorAddress: 'GCFXMOCKCHARLIE00000000000000000000000000000000000000000000',
stages: {
compliance: requireCompliance('GCFXMOCKCHARLIE00000000000000000000000000000000000000000000'),
assetIssuance: requireIssuance('ISS-007'),
mintableAsset: requireMintableAsset('eu-infra'),
portfolio: requirePortfolioAsset('fr-log'),
transactions: transactionsFor('NY-CRE').filter((tx) => tx.status === 'failed'),
diagnostics: mockDiagnosticsFixture,
},
};

/**
* Eve: pending-review investor.
* Sanctions screen still awaiting a third-party response -> asset request
* still pending -> portfolio holding stuck in "pending review" / data
* unavailable -> transaction history shows an in-flight admin action.
*/
const pendingReviewInvestorJourney: MockFlowScenario = {
id: 'pending-review-investor-journey',
title: 'Pending-review investor, in-flight checks',
description:
'An investor mid-onboarding: sanctions screening has not returned a result, the asset issuance request is still pending, and the portfolio view falls back to its data-unavailable state.',
outcome: 'pending_review',
investorAddress: 'GCFXMOCKEVE0000000000000000000000000000000000000000000000000',
stages: {
compliance: requireCompliance('GCFXMOCKEVE0000000000000000000000000000000000000000000000000'),
assetIssuance: requireIssuance('ISS-003'),
mintableAsset: requireMintableAsset('ust-6m'),
portfolio: requirePortfolioAsset('sg-pcn'),
transactions: [
transactionHistoryFixtures.find((tx) => tx.operation === 'admin_action'),
].filter((tx): tx is NormalizedTransaction => Boolean(tx)),
diagnostics: mockDiagnosticsFixture,
},
};

/** All end-to-end mock flow scenarios, keyed by id for convenient lookup. */
export const mockFlowScenarios: MockFlowScenario[] = [
compliantInvestorJourney,
restrictedInvestorJourney,
pendingReviewInvestorJourney,
];

/** Look up a single scenario by id. Returns undefined if not found. */
export function findFlowScenario(id: string): MockFlowScenario | undefined {
return mockFlowScenarios.find((scenario) => scenario.id === id);
}
2 changes: 2 additions & 0 deletions src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ export { mockDiagnosticsFixture } from './diagnostics';
export { mockIssuanceRequests } from './issuer';
export type { IssuanceRequest } from './issuer';
export { sampleBudgetResults } from '@/lib/__fixtures__/performanceBudget';
export { mockFlowScenarios, findFlowScenario } from './flows';
export type { MockFlowScenario, MockFlowOutcome } from './flows';
Loading