Files
TREK/server/tests/unit/db/dialect/sql-functions.test.ts
T

330 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { Platform } from '@mikro-orm/core';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { Users } from '../../../../src/db/entities/Users.entity';
import {
absDifference,
coalesce,
coalesceParam,
columnIncrementedBy,
columnRef,
currentTimestamp,
dateAdd,
dateOf,
lower,
lowerParam,
nowMinusDays,
trim,
} from '../../../../src/db/dialect/sql-functions';
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb); });
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('sql-functions (sqlite)', () => {
it('SQLF-001: dateOf yields the calendar date of a stored timestamp', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([dateOf(t.em.getPlatform(), 'u.created_at').as('d')])
.where({ id: user.id })
.execute('get', false);
expect(row).toEqual({ d: '2026-09-21' });
});
it('SQLF-002: dateAdd shifts by whole days', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([dateAdd(t.em.getPlatform(), 'u.created_at', 10).as('d')])
.where({ id: user.id })
.execute('get', false);
expect(row).toEqual({ d: '2026-10-01' });
});
it('SQLF-003: currentTimestamp is the DB clock in the wire format', async () => {
createUser(testDb);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([currentTimestamp(t.em.getPlatform()).as('now')])
.limit(1)
.execute('get', false);
expect(String((row as { now: string }).now)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it('SQLF-004: dateOf rejects anything that is not a plain column reference', () => {
expect(() => dateOf(t.em.getPlatform(), 'u.created_at); DROP TABLE users; --')).toThrow(/not a column reference/);
expect(() => dateOf(t.em.getPlatform(), 'u.created_at.extra')).toThrow(/not a column reference/);
expect(() => dateOf(t.em.getPlatform(), '')).toThrow(/not a column reference/);
});
it('SQLF-005: dateAdd rejects a non-integer day count', () => {
expect(() => dateAdd(t.em.getPlatform(), 'u.created_at', 1.5)).toThrow(/integer day count/);
expect(() => dateAdd(t.em.getPlatform(), 'u.created_at', Number.NaN)).toThrow(/integer day count/);
});
it('SQLF-006: dateAdd shifts backwards for a negative day count', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE users SET created_at = '2026-09-21 13:05:09' WHERE id = ?").run(user.id);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([dateAdd(t.em.getPlatform(), 'u.created_at', -10).as('d')])
.where({ id: user.id })
.execute('get', false);
expect(row).toEqual({ d: '2026-09-11' });
});
it('SQLF-007: an unknown platform fails closed instead of guessing a spelling', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => dateOf(foreign, 'u.created_at')).toThrow(/no implementation for platform FakePlatform/);
expect(() => dateAdd(foreign, 'u.created_at', 1)).toThrow(/no implementation for platform FakePlatform/);
expect(() => currentTimestamp(foreign)).toThrow(/no implementation for platform FakePlatform/);
expect(() => columnRef(foreign, 'u.max_uses')).toThrow(/no implementation for platform FakePlatform/);
expect(() => columnIncrementedBy(foreign, 'u.used_count', 1)).toThrow(/no implementation for platform FakePlatform/);
expect(() => lower(foreign, 'u.email')).toThrow(/no implementation for platform FakePlatform/);
expect(() => lowerParam(foreign, 'x')).toThrow(/no implementation for platform FakePlatform/);
});
// Plan 3b Task 1: UsersRepository's CI (case-insensitive) lookups
// (findByEmailCI, findIdByUsernameCI, ...) need `LOWER(col) = ?` as a
// `find`/`findOne` filter KEY, not just inside a QueryBuilder `.where()` —
// proving it works through `em.find`/`findOne` (the API the repository
// actually calls) rather than only through `createQueryBuilder`.
it('SQLF-012: lower is usable as a find() filter key, matching case-insensitively', async () => {
const { user } = createUser(testDb, { email: 'Mixed.Case@Example.com' });
const rows = await t.em.find(Users, { [lower(t.em.getPlatform(), 'email')]: 'mixed.case@example.com' });
expect(rows.map((r) => r.id)).toEqual([user.id]);
// A differently-cased query value that still lowercases to the same
// string finds the same row; one that does not, finds nothing.
const none = await t.em.find(Users, { [lower(t.em.getPlatform(), 'email')]: 'nobody@example.com' });
expect(none).toEqual([]);
});
it('SQLF-013: lower composes with other filter keys in one findOne call', async () => {
const { user } = createUser(testDb, { username: 'MixedCaseName' });
const row = await t.em.findOne(Users, {
[lower(t.em.getPlatform(), 'username')]: 'mixedcasename',
id: { $ne: -1 },
});
expect(row?.id).toBe(user.id);
});
// Plan 3b Task 0: InviteTokensRepository.incrementUsedCount needs a
// sibling-column comparison (`used_count < max_uses`) and a sibling-column
// increment (`used_count = used_count + 1`) inside a QueryBuilder filter —
// ESLint bans a repository from spelling `raw()` itself (src/db/repositories/**),
// so both go through these two dialect helpers instead.
it('SQLF-009: columnRef compares one column against another (not a bound value)', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5, password_version = 3 WHERE id = ?').run(user.id);
const below = await t.em.createQueryBuilder(Users, 'u')
.select('*')
.where({ id: user.id, password_version: { $lt: columnRef(t.em.getPlatform(), 'login_count') } })
.execute('get', false);
expect((below as { id: number } | undefined)?.id).toBe(user.id);
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
const notBelow = await t.em.createQueryBuilder(Users, 'u')
.select('*')
.where({ id: user.id, password_version: { $lt: columnRef(t.em.getPlatform(), 'login_count') } })
.execute('get', false);
expect(notBelow).toBeUndefined();
});
it('SQLF-010: columnIncrementedBy writes <col> + n / <col> - n, never a bound-parameter add', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 10 WHERE id = ?').run(user.id);
await t.em.createQueryBuilder(Users, 'u')
.update({ login_count: columnIncrementedBy(t.em.getPlatform(), 'login_count', 3) })
.where({ id: user.id })
.execute('run');
t.clear();
expect((testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number }).login_count).toBe(13);
await t.em.createQueryBuilder(Users, 'u')
.update({ login_count: columnIncrementedBy(t.em.getPlatform(), 'login_count', -5) })
.where({ id: user.id })
.execute('run');
t.clear();
expect((testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number }).login_count).toBe(8);
});
it('SQLF-011: columnIncrementedBy rejects a non-integer amount', () => {
expect(() => columnIncrementedBy(t.em.getPlatform(), 'u.login_count', 1.5)).toThrow(/integer amount/);
expect(() => columnIncrementedBy(t.em.getPlatform(), 'u.login_count', Number.NaN)).toThrow(/integer amount/);
});
// Plan 3b Task 0: OA8 (oauth_tokens' client-secret-rotation revoke) is the
// one call site in the whole codebase that spells "now" as `datetime('now')`
// instead of `CURRENT_TIMESTAMP` — same effective SQLite value, but D10
// wants exactly one dialect-function path. `currentTimestamp` already
// renders the literal `CURRENT_TIMESTAMP` fragment (SQLF-003 pins its wire
// format); this proves that fragment's stored text is byte-identical to
// what the legacy `datetime('now')` spelling produces, so OA8 (Task 4) can
// route through `currentTimestamp(platform)` with no behaviour change.
it("SQLF-008: currentTimestamp's CURRENT_TIMESTAMP spelling is byte-identical, in the same statement, to the legacy datetime('now') spelling", async () => {
// One atomic SELECT evaluates both expressions at the exact same instant —
// no timing window between two separate writes, so this is not a
// usually-true "close enough" comparison, it is a real strict equality.
const row = testDb.prepare("SELECT CURRENT_TIMESTAMP as a, datetime('now') as b").get() as { a: string; b: string };
expect(row.a).toBe(row.b);
expect(row.a).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
// And a real write through the repository-facing dialect helper, read
// back through the raw connection, stores that exact same shape — not
// just the fragment text in isolation.
const { user } = createUser(testDb);
await t.em.createQueryBuilder(Users, 'u')
.update({ created_at: currentTimestamp(t.em.getPlatform()) })
.where({ id: user.id })
.execute('run');
t.clear();
const stored = testDb.prepare('SELECT created_at FROM users WHERE id = ?').get(user.id) as { created_at: string };
expect(stored.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
// Program rule 18 / Plan 3b Task 7 review H1: SQLite's LOWER() is
// ASCII-only; JavaScript's toLowerCase() is full-Unicode. lowerParam()
// binds the RAW value so SQLite folds both sides of the comparison with
// the same engine, matching a legacy `LOWER(col) = LOWER(?)` statement.
it('SQLF-014: SQLite LOWER() is ASCII-only, unlike JS toLowerCase() — verified directly', () => {
const cases: Array<[string, string]> = [
['JOSÉ@x.com', 'josÉ@x.com'],
['ÄNNA@x.com', 'Änna@x.com'],
['ΣIGMA@x.com', 'Σigma@x.com'],
['İSTANBUL@x.com', 'İstanbul@x.com'],
['ASCII@x.com', 'ascii@x.com'],
];
for (const [input, sqliteLowered] of cases) {
const row = testDb.prepare('SELECT LOWER(?) as l').get(input) as { l: string };
expect(row.l).toBe(sqliteLowered);
// Every non-ASCII case proves the two engines disagree.
if (input !== 'ASCII@x.com') {
expect(row.l).not.toBe(input.toLowerCase());
}
}
});
it('SQLF-015: lowerParam pairs with lower() to fold BOTH sides in SQLite, matching a non-ASCII stored spelling that toLowerCase() would miss', async () => {
const { user } = createUser(testDb, { email: 'JOSÉ@x.com' });
const platform = t.em.getPlatform();
// The exact stored spelling matches (SQLite's LOWER() is a no-op on both
// sides for this input, so this is really an equality check — the real
// proof is the next assertion).
const exact = await t.em.findOne(Users, { [lower(platform, 'email')]: lowerParam(platform, 'JOSÉ@x.com') });
expect(exact?.id).toBe(user.id);
// A JS-lowered value-side bind (the bug: mixing engines) must NOT match
// — this is the mutation-proof: reverting `lowerParam(platform, email)`
// to a plain `email.toLowerCase()` bind makes this assertion fail
// because SQLite's LOWER(column) still reads 'josÉ@x.com', which does
// not equal the JS-lowered bind 'josé@x.com'.
const jsLowered = await t.em.findOne(Users, { [lower(platform, 'email')]: 'josé@x.com' });
expect(jsLowered).toBeNull();
const none = await t.em.findOne(Users, { [lower(platform, 'email')]: lowerParam(platform, 'nobody@example.com') });
expect(none).toBeNull();
});
// Plan 3c Task 0b (R6): no consumer yet — Tasks 1–8 wire these in as each
// converts the statement that needs them. Pinned here so a later task
// imports a tested helper instead of writing one inline.
it("SQLF-016: nowMinusDays renders datetime('now', '-N days'), matching a JS-computed date within a few seconds' tolerance", async () => {
const row = testDb.prepare(`SELECT ${nowMinusDays(t.em.getPlatform(), 5).sql} as d`).get() as { d: string };
const got = new Date(`${row.d.replace(' ', 'T')}Z`).getTime();
const expected = Date.now() - 5 * 24 * 60 * 60 * 1000;
expect(Math.abs(got - expected)).toBeLessThan(10_000);
});
it('SQLF-016b: nowMinusDays(0) is "now", not one day back', async () => {
const row = testDb.prepare(`SELECT ${nowMinusDays(t.em.getPlatform(), 0).sql} as d`).get() as { d: string };
const got = new Date(`${row.d.replace(' ', 'T')}Z`).getTime();
expect(Math.abs(got - Date.now())).toBeLessThan(10_000);
});
it('SQLF-017: nowMinusDays rejects a non-integer or negative day count', () => {
expect(() => nowMinusDays(t.em.getPlatform(), 1.5)).toThrow(/non-negative integer day count/);
expect(() => nowMinusDays(t.em.getPlatform(), -1)).toThrow(/non-negative integer day count/);
expect(() => nowMinusDays(t.em.getPlatform(), Number.NaN)).toThrow(/non-negative integer day count/);
});
it('SQLF-018: trim strips leading/trailing whitespace', async () => {
const { user } = createUser(testDb, { username: ' Padded Name ' });
const platform = t.em.getPlatform();
const row = await t.em.createQueryBuilder(Users, 'u')
.select([trim(platform, 'u.username').as('n')])
.where({ id: user.id })
.execute('get', false);
expect((row as { n: string }).n).toBe('Padded Name');
});
it('SQLF-019: coalesce(ref, fallbackRef) picks the first non-null column, else the second', async () => {
const { user: withName } = createUser(testDb, { username: 'has-display-name' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Display Name', withName.id);
const { user: withoutName } = createUser(testDb, { username: 'no-display-name' });
const platform = t.em.getPlatform();
const a = await t.em.createQueryBuilder(Users, 'u')
.select([coalesce(platform, 'u.display_name', 'u.username').as('name')])
.where({ id: withName.id })
.execute('get', false);
expect((a as { name: string }).name).toBe('Display Name');
const b = await t.em.createQueryBuilder(Users, 'u')
.select([coalesce(platform, 'u.display_name', 'u.username').as('name')])
.where({ id: withoutName.id })
.execute('get', false);
expect((b as { name: string }).name).toBe('no-display-name');
});
it('SQLF-020: coalesceParam(ref, value) writes COALESCE(col, ?) with the fallback bound, not another column', async () => {
const { user } = createUser(testDb);
const platform = t.em.getPlatform();
await t.em.createQueryBuilder(Users, 'u')
.update({ display_name: coalesceParam(platform, 'display_name', 'Fallback Name') })
.where({ id: user.id })
.execute('run');
t.clear();
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(user.id) as { display_name: string }).display_name).toBe('Fallback Name');
await t.em.createQueryBuilder(Users, 'u')
.update({ display_name: coalesceParam(platform, 'display_name', 'Never Used') })
.where({ id: user.id })
.execute('run');
t.clear();
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(user.id) as { display_name: string }).display_name).toBe('Fallback Name');
});
it('SQLF-021: absDifference is usable as a filter key, matching ABS(col - ?) <= tolerance', async () => {
const { user: near } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 12 WHERE id = ?').run(near.id);
const { user: far } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 100 WHERE id = ?').run(far.id);
const platform = t.em.getPlatform();
const rows = await t.em.find(Users, { [absDifference(platform, 'login_count', 10)]: { $lte: 5 } });
expect(rows.map((r) => r.id)).toEqual([near.id]);
const none = await t.em.find(Users, { [absDifference(platform, 'login_count', 10)]: { $lte: 0 } });
expect(none).toEqual([]);
});
it('SQLF-022: an unknown platform fails closed for every Plan 3c Task 0b helper', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => nowMinusDays(foreign, 1)).toThrow(/no implementation for platform FakePlatform/);
expect(() => trim(foreign, 'u.name')).toThrow(/no implementation for platform FakePlatform/);
expect(() => coalesce(foreign, 'u.a', 'u.b')).toThrow(/no implementation for platform FakePlatform/);
expect(() => coalesceParam(foreign, 'u.a', 'x')).toThrow(/no implementation for platform FakePlatform/);
expect(() => absDifference(foreign, 'u.lat', 1)).toThrow(/no implementation for platform FakePlatform/);
});
});