Bridge Training

Testing

The fakes and mocks of @workspace/training/testing, what each one implements and its knobs, and how the package's own tests are laid out.

Import from @workspace/training/testing. This sub-path holds test doubles only: in-memory implementations of ports with the database's semantics, a mock factory for repository interfaces, and nothing else. Fixtures come from Factories, which /testing does not re-export. The fakes import vitest, so a consumer must run under it.

ExportStands in forKind
createFakeAccountsRepositories, FakeAccountsRepositories, FakeAccountsOptionsAccountsRepository and AccountsReadRepositoryIn-memory pair over one row set with the table's semantics
createFakeBillingCustomersRepositories, FakeBillingCustomersBillingCustomersRepository and BillingCustomersReadRepositoryIn-memory pair over one binding per subject
createFakeBoardThemesRepository, FakeBoardThemesRepositoryBoardThemesRepositoryIn-memory link set
createFakeDealCollectionsRepositories, FakeDealCollections, FakeDealCollectionsOptionsDealCollectionsRepository and DealCollectionsReadRepositoryIn-memory pair over one row set, joined to a board links fake
createFakeDealCollectionBoardsRepository, FakeDealCollectionBoardsRepositoryDealCollectionBoardsRepositoryIn-memory ordered junction
createFakeQueue, FakeQueue, FakeQueueModeQueue portIn-memory, sync or manual delivery
createFakeIngestedEventsRepositoryIngestedEventsRepositoryIn-memory with the atomic status transitions
createFakeOutboundEmailsRepositories, FakeOutboundEmailsOutboundEmailsRepository and OutboundEmailsReadRepositoryIn-memory pair over one journal, one email per idempotency key
createFakeEmailSuppressionsRepositories, FakeEmailSuppressionsEmailSuppressionsRepository and EmailSuppressionsReadRepositoryIn-memory pair over one list, one row per address
createInMemoryAdsSponsorships, InMemoryAdsSponsorships, InMemoryAdsSponsorshipsOptionsAdsSponsorshipsRepository and AdsSponsorshipReadRepositoryIn-memory campaigns and their visuals
createFakeBrandAssetsRepositories, FakeBrandAssets, FakeBrandAssetsOptionsBrandAssetsRepository and BrandAssetsReadRepositoryIn-memory pair over one row set, in the table's order
createInMemoryAnnouncements, InMemoryAnnouncements, InMemoryAnnouncementsOptionsAnnouncementsRepository, AnnouncementsReadRepository and AnnouncementReadsRepositoryIn-memory catalogue and its read marks
createInMemoryEntitlements, InMemoryEntitlements, InMemoryEntitlementsOptionsEntitlementsRepository and UsageCountersRepositoryIn-memory grants and flow counters
createFakeDealReadRepository, FakeDealRowDealReadRepositoryIn-memory deals view keyed by code, with the next-deal ranking
makeEditorBoardsReadEditorBoardsReadRepositoryIn-memory read fake seeded with records
createFakeSharingGroupsRepository, FakeSharingGroupsSharingGroupsRepository and SharingGroupsReadRepositoryIn-memory groups with the table's semantics
createFakeSharingGroupMembersRepository, FakeSharingGroupMembers, FakeSharingGroupMembersOptionsSharingGroupMembersRepositoryIn-memory member links joined to the groups and accounts they project
createFakeGroupsRepositories, FakeGroupsRepositories, FakeGroupsOptions, FakeGroupRowGroupsRepository and GroupsReadRepositoryIn-memory pair over one row set
createFakeStudentsRepositories, FakeStudentsRepositories, FakeStudentsOptionsStudentsRepository and StudentsReadRepositoryIn-memory pair over one row set, serial ids
createFakeGroupStudentsRepository, FakeGroupStudentsRepositoryGroupStudentsRepositoryIn-memory junction keyed by the (groupId, studentId) pair
createFakeGroupSessionsRepositories, FakeGroupSessions, FakeGroupSessionsOptions, FakeGroupSessionRowGroupSessionsRepository and GroupSessionsReadRepositoryIn-memory pair over one row set, joined to its groups, to the collections fake and to the two link fakes
createFakeGroupSessionBoardsRepository, FakeGroupSessionBoardsRepositoryGroupSessionBoardsRepositoryIn-memory ordered junction
createFakeGroupSessionCollectionsRepository, FakeGroupSessionCollectionsRepositoryGroupSessionCollectionsRepositoryIn-memory ordered junction, a duplicate link as COLLECTION_ALREADY_LINKED
createFakeBookmarksRepositoryBookmarksRepositoryIn-memory links, unique per (userId, boardId) pair
createFakeVotingRepositoryVotingRepositoryIn-memory votes, one per (userId, boardId) pair, upserted
createFakeAuctionSystemsRepositoryAuctionSystemsRepositoryIn-memory catalogue read back by sort order
createFakeImpersonationLogRepository, FakeImpersonationLogImpersonationLogRepositoryIn-memory append-only log
createFakeTelemetryEventsRepository, FakeTelemetryEvents, FakeTelemetryEventsOptions, FakeTelemetryEventRowTelemetryEventsRepositoryIn-memory raw event log with the retention predicate
createFakePlansRepositories, FakePlansRepositories, FakePlansOptionsThe six plans repositories (grids, plans, prices, each read and write)In-memory catalogue with the tables' order and uniqueness
createFakeSubscriptionsRepositories, FakeSubscriptions, FakeSubscriptionsOptionsSubscriptionsRepository and SubscriptionsReadRepositoryIn-memory mirror joined to a catalogue and its holders, with the upsert guard and the aggregations
mockRepoAny repository interfaceProxy of auto-stubbed vi.fn()
inlineUnitOfWorkAny XxxUnitOfWork portRuns the step on the given repositories, without a transaction

createFakeAccountsRepositories

createFakeAccountsRepositories(seed?: readonly Account[], options?: FakeAccountsOptions): FakeAccountsRepositories returns { accounts, accountsRead } over one row set, plus rows (copies, in insertion order) and identityOf(accountId), the identity-provider id an account is attached to, null once detached. A seeded account is attached to an identity equal to its id. The fake carries the table's semantics: writeOne refuses a taken id with ACCOUNT_ALREADY_EXISTS, and a taken email or identity; the email filter is exact and the search case-insensitive on email and display name; update applies the rules of AccountPatch (avatar trust reset, derived display name, first onboarding time kept) and resolves a patched auctionSystemId against options.auctionSystems, an unknown one reading back as null. See Accounts for the service it feeds.

createFakeBillingCustomersRepositories

createFakeBillingCustomersRepositories(seed?: readonly BillingCustomer[], now?: () => Date): FakeBillingCustomers returns { customers, customersRead } over one binding per subject, plus rows, the bindings keyed by subject. writeOne keeps a subject already bound, as the insert does on its key; update patching the provider customer restamps createdAt at now(); findMany (only the entity projection) honours the subjectId, providerCustomerId and deletedAt filters. See Billing for the service it feeds.

createFakeOutboundEmailsRepositories

createFakeOutboundEmailsRepositories(seed?: readonly OutboundEmail[], now?: () => Date): FakeOutboundEmails returns { emails, emailsRead } over one journal, plus rows, keyed by id. writeOne answers null for a journaled idempotency key, as the insert does on its unique index; claim takes a queued email only, counting the attempt; every write stamps updatedAt at now(); findMany (only the entity projection) and the count aggregation honour every OutboundEmailWhere field, recipient compared on the normalized address.

createFakeEmailSuppressionsRepositories

createFakeEmailSuppressionsRepositories(seed?: readonly EmailSuppression[], now?: () => Date): FakeEmailSuppressions returns { suppressions, suppressionsRead } over one list, plus rows, keyed by address. writeOne keeps an address already listed, as the insert does on its key; findMany (only the entity projection) filters on recipient. See Emails for the service it feeds.

createFakePlansRepositories

createFakePlansRepositories(options?: FakePlansOptions): FakePlansRepositories returns { planGrids, planGridsRead, plans, plansRead, planPrices, planPricesRead } over one in-memory catalogue seeded from options.grids, plans and prices, plus gridRows, planRows and priceRows, the rows keyed by id. Reads order as the SQL does (grids oldest first then by key, plans by grid then rank, prices cheapest then oldest) and honour every field of the three Where shapes; PlanProjections.withOnSalePrices() keeps the active prices only and PlanPriceProjections.withPlan() drops a price whose plan is absent, as the inner join does. plans.writeOne refuses a taken key or a taken rank in the grid; every update stamps updatedAt at options.now(). See Plans for the ports.

Prop

Type

createFakeSubscriptionsRepositories

createFakeSubscriptionsRepositories(options?: FakeSubscriptionsOptions): FakeSubscriptions returns { subscriptions, subscriptionsRead } over one in-memory mirror, plus rows, in insertion order. Reads go newest first and honour every SubscriptionWhere field, the grid resolved through options.prices and options.plans; adminRow() joins the price, the plan and the holder from options.accounts, dropping a row one of them misses, as the inner joins do. The three SubscriptionAggregations are computed over the same joins, and getById throws NotFoundError(SUBSCRIPTION_NOT_FOUND). upsert applies the table's guard: one row per provider subscription, never rolled back to an older window or an earlier event, never handed to another subject, the standing row answered either way. See Subscriptions for the ports.

Prop

Type

createFakeBoardThemesRepository

createFakeBoardThemesRepository(seed: BoardThemeLink[] = []): FakeBoardThemesRepository holds the board_themes links in memory, in insertion order. findMany (only the links projection) and delete honour boardId and themeId eq, in and ne filters; replaceAll swaps one board's whole set. The extra links getter exposes the current set for assertions. See Boards for the port.

createFakeDealCollectionsRepositories

createFakeDealCollectionsRepositories(options?: FakeDealCollectionsOptions): FakeDealCollections returns { collections, collectionsRead, collectionBoards, rows } over one in-memory row set. A new collection is public with no window, reads sort by name unless a sort is given, every update bumps updatedAt, a delete cascades to the board links, and writes on an unknown id match nothing. getById throws NotFoundError(DEAL_COLLECTION_NOT_FOUND). The publiclyVisible() filter applies the visibility window at the current time, and the publicPage() projection resolves board codes and the author's name from the options, dropping a board with no code as the inner join does. Board counts and public pages read the collectionBoards fake passed in the options, or a fresh one. The rows getter exposes the state for assertions. See Collections for the ports.

Prop

Type

createFakeDealCollectionBoardsRepository

createFakeDealCollectionBoardsRepository(seed?: DealCollectionBoardWrite[]): FakeDealCollectionBoardsRepository holds the ordered deal_collection_boards links in memory. A board appears at most once per collection (a duplicate throws and nothing is written, as the unique index does), writeMany appends after the collection's last position, replaceAll rewrites one collection's positions from 0, and findMany (only the links projection) and delete honour collectionId and boardId filters. The links and boardIdsOf(collectionId) getters expose the state for assertions.

createFakeGroupSessionsRepositories

createFakeGroupSessionsRepositories(options?: FakeGroupSessionsOptions): FakeGroupSessions returns { sessions, sessionsRead, sessionBoards, sessionCollections, rows } over one in-memory row set of FakeGroupSessionRow, the session's own columns. A session reads through its group, taken from options.groups (a FakeGroupsRepositories fits): a session whose group is absent reads as missing, as the inner join does, and the group's owner is what byOwner filters on. A new session is public with no window, reads sort by date unless a sort is given, an update changes only the provided fields, a delete cascades to both link fakes, and writes on an unknown id match nothing. getById throws NotFoundError(SESSION_NOT_FOUND). Board ids and counts are the union of the linked collections' boards, read from options.collections (a FakeDealCollections fits), and the ad-hoc ones. between compares instants against the UTC days, publiclyVisible() applies the window at the current time, and publicPage() resolves board codes and the author's name from the options, dropping a board with no code. The rows getter exposes the state for assertions. See Sessions for the ports.

Prop

Type

createFakeGroupSessionBoardsRepository and createFakeGroupSessionCollectionsRepository

createFakeGroupSessionBoardsRepository(seed?: GroupSessionBoardWrite[]) and createFakeGroupSessionCollectionsRepository(seed?: GroupSessionCollectionWrite[]) hold the ordered group_session_boards and group_session_collections links in memory, with the semantics of createFakeDealCollectionBoardsRepository: one link per pair, writeMany appending after the session's last position, replaceAll rewriting one session's positions from 0, findMany (only the links projection) and delete honouring sessionId and child filters. A duplicate board throws and nothing is written; a duplicate collection throws ConflictError(COLLECTION_ALREADY_LINKED), as the Drizzle repository maps the unique index. The links getter and boardIdsOf(sessionId) or collectionIdsOf(sessionId) expose the state for assertions.

createFakeQueue

createFakeQueue(opts?: { mode?: FakeQueueMode }): FakeQueue implements the Queue port of Ports and adds inspection methods. FakeQueueMode is "sync" | "manual".

  • sync (default): send runs the topic's handlers inline and resolves when they are done. A handler that throws makes send reject.
  • manual: send stores the job; nothing runs until drain(). A test decides when the worker runs, which is how the API's webhook pipeline harness drives the real worker over a fake queue.

Each delivery gets a sequential id and retryCount: 0. schedule is a no-op: a test that cares about a cron should call the swept function directly. send after stop() throws.

MethodDescription
send, work, schedule, stopThe Queue port.
drain()Replays every pending job once, in order, then clears the queue. Handler errors are collected and rethrown together as an AggregateError.
pending()The jobs not yet delivered, as { topic, data }, a copy.
reset()Clears pending jobs and handlers and lifts a stop().
testing.ts
export async function queueInSyncMode(): Promise<unknown[]> {
  const queue = createFakeQueue(); // mode "sync" by default
  const received: unknown[] = [];
  await queue.work("topic-a", (msg) => {
    received.push(msg.data);
    return Promise.resolve();
  });
  await queue.send("topic-a", { foo: "bar" }); // handler ran inline
  return received; // => [{ foo: "bar" }]
}

export async function queueInManualMode(): Promise<FakeQueue> {
  const queue = createFakeQueue({ mode: "manual" });
  await queue.work("topic-a", () => Promise.resolve());
  await queue.send("topic-a", 1);
  await queue.send("topic-a", 2);
  queue.pending(); // => [{ topic: "topic-a", data: 1 }, { topic: "topic-a", data: 2 }]
  await queue.drain(); // runs both, rethrows handler errors as one AggregateError
  queue.pending(); // => []
  return queue;
}

createFakeIngestedEventsRepository

createFakeIngestedEventsRepository(): IngestedEventsRepository is an in-memory event store mirroring the Drizzle implementation's atomic transitions, so worker-lifecycle tests of Events run without Postgres. Ids are fake-event-<n> from a module-level counter.

BehaviourDetail
Dedup on createA second event with the same (provider, externalId) resolves to null.
claimOnly from a claimable status (pending, failed); moves to processing and increments attempts. Otherwise null.
resetForReplayOnly from failed or exhausted; back to pending. Otherwise null.
markDone(id, status = "done")Sets status and processedAt; "skipped" is the other accepted value.
markFailed(id, error, { retryLimit })exhausted once attempts >= retryLimit, failed before; records the error message.
findPendingOlderThan(cutoff)Pending rows created before the cutoff.
listPaginatedEvery row, nextCursor: null; filters are ignored.
statsByStatus, distinctEventTypes, distinctProvidersComputed over the rows held.

Paired with a manual FakeQueue and the real createEventIngestionService, it exercises the whole claim, handle and mark path.

testing.ts
const CONFIG: EventIngestionConfig = {
  topics: { process: "webhook.process", sweepPending: "webhook.sweep-pending" },
  retryLimit: 8,
  retryDelayMs: 60_000,
  retryBackoff: "exponential",
  sweepStaleMinutes: 5,
  sweepCronExpression: "*/2 * * * *",
  gracefulShutdownTimeoutMs: 30_000,
  replayBatchSize: 100,
};

export function buildWorker(handler: IngestedEventHandler): {
  queue: FakeQueue;
  ingestion: EventIngestionService;
} {
  const queue = createFakeQueue({ mode: "manual" });
  const ingestion = createEventIngestionService({
    repository: createFakeIngestedEventsRepository(),
    queue,
    registry: createIngestedEventHandlerRegistry([handler]),
    config: CONFIG,
    // no verifier: a worker-only service, ingest() would throw
  });
  return { queue, ingestion };
}

export async function processOneEvent(
  ingestion: EventIngestionService,
  queue: Queue
): Promise<void> {
  const repository = createFakeIngestedEventsRepository();
  const created = await repository.create({
    provider: "test-provider",
    externalId: "evt_1",
    eventType: "test.event",
    occurredAt: new Date("2026-01-01T00:00:00.000Z"),
    payload: { foo: "bar" },
  });
  // a second create with the same (provider, externalId) resolves to null
  if (created) {
    await queue.work<{ id: string }>("webhook.process", (msg) =>
      ingestion.process(msg.data.id)
    );
    await queue.send("webhook.process", { id: created.id });
  }
}

createInMemoryAdsSponsorships

createInMemoryAdsSponsorships(options?: InMemoryAdsSponsorshipsOptions): InMemoryAdsSponsorships holds campaigns and their campaign-owned visuals with the database's semantics: the activeAt window filter, the delivered join of the campaign and withUsage projections, the sort with its tie-breakers, visuals replaced as a set by writeOne and update, delete cascading to the visuals, and getById throwing NotFoundError. The returned object carries sponsorships and sponsorshipsRead, so it is the deps of createAdsSponsorshipsAdminService as is, see Ads.

MemberDescription
sponsorships, sponsorshipsReadThe write and read repositories.
rowsThe campaigns held, keyed by id.
visualsEvery visual ever written, disabled ones included.

Prop

Type

createFakeBrandAssetsRepositories

createFakeBrandAssetsRepositories(options?: FakeBrandAssetsOptions): FakeBrandAssets holds a deal owner's visuals with the database's semantics: findMany (only the entity projection) filters on id, accountId, slot and status and returns rows in slot order, active first, then the sort's createdAt direction; an accountId filter never matches a campaign-owned row; update stamps updatedAt and reports the matched ids; writeMany writes active, account-owned rows and returns them in slot order. The returned { assets, assetsRead } is the BrandAssetsTx a unit-of-work fake hands to createBrandAssetsService, see Ads.

Prop

Type

testing.ts
export async function campaignOverTheFake(faker: Faker) {
  const store = createInMemoryAdsSponsorships();
  const admin = createAdsSponsorshipsAdminService(store);
  const created = await admin.create({
    sponsor: faker.company.name(),
    validFrom: new Date("2026-01-01T00:00:00.000Z"),
    validUntil: null,
    visuals: [makeCampaignVisual(faker)],
  });
  await admin.update(created.id, { visuals: [] });
  return store.visuals.map((v) => v.status); // => ["disabled"]
}

createInMemoryAnnouncements

createInMemoryAnnouncements(options?: InMemoryAnnouncementsOptions): InMemoryAnnouncements holds the catalogue and the read marks with the database's semantics: the visibleAt filter through the domain's isAnnouncementVisible, newest publication first, the withReadMark projection of the query's reader, a mark keeping its first readAt, a mark on a missing announcement refused, delete cascading to the marks, and getById throwing NotFoundError. The returned object spreads into the deps of both createAnnouncementsService and createAnnouncementsAdminService, see Announcements.

MemberDescription
announcements, announcementsRead, announcementReadsThe write, read and junction repositories.
rowsThe announcements held, keyed by id.
readAtOf(accountId, announcementId)The mark's readAt, null when unread.

Prop

Type

testing.ts
export async function readMarksOverTheFake(faker: Faker) {
  const news = makeAnnouncement(faker);
  const store = createInMemoryAnnouncements({ seed: [news] });
  const announcements = createAnnouncementsService({
    ...store,
    accounts: { getById: () => Promise.resolve(null) }, // unknown age: publication rules alone
    accountId: "account-1",
  });
  await announcements.markAllRead();
  return store.readAtOf("account-1", news.id); // => the fake clock's instant
}

createInMemoryEntitlements

createInMemoryEntitlements(seed?: readonly EntitlementGrant[], options?: InMemoryEntitlementsOptions): InMemoryEntitlements holds grants and their flow counters in memory with the database's semantics: the bounded increment refuses past the cap or on a grant no longer active, and says which; a carry stays within the subject and never overwrites a counter already written. Resolution goes through the domain's resolveEntitlementsAt, so a service over this fake computes the same entitlements as production, see Entitlements.

InMemoryEntitlements

MemberDescription
entitlementsAn EntitlementsRepository: resolve, listActiveGrants, listActiveGrantsWithGrid, listGrants (newest first), createGrant (random id, source defaulting to admin, createdAt = validFrom), revokeGrant, endGrant.
countersA UsageCountersRepository: getUsed, tryIncrement, carry.
grants()Every grant held, as the repository last wrote it.
usedOf(grantId, limit, at)Units the grant's counter holds for its window at at.
seedUsed(grantId, limit, at, value)Writes the grant's counter for its window at at, as past debits would have.

InMemoryEntitlementsOptions

Prop

Type

testing.ts
const zeroStock: StockUsageReader = {
  count: () => Promise.resolve(0),
  countDeals: () => Promise.resolve(0),
};

export function serviceOverStore(store: InMemoryEntitlements): EntitlementsService {
  return createEntitlementsService({
    entitlements: store.entitlements,
    stock: zeroStock,
    flow: store.counters,
  });
}

export async function grantRaisesTheLimit(faker: Faker, subjectId: string) {
  const grant = makeEntitlementGrant(faker, {
    subjectId,
    limits: { groups_per_user_max: 50 },
  });
  const store = createInMemoryEntitlements([grant]);
  const service = serviceOverStore(store);

  const decision = await service.can(subjectId, {
    type: "create_resource",
    limit: "groups_per_user_max",
  });
  store.grants(); // => [grant]
  store.seedUsed(grant.id, "diffusions_per_period", new Date(), 3);
  store.usedOf(grant.id, "diffusions_per_period", new Date()); // => 3
  return decision; // { allowed: true }
}

createFakeDealReadRepository

createFakeDealReadRepository(seed: FakeDealRow[] = []): DealReadRepository holds deals of the unified view in memory, keyed by code. A FakeDealRow is a CodedDealView plus the board-index state the view does not project: sharing (unset reads as private), trashed, orphaned, hasComment, sharedWith (the accounts the owner shares with, for a shared row) and linkedBy (the accounts whose sessions or collections still link an orphaned row). findMany and findManyPaginated honour every DealWhere field, the teaching-mode filter matching app rows only as the index does; a DealSort ranks like the database, ties kept in seed order instead of drawn at random. Only the view projection is supported; getById on an unknown code rejects with NotFoundError(DEAL_NOT_FOUND) and aggregate rejects every kind. See Public for the services it feeds.

makeEditorBoardsRead

makeEditorBoardsRead(records: EditorBoardRecord[] = []): EditorBoardsReadRepository is an in-memory read fake seeded with records, keyed by code. findMany honours code eq and in filters, ordering on the code column and pagination through paginationToLimitOffset; aggregate supports the codeSet and count kinds and rejects any other. getById and findManyPaginated are bare vi.fn() stubs, and findMany and aggregate are vi.fn wrappers, so a test can assert on their calls. See Editor boards for the service it feeds.

testing.ts
export async function readSeededEditorBoards(faker: Faker): Promise<EditorBoardRecord | null> {
  const records = [
    makePersistableEditorBoardWrite(faker, { code: "AAA" }),
    makePersistableEditorBoardWrite(faker, { code: "BBB" }),
  ];
  const editorBoardsRead = makeEditorBoardsRead(records);
  const service = createEditorBoardsService({ editorBoardsRead });

  await service.listExistingCodes(["AAA", "ZZZ"]); // => Set { "AAA" }
  return service.getByCode("BBB");
}

createFakeSharingGroupsRepository

createFakeSharingGroupsRepository(seed?: readonly SharingGroup[]): FakeSharingGroups holds groups in memory with the table's semantics and exposes both sides over one store: sharingGroups for writes and sharingGroupsRead for reads, plus groups() for assertions. A missing name is stored as an empty string and read back as null, description is never persisted, findMany honors the query's row cap and returns groups in insertion order, and getById on an unknown group rejects with NotFoundError(SHARING_GROUP_NOT_FOUND). update reports the ids its criteria matched, an empty list when none did.

createFakeSharingGroupMembersRepository

createFakeSharingGroupMembersRepository(options?: FakeSharingGroupMembersOptions): FakeSharingGroupMembers holds the member links. A link is keyed by its group and user: writeMany keeps an existing link and counts only the new ones. Pass options.groups, the groups getter of a groups fake, to get the foreign key: a link to an unknown group is refused, and the links of a deleted group disappear as the cascade would. The accounts and memberships projections resolve against options.accounts and drop an unknown account, as the inner joins do; memberships come newest first. membersOf(groupId) lists the member ids for assertions. See Sharing for the service both fakes feed.

Groups, students and their junction

createFakeGroupStudentsRepository(seed?: GroupStudentLink[]) holds the link set, readable through links. The pair is the key: writeMany keeps an existing link and counts only the new ones; delete honours groupId and studentId eq, in and ne.

createFakeGroupsRepositories(options?: { rows?: FakeGroupRow[]; groupStudents?: FakeGroupStudentsRepository }) returns { groups, groupsRead, rows } over one row set. A FakeGroupRow is { ownerId, group }, since Group does not carry its owner. Reads sort by name unless a GroupSort is given; entity() zeroes the counts like the database row, withDetails() counts the junction's links and keeps the seeded sessionCount. Ids come from crypto.randomUUID() unless the write carries one.

createFakeStudentsRepositories(options?: { rows?: Student[]; groupStudents?: FakeGroupStudentsRepository }) returns { students, studentsRead, rows }. Reads sort by last then first name; ids continue from the highest seeded id; withGroupCount() counts the junction's links.

Pass the same junction fake to both: deleting a group or a student then removes its links, as the foreign keys do in the database.

testing.ts
export async function rosterOnFakes(faker: Faker) {
  const groupStudents = createFakeGroupStudentsRepository();
  const groupsFake = createFakeGroupsRepositories({ groupStudents });
  const studentsFake = createFakeStudentsRepositories({
    rows: [makeStudent(faker, { id: 1, userId: "user-1" })],
    groupStudents, // feeds groupCount and receives the delete cascade
  });
  const ctx = { userId: "user-1" };
  const groups = createGroupsService({
    ...groupsFake,
    groupStudents,
    studentsRead: studentsFake.studentsRead,
    entitlements: allowAllEntitlements,
  });
  const students = createStudentsService({
    ...studentsFake,
    groupStudents,
    groupsRead: groupsFake.groupsRead,
    entitlements: allowAllEntitlements,
  });

  const group = await groups.create({ name: "Mardi", startDate: "2026-01-06" }, ctx);
  await groups.addStudent(group.id, 1, ctx);
  await students.listByGroup(group.id, false, ctx); // => [student 1]
  await groups.remove(group.id, ctx);
  return groupStudents.links; // => []: the links went with the group
}

createFakeBookmarksRepository and createFakeVotingRepository

createFakeBookmarksRepository(): BookmarksRepository and createFakeVotingRepository(): VotingRepository hold the user ↔ board links in memory. findMany (only the links and votes projections) and delete honour userId and boardId eq, in and ne filters, and delete reports the count. A repeated bookmark writeOne keeps the existing link and a vote writeOne replaces the previous vote type, as the unique constraints do. See Boards for the ports.

createFakeAuctionSystemsRepository

createFakeAuctionSystemsRepository(seed?: readonly AuctionSystem[]): AuctionSystemsRepository returns the seeded systems matching the query's id and identifier filters, ordered by sortOrder, as copies, as the table read does. It feeds AuctionSystemsService, see Boards.

createFakeImpersonationLogRepository

createFakeImpersonationLogRepository(): FakeImpersonationLog holds the audit log in memory. repository is the ImpersonationLogRepository to hand to createImpersonationService; entries() returns every entry written, in order, as a copy. See Accounts.

createFakeTelemetryEventsRepository

createFakeTelemetryEventsRepository(options?: FakeTelemetryEventsOptions): FakeTelemetryEvents holds the raw events in memory, in insertion order. writeMany stamps receivedAt from options.now and copies the serve's player from options.playerOfServe, as the insert does; delete honours pastRetention and takes at most limit rows, oldest inserted first. seed(rows) places events at a given age and rows() returns copies for assertions. See Telemetry.

mockRepo

mockRepo<T>(overrides?: { [K in keyof T]?: T[K] }): { [K in keyof T]: T[K] & Mock } creates a type-safe mock of a repository interface with a Proxy. Override keys and values are type-checked against T; a method not overridden auto-stubs to vi.fn().mockResolvedValue(undefined) on first access and is cached; no cast is needed at the call site. The package's own service tests build their deps this way, one mockRepo per port, overriding only the methods the case exercises.

testing.ts
export async function addThroughMock(faker: Faker) {
  const ctx = { userId: "user-1" };
  const group = makeGroup(faker);
  const student = makeStudent(faker, { userId: ctx.userId });
  const groupStudents = mockRepo<GroupStudentsRepository>({
    writeMany: () => Promise.resolve({ linked: 1 }),
  });
  const groups = createGroupsService({
    groups: mockRepo<GroupsRepository>(),
    // the ownership checks read the caller's rows, here from fakes
    groupsRead: createFakeGroupsRepositories({
      rows: [{ ownerId: ctx.userId, group }],
    }).groupsRead,
    groupStudents,
    studentsRead: createFakeStudentsRepositories({ rows: [student] })
      .studentsRead,
    entitlements: allowAllEntitlements,
  });

  await groups.addStudent(group.id, student.id, ctx);
  await groupStudents.delete(GroupStudentsCriteria.byGroup(group.id)); // not overridden: auto-stub resolving undefined
  return groupStudents.writeMany.mock.calls; // => [[[{ groupId: group.id, studentId: student.id }]]]
}

The package's own tests

Tests live under packages/training/src/__tests__/<layer>/<module>/, mirroring src/: __tests__/domain/<module> for pure rules, __tests__/application/<module> for services, __tests__/application/shared for the query layer, and __tests__/testing for the fakes themselves. Tests import the sources by relative path.

packages/training/src/__tests__/
  application/
    accounts/ ads/ boards/ editor-boards/ entitlements/ events/ ...
    shared/              pagination.test.ts
    themes/              themes-service.test.ts
  domain/
    accounts/ ads/ boards/ entitlements/ plans/ sessions/ ...
  testing/
    queue-fake.test.ts

The vitest config is the node preset of @workspace/config plus one setup file, as in @workspace/bridge:

import nodeConfig from "@workspace/config/vitest/node";
import { defineConfig, mergeConfig } from "vitest/config";

export default mergeConfig(
  nodeConfig,
  defineConfig({ test: { setupFiles: ["./vitest.setup.ts"] } })
);

That preset enables globals, matches *.test.ts and *.spec.ts under __tests__/** and src/**/__tests__/**, aliases @ to src, and collects v8 coverage over src/**. vitest.setup.ts seeds the shared faker instance before each test with one seed per run, random unless FAKER_SEED is set, and prints Replay with FAKER_SEED=<seed> when a test fails; tests import that instance and pass it to the factories, and a test that needs a fixed draw still seeds it itself. Run with pnpm --dir packages/training test, and replay a failure with FAKER_SEED=<seed> pnpm --dir packages/training test.