mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
1032 lines
46 KiB
TypeScript
1032 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 { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
|
import { Days } from '../../../src/db/entities/Days.entity';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
|
|
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
|
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
|
import { Users } from '../../../src/db/entities/Users.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 { TripAccessService } from '../../../src/nest/trip-membership/trip-access.service';
|
|
import {
|
|
createUser,
|
|
createTrip,
|
|
addTripMember,
|
|
createDay,
|
|
createPlace,
|
|
createDayAssignment,
|
|
createTag,
|
|
} from '../../helpers/factories';
|
|
import { makeDayAccommodation } from '../../helpers/factories/itinerary';
|
|
import { tagPlace } from '../../helpers/factories/places';
|
|
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
|
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();
|
|
// Trip access reads through TripsRepository now; the module only hands out the handle.
|
|
return { db, closeDb: () => {}, reinitialize: () => {} };
|
|
});
|
|
|
|
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.
|
|
new TripAccessService(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 };
|
|
}
|
|
|
|
const orm = () => sharedTestOrm(testDb);
|
|
|
|
/** The stored stop; fails the case when it is gone. */
|
|
async function assignmentRow(id: number) {
|
|
const row = await findRow(await orm(), DayAssignments, { id });
|
|
if (!row) throw new Error(`no day assignment ${id}`);
|
|
return row;
|
|
}
|
|
|
|
async function setAssignment(
|
|
id: number,
|
|
data: { assignment_time?: string; assignment_end_time?: string; place?: number; accommodation_id?: number },
|
|
) {
|
|
await updateRows(await orm(), DayAssignments, { id }, data);
|
|
}
|
|
|
|
async function setPlace(id: number, data: { route_geometry?: string; lat?: null; lng?: null; place_time?: string }) {
|
|
await updateRows(await orm(), Places, { id }, data);
|
|
}
|
|
|
|
/** Makes `placeId` a Tour: a `tours` facet row plus the route it was planned along. */
|
|
async function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') {
|
|
await setPlace(placeId, { route_geometry: routeGeometry });
|
|
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' });
|
|
await tagPlace(await orm(), 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' });
|
|
await 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(await countRows(await orm(), DayAssignments, { place: tourPlace.id })).toBe(2);
|
|
|
|
await svc.createAssignment(day.id, ordinaryPlace.id);
|
|
await svc.createAssignment(day.id, ordinaryPlace.id);
|
|
expect(await countRows(await orm(), DayAssignments, { place: ordinaryPlace.id })).toBe(2);
|
|
});
|
|
|
|
it('ASG-SVC-032: near-concurrent Tour assignments serialize to one row and one conflict', async () => {
|
|
const { day, place } = fixture();
|
|
await 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(await countRows(await orm(), DayAssignments, { day: day.id, place: place.id })).toBe(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.
|
|
await setPlace(ordinaryPlace.id, { route_geometry: '[[1,2],[3,4]]' });
|
|
await 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' });
|
|
await tagPlace(await orm(), 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(await findRow(await orm(), DayAssignments, { id: a.id })).toBeNull();
|
|
});
|
|
|
|
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 = async (id: number) => (await assignmentRow(id)).order_index;
|
|
expect(await order(a2.id)).toBe(0);
|
|
expect(await order(a1.id)).toBe(1);
|
|
// The day-scoped WHERE leaves another day's assignment untouched.
|
|
expect(await 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(await countRows(await orm(), DayAssignments, { day: day.id })).toBe(0);
|
|
expect((await findRow(await orm(), DayAssignments, { id: kept.id }))?.id).toBe(kept.id);
|
|
expect((await findRow(await orm(), Days, { id: day.id }))?.id).toBe(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);
|
|
await 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);
|
|
const stayed = await assignmentRow(a.id);
|
|
expect({ day_id: stayed.day_id, order_index: stayed.order_index }).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);
|
|
await 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();
|
|
await updateRows(await orm(), Users, { id: user.id }, { display_name: 'Fancy Name' });
|
|
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 = async (id: number) => (await assignmentRow(id)).order_index;
|
|
/** The day the way every reader lists it: by order_index, then insertion. */
|
|
const dayOrder = async (dayId: number) =>
|
|
(
|
|
await findRows(await orm(), DayAssignments, { day: dayId }, { order_index: 'asc', created_at: 'asc', id: 'asc' })
|
|
).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.
|
|
*/
|
|
async function dayOf(stops: [string | null, number][]) {
|
|
const { trip, day, place } = fixture();
|
|
const ids: number[] = [];
|
|
for (const [time, orderIndex] of stops) {
|
|
const a = createDayAssignment(testDb, day.id, place.id, { order_index: orderIndex });
|
|
if (time) await setAssignment(a.id, { assignment_time: time });
|
|
ids.push(a.id);
|
|
}
|
|
return { trip, day, ids };
|
|
}
|
|
|
|
async function addVia(dayId: number, afterOrderIndex: number, sequence = 0): Promise<number> {
|
|
return insertRow(await orm(), RoadtripVias, {
|
|
day: dayId,
|
|
after_order_index: afterOrderIndex,
|
|
sequence,
|
|
lat: 48.1,
|
|
lng: 11.5,
|
|
});
|
|
}
|
|
const viaAnchors = async (dayId: number) =>
|
|
(await findRows(await orm(), RoadtripVias, { day: dayId }, { id: 'asc' })).map((v) => ({
|
|
id: v.id,
|
|
after_order_index: v.after_order_index,
|
|
sequence: v.sequence,
|
|
}));
|
|
|
|
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 = await dayOf([
|
|
[null, 0],
|
|
[null, 1],
|
|
[null, 2],
|
|
]);
|
|
await svc.updateTime(untimedHead.ids[2], '14:00', null);
|
|
expect(await dayOrder(untimedHead.day.id)).toEqual(untimedHead.ids);
|
|
|
|
const between = await dayOf([
|
|
['09:00', 0],
|
|
[null, 1],
|
|
[null, 2],
|
|
]);
|
|
await svc.updateTime(between.ids[2], '14:00', null);
|
|
expect(await 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] = (
|
|
await dayOf([
|
|
[null, 0],
|
|
['15:00', 1],
|
|
[null, 2],
|
|
])
|
|
).ids;
|
|
await svc.updateTime(c, '10:00', null);
|
|
expect(await Promise.all([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 = await makeDayAccommodation(await orm(), trip.id, hotel.id, day.id, day.id, { check_in: '11:00' });
|
|
await setAssignment(night.id, { accommodation_id: stay.id });
|
|
|
|
await svc.updateTime(afternoon.id, '16:00', null);
|
|
|
|
expect(await order(night.id)).toBeLessThan(await 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],
|
|
} = await dayOf([
|
|
['09:00', 0],
|
|
[null, 1],
|
|
['12:00', 2],
|
|
]);
|
|
await svc.updateTime(b, '06:00', null); // sorts b first
|
|
expect(await 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(await 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],
|
|
} = await dayOf([
|
|
['09:00', 0],
|
|
[null, 1],
|
|
['12:00', 2],
|
|
]);
|
|
await svc.updateTime(b, '06:00', '07:00'); // sorts b first
|
|
expect(await 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((await assignmentRow(c)).assignment_time).toBeNull();
|
|
expect(await 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(await order(b.id)).toBe(0);
|
|
expect(await 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],
|
|
} = await dayOf([
|
|
['09:00', 0],
|
|
[null, 5],
|
|
[null, 9],
|
|
]);
|
|
const via = await addVia(day.id, 0);
|
|
// test-sql-allow: total_changes() counts the writes on the connection itself, which no entity maps.
|
|
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(await Promise.all([order(a), order(b), order(c)])).toEqual([0, 5, 9]);
|
|
expect(await 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],
|
|
} = await dayOf([
|
|
[null, 0],
|
|
['15:00', 4],
|
|
[null, 7],
|
|
]);
|
|
// test-sql-allow: total_changes() counts the writes on the connection itself, which no entity maps.
|
|
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(await Promise.all(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],
|
|
} = await dayOf([
|
|
[null, 0],
|
|
['15:00', 0],
|
|
[null, 0],
|
|
]);
|
|
|
|
await svc.updateTime(c, '10:00', null);
|
|
|
|
expect(await dayOrder(day.id)).toEqual([a, c, b]);
|
|
expect(await Promise.all([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],
|
|
} = await dayOf([
|
|
[null, 0],
|
|
[null, 1],
|
|
['15:00', 2],
|
|
[null, 3],
|
|
[null, 4],
|
|
]);
|
|
const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' });
|
|
await setPlace(unmapped.id, { lat: null, lng: null });
|
|
await setAssignment(x, { place: unmapped.id });
|
|
const afterA = await addVia(day.id, 0);
|
|
const afterB = await addVia(day.id, 1);
|
|
const afterBToo = await addVia(day.id, 1, 1);
|
|
await 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(await 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(await 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],
|
|
} = await dayOf([
|
|
[null, 0],
|
|
['15:00', 1],
|
|
[null, 2],
|
|
]);
|
|
const via = await 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(await 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],
|
|
} = await dayOf([
|
|
['09:00', 0],
|
|
['12:00', 1],
|
|
[null, 2],
|
|
]);
|
|
const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' });
|
|
await setPlace(unmapped.id, { lat: null, lng: null });
|
|
await setAssignment(x, { place: unmapped.id });
|
|
const afterA = await 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 = await addVia(day.id, 1);
|
|
|
|
const update = await svc.updateTime(x, '10:00', null);
|
|
|
|
expect(await dayOrder(day.id)).toEqual([a, x, b]);
|
|
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, x, b] });
|
|
expect(await 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],
|
|
} = await dayOf([
|
|
[null, 0],
|
|
['15:00', 1],
|
|
[null, 2],
|
|
['18:00', 3],
|
|
]);
|
|
const afterB = await addVia(day.id, 1);
|
|
const intoTomorrow = await 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(await dayOrder(day.id)).toEqual([a, c, b, d]);
|
|
expect(await 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],
|
|
} = await dayOf([
|
|
['09:00', 0],
|
|
['12:00', 1],
|
|
['18:00', 2],
|
|
]);
|
|
const afterA = await addVia(day.id, 0);
|
|
const intoTomorrow = await addVia(day.id, 2);
|
|
|
|
const update = await svc.updateTime(c, '08:00', null);
|
|
|
|
expect(await dayOrder(day.id)).toEqual([c, a, b]);
|
|
expect(await viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
|
|
expect((await 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],
|
|
} = await dayOf([
|
|
['14:00', 0],
|
|
['10:00', 1],
|
|
['16:00', 2],
|
|
]);
|
|
await setAssignment(b, { assignment_end_time: '11:00' });
|
|
const afterA = await 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(await dayOrder(day.id)).toEqual([a, b, c]);
|
|
expect(await 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(await dayOrder(day.id)).toEqual([b, a, c]);
|
|
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [b, a, c] });
|
|
expect(await 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],
|
|
} = await dayOf([
|
|
['14:00', 0],
|
|
[null, 1],
|
|
]);
|
|
const timedPlace = createPlace(testDb, trip.id, { name: 'Opens at ten' });
|
|
await setPlace(timedPlace.id, { place_time: '10:00' });
|
|
await setAssignment(b, { place: timedPlace.id });
|
|
|
|
const update = await svc.updateTime(b, '10:00', '11:00');
|
|
|
|
expect(update.reordered).toBeNull();
|
|
expect(await 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((await assignmentRow(a.id)).notes).toBe('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((await assignmentRow(a.id)).notes).toBeNull();
|
|
});
|
|
|
|
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((await assignmentRow(a.id)).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.
|