mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
389 lines
19 KiB
TypeScript
389 lines
19 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, the unified compact tag projection, and the
|
|
* assignments.bridge delegation). 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 } from '../../../src/nest/database/database.service';
|
|
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
|
import { createAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../src/nest/assignments/assignments.bridge';
|
|
|
|
const svc = new AssignmentsService(new DatabaseService(testDb));
|
|
|
|
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({ user_id: trip.user_id }, user as never)).toBe(true);
|
|
expect(svc.canEdit({ user_id: trip.user_id }, member as never)).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]);
|
|
|
|
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, day, place } = fixture();
|
|
const { user: peer } = createUser(testDb);
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
svc.setParticipants(a.id, [user.id]);
|
|
const replaced = svc.setParticipants(a.id, [peer.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, day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
svc.setParticipants(a.id, [user.id]);
|
|
expect(svc.setParticipants(a.id, [])).toEqual([]);
|
|
expect(svc.getParticipants(a.id)).toEqual([]);
|
|
});
|
|
|
|
it('ASG-SVC-019: username COALESCEs display_name over username', () => {
|
|
const { user, 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]);
|
|
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, day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
const rows = svc.setParticipants(a.id, [user.id, user.id]);
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
it('ASG-SVC-028: a failing insert rolls back the whole replace (delete included)', () => {
|
|
const { user, day, place } = fixture();
|
|
const a = createDayAssignment(testDb, day.id, place.id);
|
|
svc.setParticipants(a.id, [user.id]);
|
|
// 999999 violates the users FK — the transaction must undo the delete too.
|
|
expect(() => svc.setParticipants(a.id, [999999])).toThrow();
|
|
expect(svc.getParticipants(a.id)).toEqual([{ user_id: user.id, username: user.username, avatar: null }]);
|
|
});
|
|
});
|
|
|
|
// ── 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();
|
|
});
|
|
});
|
|
|
|
// ── bridge delegation ─────────────────────────────────────────────────────────
|
|
|
|
describe('assignments.bridge', () => {
|
|
it('ASG-SVC-026: bridge exports delegate to the same SQL over the shared connection', () => {
|
|
const { trip, day, place } = fixture();
|
|
expect(dayExists(day.id, trip.id)).toBe(true);
|
|
expect(placeExists(place.id, trip.id)).toBe(true);
|
|
const created = createAssignment(day.id, place.id, null);
|
|
expect(created).toMatchObject({ day_id: day.id, place_id: place.id, order_index: 0 });
|
|
expect(getAssignmentForTrip(created!.id, trip.id)).toMatchObject({ id: created!.id, day_id: day.id });
|
|
});
|
|
});
|