Files
TREK/server/tests/unit/db/repositories/AssignmentParticipants.repository.test.ts
T
Maurice 9de9719a02 test(server): seed the share, quota, photo and stats specs through the factories
Converts the raw prepare() fixtures and reads in the share and place shadow
e2e suites, the google quota, travel stats and MCP guard services, the
memories MCP spec, the admin seeder spec and the assignment participants
repository spec to the ORM factories and readers.

The place shadow e2e moves from hand-rolled tables to the migrated snapshot
with its users pinned to the ids their session cookies are signed for. The
share e2e drops the raw canAccessTrip it still put on the database mock:
nothing has read it since trip access moved onto the repositories. The
quota spec computes yesterday and the retention window in UTC, the same
calendar day SQLite's date('now') gives. The participants oracle keeps its
raw statement behind an allow marker.
2026-10-08 18:46:47 +02:00

331 lines
16 KiB
TypeScript

/**
* AssignmentParticipantsRepository.listForAssignments (Plan 3c Task 1, QH3):
* the batch participants-by-assignment loader behind
* `QueryHelpersService.loadParticipantsByAssignmentIds`, moved off
* `query-helpers.service.ts`. Deliberately no `COALESCE(display_name,
* username)` — unlike `AssignmentsService.getParticipants` (AS15) — see the
* repository's own docstring.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { AssignmentParticipants } from '../../../../src/db/entities/AssignmentParticipants.entity';
import type { AssignmentParticipantsRepository } from '../../../../src/db/repositories/AssignmentParticipants.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
import { Users } from '../../../../src/db/entities/Users.entity';
import { findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let participants: AssignmentParticipantsRepository;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
participants = t.repo(AssignmentParticipants);
uow = new UnitOfWork(t.em);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
async function addParticipant(assignmentId: number, userId: number): Promise<void> {
await insertRow(t, AssignmentParticipants, { assignment: assignmentId, user: userId });
}
async function participantUserIds(assignmentId: number) {
return (await findRows(t, AssignmentParticipants, { assignment: assignmentId })).map((r) => ({ user_id: r.user_id }));
}
async function withQueryCount<T>(fn: () => Promise<T>): Promise<{ value: T; queries: number }> {
const connection = t.orm.em.getConnection();
const spy = vi.spyOn(connection, 'execute');
try {
const value = await fn();
return { value, queries: spy.mock.calls.length };
} finally {
spy.mockRestore();
}
}
describe('AssignmentParticipantsRepository.listForAssignments', () => {
it('ASSIGNPARTREPO-001: an empty assignmentIds array short-circuits to [] without querying', async () => {
const { value, queries } = await withQueryCount(() => participants.listForAssignments([]));
expect(value).toEqual([]);
expect(queries).toBe(0);
});
it('ASSIGNPARTREPO-002: joins the user\'s username/avatar, with NO COALESCE(display_name, username) — raw username only', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'raw-username' });
await updateRows(t, Users, { id: participant.id }, { display_name: 'A Display Name' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await addParticipant(assignment.id, participant.id);
const rows = await participants.listForAssignments([assignment.id]);
expect(rows).toEqual([{ assignment_id: assignment.id, user_id: participant.id, username: 'raw-username', avatar: null }]);
});
it('ASSIGNPARTREPO-003: batches across several assignments', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const { user: p2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const placeA = createPlace(testDb, trip.id, { name: 'A' });
const placeB = createPlace(testDb, trip.id, { name: 'B' });
const a1 = createDayAssignment(testDb, day.id, placeA.id);
const a2 = createDayAssignment(testDb, day.id, placeB.id);
await addParticipant(a1.id, p1.id);
await addParticipant(a2.id, p2.id);
const rows = await participants.listForAssignments([a1.id, a2.id]);
expect(rows.map((r) => r.assignment_id).sort()).toEqual([a1.id, a2.id].sort());
});
it('ASSIGNPARTREPO-004: an assignment with no participants contributes no rows', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
expect(await participants.listForAssignments([assignment.id])).toEqual([]);
});
// Task 9 fix wave (B-M3): relabelled. `listForAssignments` is a
// `qb().execute('all', false)` projection, and `TrekRepository` applies
// `disableIdentityMap: true` to every read by construction anyway, so
// there is no live identity-map entry here to bypass — this proves a DB
// round-trip, not an identity-map bypass.
it('ASSIGNPARTREPO-005 (fresh after a raw UPDATE, not D-shape): a participant added after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'fresh-participant' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// Task 2 review (task-2-review.md, L1, program note): `{
// disableIdentityMap: false }` on the setup read, for consistency with
// every other D-shape test in this file — this particular read is
// genuinely empty (no participant row exists yet), so the flag changes
// nothing here, but the mechanical fix is applied uniformly rather than
// singled out.
await t.repo(AssignmentParticipants).find({}, { disableIdentityMap: false }); // populate the identity map with an unrelated (empty) read
await addParticipant(assignment.id, participant.id);
const rows = await participants.listForAssignments([assignment.id]);
expect(rows).toEqual([{ assignment_id: assignment.id, user_id: participant.id, username: 'fresh-participant', avatar: null }]);
});
});
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService`) — appended after Task 1's own
// `listForAssignments` suite above, per this task's file-ownership rule.
// The whole file is committed under this task's message; see its report for
// the coordination note.
// ---------------------------------------------------------------------------
/** The AS2/AS15/AS31 statement, run raw — the parity oracle every assertion below is checked against. */
function legacyParticipantRows(assignmentId: number): unknown {
// test-sql-allow: the legacy statement is this parity test's oracle and has to run as written.
return testDb.prepare(`
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(assignmentId);
}
describe('AssignmentParticipantsRepository.listWithDisplayName (AS2/AS15/AS31)', () => {
it('ASSIGNPARTREPO-006: no assignment_id key — a narrower row than listForAssignments (QH3)', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'raw-username' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await addParticipant(assignment.id, participant.id);
const rows = await participants.listWithDisplayName(assignment.id);
expect(rows).toStrictEqual(legacyParticipantRows(assignment.id));
expect(rows[0]).not.toHaveProperty('assignment_id');
});
it('ASSIGNPARTREPO-007: username COALESCEs display_name over username, when set', async () => {
const { user: owner } = createUser(testDb);
const { user: withDisplayName } = createUser(testDb, { username: 'raw-username' });
await updateRows(t, Users, { id: withDisplayName.id }, { display_name: 'Fancy Name' });
const { user: withoutDisplayName } = createUser(testDb, { username: 'plain-username' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await addParticipant(assignment.id, withDisplayName.id);
await addParticipant(assignment.id, withoutDisplayName.id);
const rows = await participants.listWithDisplayName(assignment.id);
expect(rows).toEqual(expect.arrayContaining([
{ user_id: withDisplayName.id, username: 'Fancy Name', avatar: null },
{ user_id: withoutDisplayName.id, username: 'plain-username', avatar: null },
]));
expect(rows).toStrictEqual(legacyParticipantRows(assignment.id));
});
it('ASSIGNPARTREPO-008: an assignment with no participants returns []', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
expect(await participants.listWithDisplayName(assignment.id)).toEqual([]);
});
// Task 9 fix wave (B-M3): relabelled — same reasoning as ASSIGNPARTREPO-005
// above: a `qb().execute('all', false)` projection never hydrates an
// entity into the identity map, and the base default leaves it disabled
// anyway, so this proves a DB round-trip, not an identity-map bypass.
it('ASSIGNPARTREPO-009 (fresh after a raw UPDATE, not D-shape): a participant added after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'fresh-participant-2' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await t.repo(AssignmentParticipants).find({}, { disableIdentityMap: false });
await addParticipant(assignment.id, participant.id);
const rows = await participants.listWithDisplayName(assignment.id);
expect(rows).toEqual([{ user_id: participant.id, username: 'fresh-participant-2', avatar: null }]);
});
});
describe('AssignmentParticipantsRepository.deleteForAssignment / insertIgnore (AS29/AS30)', () => {
it('ASSIGNPARTREPO-010: deleteForAssignment removes every row for the assignment, none other', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const { user: p2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a1 = createDayAssignment(testDb, day.id, place.id);
const a2 = createDayAssignment(testDb, day.id, place.id);
await addParticipant(a1.id, p1.id);
await addParticipant(a2.id, p2.id);
await withRequestContext(t.orm, async () => {
await participants.deleteForAssignment(a1.id);
});
expect(await participants.listWithDisplayName(a1.id)).toEqual([]);
expect(await participants.listWithDisplayName(a2.id)).toHaveLength(1);
});
it('ASSIGNPARTREPO-011: insertIgnore writes every id once; a duplicate id in the same call collapses to one row', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, async () => {
await participants.insertIgnore(assignment.id, [p1.id, p1.id]);
});
const rows = await participantUserIds(assignment.id);
expect(rows).toEqual([{ user_id: p1.id }]);
});
it('ASSIGNPARTREPO-012: insertIgnore does not conflict with an existing row for a DIFFERENT assignment', async () => {
const { user: owner } = createUser(testDb);
const { user: shared } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a1 = createDayAssignment(testDb, day.id, place.id);
const a2 = createDayAssignment(testDb, day.id, place.id);
await addParticipant(a1.id, shared.id);
await withRequestContext(t.orm, async () => {
await participants.insertIgnore(a2.id, [shared.id]);
});
expect(await participants.listWithDisplayName(a1.id)).toHaveLength(1);
expect(await participants.listWithDisplayName(a2.id)).toHaveLength(1);
});
it('ASSIGNPARTREPO-013: an empty user_ids array writes nothing (0 queries)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
const { queries } = await withQueryCount(() => withRequestContext(t.orm, async () => {
await participants.insertIgnore(assignment.id, []);
}));
expect(queries).toBe(0);
});
it('ASSIGNPARTREPO-014: deleteForAssignment + insertIgnore inside a rolled-back transaction leave the row unchanged (joins the ambient transaction)', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const { user: p2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await addParticipant(assignment.id, p1.id);
let caught: unknown;
try {
await withRequestContext(t.orm, async () => {
await uow.transactional(async () => {
await participants.deleteForAssignment(assignment.id);
await participants.insertIgnore(assignment.id, [p2.id]);
throw new Error('force rollback');
});
});
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
const rows = await participantUserIds(assignment.id);
expect(rows).toEqual([{ user_id: p1.id }]);
});
});
// ── Plan 3c Task 8 (`TripsService.copy`, TP53) — additive ───────────────────
describe('AssignmentParticipantsRepository.listForTrip (TP53)', () => {
it('ASSIGNPARTREPO-015: the two-hop join scopes participants to the trip, returning only assignment_id/user_id', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const { user: p2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const other = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await addParticipant(assignment.id, p1.id);
await addParticipant(assignment.id, p2.id);
const otherDay = createDay(testDb, other.id);
const otherPlace = createPlace(testDb, other.id);
const otherAssignment = createDayAssignment(testDb, otherDay.id, otherPlace.id);
await addParticipant(otherAssignment.id, p1.id);
const rows = await participants.listForTrip(trip.id);
expect(rows.map((r) => r.user_id).sort((a, b) => a - b)).toEqual([p1.id, p2.id].sort((a, b) => a - b));
expect(rows.every((r) => r.assignment_id === assignment.id)).toBe(true);
});
it('ASSIGNPARTREPO-016: a trip with no participants returns []', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await participants.listForTrip(trip.id)).toEqual([]);
});
});