b7d1ffd2d8
The Trainability and Deal Qualification Scorecard publishes five bands and an action for each — Decline, Defer, Scope down, Qualified conditional, Build — and `MOTION_BANDS` published four different ones with different edges. So a reader could read the scorecard, score a deal against the exact dimensions it defines, and be told "Strategic" by a band table that document has never heard of. Two answers to the same question from the same product. The scorecard wins, on two grounds. Its edges were chosen alongside the dimension weights they sit on top of, so 78 means something there and 7500 was a round number here. And every one of its labels is a verb the reader can act on: "Qualified" describes a deal, "Scope down" says what to do about it, which is the only reason to band a score rather than show it. The labels are now duplicated between the JSON a customer reads and the table the product renders, because a rendered label cannot reach into a seeded row. That duplication gets a test asserting the whole table verbatim, so re-authoring one copy alone fails rather than drifts. `apps/api/test/motion.test.ts` asserted the literal 'Strategic'. It now derives the band through the shared function, so a band-table change is caught by the test that owns the decision instead of by a write-path test that does not.
953 lines
36 KiB
TypeScript
953 lines
36 KiB
TypeScript
/**
|
|
* Motion — the two rules that make this a system, and the one departure that
|
|
* makes it a risk.
|
|
*
|
|
* The departure first. `permissions.ts` states that every read endpoint returns
|
|
* the whole book because no row-level filter exists anywhere in the query
|
|
* layer; motion templates are the first exception, and an exception that is
|
|
* only enforced by the UI is not an exception, it is a leak with a nice screen
|
|
* in front of it. So the first suite renders the actual predicate to SQL and
|
|
* asserts on it. It looks like a test of an implementation detail and is not:
|
|
* the WHERE clause IS the access policy, and the two ways of getting it wrong —
|
|
* dropping the owner comparison, or widening it with `OR owner IS NULL` — both
|
|
* produce a query that returns rows and reports nothing.
|
|
*
|
|
* The rest drive the exported mutation definitions against a scripted
|
|
* transaction, in the register of `activities.test.ts`. The fake answers
|
|
* queries in call order and records what ran; it is deliberately not a
|
|
* database, because a fake that pretends to run SQL is a fake that will one day
|
|
* assert a broken query works.
|
|
*
|
|
* That fake is also why the last suite goes through `createApp` instead. A
|
|
* definition driven directly, or a route mounted by this file's own `mounted`,
|
|
* passes whether or not `app.ts` ever calls `createMotionRoutes` — the state
|
|
* `read-guards.ts` and `learn.ts` were both in while their tests were green.
|
|
*/
|
|
import { strict as assert } from 'node:assert';
|
|
import { describe, it } from 'node:test';
|
|
import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm';
|
|
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
import { motionBand, motionScoreBasisPoints } from '@pig/core';
|
|
import type { Database, MotionTemplate } from '@pig/db';
|
|
import { teamMemberships, users } from '@pig/db';
|
|
import { Hono } from 'hono';
|
|
import { createApp } from '../src/app';
|
|
import { AuthError, type Principal } from '../src/lib/auth';
|
|
import type { AuthProvider } from '../src/lib/auth-provider';
|
|
import { loadConfig } from '../src/lib/config';
|
|
import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation';
|
|
import {
|
|
createMotionRoutes,
|
|
motionArtifactCreateDefinition,
|
|
motionArtifactPromoteDefinition,
|
|
motionEngagementCreateDefinition,
|
|
motionEngagementUpdateDefinition,
|
|
motionScoreDefinition,
|
|
motionTemplateCreateDefinition,
|
|
motionTemplatePublishDefinition,
|
|
motionTemplateUpdateDefinition,
|
|
motionTemplateVersionDefinition,
|
|
} from '../src/routes/motion';
|
|
import { MotionService, motionSlug, visibleTemplates } from '../src/services/motion';
|
|
import { onTeam, principal } from './helpers/principal';
|
|
|
|
const OWNER = '00000000-0000-4000-8000-000000000001';
|
|
const OTHER = '00000000-0000-4000-8000-0000000000aa';
|
|
const DEAL_ID = '00000000-0000-4000-8000-0000000000d1';
|
|
const ACCOUNT_ID = '00000000-0000-4000-8000-0000000000ac';
|
|
const ENGAGEMENT_ID = '00000000-0000-4000-8000-0000000000e1';
|
|
const ARTIFACT_ID = '00000000-0000-4000-8000-0000000000f1';
|
|
const TEMPLATE_ID = '00000000-0000-4000-8000-0000000000b1';
|
|
const NOW = new Date('2026-08-17T09:00:00.000Z');
|
|
|
|
/** A demand member: `motion:write`, no `motion:publish`. */
|
|
const member = principal(onTeam('demand', 'member'));
|
|
/** A demand lead: both, which is the point of the split. */
|
|
const lead = principal(onTeam('demand', 'lead'));
|
|
|
|
// --------------------------------------------------------------- the fixtures
|
|
|
|
function template(overrides: Partial<MotionTemplate> = {}): MotionTemplate {
|
|
return {
|
|
id: TEMPLATE_ID,
|
|
kind: 'poc',
|
|
slug: 'poc-plan',
|
|
version: 1,
|
|
title: 'POC plan',
|
|
summary: 'What a proof of concept must show.',
|
|
body: '# POC plan',
|
|
fields: null,
|
|
stage: 'poc',
|
|
visibility: 'private',
|
|
ownerUserId: OWNER,
|
|
supersedesId: null,
|
|
originArtifactId: null,
|
|
isSystem: false,
|
|
usageCount: 0,
|
|
archivedAt: null,
|
|
createdAt: NOW,
|
|
updatedAt: NOW,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const deal = { id: DEAL_ID, accountId: ACCOUNT_ID, name: 'DEMO — Northwind training' };
|
|
const engagement = { id: ENGAGEMENT_ID, demandDealId: DEAL_ID, status: 'open' };
|
|
|
|
function artifact(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: ARTIFACT_ID,
|
|
engagementId: ENGAGEMENT_ID,
|
|
templateId: null,
|
|
kind: 'poc',
|
|
stage: 'poc',
|
|
title: 'Northwind POC plan',
|
|
body: '# What we proved',
|
|
fields: null,
|
|
status: 'final',
|
|
authoredByUserId: OWNER,
|
|
promotedTemplateId: null,
|
|
archivedAt: null,
|
|
createdAt: NOW,
|
|
updatedAt: NOW,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------ the fake
|
|
|
|
interface Recorded {
|
|
events: string[];
|
|
inserted: { table: string; row: Record<string, unknown> }[];
|
|
updated: { table: string; values: Record<string, unknown> }[];
|
|
}
|
|
|
|
/**
|
|
* A transaction that answers selects from a script, in call order, and records
|
|
* every write with the table it landed in. Order-dependence is the price of not
|
|
* pretending to be Postgres, and it is what makes "no UPDATE ran" assertable.
|
|
*/
|
|
function database(script: unknown[][]): { db: Database; log: Recorded } {
|
|
const log: Recorded = { events: [], inserted: [], updated: [] };
|
|
const results = [...script];
|
|
const next = (): unknown[] => results.shift() ?? [];
|
|
|
|
const selectChain = {
|
|
from: () => selectChain,
|
|
innerJoin: () => selectChain,
|
|
leftJoin: () => selectChain,
|
|
where: () => selectChain,
|
|
orderBy: () => selectChain,
|
|
groupBy: () => selectChain,
|
|
// `limit` returns the chain rather than a promise so that `.for('update')`
|
|
// can follow it, as it does on every query in this feature that allocates
|
|
// a version number. The chain is a thenable, so `await` still ends it.
|
|
limit: () => selectChain,
|
|
// Recorded, not merely tolerated: two of the rules here are held shut by a
|
|
// row lock, and a lock that quietly stops being taken changes nothing a
|
|
// behavioural assertion can see.
|
|
for: (strength: string) => {
|
|
log.events.push(`for:${strength}`);
|
|
return selectChain;
|
|
},
|
|
then: (resolve: (rows: unknown[]) => unknown) => resolve(next()),
|
|
};
|
|
|
|
const name = (table: unknown): string => (isTable(table) ? getTableName(table) : 'unknown');
|
|
|
|
const tx = {
|
|
select: () => {
|
|
log.events.push('select');
|
|
return selectChain;
|
|
},
|
|
insert: (table: unknown) => ({
|
|
values: (row: Record<string, unknown>) => {
|
|
log.events.push(`insert:${name(table)}`);
|
|
log.inserted.push({ table: name(table), row });
|
|
const written = [{ id: `${name(table)}-${log.inserted.length}`, ...row }];
|
|
return {
|
|
returning: async () => written,
|
|
then: (resolve: (value: unknown) => unknown) => resolve(undefined),
|
|
};
|
|
},
|
|
}),
|
|
update: (table: unknown) => ({
|
|
set: (values: Record<string, unknown>) => ({
|
|
where: () => {
|
|
log.events.push(`update:${name(table)}`);
|
|
log.updated.push({ table: name(table), values });
|
|
return Object.assign(Promise.resolve(undefined), {
|
|
returning: async () => [{ id: `${name(table)}-updated`, ...values }],
|
|
});
|
|
},
|
|
}),
|
|
}),
|
|
};
|
|
|
|
return {
|
|
db: {
|
|
transaction: async (work: (t: unknown) => Promise<unknown>) => {
|
|
log.events.push('transaction');
|
|
return work(tx);
|
|
},
|
|
// The same scripted chain outside a transaction, so a read handler can be
|
|
// driven through the mounted routes rather than only its service.
|
|
select: tx.select,
|
|
} as unknown as Database,
|
|
log,
|
|
};
|
|
}
|
|
|
|
/** The motion routes with a principal already resolved, as `createApp` mounts them. */
|
|
function mounted(db: Database, who: Principal): Hono<ApiEnv> {
|
|
const app = new Hono<ApiEnv>();
|
|
app.use('*', async (c, next) => {
|
|
c.set('principal', who);
|
|
await next();
|
|
});
|
|
app.route('/', createMotionRoutes(db));
|
|
return app;
|
|
}
|
|
|
|
function rendered(userId: string, isPlatformAdmin = false) {
|
|
const predicate = visibleTemplates({ userId, isPlatformAdmin });
|
|
return predicate ? new PgDialect().sqlToQuery(predicate) : null;
|
|
}
|
|
|
|
// ------------------------------------------------------------------- the tests
|
|
|
|
describe('the private library filter is a WHERE clause, not an affordance', () => {
|
|
it('matches shared rows for everyone and private rows only against the viewer id', () => {
|
|
const query = rendered(OWNER);
|
|
|
|
assert.ok(query, 'a member must be filtered at all');
|
|
assert.match(query.sql, /"visibility" = \$1/);
|
|
assert.match(query.sql, /"owner_user_id" = \$3/);
|
|
assert.deepEqual(query.params, ['shared', 'private', OWNER]);
|
|
});
|
|
|
|
it('never widens to `owner_user_id IS NULL` — an orphaned private draft belongs to nobody', () => {
|
|
const query = rendered(OWNER);
|
|
|
|
// `ON DELETE SET NULL` on the owner column can produce a private row with
|
|
// no owner. Matching it here would publish every departed colleague's
|
|
// drafts to the whole workspace, and the query would look like a fix for
|
|
// rows that had "gone missing".
|
|
assert.ok(query);
|
|
assert.doesNotMatch(query.sql, /owner_user_id" is null/i);
|
|
});
|
|
|
|
it('is deliberately absent for a platform admin — an admin CAN read a private template', () => {
|
|
// Not a hole. An unfiltered query here is the decision: someone has to be
|
|
// able to answer "what is in this workspace" during an audit or a
|
|
// departure. If this ever starts returning a predicate, that was a choice
|
|
// somebody made, and this test is where they say so.
|
|
assert.equal(rendered(OTHER, true), null);
|
|
});
|
|
});
|
|
|
|
describe('a slug is the identity of a lineage', () => {
|
|
it('strips the combining mark NFKD leaves behind rather than hyphenating through a word', () => {
|
|
// `normalize('NFKD')` splits "é" into "e" plus a combining acute, and the
|
|
// `[^a-z0-9]+` rule that follows turns that mark into a separator — so
|
|
// without the strip, "Café strategy" starts the lineage `caf-e-strategy`
|
|
// and the next person authoring the same title cannot find it.
|
|
assert.equal(motionSlug('Café strategy'), 'cafe-strategy');
|
|
assert.equal(motionSlug('Proposal Blocks'), 'proposal-blocks');
|
|
// Nothing latin survives, and an empty slug would violate the NOT NULL.
|
|
assert.equal(motionSlug('日本語'), 'untitled');
|
|
});
|
|
});
|
|
|
|
describe('a used template is never edited in place', () => {
|
|
it('refuses a PATCH once usage_count is above zero, and names the versions endpoint', async () => {
|
|
const { db, log } = database([[template({ usageCount: 3 })]]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
principal(onTeam('demand', 'member')),
|
|
async () => ({ body: '# Rewritten' }),
|
|
motionTemplateUpdateDefinition(),
|
|
{ id: TEMPLATE_ID },
|
|
),
|
|
(error: unknown) =>
|
|
error instanceof MutationError &&
|
|
error.code === 'template_in_use' &&
|
|
error.status === 409 &&
|
|
error.message.includes('/versions'),
|
|
);
|
|
assert.deepEqual(log.updated, [], 'a live engagement must not have its template move underneath it');
|
|
});
|
|
|
|
it('edits freely while nobody has instantiated it — an unused template is still a draft', async () => {
|
|
const { db, log } = database([[template({ usageCount: 0 })]]);
|
|
|
|
await executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ title: 'POC plan, tightened' }),
|
|
motionTemplateUpdateDefinition(),
|
|
{ id: TEMPLATE_ID },
|
|
);
|
|
|
|
assert.equal(log.updated.length, 1);
|
|
assert.equal(log.updated[0]?.table, 'motion_templates');
|
|
assert.equal(log.updated[0]?.values.title, 'POC plan, tightened');
|
|
});
|
|
|
|
it('locks the row before it trusts usage_count, so a concurrent instantiation cannot be missed', async () => {
|
|
const { db, log } = database([[template({ usageCount: 0 })]]);
|
|
|
|
await executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ body: '# Tightened' }),
|
|
motionTemplateUpdateDefinition(),
|
|
{ id: TEMPLATE_ID },
|
|
);
|
|
|
|
// Read without the lock, `usage_count` is a number another transaction is
|
|
// already moving: an instantiation copies the body and increments the
|
|
// count while this PATCH, having seen zero, waits on the row and then
|
|
// rewrites the body anyway — leaving an artefact whose `template_id` names
|
|
// a template that no longer contains what it copied. `no key update`
|
|
// rather than `update` because the row is a foreign-key target.
|
|
assert.deepEqual(log.events.slice(0, 3), ['transaction', 'select', 'for:no key update']);
|
|
});
|
|
|
|
it('refuses an edit to somebody else\'s template even when it is shared — publishing is not donating', async () => {
|
|
const { db, log } = database([[template({ visibility: 'shared', ownerUserId: OTHER })]]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ body: '# Mine now' }),
|
|
motionTemplateUpdateDefinition(),
|
|
{ id: TEMPLATE_ID },
|
|
),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
|
|
);
|
|
assert.deepEqual(log.updated, []);
|
|
});
|
|
});
|
|
|
|
describe('publishing is a lead\'s judgement, and the owner\'s', () => {
|
|
it('refuses a member without motion:publish before the row is read at all', async () => {
|
|
const { db, log } = database([[template()]]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(db, member, async () => ({}), motionTemplatePublishDefinition(), {
|
|
id: TEMPLATE_ID,
|
|
}),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
|
);
|
|
assert.deepEqual(log.events, [], 'permission precedes the transaction, so nothing was queried');
|
|
});
|
|
|
|
it('refuses a lead flipping somebody else\'s private template to shared', async () => {
|
|
const { db, log } = database([[template({ ownerUserId: OTHER })]]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(db, lead, async () => ({}), motionTemplatePublishDefinition(), {
|
|
id: TEMPLATE_ID,
|
|
}),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
|
|
);
|
|
assert.deepEqual(log.updated, []);
|
|
});
|
|
|
|
it('gates creating straight into the shared library on the same capability', async () => {
|
|
// The publish endpoint is not the only door into the shared library, so a
|
|
// member who simply POSTs `visibility: 'shared'` must be refused too.
|
|
const { db, log } = database([]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({
|
|
kind: 'poc',
|
|
title: 'POC plan',
|
|
summary: 'What a POC must show.',
|
|
body: '# POC',
|
|
stage: 'poc',
|
|
visibility: 'shared',
|
|
}),
|
|
motionTemplateCreateDefinition(),
|
|
),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
|
);
|
|
assert.deepEqual(log.inserted, []);
|
|
});
|
|
|
|
it('creates a private template owned by the author, because an unowned private row is unreadable', async () => {
|
|
const { db, log } = database([]);
|
|
|
|
await executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({
|
|
kind: 'poc',
|
|
title: 'POC plan',
|
|
summary: 'What a POC must show.',
|
|
body: '# POC',
|
|
stage: 'poc',
|
|
}),
|
|
motionTemplateCreateDefinition(),
|
|
);
|
|
|
|
const written = log.inserted.find((row) => row.table === 'motion_templates');
|
|
assert.equal(written?.row.visibility, 'private');
|
|
assert.equal(written?.row.ownerUserId, member.userId);
|
|
assert.equal(written?.row.version, 1);
|
|
assert.equal(written?.row.slug, 'poc-plan');
|
|
});
|
|
});
|
|
|
|
describe('promotion is the loop', () => {
|
|
function promote(db: Database, input: Record<string, unknown> = {}) {
|
|
return executeMutation(
|
|
db,
|
|
lead,
|
|
async () => input,
|
|
motionArtifactPromoteDefinition(),
|
|
{ id: ARTIFACT_ID },
|
|
);
|
|
}
|
|
|
|
it('refuses to promote an artifact that is already in the library', async () => {
|
|
const { db, log } = database([[artifact({ promotedTemplateId: TEMPLATE_ID })]]);
|
|
|
|
await assert.rejects(
|
|
promote(db),
|
|
(error: unknown) =>
|
|
error instanceof MutationError &&
|
|
error.code === 'already_promoted' &&
|
|
error.status === 409,
|
|
);
|
|
assert.deepEqual(log.inserted, [], 'a second promotion would fork the lineage silently');
|
|
});
|
|
|
|
it('refuses to promote a draft — the library is what the next deployment copies', async () => {
|
|
const { db, log } = database([[artifact({ status: 'draft' })]]);
|
|
|
|
await assert.rejects(
|
|
promote(db),
|
|
(error: unknown) =>
|
|
error instanceof MutationError &&
|
|
error.code === 'artifact_not_final' &&
|
|
error.status === 409,
|
|
);
|
|
assert.deepEqual(log.inserted, []);
|
|
});
|
|
|
|
it('writes a shared version pointing back at the artifact that proved it', async () => {
|
|
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
|
|
|
|
const result = (await promote(db, { slug: 'poc-plan' })) as {
|
|
template: { version: number; visibility: string; originArtifactId: string };
|
|
};
|
|
|
|
assert.equal(result.template.version, 1);
|
|
assert.equal(result.template.visibility, 'shared');
|
|
assert.equal(result.template.originArtifactId, ARTIFACT_ID);
|
|
assert.equal(
|
|
log.updated.find((row) => row.table === 'engagement_artifacts')?.values.promotedTemplateId,
|
|
'motion_templates-1',
|
|
);
|
|
});
|
|
|
|
it('chains supersedes_id across three promotions of one lineage', async () => {
|
|
let previous: { id: string; version: number } | null = null;
|
|
const chain: { id: string; version: number; supersedesId: string | null }[] = [];
|
|
|
|
for (let round = 0; round < 3; round += 1) {
|
|
// The lineage is read twice and on purpose: once unfiltered for the
|
|
// version number, once through the visibility filter for the row the new
|
|
// version may claim to supersede.
|
|
const lineage = previous ? [{ ...template(), ...previous, slug: 'poc-plan' }] : [];
|
|
const { db } = database([
|
|
[artifact({ id: `${ARTIFACT_ID}-${round}` })],
|
|
[{ engagement, deal }],
|
|
lineage,
|
|
lineage,
|
|
]);
|
|
|
|
const result = (await promote(db, { slug: 'poc-plan' })) as {
|
|
template: { id: string; version: number; supersedesId: string | null };
|
|
};
|
|
chain.push({
|
|
id: result.template.id,
|
|
version: result.template.version,
|
|
supersedesId: result.template.supersedesId,
|
|
});
|
|
previous = { id: result.template.id, version: result.template.version };
|
|
}
|
|
|
|
assert.deepEqual(
|
|
chain.map((row) => row.version),
|
|
[1, 2, 3],
|
|
);
|
|
assert.equal(chain[0]?.supersedesId, null, 'the first version supersedes nothing');
|
|
assert.equal(chain[1]?.supersedesId, chain[0]?.id);
|
|
assert.equal(chain[2]?.supersedesId, chain[1]?.id);
|
|
});
|
|
|
|
it('lands the audit row on the account of the deal that proved it', async () => {
|
|
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
|
|
|
|
await promote(db, { slug: 'poc-plan' });
|
|
|
|
const activity = log.inserted.find((row) => row.table === 'activities')?.row;
|
|
assert.equal(activity?.accountId, ACCOUNT_ID);
|
|
assert.equal(activity?.demandDealId, DEAL_ID);
|
|
assert.match(String(activity?.subject), /^Promoted to the library:/);
|
|
});
|
|
});
|
|
|
|
describe('a lineage nobody can read never supplies content to one everybody can', () => {
|
|
/**
|
|
* The version number and the predecessor row are two different questions, and
|
|
* answering both from one unfiltered query is the shape of the bug these pin.
|
|
* Both suites script a lineage whose newest row is private to somebody else:
|
|
* the unfiltered read finds v4, the filtered read finds nothing.
|
|
*/
|
|
const PRIVATE_SUMMARY = 'Unreleased pricing: 40% floor, Northwind only.';
|
|
/** Alice's v4, which the unfiltered read finds and the filtered read must not. */
|
|
const hidden = () =>
|
|
template({
|
|
id: '00000000-0000-4000-8000-0000000000c4',
|
|
version: 4,
|
|
ownerUserId: OTHER,
|
|
summary: PRIVATE_SUMMARY,
|
|
});
|
|
|
|
it('promotes past a private newest version without copying its summary or naming its id', async () => {
|
|
const { db, log } = database([
|
|
[artifact()],
|
|
[{ engagement, deal }],
|
|
[hidden()],
|
|
[],
|
|
]);
|
|
|
|
await executeMutation(db, lead, async () => ({ slug: 'poc-plan' }), motionArtifactPromoteDefinition(), {
|
|
id: ARTIFACT_ID,
|
|
});
|
|
|
|
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
|
|
// v5, because the unique constraint is on the whole lineage and a private
|
|
// fork still consumes a number.
|
|
assert.equal(written?.version, 5);
|
|
// But nothing else from that row. The summary fell back to the artifact's
|
|
// own title, and the new version supersedes nothing it cannot show.
|
|
assert.notEqual(written?.summary, PRIVATE_SUMMARY);
|
|
assert.equal(written?.summary, artifact().title);
|
|
assert.equal(written?.supersedesId, null);
|
|
});
|
|
|
|
it('forks a shared template without disclosing that private versions of it exist', async () => {
|
|
const source = template({ id: TEMPLATE_ID, visibility: 'shared', ownerUserId: OTHER });
|
|
const { db, log } = database([[source], [hidden()], []]);
|
|
|
|
await executeMutation(db, member, async () => ({}), motionTemplateVersionDefinition(), {
|
|
id: TEMPLATE_ID,
|
|
});
|
|
|
|
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
|
|
assert.equal(written?.version, 5, 'the number is allocated against the whole lineage');
|
|
assert.equal(
|
|
written?.supersedesId,
|
|
source.id,
|
|
'the edge points at the row that was actually forked, not at a private row the forker cannot fetch',
|
|
);
|
|
});
|
|
|
|
it('refuses to promote an artifact whose source template is unreadable, rather than 404ing on the artifact', async () => {
|
|
const { db, log } = database([
|
|
[artifact({ templateId: TEMPLATE_ID })],
|
|
[{ engagement, deal }],
|
|
[],
|
|
]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(db, lead, async () => ({}), motionArtifactPromoteDefinition(), {
|
|
id: ARTIFACT_ID,
|
|
}),
|
|
(error: unknown) =>
|
|
error instanceof MutationError &&
|
|
error.code === 'source_template_unreadable' &&
|
|
error.status === 409 &&
|
|
error.message.includes('slug'),
|
|
);
|
|
assert.deepEqual(log.inserted, []);
|
|
});
|
|
});
|
|
|
|
describe('a template id somebody sent is read before it is stored', () => {
|
|
it('refuses a playbook the setter cannot read, so a private id cannot go book-wide on an engagement', async () => {
|
|
const { db, log } = database([[{ engagement, deal }], []]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ playbookTemplateId: TEMPLATE_ID }),
|
|
motionEngagementUpdateDefinition(),
|
|
{ id: ENGAGEMENT_ID },
|
|
),
|
|
(error: unknown) => error instanceof MutationError && error.status === 404,
|
|
);
|
|
assert.deepEqual(log.updated, [], 'an unreadable id must not reach the foreign key either');
|
|
});
|
|
});
|
|
|
|
describe('a UI hint that disagrees with its endpoint is the hint that is wrong', () => {
|
|
it('does not offer publish to a read-only key, which the endpoint would refuse for scope', async () => {
|
|
const readOnlyLead = principal({ ...onTeam('demand', 'lead'), via: 'api_key', scopes: ['read'] });
|
|
const mine = template({ ownerUserId: readOnlyLead.userId });
|
|
const { db } = database([[mine], [mine]]);
|
|
|
|
const response = await mounted(db, readOnlyLead).request(`/api/motion/templates/${TEMPLATE_ID}`);
|
|
const body = (await response.json()) as { canPublish: boolean };
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(
|
|
body.canPublish,
|
|
false,
|
|
'the button would 403 insufficient_scope, and a button that cannot work must not be offered',
|
|
);
|
|
});
|
|
|
|
it('still offers it to the same lead on a full-scope session', async () => {
|
|
const mine = template({ ownerUserId: lead.userId });
|
|
const { db } = database([[mine], [mine]]);
|
|
|
|
const response = await mounted(db, lead).request(`/api/motion/templates/${TEMPLATE_ID}`);
|
|
const body = (await response.json()) as { canPublish: boolean };
|
|
|
|
assert.equal(body.canPublish, true);
|
|
});
|
|
});
|
|
|
|
describe('a slug collision is the caller\'s to fix, not a 500', () => {
|
|
it('answers 409 naming the versions endpoint when the title derives a slug already in use', async () => {
|
|
// "Proposal Blocks" is one of the nine shipped starter templates, so this
|
|
// is the first thing a new author trips over rather than an edge case.
|
|
const { db, log } = database([[{ version: 1 }]]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({
|
|
kind: 'proposal',
|
|
title: 'Proposal Blocks',
|
|
summary: 'Reusable proposal language.',
|
|
body: '# Blocks',
|
|
stage: 'proposal',
|
|
}),
|
|
motionTemplateCreateDefinition(),
|
|
),
|
|
(error: unknown) =>
|
|
error instanceof MutationError &&
|
|
error.code === 'template_slug_exists' &&
|
|
error.status === 409 &&
|
|
error.message.includes('/versions'),
|
|
);
|
|
assert.deepEqual(log.inserted, []);
|
|
});
|
|
});
|
|
|
|
describe('instantiating a template', () => {
|
|
it('increments usage_count in the same transaction that writes the artifact', async () => {
|
|
const { db, log } = database([
|
|
[{ engagement, deal }],
|
|
[template({ usageCount: 2, visibility: 'shared', ownerUserId: OTHER })],
|
|
]);
|
|
|
|
await executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ templateId: TEMPLATE_ID }),
|
|
motionArtifactCreateDefinition(),
|
|
{ id: ENGAGEMENT_ID },
|
|
);
|
|
|
|
// One transaction, both writes: a count that could be committed without the
|
|
// artifact would close the template to edits for a use that never happened.
|
|
assert.equal(log.updated[0]?.table, 'motion_templates');
|
|
// And the count is incremented by the database, not by JS arithmetic on the
|
|
// row that was read a moment ago. Two people instantiating one template
|
|
// under READ COMMITTED both read 2 and both write 3, so a use is lost — and
|
|
// `usage_count` is the only thing holding §7a shut. Asserting the rendered
|
|
// SQL is what makes the difference between the two visible at all: the
|
|
// wrong version writes the literal 3 and passes every behavioural test.
|
|
const increment = log.updated[0]?.values.usageCount;
|
|
assert.ok(
|
|
isSQLWrapper(increment),
|
|
'usage_count must be written as an expression, never as a number computed in JS',
|
|
);
|
|
assert.match(new PgDialect().sqlToQuery(increment as SQL).sql, /"usage_count" \+ \$?1/);
|
|
assert.equal(log.inserted[0]?.table, 'engagement_artifacts');
|
|
assert.equal(log.inserted[0]?.row.templateId, TEMPLATE_ID);
|
|
// The body is copied, not referenced — editing the artifact must not reach
|
|
// back into the library.
|
|
assert.equal(log.inserted[0]?.row.body, template().body);
|
|
});
|
|
|
|
it('holds the template row while it copies the body, not only while it counts the use', async () => {
|
|
const { db, log } = database([
|
|
[{ engagement, deal }],
|
|
[template({ visibility: 'shared', ownerUserId: OTHER })],
|
|
]);
|
|
|
|
await executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ templateId: TEMPLATE_ID }),
|
|
motionArtifactCreateDefinition(),
|
|
{ id: ENGAGEMENT_ID },
|
|
);
|
|
|
|
// Locking only the PATCH does not close the race: the copy has to hold the
|
|
// row until its own increment commits, or an edit lands between the read
|
|
// that copied the body and the count that was supposed to have shut the
|
|
// template to edits.
|
|
assert.deepEqual(
|
|
log.events.slice(0, 4),
|
|
['transaction', 'select', 'select', 'for:no key update'],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('one engagement per deal', () => {
|
|
it('answers 409 with the id of the engagement that already exists', async () => {
|
|
const { db, log } = database([[deal], [engagement]]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ demandDealId: DEAL_ID }),
|
|
motionEngagementCreateDefinition(),
|
|
),
|
|
(error: unknown) =>
|
|
error instanceof MutationError &&
|
|
error.code === 'engagement_exists' &&
|
|
error.status === 409 &&
|
|
error.message.includes(ENGAGEMENT_ID),
|
|
);
|
|
assert.deepEqual(log.inserted, []);
|
|
});
|
|
|
|
it('locks the deal before it looks, so two simultaneous opens queue instead of both inserting', async () => {
|
|
const { db, log } = database([[deal], []]);
|
|
|
|
await executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ demandDealId: DEAL_ID }),
|
|
motionEngagementCreateDefinition(),
|
|
);
|
|
|
|
// The race is not expressible against this fake — it runs no SQL and has
|
|
// no concurrency — so what is pinned is the lock that removes it. Without
|
|
// it both requests' checks above see nothing, both insert, and the loser
|
|
// gets `23505` on `engagements_demand_deal_key`, which is not a
|
|
// `MutationError` and so leaves as `500 Internal error` with no way to
|
|
// tell that an engagement now exists. The lock is taken on the deal
|
|
// because the row the loser must wait behind is the engagement that does
|
|
// not exist yet.
|
|
assert.deepEqual(log.events.slice(0, 3), ['transaction', 'select', 'for:update']);
|
|
});
|
|
});
|
|
|
|
describe('qualification scores', () => {
|
|
it('computes the score from the dimensions rather than believing the body', async () => {
|
|
const { db, log } = database([[{ engagement, deal }]]);
|
|
const dimensions = [
|
|
{ id: 'budget', weight: 30, score: 4 },
|
|
{ id: 'urgency', weight: 20, score: 2 },
|
|
{ id: 'fit', weight: 50, score: 3 },
|
|
];
|
|
|
|
await executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ dimensions }),
|
|
motionScoreDefinition(),
|
|
{ id: ENGAGEMENT_ID },
|
|
);
|
|
|
|
const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row;
|
|
assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions));
|
|
// Derived from the score by the shared function, never asserted as a
|
|
// literal here: a band table edited in @pig/core would otherwise be caught
|
|
// by this test rather than by the one that owns the decision.
|
|
assert.equal(written?.band, motionBand(motionScoreBasisPoints(dimensions)).label);
|
|
assert.equal(typeof written?.basisPoints, 'number');
|
|
assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is');
|
|
});
|
|
|
|
it('refuses a posted basisPoints outright — a score somebody can send is a score somebody can fix', async () => {
|
|
const { db } = database([[{ engagement, deal }]]);
|
|
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
member,
|
|
async () => ({ dimensions: [{ id: 'fit', weight: 1, score: 1 }], basisPoints: 10_000 }),
|
|
motionScoreDefinition(),
|
|
{ id: ENGAGEMENT_ID },
|
|
),
|
|
(error: unknown) => error instanceof MutationError && error.code === 'invalid_request',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('an id that cannot name a row is a row that does not exist', () => {
|
|
/*
|
|
* Measured against a running server before this test existed: every `:id`
|
|
* route answered `500 {"error":"Internal error"}` for an id like `nope`.
|
|
* Nothing was wrong with the code that handled a missing row — that branch
|
|
* was simply never reached, because each id column is a `uuid` and Postgres
|
|
* refuses the cast with `22P02` several layers below it. The reads therefore
|
|
* check the shape before they ask, and the writes do it in `requiredId`.
|
|
*
|
|
* 404 rather than 400 is the load-bearing half. A 400 for a malformed id and
|
|
* a 404 for a well-formed one tells anyone probing which of their guesses
|
|
* are the right shape, and this feature already routes "somebody else's
|
|
* private draft" through the same 404 precisely so that no answer here
|
|
* distinguishes between the reasons a row is not yours to see.
|
|
*/
|
|
const viewer = { userId: OWNER, isPlatformAdmin: false };
|
|
|
|
it('answers null for a malformed template id without going to the database at all', async () => {
|
|
const { db, log } = database([]);
|
|
|
|
assert.equal(await new MotionService(db).template(viewer, 'nope'), null);
|
|
assert.deepEqual(log.events, [], 'a malformed id must not reach Postgres to be refused');
|
|
});
|
|
|
|
it('answers null for a malformed engagement id, likewise', async () => {
|
|
const { db, log } = database([]);
|
|
|
|
assert.equal(await new MotionService(db).engagement(viewer, 'nope'), null);
|
|
assert.deepEqual(log.events, []);
|
|
});
|
|
|
|
it('still reads a well-formed id — the guard is a shape check, not a rejection of unknown ids', async () => {
|
|
const { db, log } = database([[]]);
|
|
|
|
assert.equal(await new MotionService(db).template(viewer, ENGAGEMENT_ID), null);
|
|
assert.deepEqual(log.events, ['select'], 'a well-formed id is answered by the database');
|
|
});
|
|
|
|
it('refuses a malformed id on a write as not_found, the same answer an unknown one gets', async () => {
|
|
const { db, log } = database([]);
|
|
|
|
// A lead, so the write gets past the capability check and reaches the id:
|
|
// authorization deliberately precedes it, and a member would be refused
|
|
// here for the other reason entirely.
|
|
await assert.rejects(
|
|
executeMutation(
|
|
db,
|
|
lead,
|
|
async () => ({}),
|
|
motionTemplatePublishDefinition(),
|
|
{ id: 'nope' },
|
|
),
|
|
(error: unknown) => error instanceof MutationError && error.code === 'not_found',
|
|
);
|
|
// The transaction opens first — `executeMutation` owns that — but nothing
|
|
// is ever asked of it, which is the property that matters: no statement
|
|
// carrying `nope` was sent to Postgres to be refused there.
|
|
assert.deepEqual(log.events, ['transaction'], 'refused on shape, without a query');
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------- the mount, not the mock
|
|
|
|
const SUBJECT = 'motion-auth-subject';
|
|
|
|
/**
|
|
* The whole app, with the cheapest database that can carry an authenticated
|
|
* request through it — the fixture `http-auth.test.ts` uses, which answers by
|
|
* table identity rather than in call order because the queries `loadPrincipal`
|
|
* and the overview make are an implementation detail.
|
|
*/
|
|
function createdApp(): ReturnType<typeof createApp> {
|
|
const user = {
|
|
id: OWNER,
|
|
email: 'seller@example.com',
|
|
name: 'Seller',
|
|
authSubject: SUBJECT,
|
|
deactivatedAt: null,
|
|
isPlatformAdmin: false,
|
|
};
|
|
|
|
function chain(rows: unknown[]): Record<string, unknown> {
|
|
const self: Record<string, unknown> = {
|
|
leftJoin: () => self,
|
|
innerJoin: () => self,
|
|
where: () => self,
|
|
orderBy: () => self,
|
|
groupBy: () => self,
|
|
limit: () => self,
|
|
then: (resolve: (value: unknown[]) => unknown) => resolve(rows),
|
|
};
|
|
return self;
|
|
}
|
|
|
|
const db = {
|
|
select: () => ({
|
|
from: (table: unknown) => {
|
|
if (table === users) return chain([user]);
|
|
if (table === teamMemberships) return chain([{ team: 'demand', role: 'member' }]);
|
|
return chain([]);
|
|
},
|
|
}),
|
|
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
|
transaction: async (work: (tx: unknown) => Promise<unknown>) => work({}),
|
|
} as unknown as Database;
|
|
|
|
const provider: AuthProvider = {
|
|
name: 'test',
|
|
async verifyAccessToken(token: string) {
|
|
if (token !== 'good-token') throw new Error('bad token');
|
|
return { subject: SUBJECT, email: user.email };
|
|
},
|
|
};
|
|
|
|
// A real `loadConfig`, for the reason `http-auth.test.ts` gives: a hand-built
|
|
// Config object would let this pass under one the server refuses to start on.
|
|
const config = loadConfig({
|
|
NODE_ENV: 'test',
|
|
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig-not-connected',
|
|
PIG_PUBLIC_URL: 'http://localhost:8920',
|
|
PIG_ADMIN_EMAILS: '',
|
|
} as NodeJS.ProcessEnv);
|
|
|
|
return createApp(config, db, provider);
|
|
}
|
|
|
|
describe('the routes are mounted in app.ts, not only in this file', () => {
|
|
it('answers a member holding book:read on GET /api/motion', async () => {
|
|
// AGENTS.md §5, verbatim: every other test here mounts the factory itself,
|
|
// so all twelve routes could be dropped from `app.ts` and this file would
|
|
// stay green. The request is authenticated deliberately — `app.use('/api/*')`
|
|
// authenticates ahead of every feature route, so an anonymous GET answers
|
|
// 401 whether or not anything is mounted behind it, and the same assertion
|
|
// would pass against a feature that had been deleted outright.
|
|
const response = await createdApp().request('/api/motion', {
|
|
headers: { authorization: 'Bearer good-token' },
|
|
});
|
|
|
|
assert.notEqual(response.status, 404, 'createMotionRoutes is not mounted in createApp');
|
|
assert.notEqual(response.status, 401, 'the request never reached the motion handler');
|
|
assert.notEqual(response.status, 403, 'the read guard answered, so the mount is untested');
|
|
});
|
|
});
|