Files
pig/apps/piggy/e2e/page-tools.test.ts
T
karti 13dec6b4b8
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped
Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:02:48 -07:00

409 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The page tools, executed against a real Postgres.
*
* `test/chat-tools.test.ts` passes `{} as Database` and asserts on tool names,
* which is the right shape for a selection test and no shape at all for the
* five `execute` bodies underneath: every where clause, every `Number(gpuHours)`
* coercion and every date comparison was covered by tsc alone. The defect that
* prompted this suite — a headline quoting `list.length` from a capped query as
* if it were a total — typechecks perfectly.
*
* It lives in `e2e/` rather than `test/` for one reason: the unit suite runs in
* CI BEFORE the migration step, against a database with no tables. `test:e2e`
* runs after migrate and seed, which is the only point at which a query here
* can mean anything.
*
* Every assertion is a DELTA against a reading taken before the fixture is
* inserted. The tools are book-wide by design — there is no tenant to scope
* them to — so they see the seed, the demo book and whatever the API's own E2E
* left behind. Absolute figures would be a test of the seed, not of the tool.
*/
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import test, { after, before } from 'node:test';
import { eq, inArray } from 'drizzle-orm';
import {
accounts,
allocations,
capacityCommitments,
contractObligations,
contracts,
createDatabase,
demandDeals,
type Database,
} from '@pig/db';
import { createPagePigTools } from '../src/page-tools';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is required for the Piggy page-tool E2E tests.');
const db: Database = createDatabase({ url: databaseUrl, max: 4 });
const MINUTE = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;
/** Marks every fixture row so cleanup can never reach somebody else's data. */
const marker = `PIGGY-E2E-${randomUUID()}`;
/** How many exemplars each result may carry — mirrors EXEMPLARS in page-tools. */
const EXEMPLARS = 8;
interface Reading {
headline: string;
truncated?: unknown;
[key: string]: unknown;
}
async function run(route: '/margin' | '/capacity' | '/demand' | '/calendar' | '/'): Promise<Reading> {
const [tool] = createPagePigTools(db, route);
assert.ok(tool, `no tool for ${route}`);
// The calendar tool is the only one taking input, and its default is 30.
return (await tool.execute({})) as Reading;
}
const created = {
accountId: '',
contractId: '',
commitmentId: '',
dealIds: [] as string[],
};
/**
* The fixture is deliberately lopsided.
*
* Twenty obligations fall due inside the horizon and twelve are already late,
* because the capped exemplar lists hold sixteen and eight — a headline that
* reports the list length rather than the count cannot survive those numbers.
*/
const UPCOMING_OBLIGATIONS = 20;
const OVERDUE_OBLIGATIONS = 12;
let before30: Reading;
let after30: Reading;
let marginBefore: Reading;
let marginAfter: Reading;
let idleBefore: Reading;
let idleAfter: Reading;
let pipelineBefore: Reading;
let pipelineAfter: Reading;
let workspaceBefore: Reading;
let workspaceAfter: Reading;
before(async () => {
const now = Date.now();
[before30, marginBefore, idleBefore, pipelineBefore, workspaceBefore] = await Promise.all([
run('/calendar'),
run('/margin'),
run('/capacity'),
run('/demand'),
run('/'),
]);
const [account] = await db
.insert(accounts)
.values({ name: `${marker} counterparty`, side: 'supply' })
.returning();
assert.ok(account);
created.accountId = account.id;
const [contract] = await db
.insert(contracts)
.values({
accountId: account.id,
type: 'msa',
status: 'executed',
side: 'demand',
title: `${marker} master agreement`,
// Inside the 30-day horizon, so the projection must emit a
// contract_expiry — a kind the old two-table version could not see.
expiresAt: new Date(now + 10 * DAY),
// Auto-renewal with notice puts a renewal_notice inside the horizon too.
isAutoRenew: true,
noticeDays: 5,
})
.returning();
assert.ok(contract);
created.contractId = contract.id;
await db.insert(contractObligations).values([
...Array.from({ length: UPCOMING_OBLIGATIONS }, (_, i) => ({
contractId: contract.id,
title: `${marker} upcoming ${i}`,
kind: 'milestone',
dueAt: new Date(now + (i + 1) * MINUTE),
})),
...Array.from({ length: OVERDUE_OBLIGATIONS }, (_, i) => ({
contractId: contract.id,
title: `${marker} overdue ${i}`,
kind: 'milestone',
// i = 0 lapsed a minute ago, i = 11 twelve minutes ago.
dueAt: new Date(now - (i + 1) * MINUTE),
})),
{
contractId: contract.id,
title: `${marker} already done`,
kind: 'milestone',
dueAt: new Date(now + 2 * DAY),
// Completed work is a dated fact, not a workload; it must not be counted.
completedAt: new Date(now - DAY),
},
]);
// 1,000 GPU-hours bought at 100c. Half sells at exactly cost, 100 more are
// held; the block therefore loses money on the hours nobody bought, which is
// the arithmetic PIG exists to keep honest.
const [commitment] = await db
.insert(capacityCommitments)
.values({
accountId: account.id,
name: `${marker} block`,
gpuType: 'H100',
gpuCount: 8,
startsAt: new Date(now - DAY),
endsAt: new Date(now + 20 * DAY),
totalGpuHours: '1000.00',
costPerGpuHourCents: 100,
})
.returning();
assert.ok(commitment);
created.commitmentId = commitment.id;
const [openDeal, closingDeal] = await db
.insert(demandDeals)
.values([
{
accountId: account.id,
name: `${marker} open deal`,
stage: 'proposal',
acvCents: 1_000_000,
tcvCents: 2_500_000,
},
{
accountId: account.id,
name: `${marker} closing deal`,
stage: 'procurement',
acvCents: 4_000_000,
tcvCents: 4_000_000,
probability: '0.50',
expectedCloseDate: new Date(now + 3 * DAY),
},
])
.returning();
assert.ok(openDeal && closingDeal);
created.dealIds = [openDeal.id, closingDeal.id];
await db.insert(allocations).values([
{
capacityCommitmentId: commitment.id,
demandDealId: openDeal.id,
gpuHours: '500.00',
pricePerGpuHourCents: 100,
startsAt: new Date(now - HOUR),
endsAt: new Date(now + 10 * DAY),
status: 'committed',
},
{
capacityCommitmentId: commitment.id,
demandDealId: closingDeal.id,
gpuHours: '100.00',
pricePerGpuHourCents: 300,
startsAt: new Date(now - HOUR),
endsAt: new Date(now + 10 * DAY),
status: 'planned',
// A live hold: removed from availability, never revenue. Inside the
// horizon, so it is also a hold_expiry event on the calendar.
holdExpiresAt: new Date(now + 4 * DAY),
},
]);
[after30, marginAfter, idleAfter, pipelineAfter, workspaceAfter] = await Promise.all([
run('/calendar'),
run('/margin'),
run('/capacity'),
run('/demand'),
run('/'),
]);
});
after(async () => {
if (created.commitmentId) {
await db
.delete(allocations)
.where(eq(allocations.capacityCommitmentId, created.commitmentId));
await db.delete(capacityCommitments).where(eq(capacityCommitments.id, created.commitmentId));
}
if (created.dealIds.length > 0) {
await db.delete(demandDeals).where(inArray(demandDeals.id, created.dealIds));
}
// Obligations cascade from the contract.
if (created.contractId) await db.delete(contracts).where(eq(contracts.id, created.contractId));
if (created.accountId) await db.delete(accounts).where(eq(accounts.id, created.accountId));
await db.$client.end();
});
// ---------------------------------------------------------------------------
// The calendar
// ---------------------------------------------------------------------------
interface CalendarReading extends Reading {
exactTotals: { obligationsDue: number; dealsExpectedToClose: number };
upcoming: { count: number; byKind: Record<string, number>; events: { startsAt: string }[] };
overdue: {
count: number;
byKind: Record<string, number>;
events: { title: string; startsAt: string }[];
};
}
test('the calendar headline counts the whole set, not the capped exemplar list', () => {
const from = before30 as CalendarReading;
const to = after30 as CalendarReading;
// The defect, pinned. Twenty obligations were added and the exemplar list
// holds sixteen; a headline built from `list.length` reports sixteen.
assert.equal(
to.exactTotals.obligationsDue - from.exactTotals.obligationsDue,
UPCOMING_OBLIGATIONS,
);
assert.equal(
(to.upcoming.byKind.obligation_due ?? 0) - (from.upcoming.byKind.obligation_due ?? 0),
UPCOMING_OBLIGATIONS,
);
assert.equal(to.upcoming.events.length, EXEMPLARS * 2);
assert.ok(to.upcoming.count > to.upcoming.events.length);
assert.match(to.headline, new RegExp(`${to.exactTotals.obligationsDue} obligation\\(s\\) due`));
assert.equal(
(to.overdue.byKind.obligation_due ?? 0) - (from.overdue.byKind.obligation_due ?? 0),
OVERDUE_OBLIGATIONS,
);
assert.equal(to.overdue.events.length, EXEMPLARS);
assert.ok(to.overdue.count > to.overdue.events.length);
assert.match(to.headline, new RegExp(`${to.overdue.count} item\\(s\\) overdue`));
});
test('a completed obligation is a dated fact, not a workload', () => {
const to = after30 as CalendarReading;
const from = before30 as CalendarReading;
// Twenty-one obligations were inserted inside the horizon; the completed one
// is excluded from both the SQL total and the state-filtered list.
assert.equal(
to.exactTotals.obligationsDue - from.exactTotals.obligationsDue,
UPCOMING_OBLIGATIONS,
);
const states = (to.upcoming as unknown as { byState: Record<string, number> }).byState;
assert.equal(states.done, undefined);
});
test('the projection reaches the kinds the old two-table version could not', () => {
const to = after30 as CalendarReading;
const from = before30 as CalendarReading;
const gained = (kind: string): number =>
(to.upcoming.byKind[kind] ?? 0) - (from.upcoming.byKind[kind] ?? 0);
assert.equal(gained('contract_expiry'), 1);
assert.equal(gained('renewal_notice'), 1);
assert.equal(gained('hold_expiry'), 1);
assert.equal(gained('expected_close'), 1);
// The commitment window overlaps the horizon on both edges.
assert.ok(gained('capacity_window') >= 1);
assert.ok(gained('allocation_window') >= 1);
assert.equal(to.exactTotals.dealsExpectedToClose - from.exactTotals.dealsExpectedToClose, 1);
});
test('overdue exemplars are the most recently lapsed, not the oldest in the book', () => {
const to = after30 as CalendarReading;
const dates = to.overdue.events.map((event) => event.startsAt);
assert.deepEqual(dates, [...dates].sort().reverse(), 'overdue exemplars run newest first');
const titles = to.overdue.events.map((event) => event.title);
// Lapsed one minute ago: present. Lapsed twelve minutes ago: cut, because
// twelve rows were inserted and only eight are carried.
assert.ok(titles.includes(`${marker} overdue 0`));
assert.ok(!titles.includes(`${marker} overdue ${OVERDUE_OBLIGATIONS - 1}`));
});
test('a book this size is not truncated, and says so', () => {
assert.equal(after30.truncated, false);
assert.doesNotMatch(after30.headline, /at least/);
});
// ---------------------------------------------------------------------------
// The book
// ---------------------------------------------------------------------------
interface MarginReading extends Reading {
totals: { revenueCents: number; costCents: number; grossMarginCents: number };
liveCommitments: number;
}
test('margin charges cost against the full commitment and coerces numeric strings', () => {
const from = marginBefore as MarginReading;
const to = marginAfter as MarginReading;
// 500 sold hours × 100c. The held 100 are not revenue.
assert.equal(to.totals.revenueCents - from.totals.revenueCents, 50_000);
// 1,000 committed hours × 100c — not the 500 that sold.
assert.equal(to.totals.costCents - from.totals.costCents, 100_000);
// Charging cost against the sold hours alone would report break-even here
// instead of a 50,000c hole, which is the reading the rule forbids.
assert.equal(to.totals.grossMarginCents - from.totals.grossMarginCents, -50_000);
assert.equal(to.liveCommitments - from.liveCommitments, 1);
// `gpuHours` arrives as a string. Concatenation would give "1000.00500.00"
// and a revenue in the billions rather than a delta of exactly 50,000c.
assert.ok(Number.isSafeInteger(to.totals.revenueCents));
});
test('the idle block appears with its break-even price', () => {
const from = idleBefore as Reading & { totalIdleCostCents: number; blocks: unknown[] };
const to = idleAfter as Reading & {
totalIdleCostCents: number;
blocks: { name: string; idleGpuHours: number; breakEvenPricePerGpuHourCents: number }[];
};
// 50% unsold, well past the 25% threshold: 500 idle hours at 100c.
assert.equal(to.totalIdleCostCents - from.totalIdleCostCents, 50_000);
const mine = to.blocks.find((block) => block.name === `${marker} block`);
assert.ok(mine, 'the fixture block is idle enough to be listed');
assert.equal(mine.idleGpuHours, 500);
// The remaining 500 hours must fetch 100c each to cover the whole block.
assert.equal(mine.breakEvenPricePerGpuHourCents, 100);
});
// ---------------------------------------------------------------------------
// The pipelines
// ---------------------------------------------------------------------------
interface PipelineReading extends Reading {
demand: { openDeals: number; valueCents: number; byStage: Record<string, number> };
supply: { openDeals: number };
}
test('the pipeline totals TCV where it is known and reports open stages', () => {
const from = pipelineBefore as PipelineReading;
const to = pipelineAfter as PipelineReading;
assert.equal(to.demand.openDeals - from.demand.openDeals, 2);
// 2,500,000 + 4,000,000, both by TCV.
assert.equal(to.demand.valueCents - from.demand.valueCents, 6_500_000);
assert.equal((to.demand.byStage.proposal ?? 0) - (from.demand.byStage.proposal ?? 0), 1);
assert.equal((to.demand.byStage.procurement ?? 0) - (from.demand.byStage.procurement ?? 0), 1);
assert.deepEqual(to.truncated, { demandDeals: false, supplyDeals: false });
});
test('the workspace summary agrees with the tools it summarises', () => {
const from = workspaceBefore as Reading & { book: { costCents: number }; openDemandDeals: number };
const to = workspaceAfter as Reading & { book: { costCents: number }; openDemandDeals: number };
assert.equal(to.book.costCents - from.book.costCents, 100_000);
assert.equal(to.openDemandDeals - from.openDemandDeals, 2);
assert.deepEqual(to.truncated, {
commitments: false,
demandDeals: false,
supplyDeals: false,
});
});