fix(tours): prevent duplicate day assignments

This commit is contained in:
elmocito
2026-10-05 23:53:11 +02:00
parent a9f9a23908
commit af7dbce105
14 changed files with 313 additions and 15 deletions
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { isEmptyReanchoring, reanchorByStopOrder, type AnchoredVia } from '@trek/shared/roadtrip';
import { RealtimeService } from '../realtime/realtime.service';
@@ -176,6 +176,15 @@ export class AssignmentsService {
*/
createAssignment(dayId: string | number, placeId: unknown, notes?: string | null, opts: { accommodationId?: number; orderIndex?: number } = {}) {
const result = this.dbs.transaction(() => {
const duplicateTourAssignment = this.dbs.get<{ id: number }>(
`SELECT da.id FROM day_assignments da
JOIN tours t ON t.place_id = da.place_id
WHERE da.day_id = ? AND da.place_id = ?
LIMIT 1`,
dayId, placeId,
);
if (duplicateTourAssignment) throw new ConflictException('Tour is already assigned to this day');
const maxOrder = this.dbs.get<{ max: number | null }>('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?', dayId)!;
const end = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
// Somewhere in the middle when the caller says so, which means everything from
+32
View File
@@ -204,6 +204,38 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
expect(miss.body).toEqual({ error: 'Place not found' });
});
it('prevents duplicate Tour/day assignments through concurrent REST requests', async () => {
const tourPlaceId = 20;
db.prepare('INSERT INTO places (id, trip_id, name) VALUES (?, 5, ?)').run(tourPlaceId, 'Ridge walk');
db.prepare('INSERT INTO tours (place_id) VALUES (?)').run(tourPlaceId);
try {
const createTourAssignment = () => request(server)
.post('/api/trips/5/days/3/assignments')
.set('Cookie', sessionCookie(1))
.send({ place_id: tourPlaceId });
const results = await Promise.all([createTourAssignment(), createTourAssignment()]);
expect(results.map(result => result.status).sort()).toEqual([201, 409]);
expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = 3 AND place_id = ?').get(tourPlaceId)).toEqual({ n: 1 });
const otherDay = await request(server).post('/api/trips/5/days/4/assignments')
.set('Cookie', sessionCookie(1)).send({ place_id: tourPlaceId });
expect(otherDay.status).toBe(201);
expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlaceId)).toEqual({ n: 2 });
const firstOrdinary = await request(server).post('/api/trips/5/days/3/assignments')
.set('Cookie', sessionCookie(1)).send({ place_id: 2 });
const secondOrdinary = await request(server).post('/api/trips/5/days/3/assignments')
.set('Cookie', sessionCookie(1)).send({ place_id: 2 });
expect(firstOrdinary.status).toBe(201);
expect(secondOrdinary.status).toBe(201);
} finally {
db.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(tourPlaceId);
db.prepare('DELETE FROM tours WHERE place_id = ?').run(tourPlaceId);
db.prepare('DELETE FROM places WHERE id = ?').run(tourPlaceId);
}
});
it('200 delete assignment reconciles journey skeletons', async () => {
reconcileTripSkeletons.mockClear();
const id = seedAssignment();
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { ConflictException, HttpException } from '@nestjs/common';
import { DayAssignmentsController, AssignmentOpsController } from '../../../src/nest/assignments/assignments.controller';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import type { User } from '../../../src/types';
@@ -47,6 +47,18 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('409 duplicate Tour assignment propagates without announcing a new assignment', () => {
const broadcast = vi.fn();
const reconcile = vi.fn();
const createAssignment = vi.fn(() => { throw new ConflictException('Tour is already assigned to this day') });
const controller = new DayAssignmentsController(svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>));
expect(thrown(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({ status: 409 });
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined);
expect(broadcast).not.toHaveBeenCalled();
expect(reconcile).not.toHaveBeenCalled();
});
});
it('PUT /reorder 404 day, else reorders + broadcasts', () => {
@@ -10,6 +10,7 @@
* logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { ConflictException } from '@nestjs/common';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -99,6 +100,11 @@ function fixture() {
return { user, trip, day, place };
}
function addTourFacet(placeId: number) {
testDb.prepare("INSERT OR IGNORE INTO tour_types (key, label_key, icon, color) VALUES ('hike', 'hike', 'Mountain', '#000000')").run();
testDb.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(placeId);
}
// ── verifyTripAccess / canEdit ────────────────────────────────────────────────
describe('verifyTripAccess', () => {
@@ -193,6 +199,40 @@ describe('createAssignment', () => {
expect(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', () => {
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 = svc.createAssignment(day.id, tourPlace.id);
expect(() => svc.createAssignment(day.id, tourPlace.id)).toThrow(ConflictException);
const otherDayTourAssignment = 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 });
svc.createAssignment(day.id, ordinaryPlace.id);
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([
Promise.resolve().then(() => svc.createAssignment(day.id, place.id)),
Promise.resolve().then(() => 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 });
});
});
// ── listDayAssignments ────────────────────────────────────────────────────────