mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
7ca7dfd555
Brings in the Tours addon and the MikroORM follow-ups. Conflicts were resolved to dev's content, then every file dev brought in was run through prettier and eslint --fix so the branch stays uniformly formatted.
1049 lines
46 KiB
TypeScript
1049 lines
46 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 { db as testDb } from '../../../src/db/database';
|
|
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
|
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
|
import { type TripAccess } from '../../../src/db/repositories/Trips.repository';
|
|
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
|
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
|
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import {
|
|
createUser,
|
|
createTrip,
|
|
addTripMember,
|
|
createDay,
|
|
createPlace,
|
|
createDayAssignment,
|
|
createTag,
|
|
} from '../../helpers/factories';
|
|
import {
|
|
createTestJourneysRepo,
|
|
createTestJourneyContributorsRepo,
|
|
createTestJourneyTripsRepo,
|
|
createTestJourneyEntriesRepo,
|
|
createTestJourneyPhotosRepo,
|
|
createTestJourneyEntryPhotosRepo,
|
|
} from '../../helpers/journey-repos';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import {
|
|
createTestUnitOfWork,
|
|
createTestAppSettingsRepo,
|
|
createTestTagsRepo,
|
|
createTestPlaceRatingsRepo,
|
|
createTestAssignmentParticipantsRepo,
|
|
createTestDayAssignmentsRepo,
|
|
createTestDaysRepo,
|
|
createTestPlacesRepo,
|
|
createTestTripMembersRepo,
|
|
createTestRoadtripViasRepo,
|
|
sharedTestOrm,
|
|
createTestTripsRepo,
|
|
} from '../../helpers/test-uow';
|
|
import { createTestToursRepo, createTour } from '../../helpers/tours-repos';
|
|
import { ConflictException } from '@nestjs/common';
|
|
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
// ── 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() }));
|
|
|
|
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.
|