Files
TREK/server/tests/unit/db/trip-days-backfill-migration.test.ts
T
Maurice 62cccf32f3 fix(db): give clipped trips their days back and let road-trip day numbers follow the trip limit
Two boot migrations. The first appends the missing dated days to a trip
the old 365-day cap clipped, only where the dated days still run unbroken
from the start date, so a trip that was re-dated by hand or lost a day in
the middle is left alone; dateless days that hold content stay behind the
dated ones as generateDays keeps them. The second rebuilds
roadtrip_day_boundaries without the CHECK that held day_number to 366,
which would have refused a boundary on a later day of a longer trip.

The road-trip contracts follow the same limit: the day boundary and
corridor schemas, the set_day_boundary tool and the day window's runaway
guard read MAX_TRIP_DAYS instead of their own 365 or 366, and the
tooLong message carries the number as a placeholder in all 23 locales.
2026-09-20 12:34:34 +02:00

171 lines
6.8 KiB
TypeScript

/**
* Boot migration: days a trip lost past the old 365-day limit (#2403).
*
* generateDays used to clip the day rows while the trip kept its full end
* date. The migration appends the missing dated days to a trip whose dated
* rows are still the unbroken run from its start date, keeps content-bearing
* dateless days behind them, and leaves every other trip alone.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import Database from 'better-sqlite3';
import { describe, it, expect } from 'vitest';
/** Positions of the two #2403 steps in the migration array (the number the boot log prints). */
const BACKFILL_VERSION = 240;
const REBUILD_VERSION = 241;
function dayAfter(start: string, n: number) {
return new Date(Date.parse(start + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10);
}
function freshDb(): Database.Database {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
runMigrations(db);
db.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@example.test', 'x')").run();
return db;
}
/** A trip the old code produced: `dated` day rows from start_date, whatever the end date says. */
function seedClippedTrip(db: Database.Database, id: number, start: string, end: string, dated: number) {
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (?, 1, ?, ?, ?)').run(
id,
`T${id}`,
start,
end,
);
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
for (let i = 0; i < dated; i++) insert.run(id, i + 1, dayAfter(start, i));
}
function days(db: Database.Database, tripId: number) {
return db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
id: number;
day_number: number;
date: string | null;
}[];
}
/** Rewind schema_version so the given step (and everything after it) replays. */
function replayFrom(db: Database.Database, version: number) {
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
runMigrations(db);
}
describe('trip days backfill migration (#2403)', () => {
it('MIGRATE-DAYS-001: appends the days a clipped trip is missing, in order and dated', () => {
const db = freshDb();
try {
seedClippedTrip(db, 1, '2025-01-26', '2026-01-28', 365);
const before = days(db, 1);
replayFrom(db, BACKFILL_VERSION);
const after = days(db, 1);
expect(after).toHaveLength(368);
// The rows that were there keep their ids and numbers.
expect(after.slice(0, 365).map((d) => d.id)).toEqual(before.map((d) => d.id));
expect(after.map((d) => d.day_number)).toEqual(after.map((_, i) => i + 1));
expect(after.slice(365).map((d) => d.date)).toEqual(['2026-01-26', '2026-01-27', '2026-01-28']);
} finally {
db.close();
}
});
it('MIGRATE-DAYS-002: content-bearing dateless days stay behind the dated ones', () => {
const db = freshDb();
try {
seedClippedTrip(db, 1, '2025-01-26', '2026-01-28', 365);
const spare = Number(
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (1, 366, NULL)').run().lastInsertRowid,
);
db.prepare("INSERT INTO day_notes (day_id, trip_id, text) VALUES (?, 1, 'keep me')").run(spare);
replayFrom(db, BACKFILL_VERSION);
const after = days(db, 1);
expect(after).toHaveLength(369);
expect(after[367]).toMatchObject({ day_number: 368, date: '2026-01-28' });
expect(after[368]).toMatchObject({ id: spare, day_number: 369, date: null });
} finally {
db.close();
}
});
it('MIGRATE-DAYS-003: leaves trips alone that were never clipped, were re-dated by hand, or exceed the new limit', () => {
const db = freshDb();
try {
// Complete already.
seedClippedTrip(db, 1, '2026-07-01', '2026-07-07', 7);
// Longer than a year but its days no longer start on start_date.
seedClippedTrip(db, 2, '2025-01-26', '2026-01-28', 365);
db.prepare("UPDATE days SET date = '2024-12-31' WHERE trip_id = 2 AND day_number = 1").run();
// Past the new limit: still refused, so still not extended.
seedClippedTrip(db, 3, '2020-01-01', '2030-01-01', 365);
// Dateless trip.
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (4, 1, 'no dates')").run();
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (4, 1, NULL)').run();
const snapshot = [1, 2, 3, 4].map((id) => days(db, id));
replayFrom(db, BACKFILL_VERSION);
expect([1, 2, 3, 4].map((id) => days(db, id))).toEqual(snapshot);
} finally {
db.close();
}
});
it('MIGRATE-DAYS-004: running it twice changes nothing more', () => {
const db = freshDb();
try {
seedClippedTrip(db, 1, '2025-01-26', '2026-01-28', 365);
replayFrom(db, BACKFILL_VERSION);
const once = days(db, 1);
replayFrom(db, BACKFILL_VERSION);
expect(days(db, 1)).toEqual(once);
} finally {
db.close();
}
});
});
describe('road-trip day boundary rebuild (#2403)', () => {
it('MIGRATE-DAYS-005: keeps existing boundaries and accepts a day past the old 366 ceiling', () => {
const db = freshDb();
try {
// The table as every installation before this migration has it.
db.exec(`
DROP TABLE roadtrip_day_boundaries;
CREATE TABLE roadtrip_day_boundaries (
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366),
from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1),
PRIMARY KEY (trip_id, day_number)
)`);
seedClippedTrip(db, 1, '2025-01-01', '2027-06-30', 911);
db.prepare('INSERT INTO places (trip_id, name) VALUES (1, ?), (1, ?)').run('A', 'B');
db.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (1, 1), (2, 2)').run();
const boundary = db.prepare(
'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (1, ?, 1, 2, 0.5)',
);
boundary.run(2);
expect(() => boundary.run(400)).toThrow(/CHECK/);
replayFrom(db, REBUILD_VERSION);
boundary.run(400);
const rows = db.prepare('SELECT day_number, fraction FROM roadtrip_day_boundaries ORDER BY day_number').all();
expect(rows).toEqual([
{ day_number: 2, fraction: 0.5 },
{ day_number: 400, fraction: 0.5 },
]);
expect(() => boundary.run(0)).toThrow(/CHECK/);
} finally {
db.close();
}
});
});