Files
TREK/server/tests/unit/db/timestamp-spelling-migration.test.ts
T
Maurice a926990e4a fix(db): timestamp columns are written and stored in one canonical spelling
A DbTimestamp brand, produced only by dbNow(), types the expiry writes of OAuth tokens and password reset links, and collection edits stamp updated_at the same way. parseDbTimestamp() reads the stored text as UTC and replaces the oauth-local parser; utcSuffix lives once beside it. A data migration rewrites the ISO values those writers left behind, and the OAuth session lists keep answering the expiries in ISO.

Two outputs change on purpose. collections.updated_at used to come back in two spellings: a collection never edited answered the column default (2026-01-02 03:04:05), an edited one the ISO text its writer stored (2026-01-02T03:04:05.678Z). It now answers the canonical text in every case, in the PUT and cover responses and, once the migration has run, in every read; the shared schema types it as a plain string and the client does not read it. The ISO expiries of the OAuth session lists are rebuilt from the stored second-precision text, so they end in .000Z instead of carrying the milliseconds of the write.
2026-10-10 20:15:55 +02:00

91 lines
4.2 KiB
TypeScript

/**
* `Migration20200101042700_timestamps_stored_in_one_spelling`: the four
* DATETIME columns the app also wrote with `toISOString()` end up in the
* canonical `YYYY-MM-DD HH:MM:SS` text; canonical values, NULLs and anything
* not in the full ISO shape stay as they are, and a second run changes nothing.
*/
import { describe, expect, it } from 'vitest';
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery, runMigrationDirect } from '../../helpers/migration-step';
import { Migration20200101042700_timestamps_stored_in_one_spelling } from '../../../src/db/migrations/Migration20200101042700_timestamps_stored_in_one_spelling';
import type { MikroORM } from '@mikro-orm/sqlite';
const TARGET = 'Migration20200101042700_timestamps_stored_in_one_spelling';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
async function seed(orm: MikroORM): Promise<void> {
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')");
await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (1, 1, 'iso', '2026-03-04T05:06:07.890Z')");
await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (2, 1, 'canonical', '2026-03-04 05:06:07')");
await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (3, 1, 'null', NULL)");
await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (4, 1, 'offset', '2026-03-04T05:06:07+02:00')");
await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (5, 1, 'no-millis', '2026-03-04T05:06:07Z')");
await rawExec(
orm,
"INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (1, 1, 'h1', '2026-05-06T07:08:09.123Z'), (2, 1, 'h2', '2026-05-06 07:08:09')",
);
await rawExec(orm, "INSERT INTO oauth_clients (id, name, client_id, client_secret_hash) VALUES ('c', 'Client', 'cid', 'x')");
await rawExec(
orm,
`INSERT INTO oauth_tokens (id, client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at)
VALUES (1, 'cid', 1, 'a1', 'r1', '2026-07-08T09:10:11.000Z', '2026-08-08T09:10:11.999Z'),
(2, 'cid', 1, 'a2', 'r2', '2026-07-08 09:10:11', '2026-08-08 09:10:11')`,
);
}
const read = async (orm: MikroORM) => ({
collections: await rawQuery(orm, 'SELECT id, updated_at FROM collections ORDER BY id'),
resets: await rawQuery(orm, 'SELECT id, expires_at FROM password_reset_tokens ORDER BY id'),
tokens: await rawQuery(orm, 'SELECT id, access_token_expires_at, refresh_token_expires_at FROM oauth_tokens ORDER BY id'),
});
const EXPECTED = {
collections: [
{ id: 1, updated_at: '2026-03-04 05:06:07' },
{ id: 2, updated_at: '2026-03-04 05:06:07' },
{ id: 3, updated_at: null },
{ id: 4, updated_at: '2026-03-04T05:06:07+02:00' },
{ id: 5, updated_at: '2026-03-04 05:06:07' },
],
resets: [
{ id: 1, expires_at: '2026-05-06 07:08:09' },
{ id: 2, expires_at: '2026-05-06 07:08:09' },
],
tokens: [
{ id: 1, access_token_expires_at: '2026-07-08 09:10:11', refresh_token_expires_at: '2026-08-08 09:10:11' },
{ id: 2, access_token_expires_at: '2026-07-08 09:10:11', refresh_token_expires_at: '2026-08-08 09:10:11' },
],
};
describe('timestamp spelling migration', () => {
it('TSSPELLMIG-001: ISO values become the canonical text, everything else stays', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
expect(await read(orm)).toEqual(EXPECTED);
} finally {
await orm.close(true);
}
}, 30000);
it('TSSPELLMIG-002: running it again changes nothing', async () => {
const orm = await ormBeforeTarget();
try {
await seed(orm);
await migrateTo(orm, TARGET);
await runMigrationDirect(orm, Migration20200101042700_timestamps_stored_in_one_spelling);
expect(await read(orm)).toEqual(EXPECTED);
} finally {
await orm.close(true);
}
}, 30000);
});