Files
TREK/server/tests/unit/nest/assignments.service.test.ts
T
Maurice c956fe92ba Merge dev into feat/tours and port Tours onto MikroORM
dev now runs on MikroORM, so the positional migrations, seeds.ts and
DatabaseService the Tours server side was built on are gone. Behaviour
is unchanged; persistence moves onto dev's architecture:

- one MikroORM migration for tour_types, tours and tour_waypoints,
  idempotent so instances that ran the old positional steps keep
  their data; the hike tour type is seeded inside it, the addon row
  in the AddonSeeder
- entities and repositories for the three tables, tours domain on
  repositories and UnitOfWork
- tour facet joins in places, assignments and days re-applied on the
  repository code

Fixes found while porting:

- GPX import checks addon, trip access and permission in the handler
  instead of guards, so a refused upload gets a 403/404 instead of a
  connection reset
- copying a trip copies its tours and waypoints
- a tour cannot be moved onto a day that already has it
- updateTour checks existence inside its transaction

Adds tours e2e tests and rewrites the tours unit tests onto the
migrated test database.
2026-10-06 22:43:09 +02:00

864 lines
45 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';
import { ConflictException } from '@nestjs/common';
// ── DB setup ──────────────────────────────────────────────────────────────────
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
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 mock;
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createTag } from '../../helpers/factories';
import { type TripAccess } from '../../../src/db/repositories/Trips.repository';
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 { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
createTestTripMembersRepo, createTestRoadtripViasRepo, sharedTestOrm, createTestTripsRepo,
} from '../../helpers/test-uow';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { createTestToursRepo, createTour } from '../../helpers/tours-repos';
let svc: AssignmentsService;
beforeAll(async () => {
const realtime = new RealtimeService();
svc = new AssignmentsService(
// Plan 4 Task 2 — AssignmentsService's own canAccessTrip delegate is now
// TripsRepository.findAccessible, in the same constructor slot.
await createTestTripsRepo(testDb),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
realtime,
new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)),
// 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(
realtime, new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), await createTestUnitOfWork(testDb),
await createTestJourneysRepo(testDb), await createTestJourneyContributorsRepo(testDb),
await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb),
),
await createTestUnitOfWork(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestAssignmentParticipantsRepo(testDb),
await createTestDaysRepo(testDb),
await createTestPlacesRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestRoadtripViasRepo(testDb),
await createTestToursRepo(testDb),
);
});
/**
* 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;
}
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 };
}
/** Makes `placeId` a Tour: a `tours` facet row plus the route it was planned along. */
function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') {
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run(routeGeometry, placeId);
createTour(testDb, placeId);
}
// ── verifyTripAccess / canEdit ────────────────────────────────────────────────
describe('verifyTripAccess', () => {
it('ASG-SVC-001: returns trip for owner', async () => {
const { user, trip } = fixture();
const result = await 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', async () => {
const { trip } = fixture();
const { user: stranger } = createUser(testDb);
expect(await svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
});
});
describe('canEdit', () => {
it('ASG-SVC-003: owner and member pass day_edit (default trip_member)', async () => {
const { user, trip } = fixture();
const { user: member } = createUser(testDb);
addTripMember(testDb, trip.id, member.id);
expect(await svc.canEdit(tripOwnedBy(trip.user_id), user)).toBe(true);
expect(await svc.canEdit(tripOwnedBy(trip.user_id), member)).toBe(true);
});
});
// ── existence checks ──────────────────────────────────────────────────────────
describe('dayExists / placeExists / assignmentExistsInDay', () => {
it('ASG-SVC-004: dayExists is trip-scoped', async () => {
const { trip, day } = fixture();
const { user: other } = createUser(testDb);
const otherTrip = createTrip(testDb, other.id);
expect(await svc.dayExists(day.id, trip.id)).toBe(true);
expect(await svc.dayExists(day.id, otherTrip.id)).toBe(false);
expect(await svc.dayExists(9999, trip.id)).toBe(false);
});
it('ASG-SVC-005: placeExists is trip-scoped', async () => {
const { trip, place } = fixture();
const { user: other } = createUser(testDb);
const otherTrip = createTrip(testDb, other.id);
expect(await svc.placeExists(place.id, trip.id)).toBe(true);
expect(await svc.placeExists(place.id, otherTrip.id)).toBe(false);
});
it('ASG-SVC-006: assignmentExistsInDay requires matching day AND trip', async () => {
const { trip, day, place } = fixture();
const otherDay = createDay(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await svc.assignmentExistsInDay(a.id, day.id, trip.id)).toBe(true);
expect(await svc.assignmentExistsInDay(a.id, otherDay.id, trip.id)).toBe(false);
expect(await svc.assignmentExistsInDay(a.id, day.id, trip.id + 1)).toBe(false);
});
// Task 3 review H1 (live regression, fixed and absorbed in Task 4): these
// four gates used to bind a non-canonical id (`"<id> "`, `"<id>.0"`,
// `"+<id>"`) straight into their own read, which SQLite's text/integer
// affinity matches — so the gate answered "found" for an id `toRowId`
// (the SAME conversion the write behind each gate applies) rejects as
// `null`. Each case below proves the gate itself now says "not found" for
// exactly the id shapes `toRowId` rejects, so the mutation behind it can
// never be reached with a mismatched id.
describe('H1 — non-canonical ids resolve to not-found in the gate itself', () => {
it('ASG-SVC-042: dayExists rejects "<id> "/"<id>.0"/"+<id>", not just non-numeric ids', async () => {
const { trip, day } = fixture();
expect(await svc.dayExists(`${day.id} `, trip.id)).toBe(false);
expect(await svc.dayExists(`${day.id}.0`, trip.id)).toBe(false);
expect(await svc.dayExists(`+${day.id}`, trip.id)).toBe(false);
expect(await svc.dayExists('abc', trip.id)).toBe(false);
// The canonical shape still passes, proving the fix didn't just make everything false.
expect(await svc.dayExists(String(day.id), trip.id)).toBe(true);
});
it('ASG-SVC-043: placeExists rejects "<id> "/"<id>.0"/"+<id>"', async () => {
const { trip, place } = fixture();
expect(await svc.placeExists(`${place.id} `, trip.id)).toBe(false);
expect(await svc.placeExists(`${place.id}.0`, trip.id)).toBe(false);
expect(await svc.placeExists(`+${place.id}`, trip.id)).toBe(false);
expect(await svc.placeExists(String(place.id), trip.id)).toBe(true);
});
it('ASG-SVC-044: assignmentExistsInDay rejects a non-canonical assignment id', async () => {
const { trip, day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
expect(await svc.assignmentExistsInDay(`${a.id} `, day.id, trip.id)).toBe(false);
expect(await svc.assignmentExistsInDay(`${a.id}.0`, day.id, trip.id)).toBe(false);
expect(await svc.assignmentExistsInDay(String(a.id), day.id, trip.id)).toBe(true);
});
it('ASG-SVC-045: getAssignmentForTrip rejects a non-canonical assignment id (undefined, not the row)', async () => {
const { trip, day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
expect(await svc.getAssignmentForTrip(`${a.id} `, trip.id)).toBeUndefined();
expect(await svc.getAssignmentForTrip(`${a.id}.0`, trip.id)).toBeUndefined();
expect(await svc.getAssignmentForTrip(String(a.id), trip.id)).toMatchObject({ id: a.id });
});
it('ASG-SVC-046: deleteAssignment is never reached with a mismatched id — the controller-shape gate-then-write leaves the row intact for a non-canonical id', async () => {
const { trip, day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
// The controller's own shape: gate, then act only if the gate passed.
const gate = await svc.assignmentExistsInDay(`${a.id} `, day.id, trip.id);
expect(gate).toBe(false);
if (gate) await svc.deleteAssignment(`${a.id} `);
expect(await svc.getAssignmentForTrip(String(a.id), trip.id)).toMatchObject({ id: a.id });
});
});
});
// ── createAssignment ──────────────────────────────────────────────────────────
describe('createAssignment', () => {
it('ASG-SVC-007: first assignment gets order_index 0, then MAX+1 appends', async () => {
const { day, place } = fixture();
const first = await svc.createAssignment(day.id, place.id, null);
const second = await 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`)', async () => {
const { day, place } = fixture();
const a = svc.createAssignment(day.id, place.id, '');
expect((await a!).notes).toBeNull();
});
it('ASG-SVC-009: returns the re-selected nested shape with place, category and compact tags', async () => {
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 = await 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((await a!).place.tags).toEqual([{ id: tag.id, name: 'museum', color: '#10b981', created_at: (tag as { created_at?: string }).created_at }]);
void trip;
});
it('ASG-SVC-031: rejects a duplicate Tour assignment on the same day but allows another day and ordinary Place duplicates', async () => {
const { trip, day, place: tourPlace } = fixture();
const secondDay = createDay(testDb, trip.id);
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
addTourFacet(tourPlace.id);
const firstTourAssignment = await svc.createAssignment(day.id, tourPlace.id);
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow(ConflictException);
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow('Tour is already assigned to this day');
const otherDayTourAssignment = await svc.createAssignment(secondDay.id, tourPlace.id);
expect(firstTourAssignment?.day_id).toBe(day.id);
expect(otherDayTourAssignment?.day_id).toBe(secondDay.id);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlace.id)).toEqual({ n: 2 });
await svc.createAssignment(day.id, ordinaryPlace.id);
await svc.createAssignment(day.id, ordinaryPlace.id);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(ordinaryPlace.id)).toEqual({ n: 2 });
});
it('ASG-SVC-032: near-concurrent Tour assignments serialize to one row and one conflict', async () => {
const { day, place } = fixture();
addTourFacet(place.id);
const results = await Promise.allSettled([
svc.createAssignment(day.id, place.id),
svc.createAssignment(day.id, place.id),
]);
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
expect(rejected).toHaveLength(1);
expect(rejected[0].reason).toBeInstanceOf(ConflictException);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ? AND place_id = ?').get(day.id, place.id)).toEqual({ n: 1 });
});
it('ASG-SVC-048: a Tour stop carries its facet id and route geometry, an ordinary stop nulls both', async () => {
const { trip, day, place: tourPlace } = fixture();
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
// Geometry on a plain place (an imported track) must not leak into the Tour fields.
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', ordinaryPlace.id);
addTourFacet(tourPlace.id, '[[47,11],[47.5,11.5]]');
const tour = await svc.createAssignment(day.id, tourPlace.id);
const ordinary = await svc.createAssignment(day.id, ordinaryPlace.id);
expect(tour).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.5,11.5]]' });
expect(ordinary).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
const list = await svc.listDayAssignments(day.id);
expect(list.map(a => [a.tour_place_id, a.tour_route_geometry])).toEqual([
[tourPlace.id, '[[47,11],[47.5,11.5]]'],
[null, null],
]);
});
});
// ── listDayAssignments ────────────────────────────────────────────────────────
describe('listDayAssignments', () => {
it('ASG-SVC-010: orders by order_index and formats with compact tags + participants', async () => {
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 });
await svc.setParticipants(a1.id, [user.id], trip.id);
const list = await 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', async () => {
const { day } = fixture();
expect(await svc.listDayAssignments(day.id)).toEqual([]);
});
});
// ── delete / reorder ──────────────────────────────────────────────────────────
describe('deleteAssignment / reorderAssignments', () => {
it('ASG-SVC-012: deleteAssignment removes the row', async () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
await 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', async () => {
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 });
await 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);
});
});
describe('clearDay (#2470)', () => {
it('ASG-SVC-013b: removes every place of the day, leaves other days alone, returns the ids', async () => {
const { trip, day, place } = fixture();
const otherDay = createDay(testDb, trip.id);
const a1 = createDayAssignment(testDb, day.id, place.id);
const a2 = createDayAssignment(testDb, day.id, place.id);
const kept = createDayAssignment(testDb, otherDay.id, place.id);
expect((await svc.clearDay(day.id)).sort()).toEqual([a1.id, a2.id].sort());
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(kept.id)).toEqual({ id: kept.id });
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toEqual({ id: day.id });
});
it('ASG-SVC-013c: an empty day clears to an empty list', async () => {
const { day } = fixture();
expect(await svc.clearDay(day.id)).toEqual([]);
});
});
// ── getAssignmentForTrip / moveAssignment ─────────────────────────────────────
describe('getAssignmentForTrip', () => {
it('ASG-SVC-014: returns the raw row for the trip, undefined cross-trip', async () => {
const { trip, day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
expect(await svc.getAssignmentForTrip(a.id, trip.id)).toMatchObject({ id: a.id, day_id: day.id });
expect(await 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', async () => {
const { trip, day, place } = fixture();
const target = createDay(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
const result = await 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', async () => {
const { trip, day, place } = fixture();
const target = createDay(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 7 });
expect((await svc.moveAssignment(a.id, target.id, undefined)).assignment!.order_index).toBe(0);
expect((await svc.moveAssignment(a.id, day.id, null)).assignment!.order_index).toBe(0);
expect((await svc.moveAssignment(a.id, target.id, 0)).assignment!.order_index).toBe(0);
});
it('ASG-SVC-049: refuses to move a Tour onto a day that already holds it, and leaves the row where it was', async () => {
const { trip, day, place } = fixture();
const target = createDay(testDb, trip.id);
addTourFacet(place.id);
createDayAssignment(testDb, target.id, place.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
await expect(svc.moveAssignment(a.id, target.id, 0)).rejects.toThrow(ConflictException);
expect(testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ day_id: day.id, order_index: 3 });
});
it('ASG-SVC-050: a Tour may be reordered within its own day, and an ordinary place may join a day that already holds it', async () => {
const { trip, day, place } = fixture();
const target = createDay(testDb, trip.id);
addTourFacet(place.id);
const tour = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
expect((await svc.moveAssignment(tour.id, day.id, 2)).assignment).toMatchObject({ day_id: day.id, order_index: 2 });
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' });
createDayAssignment(testDb, target.id, ordinary.id);
const second = createDayAssignment(testDb, day.id, ordinary.id);
expect((await svc.moveAssignment(second.id, target.id, 1)).assignment).toMatchObject({ day_id: target.id });
});
});
// ── participants ──────────────────────────────────────────────────────────────
describe('getParticipants / setParticipants', () => {
it('ASG-SVC-017: setParticipants replaces the list and returns the joined rows', async () => {
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);
await svc.setParticipants(a.id, [user.id], trip.id);
const replaced = await svc.setParticipants(a.id, [peer.id], trip.id);
expect(replaced).toEqual([{ user_id: peer.id, username: peer.username, avatar: null }]);
expect(await svc.getParticipants(a.id)).toEqual(replaced);
});
it('ASG-SVC-018: empty array clears all participants', async () => {
const { user, trip, day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
await svc.setParticipants(a.id, [user.id], trip.id);
expect(await svc.setParticipants(a.id, [], trip.id)).toEqual([]);
expect(await svc.getParticipants(a.id)).toEqual([]);
});
it('ASG-SVC-019: username COALESCEs display_name over username', async () => {
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 = await 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', async () => {
const { user, trip, day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
const rows = await 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', async () => {
const { user, trip, day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
await svc.setParticipants(a.id, [user.id], trip.id);
await expect(svc.setParticipants(a.id, [999999], trip.id)).resolves.toBeDefined();
expect(await svc.getParticipants(a.id)).toEqual([]);
});
it('ASG-SVC-029: keeps the trip members and drops the stranger in one call', async () => {
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 = await 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', () => {
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
/** The day the way every reader lists it: by order_index, then insertion. */
const dayOrder = (dayId: number) =>
(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index, created_at, id').all(dayId) as { id: number }[]).map(r => r.id);
/**
* A day of stops at the given order_index keys, each with its own start (null for
* none). Times go in by SQL so the setup itself never sorts anything.
*/
function dayOf(stops: [string | null, number][]) {
const { trip, day, place } = fixture();
const ids = stops.map(([time, orderIndex]) => {
const a = createDayAssignment(testDb, day.id, place.id, { order_index: orderIndex });
if (time) testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, a.id);
return a.id;
});
return { trip, day, ids };
}
function addVia(dayId: number, afterOrderIndex: number, sequence = 0): number {
return Number(testDb.prepare(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, 48.1, 11.5)'
).run(dayId, afterOrderIndex, sequence).lastInsertRowid);
}
const viaAnchors = (dayId: number) =>
testDb.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId) as
{ id: number; after_order_index: number; sequence: number }[];
it('ASG-SVC-021: persists both times and re-selects the nested shape', async () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
const { assignment } = await svc.updateTime(a.id, '09:00', '11:30');
expect(assignment).toMatchObject({ id: a.id, assignment_time: '09:00', assignment_end_time: '11:30' });
});
it('ASG-SVC-022: a start sorts the timed stops and leaves every untimed stop behind the one it followed', async () => {
// The tester's day: two stops without a time, then a start on the last one. It
// used to jump to the top, because every untimed stop was appended after it.
const untimedHead = dayOf([[null, 0], [null, 1], [null, 2]]);
await svc.updateTime(untimedHead.ids[2], '14:00', null);
expect(dayOrder(untimedHead.day.id)).toEqual(untimedHead.ids);
const between = dayOf([['09:00', 0], [null, 1], [null, 2]]);
await svc.updateTime(between.ids[2], '14:00', null);
expect(dayOrder(between.day.id)).toEqual(between.ids);
// Timed stops still keep time order among themselves. A has nothing timed in
// front of it and stays first.
const [a, b, c] = dayOf([[null, 0], ['15:00', 1], [null, 2]]).ids;
await svc.updateTime(c, '10:00', null);
expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]);
});
it('ASG-SVC-022b: a booked night sorts by its check-in, which is where its hour lives', async () => {
// 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);
await svc.updateTime(afternoon.id, '16:00', null);
expect(order(night.id)).toBeLessThan(order(afternoon.id));
});
it('ASG-SVC-023: clearing with null persists but skips the auto-sort (falsy gate)', async () => {
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]);
await svc.updateTime(b, '06:00', null); // sorts b first
expect(dayOrder(day.id)).toEqual([b, a, c]);
// Dragged back out of time order on purpose: a sort now would put b first again,
// with c behind it.
await svc.reorderAssignments(day.id, [a, b, c]);
const { assignment } = await svc.updateTime(c, null, null); // clear, no re-sort
expect((await assignment!).assignment_time).toBeNull();
expect(dayOrder(day.id)).toEqual([a, b, c]);
});
it('ASG-SVC-027: an empty-string time clears the override like null (and skips the auto-sort)', async () => {
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]);
await svc.updateTime(b, '06:00', '07:00'); // sorts b first
expect(dayOrder(day.id)).toEqual([b, a, c]);
await svc.reorderAssignments(day.id, [a, b, c]);
const { assignment } = await svc.updateTime(c, '', '');
expect((await assignment!).assignment_time).toBeNull();
expect((await assignment!).assignment_end_time).toBeNull();
expect(testDb.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(c)).toEqual({ assignment_time: null });
expect(dayOrder(day.id)).toEqual([a, b, c]);
});
it('ASG-SVC-024: a time without ":" sorts last among timed via the 99:99 sentinel', async () => {
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 });
await svc.updateTime(a.id, 'morning', null); // no colon → '99:99' in the sort
await svc.updateTime(b.id, '13:00', null);
expect(order(b.id)).toBe(0);
expect(order(a.id)).toBe(1);
});
it('ASG-SVC-033: writes nothing but the time when the day is already in order', async () => {
// Keys with gaps, as a deleted stop leaves them. A day the start leaves in order
// keeps them: nothing about it changed.
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 5], [null, 9]]);
const via = addVia(day.id, 0);
const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n;
const before = changes();
const update = await svc.updateTime(c, '14:00', null);
expect(changes() - before).toBe(1);
expect([order(a), order(b), order(c)]).toEqual([0, 5, 9]);
expect(viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]);
expect(update.reordered).toBeNull();
expect(update.vias).toBeNull();
});
it('ASG-SVC-034: numbers a reordered day from 0, the key each client gives a stop of the order it is sent', async () => {
// A gap, as a deleted stop leaves it. Kept, the writer would read 0, 4, 7 back and
// everyone else would apply 0, 1, 2, and a note at 3 would sit behind a different
// stop for each of them.
const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 4], [null, 7]]);
const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n;
const before = changes();
const update = await svc.updateTime(c, '10:00', null);
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] });
expect(update.reordered!.orderedIds.map(order)).toEqual([0, 1, 2]);
// The time, then c and b. A already holds 0 and is not written.
expect(changes() - before).toBe(3);
});
it('ASG-SVC-035: numbers a day with colliding keys from 0 again, since a shared key cannot hold an order', async () => {
// A move drops a stop in at order_index 0 next to the one already there.
const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 0], [null, 0]]);
await svc.updateTime(c, '10:00', null);
expect(dayOrder(day.id)).toEqual([a, c, b]);
expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]);
});
it('ASG-SVC-036: keeps each drawn road behind the stop it was drawn after, counting located stops only', async () => {
// X has no coordinates, so the router never sees it and the vias do not count it.
const { trip, day, ids: [a, x, b, c, d] } = dayOf([[null, 0], [null, 1], ['15:00', 2], [null, 3], [null, 4]]);
const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' });
testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unmapped.id);
testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(unmapped.id, x);
const afterA = addVia(day.id, 0);
const afterB = addVia(day.id, 1);
const afterBToo = addVia(day.id, 1, 1);
addVia(day.id, 2); // after C
// D at 10:00 goes in front of B at 15:00, and C stays behind B: A, X, D, B, C.
const update = await svc.updateTime(d, '10:00', null);
expect(dayOrder(day.id)).toEqual([a, x, d, b, c]);
// A keeps its leg, B's two points follow B to 2 in their order, and C, now last,
// has no leg left to bend.
expect(viaAnchors(day.id)).toEqual([
{ id: afterA, after_order_index: 0, sequence: 0 },
{ id: afterB, after_order_index: 2, sequence: 0 },
{ id: afterBToo, after_order_index: 2, sequence: 1 },
]);
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, x, d, b, c] });
expect(update.vias?.dayId).toBe(day.id);
expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterA, 0], [afterB, 2], [afterBToo, 2]]);
});
it('ASG-SVC-037: reports no vias when the reorder leaves every one on its stop', async () => {
const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 1], [null, 2]]);
const via = addVia(day.id, 0);
const update = await svc.updateTime(c, '10:00', null);
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] });
expect(viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]);
expect(update.vias).toBeNull();
});
it('ASG-SVC-038: leaves every via alone when the sort moves only stops without coordinates', async () => {
// X has no coordinates. It goes between A and B, and the located stops stay A, B.
const { trip, day, ids: [a, b, x] } = dayOf([['09:00', 0], ['12:00', 1], [null, 2]]);
const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' });
testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unmapped.id);
testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(unmapped.id, x);
const afterA = addVia(day.id, 0);
// Behind the day's last stop: the drive into the next day on a trip with connected
// days. It was deleted here, since a last stop has no leg of its own.
const intoTomorrow = addVia(day.id, 1);
const update = await svc.updateTime(x, '10:00', null);
expect(dayOrder(day.id)).toEqual([a, x, b]);
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, x, b] });
expect(viaAnchors(day.id)).toEqual([
{ id: afterA, after_order_index: 0, sequence: 0 },
{ id: intoTomorrow, after_order_index: 1, sequence: 0 },
]);
expect(update.vias).toBeNull();
});
it('ASG-SVC-039: keeps the via behind the last stop when that stop is still last', async () => {
const { day, ids: [a, b, c, d] } = dayOf([[null, 0], ['15:00', 1], [null, 2], ['18:00', 3]]);
const afterB = addVia(day.id, 1);
const intoTomorrow = addVia(day.id, 3);
// C at 10:00 goes in front of B. D stays last, and so does the drive out of it.
const update = await svc.updateTime(c, '10:00', null);
expect(dayOrder(day.id)).toEqual([a, c, b, d]);
expect(viaAnchors(day.id)).toEqual([
{ id: afterB, after_order_index: 2, sequence: 0 },
{ id: intoTomorrow, after_order_index: 3, sequence: 0 },
]);
expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterB, 2], [intoTomorrow, 3]]);
});
it('ASG-SVC-047: a sort that moves the last stop up the day takes the drive behind it along, never leaving it on no stop', async () => {
// The rule the planner applies too (seamViaIndex): a via behind the last stop stays
// there only while that stop is last. Once another stop is, it goes: it lies on the
// road to tomorrow, and read as the leg its stop leaves by now it would bend the
// drive from C to A through a point on that road.
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], ['12:00', 1], ['18:00', 2]]);
const afterA = addVia(day.id, 0);
const intoTomorrow = addVia(day.id, 2);
const update = await svc.updateTime(c, '08:00', null);
expect(dayOrder(day.id)).toEqual([c, a, b]);
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(intoTomorrow);
expect(update.vias?.dayId).toBe(day.id);
});
it('ASG-SVC-040: an End saved with the start as it stood leaves a day dragged out of time order alone', async () => {
// B belongs first by time, and the traveller put it second on purpose.
const { day, ids: [a, b, c] } = dayOf([['14:00', 0], ['10:00', 1], ['16:00', 2]]);
testDb.prepare("UPDATE day_assignments SET assignment_end_time = '11:00' WHERE id = ?").run(b);
const afterA = addVia(day.id, 0);
// The stay dialog taking the End off, then the place form setting a new one.
for (const end of [null, '12:00']) {
const update = await svc.updateTime(b, '10:00', end);
expect(update.assignment).toMatchObject({ assignment_time: '10:00', assignment_end_time: end });
expect(update.reordered).toBeNull();
expect(update.vias).toBeNull();
expect(dayOrder(day.id)).toEqual([a, b, c]);
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0, sequence: 0 }]);
}
// A start that moves still sorts, and the via follows A.
const update = await svc.updateTime(b, '09:00', '12:00');
expect(dayOrder(day.id)).toEqual([b, a, c]);
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [b, a, c] });
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
});
it('ASG-SVC-041: a visit given the start its place already had does not sort either', async () => {
// The place form fills Start from the place when the visit has none of its own.
const { trip, day, ids: [a, b] } = dayOf([['14:00', 0], [null, 1]]);
const timedPlace = createPlace(testDb, trip.id, { name: 'Opens at ten' });
testDb.prepare("UPDATE places SET place_time = '10:00' WHERE id = ?").run(timedPlace.id);
testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(timedPlace.id, b);
const update = await svc.updateTime(b, '10:00', '11:00');
expect(update.reordered).toBeNull();
expect(dayOrder(day.id)).toEqual([a, b]);
});
});
describe('updateNotes (#2163)', () => {
it('ASG-SVC-030: persists the note and re-selects the nested shape', async () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
const updated = await 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)', async () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
await svc.updateNotes(a.id, 'gone soon');
expect((await svc.updateNotes(a.id, '')!).notes).toBeNull();
await svc.updateNotes(a.id, 'gone again');
expect((await 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', async () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
await svc.updateTime(a.id, '09:00', '10:00');
const updated = await svc.updateNotes(a.id, 'note');
expect(updated).toMatchObject({ assignment_time: '09:00', assignment_end_time: '10:00' });
// 3, not 0: a start on a day already in order no longer renumbers it, so the key
// this note edit must not touch is still the one the stop was created with.
expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(3);
});
});
describe('setLegTransportMode', () => {
it('ASG-SVC-025: sets and clears the leg override (sticky per #1281)', async () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
expect((await svc.setLegTransportMode(a.id, 'cycling')!).leg_transport_mode).toBe('cycling');
expect((await svc.setLegTransportMode(a.id, null)!).leg_transport_mode).toBeNull();
});
});
describe('setRouteExcluded (#2532)', () => {
it('ASG-SVC-025b: keeps the stop but flags it out of the route, and back', async () => {
const { day, place } = fixture();
const a = createDayAssignment(testDb, day.id, place.id);
expect((await svc.getAssignmentWithPlace(a.id))!.route_excluded).toBe(false);
expect((await svc.setRouteExcluded(a.id, true))!.route_excluded).toBe(true);
expect((await svc.setRouteExcluded(a.id, false))!.route_excluded).toBe(false);
});
});
// 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.