fix(trips): preserve day content when trip date range changes

Rewrites generateDays to remap days positionally by day_number instead
of matching by date identity. Previously any date range shift with no
overlap would cascade-delete all day_assignments, day_notes, and
day_accommodations.

New behaviour:
- Shift/partial overlap: existing days remapped to new dates in order
- Shrink: overflow days become dateless (date=NULL) instead of deleted,
  preserving all child data for manual reassignment
- Grow: existing days kept, new empty days appended
- Clear dates: all days nullified, content intact

Also fixes a UNIQUE(trip_id, day_number) collision that would occur when
spare dateless days remained after growing into a partially-dateless trip
(maxAssigned base was wrong).

Closes #646
This commit is contained in:
jubnl
2026-04-15 01:28:53 +02:00
parent a8899a551b
commit a438652a50
3 changed files with 307 additions and 37 deletions
+60 -1
View File
@@ -49,7 +49,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote } from '../helpers/factories';
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { invalidatePermissionsCache } from '../../src/services/permissions';
@@ -430,6 +430,65 @@ describe('Update trip', () => {
expect(res.status).toBe(404);
});
it('TRIP-023 — Shifting trip date range preserves day assignments positionally', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-05' });
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[];
expect(days).toHaveLength(5);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, days[0].id, place.id);
const note = createDayNote(testDb, days[1].id, trip.id, { text: 'pack sunscreen' });
// Shift forward 10 days (zero overlap with original range)
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ start_date: '2026-08-11', end_date: '2026-08-15' });
expect(res.status).toBe(200);
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual(['2026-08-11', '2026-08-12', '2026-08-13', '2026-08-14', '2026-08-15']);
const assignmentsAfter = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(assignment.id) as { day_id: number } | undefined;
expect(assignmentsAfter).toBeDefined();
expect(assignmentsAfter!.day_id).toBe(daysAfter[0].id);
const notesAfter = testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id) as { day_id: number } | undefined;
expect(notesAfter).toBeDefined();
expect(notesAfter!.day_id).toBe(daysAfter[1].id);
});
it('TRIP-024 — Shrinking trip date range keeps overflow days as dateless with content intact', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
const place = createPlace(testDb, trip.id);
const a4 = createDayAssignment(testDb, days[3].id, place.id);
const a5 = createDayAssignment(testDb, days[4].id, place.id);
// Shrink from 5 to 3 days
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ start_date: '2026-09-01', end_date: '2026-09-03' });
expect(res.status).toBe(200);
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
expect(daysAfter).toHaveLength(5);
expect(daysAfter.filter(d => d.date !== null)).toHaveLength(3);
expect(daysAfter.filter(d => d.date === null)).toHaveLength(2);
// Overflow assignments survived
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as { id: number }[];
expect(all).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────────────