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
16 changes: 15 additions & 1 deletion frontend/src/features/orders/api/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { userManager } from '@/core/auth'
import { env } from '@/core/env'
import { getJsonAuthed } from '@/shared/api/http'

import { isSagaSettled, type OrderStatus } from '../saga'

export interface OrderLineSummary {
productId: string
productName: string
Expand All @@ -14,13 +16,16 @@ export interface OrderLineSummary {
export interface OrderSummary {
orderId: string
buyerId: string
status: string
status: OrderStatus
totalAmount: number
currency: string
placedAt: string
lines: OrderLineSummary[]
}

/** Poll cadence while the saga is in flight — it completes in seconds on the live stack. */
export const SAGA_POLL_INTERVAL_MS = 2000

// Query-key factory per frontend/CLAUDE.md "Server state": [feature, entity, params].
export const orderKeys = {
byBuyer: (buyerId: string) => ['orders', 'byBuyer', buyerId] as const,
Expand Down Expand Up @@ -51,5 +56,14 @@ export function orderByIdQuery(orderId: string) {
const { data } = await getJsonAuthed<OrderSummary>(env.orderApiUrl, `/api/v1/orders/${orderId}`, await token(), signal)
return data
},
// The saga narrator: poll while the order is still moving through the saga
// (Placed/Paid), stop the moment it settles (Shipped/Delivered/Cancelled/
// PaymentFailed). Polling lives HERE, in the query definition, so every consumer
// of this query gets the same lifecycle — no component-level timers.
refetchInterval: (query) => {
const status = query.state.data?.status
if (status == null || isSagaSettled(status)) return false
return SAGA_POLL_INTERVAL_MS
},
})
}
9 changes: 6 additions & 3 deletions frontend/src/features/orders/components/OrderDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { formatPrice } from '@/shared/format'

import { orderByIdQuery } from '../api/orders'

import { SagaTimeline } from './SagaTimeline'

/**
* Single order view. Phase 3 turns the status line into the saga timeline + narrator
* (Placed → Paid → Shipped with "what the backend just did" panels) and polls this query;
* for now it's a static read of current status.
* Single order viewthe saga-narrator page (epic #130 Phase 3). orderByIdQuery polls
* while the saga is in flight, so the timeline below advances live as PaymentService and
* ShippingService consume and re-publish events; polling stops once the order settles.
*/
export function OrderDetail({ orderId }: Readonly<{ orderId: string }>) {
const { data, isPending, isError, fetchStatus } = useQuery(orderByIdQuery(orderId))
Expand All @@ -33,6 +35,7 @@ export function OrderDetail({ orderId }: Readonly<{ orderId: string }>) {
<h1 className="text-xl font-semibold">Order {data.orderId.slice(0, 8)}</h1>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-xs">{data.status}</span>
</div>
<SagaTimeline status={data.status} />
<ul className="divide-y divide-zinc-200">
{data.lines.map((line) => (
<li key={line.productId} className="flex items-center gap-3 py-2 text-sm">
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/features/orders/components/SagaTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import { SagaTimeline } from './SagaTimeline'

// Pure-render component (polling lives in the query, derivation in saga.ts), so these
// tests assert the user-visible contract per status — RTL by role/label, no internals.
describe('SagaTimeline', () => {
it('marks the live step and explains the async 202 while the order is Placed', () => {
// ARRANGE/ACT — freshly placed order: the saga has NOT run yet; this is the state a
// buyer lands on right after checkout redirects here.
render(<SagaTimeline status="Placed" />)

// ASSERT —
// 1. The Placed step carries aria-current="step": screen readers and tests agree on
// which step is live (the pulsing dot alone would be color-only signaling).
const steps = screen.getAllByRole('listitem')
expect(steps[0]).toHaveAttribute('aria-current', 'step')
// 2. The narrator explains the load-bearing backend fact — 202 + outbox — because
// "why is my order not confirmed instantly" is exactly what the panel exists to teach.
expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/202 Accepted/)
expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/transactional outbox/)
// 3. The polling hint is shown — the saga is still moving.
expect(screen.getByText(/polls the order every 2/)).toBeInTheDocument()
})

it('narrates the payment hop when the order reaches Paid', () => {
// ARRANGE/ACT — mid-saga: PaymentService has consumed and re-published.
render(<SagaTimeline status="Paid" />)

// ASSERT — the narration advances with the saga: now it teaches the consume→publish
// hop and idempotency (at-least-once delivery), not the 202 story.
const panel = screen.getByLabelText('What the backend just did')
expect(panel).toHaveTextContent(/PaymentService consumed OrderPlacedEvent/)
expect(panel).toHaveTextContent(/idempotent/)
})

it('shows a fully-complete timeline with no polling hint once Shipped', () => {
// ARRANGE/ACT — the saga settled on the happy path.
render(<SagaTimeline status="Shipped" />)

// ASSERT —
// 1. No step is "current" anymore — nothing is in flight.
for (const step of screen.getAllByRole('listitem')) {
expect(step).not.toHaveAttribute('aria-current')
}
// 2. The final narration teaches the punchline: choreography, no orchestrator.
expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/choreography saga/)
// 3. Polling hint gone — isSagaSettled stopped the refetch loop, the UI says so implicitly.
expect(screen.queryByText(/polls the order every 2/)).not.toBeInTheDocument()
})

it('surfaces the failure branch when payment is declined', () => {
// ARRANGE/ACT — the saga's compensating path: PaymentFailedEvent came back instead.
render(<SagaTimeline status="PaymentFailed" />)

// ASSERT — the failure is loud and explained, not a silent stuck timeline:
// the panel switches to the terminal narration naming the failure event.
expect(screen.getByText(/Saga stopped: PaymentFailed/)).toBeInTheDocument()
expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/PaymentFailedEvent/)
})

it('surfaces the cancel branch without blaming the payment step', () => {
// ARRANGE/ACT — the other terminal: the buyer (or a compensation) cancelled the order.
render(<SagaTimeline status="Cancelled" />)

// ASSERT —
// 1. The terminal panel is loud and names the state, same contract as PaymentFailed.
expect(screen.getByText(/Saga stopped: Cancelled/)).toBeInTheDocument()
expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/cancelled/i)
// 2. No step renders the ✕ failure glyph: Cancel() is legal from Placed AND Paid, and
// the status alone can't say whether payment happened — a red Paid step would
// misstate a cancel-before-payment. (The failure glyph is PaymentFailed's contract.)
expect(screen.queryByText('✕')).not.toBeInTheDocument()
// 3. Settled: no polling hint — nothing more will happen to this order.
expect(screen.queryByText(/polls the order every 2/)).not.toBeInTheDocument()
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
86 changes: 86 additions & 0 deletions frontend/src/features/orders/components/SagaTimeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { SAGA_STEPS, TERMINAL_NARRATION, deriveStepStates, isSagaSettled, type OrderStatus, type StepState } from '../saga'

/**
* The saga narrator (epic #130 Phase 3): a Placed → Paid → Shipped timeline that renders
* live off the polled order status, plus a panel explaining what the backend just did to
* reach the current step. The demo IS the engineering — this component turns the
* choreography saga (outbox → RabbitMQ fanout → idempotent consumers) into something you
* can watch happen.
*
* Pure render off the status prop: polling cadence lives in orderByIdQuery, narration
* copy + step derivation live in ../saga.ts. This file only draws.
*/

const DOT_CLASSES: Record<StepState, string> = {
complete: 'bg-emerald-600 text-white',
active: 'animate-pulse bg-blue-600 text-white',
failed: 'bg-red-600 text-white',
pending: 'bg-zinc-200 text-zinc-500',
}

function dotGlyph(state: StepState, index: number): string {
if (state === 'complete') return '✓'
if (state === 'failed') return '✕'
return String(index + 1)
}

export function SagaTimeline({ status }: Readonly<{ status: OrderStatus }>) {
const stepStates = deriveStepStates(status)
const settled = isSagaSettled(status)
// Narrate the terminal branch if the saga died, otherwise the furthest step it reached.
const terminalNarration = TERMINAL_NARRATION[status]
let narratedIndex = -1
for (let index = stepStates.length - 1; index >= 0; index--) {
if (stepStates[index] !== 'pending') {
narratedIndex = index
break
}
}
const narration = terminalNarration ?? SAGA_STEPS[narratedIndex]?.narration
const panelClasses =
terminalNarration == null ? 'border-zinc-200 bg-zinc-50 text-zinc-700' : 'border-red-200 bg-red-50 text-red-900'
const panelHeading = terminalNarration == null ? 'What the backend just did' : `Saga stopped: ${status}`

return (
<section aria-label="Order saga progress" className="space-y-3">
<ol className="flex items-center gap-0">
{SAGA_STEPS.map((step, index) => {
// deriveStepStates maps 1:1 over SAGA_STEPS; the fallback only satisfies
// noUncheckedIndexedAccess (the index can't actually miss).
const state = stepStates[index] ?? 'pending'
return (
<li key={step.status} className="flex flex-1 items-center" aria-current={state === 'active' ? 'step' : undefined}>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${DOT_CLASSES[state]}`}
>
{dotGlyph(state, index)}
</span>
<span className="ml-2 text-sm font-medium">
{step.title}
{/* Live step gets a visually-quiet hint that the page is polling. */}
{state === 'active' && <span className="ml-1 text-xs font-normal text-zinc-500">(in progress…)</span>}
</span>
{index < SAGA_STEPS.length - 1 && (
<span aria-hidden className={`mx-3 h-px flex-1 ${stepStates[index + 1] === 'pending' ? 'bg-zinc-200' : 'bg-emerald-600'}`} />
)}
</li>
)
})}
</ol>

{status === 'Delivered' && <p className="text-sm text-zinc-600">Delivered — the saga completed end-to-end.</p>}

{narration != null && (
<aside aria-label="What the backend just did" className={`rounded-md border p-3 text-sm leading-relaxed ${panelClasses}`}>
<p className="mb-1 font-semibold">{panelHeading}</p>
<p>{narration}</p>
{settled ? null : (
<p className="mt-2 text-xs text-zinc-500">
This page polls the order every 2 s — the status you're watching advances as each service consumes and re-publishes events.
</p>
)}
</aside>
)}
</section>
)
}
44 changes: 44 additions & 0 deletions frontend/src/features/orders/saga.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'

import { deriveStepStates, isSagaSettled, type OrderStatus } from './saga'

describe('isSagaSettled', () => {
it('keeps polling while the saga is in flight and stops once it settles', () => {
// ARRANGE/ACT/ASSERT — table test: this predicate IS the polling lifecycle
// (orderByIdQuery's refetchInterval returns false exactly when this returns true),
// so a wrong entry here means either polling forever (battery/network burn) or
// a timeline frozen at Placed while the backend moves on.
expect(isSagaSettled('Placed')).toBe(false) // saga just started — keep watching
expect(isSagaSettled('Paid')).toBe(false) // mid-saga — shipping still to come
expect(isSagaSettled('Shipped')).toBe(true) // demo happy-path terminal
expect(isSagaSettled('Delivered')).toBe(true) // post-shipping terminal
expect(isSagaSettled('Cancelled')).toBe(true) // cancel branch — nothing more will happen
expect(isSagaSettled('PaymentFailed')).toBe(true) // failure branch — saga stopped
})
})

describe('deriveStepStates', () => {
// Timeline is [Placed, Paid, Shipped]; each case pins the full visual contract for a status.
const cases: [OrderStatus, ReturnType<typeof deriveStepStates>][] = [
// In-flight: the reached step pulses "active" (page is polling), the rest are pending.
['Placed', ['active', 'pending', 'pending']],
['Paid', ['complete', 'active', 'pending']],
// Settled happy path: everything reached is complete — no "active" pulse once polling stops.
['Shipped', ['complete', 'complete', 'complete']],
['Delivered', ['complete', 'complete', 'complete']],
// Failure branch: Placed succeeded, the payment step is where the saga died.
['PaymentFailed', ['complete', 'failed', 'pending']],
// Cancelled: terminal but NOT a step failure. The DTO carries only the current status
// and the backend allows Cancel() from both Placed and Paid, so whether payment ever
// happened is unknowable client-side — marking Paid as failed would misstate a
// cancel-before-payment. Remaining steps stay pending; the terminal panel tells the story.
['Cancelled', ['complete', 'pending', 'pending']],
]

it.each(cases)('%s renders as %j', (status, expected) => {
// ACT — derive the per-step visual states from the polled status.
// ASSERT — the whole array at once: a step silently flipping between
// active/complete is exactly the regression class this guards.
expect(deriveStepStates(status)).toEqual(expected)
})
})
95 changes: 95 additions & 0 deletions frontend/src/features/orders/saga.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// The saga-narrator model: what the timeline shows and what each step teaches.
//
// This feature IS the documentation (frontend/CLAUDE.md "Conventions") — the narration
// strings explain, in the UI, what the backend just did. They mirror the real mechanics
// in OrderService/PaymentService/ShippingService: transactional outbox, RabbitMQ fanout
// exchanges, idempotent consumers, choreography (no orchestrator). If the backend's saga
// changes shape, this copy changes with it — it's a projection of the architecture, not
// marketing text.

/** Mirror of OrderService.Domain.OrderStatus — persisted (and serialized) as strings. */
export type OrderStatus = 'Placed' | 'Paid' | 'Shipped' | 'Delivered' | 'Cancelled' | 'PaymentFailed'

export type StepState = 'complete' | 'active' | 'pending' | 'failed'

export interface SagaStep {
status: OrderStatus
title: string
/** What the backend just did to reach this step — shown in the narrator panel. */
narration: string
}

/** The happy-path choreography, in order. Failure/cancel states overlay onto these. */
export const SAGA_STEPS: readonly SagaStep[] = [
{
status: 'Placed',
title: 'Placed',
narration:
'POST /orders returned 202 Accepted, not 201 — placement is asynchronous and the order row itself is the tracking record. ' +
'OrderService saved the order and staged OrderPlacedEvent in the SAME database transaction (transactional outbox — the event cannot be lost, even if the broker is down), ' +
'then Wolverine published it to the order-events fanout exchange on RabbitMQ. This page is polling while the saga runs.',
},
{
status: 'Paid',
title: 'Paid',
narration:
'PaymentService consumed OrderPlacedEvent from its payment-orders queue, processed the payment, and published PaymentCompletedEvent to the payment-events exchange. ' +
'OrderService consumed that and marked the order Paid. Delivery is at-least-once, so the handler is idempotent — a duplicate event hits the status guard and is ignored.',
},
{
status: 'Shipped',
title: 'Shipped',
narration:
'ShippingService reacted to the same PaymentCompletedEvent (its own queue on the fanout exchange), dispatched a shipment, and published ShipmentDispatchedEvent; ' +
'OrderService marked the order Shipped. No orchestrator coordinated any of this — every service just reacted to events. That is the choreography saga.',
},
]

/** Narration for the non-happy-path terminals (rendered in place of the next step's panel). */
export const TERMINAL_NARRATION: Partial<Record<OrderStatus, string>> = {
PaymentFailed:
'PaymentService declined the payment and published PaymentFailedEvent; OrderService consumed it and marked the order PaymentFailed. ' +
'This is the saga’s failure branch — same event mechanics as the happy path, different event.',
Cancelled: 'The order was cancelled before it shipped. Cancel is allowed from any non-shipped state.',
}

/** How far along the happy path a status is. Delivered sits past Shipped; terminals sit where they interrupted. */
const PROGRESS: Record<OrderStatus, number> = {
Placed: 0,
Paid: 1,
Shipped: 2,
Delivered: 3,
// Failure terminals: how many happy steps completed before the saga stopped.
PaymentFailed: 1, // Placed completed; payment step failed.
Cancelled: 1, // Placed completed; cancelled before shipping.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** The saga stopped moving — stop polling. */
export function isSagaSettled(status: OrderStatus): boolean {
return status === 'Shipped' || status === 'Delivered' || status === 'Cancelled' || status === 'PaymentFailed'
}

/**
* Derive each happy-path step's visual state from the order's current status.
* A step is complete once reached, active if it's the furthest reached and the saga is
* still moving, failed if the saga terminated at that step, pending otherwise.
*/
export function deriveStepStates(status: OrderStatus): StepState[] {
const progress = PROGRESS[status]
return SAGA_STEPS.map((_, index) => {
if (index < progress) return 'complete'
if (index === progress) {
if (status === 'PaymentFailed') return 'failed'
// Cancelled is terminal but NOT a step failure: the DTO carries only the current
// status, and the backend allows Cancel() from both Placed and Paid — so whether
// payment ever happened is unknowable here. Marking Paid as failed would misstate
// a cancel-before-payment; leave the remaining steps pending and let the terminal
// panel (TERMINAL_NARRATION) tell the story.
if (status === 'Cancelled') return 'pending'
// The furthest-reached step: completed if the saga already moved past or settled
// here (Shipped/Delivered), otherwise it's the live step we're polling on.
return isSagaSettled(status) ? 'complete' : 'active'
}
return 'pending'
})
}
Loading