mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
0337ebbbd2
setParticipants took the ids on trust. The caller is proven to be on the trip and the assignment is proven to sit on it, but nothing said the same about the ids in the body, and the read-back joins users — so a stranger's name and avatar came back to the writer and went out over the assignment:participants broadcast to everyone else in the room. Takes the tripId both callers already hold and filters against the roster. Same drop-rather-than-reject rule as bag members and reservation travellers: the participants box posts the whole list on every edit, so a trip whose membership changed underneath it would otherwise be stuck. ASG-SVC-028 covered a foreign key error rolling the replace back. The filter takes an unknown id out before the insert can reach it, so that path is gone and the case now pins what happens instead: a list of nothing but strangers clears, like the empty list ASG-SVC-018 already pins.
421 lines
20 KiB
TypeScript
421 lines
20 KiB
TypeScript
/**
|
|
* Unit tests for the DI-native AssignmentsService — ASG-SVC-001 through
|
|
* ASG-SVC-028 (written for the DI migration: the legacy
|
|
* services/assignmentService.ts had no dedicated unit suite, so these pin the
|
|
* relocated SQL — MAX(order_index)+1 ordering, post-write re-selects, the
|
|
* time-driven auto-sort with its '99:99' sentinel — plus the hardening that
|
|
* followed the move: transactional writes, the DB-derived move source day,
|
|
* empty-string time clearing, and the unified compact tag projection; the
|
|
* bridge-delegation case died with assignments.bridge). Uses a real in-memory SQLite DB so SQL
|
|
* logic is exercised faithfully.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: unknown, userId: number) =>
|
|
db.prepare(`
|
|
SELECT t.id, t.user_id FROM trips t
|
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
|
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
|
`).get(userId, tripId, userId),
|
|
isOwner: (tripId: unknown, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createTag } from '../../helpers/factories';
|
|
import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
|
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
|
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
|
|
|
const dbs = new DatabaseService(testDb);
|
|
const realtime = new RealtimeService();
|
|
const svc = new AssignmentsService(
|
|
dbs,
|
|
new PermissionsService(dbs),
|
|
realtime,
|
|
new QueryHelpersService(dbs),
|
|
// Real collaborator rather than a stub: reconcile() runs after every mutation
|
|
// and needs the same connection to see the rows these cases write.
|
|
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)),
|
|
);
|
|
|
|
/**
|
|
* canEdit only reads trip.user_id, so the case below hands it just that field.
|
|
* The cast names the omission instead of padding the fixture with an id and a
|
|
* currency no assertion looks at.
|
|
*/
|
|
function tripOwnedBy(userId: number): TripAccess {
|
|
return { user_id: userId } as TripAccess;
|
|
}
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
/** owner + trip + one day + one place — the minimal itinerary fixture. */
|
|
function fixture() {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
|
return { user, trip, day, place };
|
|
}
|
|
|
|
// ── verifyTripAccess / canEdit ────────────────────────────────────────────────
|
|
|
|
describe('verifyTripAccess', () => {
|
|
it('ASG-SVC-001: returns trip for owner', () => {
|
|
const { user, trip } = fixture();
|
|
const result = svc.verifyTripAccess(trip.id, user.id);
|
|
expect(result).toBeDefined();
|
|
expect((result as { user_id: number }).user_id).toBe(user.id);
|
|
});
|
|
|
|
it('ASG-SVC-002: returns falsy for non-member', () => {
|
|
const { trip } = fixture();
|
|
const { user: stranger } = createUser(testDb);
|
|
expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
describe('canEdit', () => {
|
|
it('ASG-SVC-003: owner and member pass day_edit (default trip_member)', () => {
|
|
const { user, trip } = fixture();
|
|
const { user: member } = createUser(testDb);
|
|
addTripMember(testDb, trip.id, member.id);
|
|
expect(svc.canEdit(tripOwnedBy(trip.user_id), user)).toBe(true);
|
|
expect(svc.canEdit(tripOwnedBy(trip.user_id), member)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── existence checks ──────────────────────────────────────────────────────────
|
|
|
|
describe('dayExists / placeExists / assignmentExistsInDay', () => {
|
|
it('ASG-SVC-004: dayExists is trip-scoped', () => {
|
|
const { trip, day } = fixture();
|
|
const { user: other } = createUser(testDb);
|
|
const otherTrip = createTrip(testDb, other.id);
|
|
expect(svc.dayExists(day.id, trip.id)).toBe(true);
|
|
expect(svc.dayExists(day.id, otherTrip.id)).toBe(false);
|
|
expect(svc.dayExists(9999, trip.id)).toBe(false);
|
|
});
|
|
|
|
it('ASG-SVC-005: placeExists is trip-scoped', () => {
|
|
const { trip, place } = fixture();
|
|
const { user: other } = createUser(testDb);
|
|
const otherTrip = createTrip(testDb, other.id);
|
|
expect(svc.placeExists(place.id, trip.id)).toBe(true);
|
|
expect(svc.placeExists(place.id, otherTrip.id)).toBe(false);
|
|
});
|
|
|
|
it('ASG-SVC-006: assignmentExistsInDay requires matching day AND trip', () => {
|
|
const { trip, day, place } = fixture();
|
|
const otherDay = createDay(testDb, trip.id);
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
expect(svc.assignmentExistsInDay(a.id, day.id, trip.id)).toBe(true);
|
|
expect(svc.assignmentExistsInDay(a.id, otherDay.id, trip.id)).toBe(false);
|
|
expect(svc.assignmentExistsInDay(a.id, day.id, trip.id + 1)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── createAssignment ──────────────────────────────────────────────────────────
|
|
|
|
describe('createAssignment', () => {
|
|
it('ASG-SVC-007: first assignment gets order_index 0, then MAX+1 appends', () => {
|
|
const { day, place } = fixture();
|
|
const first = svc.createAssignment(day.id, place.id, null);
|
|
const second = svc.createAssignment(day.id, place.id, null);
|
|
expect(first!.order_index).toBe(0);
|
|
expect(second!.order_index).toBe(1);
|
|
});
|
|
|
|
it('ASG-SVC-008: empty-string notes coerce to null (`notes || null`)', () => {
|
|
const { day, place } = fixture();
|
|
const a = svc.createAssignment(day.id, place.id, '');
|
|
expect(a!.notes).toBeNull();
|
|
});
|
|
|
|
it('ASG-SVC-009: returns the re-selected nested shape with place, category and compact tags', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
const tag = createTag(testDb, user.id, { name: 'museum' });
|
|
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
|
const a = svc.createAssignment(day.id, place.id, 'skip the line');
|
|
expect(a).toMatchObject({
|
|
day_id: day.id,
|
|
place_id: place.id,
|
|
notes: 'skip the line',
|
|
assignment_time: null,
|
|
assignment_end_time: null,
|
|
leg_transport_mode: null,
|
|
participants: [],
|
|
place: { id: place.id, name: 'Louvre' },
|
|
});
|
|
// The same compact tag projection as listDayAssignments — id/name/color/
|
|
// created_at only, no user_id — so both read paths share one wire shape.
|
|
expect(a!.place.tags).toEqual([{ id: tag.id, name: 'museum', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]);
|
|
void trip;
|
|
});
|
|
});
|
|
|
|
// ── listDayAssignments ────────────────────────────────────────────────────────
|
|
|
|
describe('listDayAssignments', () => {
|
|
it('ASG-SVC-010: orders by order_index and formats with compact tags + participants', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
const second = createPlace(testDb, trip.id, { name: 'Orsay' });
|
|
const tag = createTag(testDb, user.id, { name: 'art' });
|
|
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
|
const a1 = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
|
const a2 = createDayAssignment(testDb, day.id, second.id, { order_index: 0 });
|
|
svc.setParticipants(a1.id, [user.id], trip.id);
|
|
|
|
const list = svc.listDayAssignments(day.id);
|
|
expect(list.map(a => a.id)).toEqual([a2.id, a1.id]);
|
|
// Compact tag projection (id/name/color/created_at only — no user_id key).
|
|
expect(list[1].place.tags).toEqual([{ id: tag.id, name: 'art', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]);
|
|
expect(list[1].participants).toEqual([{ user_id: user.id, username: user.username, avatar: null }]);
|
|
expect(list[0].participants).toEqual([]);
|
|
expect(list[0].place.tags).toEqual([]);
|
|
});
|
|
|
|
it('ASG-SVC-011: returns [] for an empty day', () => {
|
|
const { day } = fixture();
|
|
expect(svc.listDayAssignments(day.id)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── delete / reorder ──────────────────────────────────────────────────────────
|
|
|
|
describe('deleteAssignment / reorderAssignments', () => {
|
|
it('ASG-SVC-012: deleteAssignment removes the row', () => {
|
|
const { day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
svc.deleteAssignment(a.id);
|
|
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(a.id)).toBeUndefined();
|
|
});
|
|
|
|
it('ASG-SVC-013: reorderAssignments applies positional order, scoped to the day', () => {
|
|
const { trip, day, place } = fixture();
|
|
const otherDay = createDay(testDb, trip.id);
|
|
const a1 = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
|
const a2 = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
|
const foreign = createDayAssignment(testDb, otherDay.id, place.id, { order_index: 5 });
|
|
|
|
svc.reorderAssignments(day.id, [a2.id, a1.id, foreign.id]);
|
|
|
|
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
|
expect(order(a2.id)).toBe(0);
|
|
expect(order(a1.id)).toBe(1);
|
|
// The day-scoped WHERE leaves another day's assignment untouched.
|
|
expect(order(foreign.id)).toBe(5);
|
|
});
|
|
});
|
|
|
|
// ── getAssignmentForTrip / moveAssignment ─────────────────────────────────────
|
|
|
|
describe('getAssignmentForTrip', () => {
|
|
it('ASG-SVC-014: returns the raw row for the trip, undefined cross-trip', () => {
|
|
const { trip, day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
expect(svc.getAssignmentForTrip(a.id, trip.id)).toMatchObject({ id: a.id, day_id: day.id });
|
|
expect(svc.getAssignmentForTrip(a.id, trip.id + 1)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('moveAssignment', () => {
|
|
it('ASG-SVC-015: moves to the new day, re-selects the nested shape, derives oldDayId from the row', () => {
|
|
const { trip, day, place } = fixture();
|
|
const target = createDay(testDb, trip.id);
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
const result = svc.moveAssignment(a.id, target.id, 2);
|
|
expect(result.assignment).toMatchObject({ id: a.id, day_id: target.id, order_index: 2 });
|
|
expect(result.oldDayId).toBe(day.id);
|
|
});
|
|
|
|
it('ASG-SVC-016: nullish orderIndex coerces to 0; explicit 0 stays 0', () => {
|
|
const { trip, day, place } = fixture();
|
|
const target = createDay(testDb, trip.id);
|
|
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 7 });
|
|
expect(svc.moveAssignment(a.id, target.id, undefined).assignment!.order_index).toBe(0);
|
|
expect(svc.moveAssignment(a.id, day.id, null).assignment!.order_index).toBe(0);
|
|
expect(svc.moveAssignment(a.id, target.id, 0).assignment!.order_index).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── participants ──────────────────────────────────────────────────────────────
|
|
|
|
describe('getParticipants / setParticipants', () => {
|
|
it('ASG-SVC-017: setParticipants replaces the list and returns the joined rows', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
const { user: peer } = createUser(testDb);
|
|
addTripMember(testDb, trip.id, peer.id);
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
svc.setParticipants(a.id, [user.id], trip.id);
|
|
const replaced = svc.setParticipants(a.id, [peer.id], trip.id);
|
|
expect(replaced).toEqual([{ user_id: peer.id, username: peer.username, avatar: null }]);
|
|
expect(svc.getParticipants(a.id)).toEqual(replaced);
|
|
});
|
|
|
|
it('ASG-SVC-018: empty array clears all participants', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
svc.setParticipants(a.id, [user.id], trip.id);
|
|
expect(svc.setParticipants(a.id, [], trip.id)).toEqual([]);
|
|
expect(svc.getParticipants(a.id)).toEqual([]);
|
|
});
|
|
|
|
it('ASG-SVC-019: username COALESCEs display_name over username', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Fancy Name', user.id);
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
const rows = svc.setParticipants(a.id, [user.id], trip.id);
|
|
expect(rows).toEqual([{ user_id: user.id, username: 'Fancy Name', avatar: null }]);
|
|
});
|
|
|
|
it('ASG-SVC-020: duplicate user ids collapse via INSERT OR IGNORE', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
const rows = svc.setParticipants(a.id, [user.id, user.id], trip.id);
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
// Used to assert that an id violating the users FK threw and rolled the delete
|
|
// back. It cannot reach the insert any more: the roster filter drops an id that
|
|
// is not on the trip, so a list of nothing but strangers is simply an empty
|
|
// list, and the delete stands. The rollback itself is still covered — the
|
|
// transaction wraps delete and insert together, and ASG-SVC-018 pins that an
|
|
// explicit empty list clears.
|
|
it('ASG-SVC-028: a list of ids that are not on the trip clears rather than throwing', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
svc.setParticipants(a.id, [user.id], trip.id);
|
|
expect(() => svc.setParticipants(a.id, [999999], trip.id)).not.toThrow();
|
|
expect(svc.getParticipants(a.id)).toEqual([]);
|
|
});
|
|
|
|
it('ASG-SVC-029: keeps the trip members and drops the stranger in one call', () => {
|
|
const { user, trip, day, place } = fixture();
|
|
const { user: member } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
addTripMember(testDb, trip.id, member.id);
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
|
|
const rows = svc.setParticipants(a.id, [user.id, stranger.id, member.id], trip.id) as { user_id: number }[];
|
|
|
|
expect(rows.map(r => r.user_id).sort()).toEqual([user.id, member.id].sort());
|
|
expect(JSON.stringify(rows)).not.toContain(stranger.username);
|
|
});
|
|
});
|
|
|
|
// ── updateTime / setLegTransportMode ──────────────────────────────────────────
|
|
|
|
describe('updateTime', () => {
|
|
it('ASG-SVC-021: persists both times and re-selects the nested shape', () => {
|
|
const { day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
const updated = svc.updateTime(a.id, '09:00', '11:30');
|
|
expect(updated).toMatchObject({ id: a.id, assignment_time: '09:00', assignment_end_time: '11:30' });
|
|
});
|
|
|
|
it('ASG-SVC-022: a truthy place_time auto-sorts the day chronologically (untimed keep relative position)', () => {
|
|
const { day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
|
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
|
const c = createDayAssignment(testDb, day.id, place.id, { order_index: 2 });
|
|
|
|
svc.updateTime(c.id, '08:00', null);
|
|
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
|
expect([order(c.id), order(a.id), order(b.id)]).toEqual([0, 1, 2]);
|
|
|
|
svc.updateTime(a.id, '07:00', null);
|
|
expect([order(a.id), order(c.id), order(b.id)]).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
it('ASG-SVC-023: clearing with null persists but skips the auto-sort (falsy gate)', () => {
|
|
const { day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
|
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
|
svc.updateTime(b.id, '06:00', null); // sorts b first
|
|
const updated = svc.updateTime(b.id, null, null); // clear — no re-sort
|
|
expect(updated!.assignment_time).toBeNull();
|
|
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
|
expect(order(b.id)).toBe(0);
|
|
expect(order(a.id)).toBe(1);
|
|
});
|
|
|
|
it('ASG-SVC-027: an empty-string time clears the override like null (and skips the auto-sort)', () => {
|
|
const { day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
|
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
|
svc.updateTime(b.id, '06:00', '07:00'); // sorts b first
|
|
const updated = svc.updateTime(b.id, '', '');
|
|
expect(updated!.assignment_time).toBeNull();
|
|
expect(updated!.assignment_end_time).toBeNull();
|
|
expect(testDb.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(b.id)).toEqual({ assignment_time: null });
|
|
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
|
expect(order(b.id)).toBe(0);
|
|
expect(order(a.id)).toBe(1);
|
|
});
|
|
|
|
it('ASG-SVC-024: a time without ":" sorts last among timed via the 99:99 sentinel', () => {
|
|
const { day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
|
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
|
svc.updateTime(a.id, 'morning', null); // no colon → '99:99' in the sort
|
|
svc.updateTime(b.id, '13:00', null);
|
|
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
|
expect(order(b.id)).toBe(0);
|
|
expect(order(a.id)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('setLegTransportMode', () => {
|
|
it('ASG-SVC-025: sets and clears the leg override (sticky per #1281)', () => {
|
|
const { day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
expect(svc.setLegTransportMode(a.id, 'cycling')!.leg_transport_mode).toBe('cycling');
|
|
expect(svc.setLegTransportMode(a.id, null)!.leg_transport_mode).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ASG-SVC-026 pinned the deleted assignments.bridge and died with it — the
|
|
// same paths are covered on the service above and via places.mcp injection.
|