Menu

Tutorial

View as Markdown

Testing & verification

In this chapter the app gains its production posture — a typed session, a guard chain, and a principal-scoped cart and order history — then proves its entire behavior surface without running a browser: kovo check over the app graph, kovo explain as a queryable dependency graph, and harness tests that verify observed writes against declared touches. Step state: site/tutorial/steps/07-verification/.

This chapter has two halves. Guards (sessions, the guard chain, the new domain) lock down who can do what. Verification (the app graph, kovo check, kovo explain, write verification) proves the whole behavior surface mechanically. The mechanics of how this tutorial keeps itself honest against the reference app — run-steps gating and parity — live in the wrap-up; this chapter shows the assertion and moves on.

Guards: lock down who can do what#

Add a typed session and a guard chain#

req.session is a declared schema, not an any bag. Guard refinements and the order's userId rest on typed fields; an untyped session would be a hole directly under the proof surface:

tsx
// SPEC.md section 6.5: the session is a declared schema, not an any-bag —
// guard refinements and the cart/order userId fields rest on typed fields.
export const shopSession = session(
  s.object({
    id: s.string(),
    user: s.object({
      id: s.string(),
    }),
  }),
);
ts
export interface ShopRequest extends Request, TaskSchedulingRequest {
  db: ShopDb;
  env: Readonly<Record<never, never>>;
  session: { id?: string; user?: { id: string } | null } | null;
  tutorialDbToken: object;
}

The production path. The tutorial keeps a plain in-memory store and a hand-declared touch set. With @kovojs/drizzle, the per-request database is real, touches are extracted from the write ASTs, and write verification runs against actual table writes. The data-layer guide is the home for the production story.

The mutation now declares the canonical access guard chain — authed plus a rate limit — writes a third domain (order), and keeps everything else from chapter 5: schema, errors, CSRF, and declared touches:

tsx
export const addToCart = app.mutation({
  input: addToCartInput,
  errors: {
    OUT_OF_STOCK: s.object({ availableQuantity: s.number().int().min(0) }),
  },
  access: [app.all(app.authenticated, app.rateLimit({ max: 10, per: 'session' }))],
  registry: {
    queries: [cartQuery, productsQuery, orderHistoryQuery],
    touches: [cart, order, product],
  },
  queue: 'cart',
  optimistic: [
    cartQuery.optimistic(addToCartInput, predictCart),
    orderHistoryQuery.optimistic('await-fragment'),
    productsQuery.optimistic('await-fragment'),
  ],
  transaction<Result>(
    request: ShopRequest,
    run: (transactionRequest: ShopRequest) => Promise<Result>,
  ): Promise<Result> {
    return tutorialShopDb(request).transaction((db) =>
      run(Object.assign(request.clone(), request, { db })),
    );
  },
  handler(input, request, context) {
    const currentSession = shopSession.parse(request);
    const found = request.db.products.get(input.productId);
    if (!found || found.stock < input.quantity) {
      return context.fail('OUT_OF_STOCK', { availableQuantity: found?.stock ?? 0 });
    }

    request.db.cartItems.push({
      productId: input.productId,
      qty: input.quantity,
      unitPrice: found.unitPrice,
      userId: currentSession.user.id,
    });
    request.db.orders.push({
      id: `order-${request.db.orders.length + 1}`,
      productId: input.productId,
      qty: input.quantity,
      total: found.unitPrice * input.quantity,
      userId: currentSession.user.id,
    });
    request.db.products.set(input.productId, { ...found, stock: found.stock - input.quantity });
    return { productId: input.productId, quantity: input.quantity };
  },
});

An order history island consumes the new domain. What had to change for it to participate: nothing. It declares queries: { orderHistory }, and every cart mutation ever written updates it, because optimism and invalidation are keyed to queries, not call sites:

tsx
export const OrderHistory = component({
  queries: { orderHistory: orderHistoryQuery },
  render: ({ orderHistory }: { orderHistory: OrderHistoryResult }) => (
    <ol>
      {orderHistory.items.map((item) => (
        <li key={item.id}>
          {item.productId} x {item.qty} - {item.total}
        </li>
      ))}
    </ol>
  ),
});

The cart becomes private in this final production-posture step. Each row carries its owner, and its canonical access: [guards.authed()] chain and loader use the same typed session principal. Earlier tutorial steps deliberately model one anonymous cart and label that simplification as public; step 7 does not:

ts
export function loadCart(db: ShopReadModel, userId: string): CartResult {
  return {
    count: db.cartItems
      .filter((item) => item.userId === userId)
      .reduce((total, item) => total + item.qty, 0),
  };
}

export const cartQuery = app.query({
  access: [app.authenticated],
  load: (_input, context) =>
    loadCart(tutorialShopDb(context.request).query.snapshot(), requireShopUserId(context)),
  reads: [cart],
});

Order history follows the same rule. Its loader filters userId instead of trusting the guard to imply row ownership:

ts
export function loadOrderHistory(db: ShopReadModel, userId: string): OrderHistoryResult {
  return { items: db.orders.filter((item) => item.userId === userId) };
}

export const orderHistoryQuery = app.query({
  access: [app.authenticated],
  load: (_input, context) =>
    loadOrderHistory(tutorialShopDb(context.request).query.snapshot(), requireShopUserId(context)),
  reads: [order],
});

The victim/attacker regression exercises both boundaries and proves an authenticated attacker sees only their own cart count and order rows, while anonymous requests are rejected before either loader succeeds:

ts
it('guards and scopes cart and order history to the authenticated principal', async () => {
  const db = createShopDb();
  db.cartItems.push(
    {
      productId: 'p1',
      qty: 4,
      unitPrice: 1499,
      userId: 'victim',
    },
    {
      productId: 'p2',
      qty: 1,
      unitPrice: 2599,
      userId: 'attacker',
    },
  );
  db.orders.push(
    {
      id: 'order-victim',
      productId: 'p1',
      qty: 1,
      total: 1499,
      userId: 'victim',
    },
    {
      id: 'order-attacker',
      productId: 'p2',
      qty: 1,
      total: 2599,
      userId: 'attacker',
    },
  );

  const attackerRequest = createShopRequest(db, {
    id: 's-attacker',
    user: { id: 'attacker' },
  });
  const attackerPage = await renderShopPageForTest(attackerRequest);
  expect(attackerPage).toContain('<cart-badge');
  expect(attackerPage).toContain('kovo-key="order-attacker"');
  expect(attackerPage).not.toContain('order-victim');

  const anonymousPage = await renderShopPageForTest(
    createShopRequest(db, { id: 's-anonymous', user: null }),
  );
  expect(anonymousPage).toContain('Pour-over kettle');
  expect(anonymousPage).not.toContain('<cart-badge');
  expect(anonymousPage).not.toContain('order-attacker');
});

Verification: prove the behavior surface#

Check the app graph#

Everything the app has declared — components and their queries, the mutation's guards and writes, optimistic statuses, the page's query set, the touch graph (write sites mapped to touched domains), and the framework-produced access ledger — composes into one value. examples/commerce commits this as a generated artifact so graph changes appear as diffs in code review; the tutorial keeps the behavior fixture inline and derives its access facts from the actual route/query/mutation declarations:

tsx
// The app graph: every fact kovo check and kovo explain reason over. In the
// blessed @kovojs/drizzle path most of this is derived (SPEC.md section 11.1);
// examples/commerce commits it as a generated artifact. Declared or derived,
// it is the same machine-checkable shape (section 11.4).
export const shopGraph = {
  components: [
    { fragments: ['cart-badge'], name: 'CartBadge', queries: ['cart'] },
    { fragments: ['product-list'], name: 'ProductList', queries: ['products'] },
    { fragments: ['order-history'], name: 'OrderHistory', queries: ['orderHistory'] },
  ],
  mutations: [
    {
      guards: ['authed', 'rateLimit:session'],
      invalidates: ['cart', 'product', 'order'],
      inputFields: ['productId', 'quantity'],
      key: addToCart.key,
      session: 'shopSession',
      writes: ['cart', 'product', 'order'],
    },
  ],
  optimistic: [
    { mutation: addToCart.key, query: 'cart', status: 'hand-written' },
    { mutation: addToCart.key, query: 'products', status: 'await-fragment' },
    { mutation: addToCart.key, query: 'orderHistory', status: 'await-fragment' },
  ],
  pages: [
    {
      modulepreloads: [],
      prefetch: false,
      queries: ['cart', 'products', 'orderHistory'],
      route: '/',
      stylesheets: [],
    },
  ],
  queries: [
    { domains: ['cart'], query: 'cart' },
    { domains: ['product'], query: 'products' },
    { domains: ['order'], query: 'orderHistory' },
  ],
  touchGraph: shopTouchGraph,
} as const;

kovo check is the CI gate over that graph — touch-graph consistency and optimistic exhaustiveness in one stable, diffable output:

ts
it('passes kovo check with no unhandled optimistic pair', () => {
  expect(kovoCheck(verifiedShopGraph)).toEqual({
    exitCode: 0,
    output: [
      'kovo-check/v1',
      `COVERAGE component=CartBadge query=${cartQuery.key} position="text" status=plan`,
      '',
    ].join('\n'),
  });
});

Query the graph with kovo explain#

kovo explain prints the compiler's and data plane's decisions as stable text, so agents consume the same artifact humans read. The step pins the cart/add explanation, including the optimistic status of every invalidated query:

ts
it('explains the addToCart mutation as a stable, diffable artifact', () => {
  const explanation = kovoExplain(verifiedShopGraph, {
    view: 'mutation',
    optimistic: true,
    target: addToCart.key,
  });

  expect(explanation.exitCode).toBe(0);
  expect(explainLine(explanation.output, 'writes: ')).toBe('cart,product,order');
  expect(explainLine(explanation.output, 'invalidates: ')).toBe(
    `${cartQuery.key},${productsQuery.key},${orderHistoryQuery.key}`,
  );
  expect(optimisticStatuses(explanation.output)).toEqual(
    new Map([
      [cartQuery.key, 'hand-written'],
      [productsQuery.key, 'await-fragment'],
      [orderHistoryQuery.key, 'await-fragment'],
    ]),
  );
  expect(explainLine(explanation.output, 'OPTIMISTIC-SUMMARY ')).toContain('UNHANDLED=0');
});

Because the output is stable, intent-level questions become set operations over printed graphs. Here is the acceptance question — "what updates when cart/add commits?" — answered mechanically:

ts
it('answers "what updates when addToCart commits" mechanically', () => {
  const mutationExplain = kovoExplain(verifiedShopGraph, {
    view: 'mutation',
    target: addToCart.key,
  });
  const pageExplain = kovoExplain(verifiedShopGraph, { view: 'page', target: '/' });
  const pageQueries = explainList(explainLine(pageExplain.output, 'queries: '));

  expect(pageQueries).toEqual([cartQuery.key, productsQuery.key, orderHistoryQuery.key]);

  // Set operations over printed graphs: every query this page renders is
  // updated by addToCart, and each names its consuming component.
  const updates = explainLine(mutationExplain.output, 'updates: ');
  for (const query of pageQueries) {
    const queryExplain = kovoExplain(verifiedShopGraph, { view: 'query', target: query });
    const consumers = explainList(explainLine(queryExplain.output, 'consumers: '));

    expect(updates).toContain(`${query}->`);
    expect(consumers.some((consumer) => consumer.startsWith('component:'))).toBe(true);
    expect(explainList(explainLine(queryExplain.output, 'invalidated-by: '))).toContain(
      addToCart.key,
    );
  }
});

The access audit rides the same surface. The product catalog remains visibly public; the cart query, order-history query, and cart mutation remain visibly guarded. kovo explain unguarded reports zero only because no route, query, or mutation is missing a decision — it does not relabel public surfaces as authenticated.

Verify writes against the built app#

The public @kovojs/test harness binds an imported app contract to an explicit successful-build artifact. Mutation, query, route, request, and DB types come from the app; touch/read facts come from the digest-verified artifact. If source changes after the build or a handler writes outside the proved graph, the test fails before a user sees stale data. The runnable pattern lives in Testing with @kovojs/test.

Because application wiring is proof-carrying, the app needs few or no browser tests of its own. The framework keeps morph survival and L0 behaviors under its own browser suites; what it removes is the testing SPAs need to compensate for unverifiable wiring.

Assert parity with the reference app#

The last verification step pins this app to the reference: examples/commerce is the acceptance target, and this step asserts behavior parity with its on-demand graph artifact — same mutation key and named POST, same input fields and write set, same optimistic statuses per pair, same fragment wire and failure code:

ts
it('matches the reference commerce app: wire vocabulary and optimistic statuses', async () => {
  // The on-demand graph artifact of examples/commerce — the
  // rules/v1-acceptance.md acceptance target this tutorial has been building
  // toward without checking in generated output.
  interface TutorialGraphComparison {
    mutations: Array<{ inputFields: string[]; key: string; writes: string[] }>;
    optimistic: Array<{ mutation: string; query: string; status: string }>;
  }

  const compareStrings = (left: string, right: string) => left.localeCompare(right);
  const commerceGraph = readTempCommerceGraph() as TutorialGraphComparison;
  const commerceCartAdd = commerceGraph.mutations.find((entry) => entry.key === 'cart/add');
  const shopCartAdd = shopGraph.mutations.find((entry) => entry.key === addToCart.key);

  // The tutorial now lets the compiler derive the mutation key from the
  // exported binding and module path; the no-JS form action follows that key.
  const shopPage = await renderShopPageForTest(shopRequest());
  expect(shopPage).toContain(`action="/_m/${addToCart.key}"`);
  expect(shopPage).toContain('name="kovo-form-key" value="p1"');

  // Same input field vocabulary and write set.
  expect(shopCartAdd?.inputFields).toEqual(commerceCartAdd?.inputFields);
  expect([...(shopCartAdd?.writes ?? [])].sort(compareStrings)).toEqual(
    [...(commerceCartAdd?.writes ?? [])].sort(compareStrings),
  );

  // Same optimistic COVERAGE per pair (the list query is named productGrid in
  // commerce, products here). The tutorial teaches v1 hand-written/await-fragment
  // optimism; the reference commerce app has since adopted v2 derived optimism
  // (SPEC.md §10.5). Both cover exactly the same (mutation × query) pairs with an
  // explicit, non-UNHANDLED status — that coverage parity is the invariant here,
  // not the v1-vs-v2 status string.
  const queryNameMap: Record<string, string> = {
    cart: 'cart',
    orderHistory: 'orderHistory',
    products: 'productGrid',
  };
  const mutationKeyMap: Record<string, string> = {
    [addToCart.key]: 'cart/add',
  };
  const pairKey = (entry: { mutation: string; query: string }) =>
    `${entry.mutation} ${entry.query}`;
  const shopPairs = shopGraph.optimistic.map((entry) =>
    pairKey({
      mutation: mutationKeyMap[entry.mutation] ?? entry.mutation,
      query: queryNameMap[entry.query] ?? entry.query,
    }),
  );
  const commercePairs = commerceGraph.optimistic
    .filter((entry) => entry.mutation === 'cart/add')
    .map(pairKey);
  // Both apps cover exactly the same three normalized (mutation x query) pairs.
  expect([...shopPairs].sort(compareStrings)).toEqual([...commercePairs].sort(compareStrings));
  expect(shopPairs).toHaveLength(3);
  // No pair is UNHANDLED on either side (commerce derived, shop hand-written/await).
  expect(commerceGraph.optimistic.every((entry) => entry.status !== 'UNHANDLED')).toBe(true);

  // Same enhanced wire: kovo-query truth plus fragments, same failure code.
  const request = shopRequest();
  const success = await submitAddToCart(
    formInput(request, { productId: 'p1', quantity: '2' }),
    request,
    {
      'Kovo-Fragment': 'true',
      'Kovo-Live-Targets':
        'cart-badge#components/cart-badge/cart-badge:{}; product-list#components/product-list/product-list:{}; order-history#components/order-history/order-history:{}',
      'Kovo-Targets': `cart-badge=${cartQuery.key}; product-list=${productsQuery.key}; order-history=${orderHistoryQuery.key}`,
    },
  );
  expect(success.headers['Content-Type']).toBe('text/vnd.kovo.fragment+html; charset=utf-8');
  expect(success.body).toContain(`<kovo-query name="${cartQuery.key}">{"count":2}</kovo-query>`);
  expect(success.body).toContain('<kovo-fragment target="order-history">');
  expect(success.body).toContain('kovo-key="order-1"');
  expect(success.headers['Kovo-Changes']).toBe(
    `[{"domain":"${cart.key}"},{"domain":"${order.key}"},{"domain":"${product.key}"}]`,
  );

  const failure = await submitAddToCart(
    formInput(request, { productId: 'p2', quantity: '3' }),
    request,
    {
      'Kovo-Form-Target': 'add-to-cart:p2',
      'Kovo-Fragment': 'true',
      'Kovo-Targets': 'add-to-cart:p2',
    },
  );
  expect(failure.status).toBe(422);
  expect(failure.body).toContain('data-error-code="OUT_OF_STOCK"');
}, 120_000);

How this parity (and every code block in the tutorial) stays true in CI — the run-steps gate and what it enforces — is explained in the wrap-up. The testing guide covers pglite-backed harnesses and HTTP-level assertions; the kovo explain guide tours the full command surface.

Guarded, session-typed, and provable without a browser. One short chapter remains: shipping it.

Spec & diagnostics

Behavior surface proven without a browser: SPEC §11.4. Typed session schema: SPEC §6.5. Optimism and invalidation keyed to queries, not call sites: SPEC §10.4. App graph as one composed value: SPEC §11.4. kovo check exhaustiveness and consistency gate: SPEC §10.6. kovo explain stable text for humans and agents: SPEC §5.3. Acceptance intent question: rules/v1-acceptance.md. Complete explicit access posture: SPEC §10.2, KV436. Principal-scoped cart and order-history reads: SPEC §10.2§10.3. observed ⊆ static write-verification invariant: SPEC §11.2. Reference-app parity: rules/v1-acceptance.md.