// ── The inbox's raw SQL, against a real MariaDB ──────────────────────────── // // ENGAGEMENT.md Phase 7. `engagementInapp.test.js` stubs the table and exercises // everything the channel DECIDES. It cannot prove the three statements whose // correctness is a server contract rather than a reading of this code: // // • **`UNIQUE (user_id, dedupe_key)` must admit many NULLs.** The whole // "this item does not dedupe" case rests on it, and a unique index that // rejected a second NULL would mean the second un-keyed notification any // user ever received was silently dropped. It is standard SQL and it is also // exactly the kind of assumption Phase 4a's `foundRows` defect was. // • **`INSERT IGNORE` on a duplicate reports `affectedRows = 0`** — the value // `insert()` returns `inserted: false` from, and therefore the value that // decides whether the send log says "delivered" or "duplicate". // • **`read_at IS NULL` in the mark-read predicate is what makes it // idempotent**: the timestamp must not move on a second call. // // Plus the prune's one policy: it deletes read rows and leaves unread ones, // however old. // // **It SKIPS when there is no database**, exactly as `engagementEngineSql` // does and for its reason: CI runs the suite with the pool pointed at a dead // port, and a file that failed there would make every PR red for a reason // unrelated to itself. Run it against this machine's container with: // // DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=... DB_PASSWORD=... \ // node --test test/userNotificationsSql.test.js // // It creates a throwaway database named after the process and drops it again, so // it can never touch a real schema. const { test, before, after } = require('node:test') const assert = require('node:assert/strict') const mariadb = require('mariadb') // Verbatim from schema.sql, minus the FK to `users` — the point of this file is // the index semantics, and a foreign key would mean seeding an accounts table // that has nothing to do with any of them. const SCHEMA = ` CREATE TABLE user_notifications ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, trigger_id VARCHAR(96) NOT NULL, title VARCHAR(300) NOT NULL, body TEXT NULL, url VARCHAR(500) NULL, dedupe_key VARCHAR(190) NULL, read_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_un_dedupe (user_id, dedupe_key), INDEX idx_un_unread (user_id, read_at, created_at), INDEX idx_un_prune (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ` // The statements under test, verbatim from `userNotifications.db.js`. Duplicated // rather than required for `engagementEngineSql`'s reason: requiring the model // would drag in `utils/db`'s pool, which the harness has pointed at a dead port. const INSERT = ` INSERT IGNORE INTO user_notifications (user_id, trigger_id, title, body, url, dedupe_key) VALUES (?, ?, ?, ?, ?, ?)` const MARK_READ = ` UPDATE user_notifications SET read_at = NOW() WHERE id = ? AND user_id = ? AND read_at IS NULL` const PRUNE = ` DELETE FROM user_notifications WHERE read_at IS NOT NULL AND created_at < (NOW() - INTERVAL ? DAY) LIMIT ?` const DB = `rg_inbox_test_${process.pid}` let pool = null let available = false const opts = () => ({ host: process.env.DB_HOST || '127.0.0.1', port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER || 'root', password: process.env.DB_PASSWORD || '', }) before(async () => { const admin = mariadb.createPool({ ...opts(), connectionLimit: 1, connectTimeout: 2000, initializationTimeout: 2000, }) try { await admin.query(`CREATE DATABASE ${DB}`) available = true } catch { available = false } finally { await admin.end().catch(() => {}) } if (!available) return pool = mariadb.createPool({ ...opts(), database: DB, connectionLimit: 3, multipleStatements: true, bigIntAsNumber: true, insertIdAsNumber: true, }) await pool.query(SCHEMA) }) after(async () => { if (pool) { await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {}) await pool.end().catch(() => {}) } }) // Checked INSIDE each test, never as a `{ skip }` option — the trap // `engagementEngineSql` documents and this file fell into anyway: the option is // evaluated when the file is READ, which is before `before()` has had a chance // to find out whether there is a database, so every test skips unconditionally. // It looks exactly like a passing suite. const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run' const needDb = (t) => { if (available) return false t.skip(SKIP) return true } const write = (userId, key, over = {}) => pool.query(INSERT, [userId, over.trigger || 't.x', over.title || 'Hi', null, null, key]) test('a duplicate (user, dedupe key) is ignored and reports affectedRows 0', async (t) => { if (needDb(t)) return const first = await write(901, 'evt:1') assert.equal(first.affectedRows, 1) const second = await write(901, 'evt:1') assert.equal(second.affectedRows, 0) // Scoped to the USER, not global: one event legitimately reaches fifty people, // and a global unique key would admit the first and drop forty-nine — the // defect Phase 4a found in §4.2a's outbox index, in a second place. const other = await write(902, 'evt:1') assert.equal(other.affectedRows, 1) }) test('a NULL dedupe key never collides, however many there are', async (t) => { if (needDb(t)) return for (let i = 0; i < 3; i += 1) { const res = await write(903, null) assert.equal(res.affectedRows, 1) } const rows = await pool.query('SELECT COUNT(*) AS n FROM user_notifications WHERE user_id = 903') assert.equal(Number(rows[0].n), 3) }) test('mark-read stamps once and a second call moves nothing', async (t) => { if (needDb(t)) return const ins = await write(904, 'evt:read') const id = ins.insertId const first = await pool.query(MARK_READ, [id, 904]) assert.equal(first.affectedRows, 1) const [after1] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id]) // A second later, so a re-stamp would be visible rather than equal by accident. await pool.query('UPDATE user_notifications SET read_at = read_at - INTERVAL 1 SECOND WHERE id = ?', [id]) const [before2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id]) const second = await pool.query(MARK_READ, [id, 904]) assert.equal(second.affectedRows, 0) const [after2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id]) assert.deepEqual(after2.read_at, before2.read_at) assert.notDeepEqual(after1.read_at, before2.read_at) // the shift really happened }) test('mark-read scoped to the owner matches nothing for anyone else', async (t) => { if (needDb(t)) return const ins = await write(905, 'evt:owner') const wrong = await pool.query(MARK_READ, [ins.insertId, 906]) assert.equal(wrong.affectedRows, 0) const [row] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [ins.insertId]) assert.equal(row.read_at, null) }) test('the prune drops old READ rows and keeps unread ones however old', async (t) => { if (needDb(t)) return const old = await write(907, 'evt:old') const oldUnread = await write(907, 'evt:old-unread') const recent = await write(907, 'evt:recent') await pool.query( 'UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY, read_at = NOW() WHERE id = ?', [old.insertId], ) await pool.query('UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY WHERE id = ?', [ oldUnread.insertId, ]) await pool.query('UPDATE user_notifications SET read_at = NOW() WHERE id = ?', [recent.insertId]) const res = await pool.query(PRUNE, [90, 1000]) assert.equal(res.affectedRows, 1) const rows = await pool.query('SELECT id FROM user_notifications WHERE user_id = 907 ORDER BY id') assert.deepEqual(rows.map((r) => Number(r.id)), [oldUnread.insertId, recent.insertId]) })