diff --git a/docs/mock-flow-fixtures.md b/docs/mock-flow-fixtures.md new file mode 100644 index 0000000..0b34000 --- /dev/null +++ b/docs/mock-flow-fixtures.md @@ -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 \ No newline at end of file diff --git a/docs/mock-mode.md b/docs/mock-mode.md index 4d38417..969a57e 100644 --- a/docs/mock-mode.md +++ b/docs/mock-mode.md @@ -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. @@ -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 \ No newline at end of file diff --git a/src/__tests__/fixtures/flows.test.ts b/src/__tests__/fixtures/flows.test.ts new file mode 100644 index 0000000..008740c --- /dev/null +++ b/src/__tests__/fixtures/flows.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/src/fixtures/flows.ts b/src/fixtures/flows.ts new file mode 100644 index 0000000..1521d9c --- /dev/null +++ b/src/fixtures/flows.ts @@ -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); +} \ No newline at end of file diff --git a/src/fixtures/index.ts b/src/fixtures/index.ts index 172170f..3e39171 100644 --- a/src/fixtures/index.ts +++ b/src/fixtures/index.ts @@ -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'; \ No newline at end of file