mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
fix(tours): prevent duplicate day assignments
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user