Files
TREK/server/tests/unit/nest/assignments.service.test.ts
T
Maurice fcee6bd2c5 fix(roadtrip): sort a booked night by its check-in, and clear the lint the cut left
Pinning an afternoon stop sorted that one and left the hotel it was booked around
sitting behind it. The day's auto-sort reads each stop's own hour, and a booked
night has none: nobody types a time into a hotel row, they type a check-in, and
that lives on the booking. It counts now, so the night moves with everything else
instead of staying where it was dropped.

The assignments e2e builds its own tables and had neither the accommodation
column nor day_accommodations, so every create in that file 500'd as soon as the
insert learned to carry a booking id. Both are there now.

And the shared lint, which is stricter than the other two and which I had not run:
three leftovers from taking the check-out out of the chain, none of them used any
more.
2026-09-13 16:19:57 +02:00

469 lines
23 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-022b: a booked night sorts by its check-in, which is where its hour lives', () => {
// Nobody types a time into a hotel row, they type a check-in, and it sits on the
// booking. Counted as untimed, the night stayed wherever it had been dropped: pin an
// afternoon stop and the hotel it was booked around ended up behind it.
const { trip, day, place } = fixture();
const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' });
const afternoon = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
const night = createDayAssignment(testDb, day.id, hotel.id, { order_index: 1 });
const stay = testDb.prepare(
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (?, ?, ?, ?, '11:00')"
).run(trip.id, hotel.id, day.id, day.id);
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(Number(stay.lastInsertRowid), night.id);
svc.updateTime(afternoon.id, '16: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(night.id)).toBeLessThan(order(afternoon.id));
});
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('updateNotes (#2163)', () => {
it('ASG-SVC-030: persists the note and re-selects the nested shape', () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
const updated = svc.updateNotes(a.id, 'Book the 10:00 timed entry');
expect(updated).toMatchObject({ id: a.id, notes: 'Book the 10:00 timed entry' });
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: 'Book the 10:00 timed entry' });
});
it('ASG-SVC-031: empty string and null both clear the column (same normalisation as createAssignment)', () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
svc.updateNotes(a.id, 'gone soon');
expect(svc.updateNotes(a.id, '')!.notes).toBeNull();
svc.updateNotes(a.id, 'gone again');
expect(svc.updateNotes(a.id, null)!.notes).toBeNull();
expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: null });
});
it('ASG-SVC-032: leaves order and times untouched', () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
svc.updateTime(a.id, '09:00', '10:00');
const updated = svc.updateNotes(a.id, 'note');
expect(updated).toMatchObject({ assignment_time: '09:00', assignment_end_time: '10:00' });
expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(0);
});
});
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.