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,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 ────────────────────────────────────────────────────────