From e6d7a00ba136776bb3da358c19dea768643f330c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:05:03 -0700 Subject: [PATCH 1/3] fix(messages): first-greeting language reads a person's newest rows by time (TASK-503) recentContentByUser ordered by id with no created_at bound. The history copy inserts months-old rows with fresh high ids, so they would read as the person's recent words. Order by created_at, then id. The new pg-mem test fails on main. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/medium --- .../models/recentContentByUser.pgmem.test.js | 58 +++++++++++++++++++ backend/models/pg/Message.ts | 7 ++- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/unit/models/recentContentByUser.pgmem.test.js diff --git a/backend/__tests__/unit/models/recentContentByUser.pgmem.test.js b/backend/__tests__/unit/models/recentContentByUser.pgmem.test.js new file mode 100644 index 0000000000..5eb5ef40aa --- /dev/null +++ b/backend/__tests__/unit/models/recentContentByUser.pgmem.test.js @@ -0,0 +1,58 @@ +/** + * `recentContentByUser`, EXECUTED against pg-mem. firstContactService picks the + * language of a person's first greeting from it. The TASK-503 history copy + * inserts months-old rows into PostgreSQL with fresh, high ids, so "recent" + * must mean newest by created_at, not newest by insert order. + */ + +const { newDb } = require('pg-mem'); + +const mockDb = newDb(); +const mockPool = new (mockDb.adapters.createPg().Pool)(); + +jest.mock('../../../config/db-pg', () => ({ pool: mockPool })); + +const PGMessage = require('../../../models/pg/Message'); + +const POD = 'aaaaaaaaaaaaaaaaaaaaaa01'; +const LIN = 'cccccccccccccccccccccc01'; +const OTHER = 'cccccccccccccccccccccc02'; + +const insert = (userId, content, createdAt, type = 'text') => mockPool.query( + 'INSERT INTO messages (pod_id, user_id, content, message_type, created_at) VALUES ($1, $2, $3, $4, $5)', + [POD, userId, content, type, createdAt], +); + +beforeAll(async () => { + await mockPool.query(`CREATE TABLE messages ( + id SERIAL PRIMARY KEY, + pod_id VARCHAR(24) NOT NULL, + user_id VARCHAR(24) NOT NULL, + content TEXT NOT NULL, + message_type VARCHAR(20) DEFAULT 'text' NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + )`); + // Written live, so its id is low. + await insert(LIN, 'hello, this week in English', new Date('2026-10-28T10:00:00Z')); + await insert(OTHER, 'someone else', new Date('2026-10-29T10:00:00Z')); + await insert(LIN, 'joined the pod', new Date('2026-10-29T11:00:00Z'), 'system'); + // Copied from MongoDB afterwards: an old row with a higher id. + await insert(LIN, '你好,这是几个月前的', new Date('2026-06-01T10:00:00Z')); +}); + +describe('recentContentByUser', () => { + test('an old row with a higher id loses to a newer row with a lower id', async () => { + await expect(PGMessage.recentContentByUser(LIN, 1)).resolves.toEqual(['hello, this week in English']); + await expect(PGMessage.recentContentByUser(LIN)) + .resolves.toEqual(['hello, this week in English', '你好,这是几个月前的']); + }); + + test("returns only this person's text rows", async () => { + await expect(PGMessage.recentContentByUser(OTHER)).resolves.toEqual(['someone else']); + await expect(PGMessage.recentContentByUser(LIN)).resolves.not.toContain('joined the pod'); + }); + + test('refuses a blank user', async () => { + await expect(PGMessage.recentContentByUser('')).resolves.toEqual([]); + }); +}); diff --git a/backend/models/pg/Message.ts b/backend/models/pg/Message.ts index 907ba940c0..a4184638f7 100644 --- a/backend/models/pg/Message.ts +++ b/backend/models/pg/Message.ts @@ -417,13 +417,16 @@ class Message { return (result.rows as Array<{ content?: unknown }>).map((row) => String(row.content || '')); } - /** Recent words by one person across pods, for their first greeting language. */ + /** + * Recent words by one person across pods, for their first greeting language. + * Newest by created_at, not id: the TASK-503 history copy gives old rows high ids. + */ static async recentContentByUser(userId: unknown, limit = 10): Promise { if (!userId) return []; const result = await (pool as PgPool).query( `SELECT content FROM messages WHERE user_id = $1 AND message_type = 'text' AND content <> '' - ORDER BY id DESC LIMIT $2`, + ORDER BY created_at DESC, id DESC LIMIT $2`, [String(userId), limit], ); return (result.rows as Array<{ content?: unknown }>).map((row) => String(row.content || '')); From 397747132a28a6cd2c3dfcc3af8b49c7e607a2a1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:22:50 -0700 Subject: [PATCH 2/3] fix(messages): an undated row never counts as a person's newest words (TASK-503) messages.created_at is nullable, and created_at DESC sorts NULL first. Use NULLS LAST. The pg-mem test adds an undated row with the highest id; pg-mem honours NULLS LAST, and without it both ordering tests fail. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/medium --- .../unit/models/recentContentByUser.pgmem.test.js | 9 ++++++++- backend/models/pg/Message.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/models/recentContentByUser.pgmem.test.js b/backend/__tests__/unit/models/recentContentByUser.pgmem.test.js index 5eb5ef40aa..f47cd8018e 100644 --- a/backend/__tests__/unit/models/recentContentByUser.pgmem.test.js +++ b/backend/__tests__/unit/models/recentContentByUser.pgmem.test.js @@ -38,13 +38,20 @@ beforeAll(async () => { await insert(LIN, 'joined the pod', new Date('2026-10-29T11:00:00Z'), 'system'); // Copied from MongoDB afterwards: an old row with a higher id. await insert(LIN, '你好,这是几个月前的', new Date('2026-06-01T10:00:00Z')); + // An undated row, inserted last so it also has the highest id. + await insert(LIN, 'bonjour, sans date', null); }); describe('recentContentByUser', () => { test('an old row with a higher id loses to a newer row with a lower id', async () => { await expect(PGMessage.recentContentByUser(LIN, 1)).resolves.toEqual(['hello, this week in English']); await expect(PGMessage.recentContentByUser(LIN)) - .resolves.toEqual(['hello, this week in English', '你好,这是几个月前的']); + .resolves.toEqual(['hello, this week in English', '你好,这是几个月前的', 'bonjour, sans date']); + }); + + test('an undated row loses to every dated one', async () => { + const rows = await PGMessage.recentContentByUser(LIN, 2); + expect(rows).not.toContain('bonjour, sans date'); }); test("returns only this person's text rows", async () => { diff --git a/backend/models/pg/Message.ts b/backend/models/pg/Message.ts index a4184638f7..c26990244f 100644 --- a/backend/models/pg/Message.ts +++ b/backend/models/pg/Message.ts @@ -420,13 +420,14 @@ class Message { /** * Recent words by one person across pods, for their first greeting language. * Newest by created_at, not id: the TASK-503 history copy gives old rows high ids. + * created_at is nullable, and DESC puts NULL first, so an undated row sorts last. */ static async recentContentByUser(userId: unknown, limit = 10): Promise { if (!userId) return []; const result = await (pool as PgPool).query( `SELECT content FROM messages WHERE user_id = $1 AND message_type = 'text' AND content <> '' - ORDER BY created_at DESC, id DESC LIMIT $2`, + ORDER BY created_at DESC NULLS LAST, id DESC LIMIT $2`, [String(userId), limit], ); return (result.rows as Array<{ content?: unknown }>).map((row) => String(row.content || '')); From 6bb453de6f0f4e136363bdef40849afb2e6dea9b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:02:27 -0700 Subject: [PATCH 3/3] test(messages): recentContentByUser ordering on real PostgreSQL (TASK-503) Checklist item 55: a SQL change tested only on pg-mem is not tested. The tier-1 arm runs the real schema: created_at first, id breaking a tie, and an undated row (the highest id) last. Run on postgres:17.10; dropping NULLS LAST fails both cases, dropping the id tiebreak fails one, and main's id DESC fails both. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/medium --- .../recentContentByUser.realPg.test.js | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 backend/__tests__/service/recentContentByUser.realPg.test.js diff --git a/backend/__tests__/service/recentContentByUser.realPg.test.js b/backend/__tests__/service/recentContentByUser.realPg.test.js new file mode 100644 index 0000000000..ec3e30884d --- /dev/null +++ b/backend/__tests__/service/recentContentByUser.realPg.test.js @@ -0,0 +1,52 @@ +/** + * `recentContentByUser` on real PostgreSQL. The pg-mem arm + * (unit/models/recentContentByUser.pgmem.test.js) proves the ordering only in + * pg-mem's own planner. Here the same rules run on the real schema: the TASK-503 + * history copy gives months-old rows high ids, created_at is nullable, and DESC + * sorts NULL first unless the query says NULLS LAST. + */ +const { setupPgDb, closePgDb } = require('../utils/testUtils'); + +const { pool } = require('../../config/db-pg'); +const PGMessage = require('../../models/pg/Message'); + +const d = process.env.INTEGRATION_TEST === 'true' && process.env.PG_HOST ? describe : describe.skip; +const POD = 'aaaaaaaaaaaaaaaaaaaaaa01'; +const LIN = 'cccccccccccccccccccccc01'; + +const insert = (content, createdAt) => pool.query( + 'INSERT INTO messages (pod_id, user_id, content, message_type, created_at) VALUES ($1, $2, $3, $4, $5)', + [POD, LIN, content, 'text', createdAt], +); + +d('recentContentByUser on real PostgreSQL', () => { + beforeAll(async () => { + await setupPgDb(); + await pool.query("INSERT INTO pods (id, name, type, created_by) VALUES ($1, 'History', 'chat', $2)", [POD, LIN]); + await insert('written live this week', new Date('2026-10-28T10:00:00Z')); + await insert('copied from months ago', new Date('2026-06-01T10:00:00Z')); + // Two rows at the same instant: the later insert (higher id) comes first. + await insert('same instant, lower id', new Date('2026-05-01T10:00:00Z')); + await insert('same instant, higher id', new Date('2026-05-01T10:00:00Z')); + await insert('undated, highest id', null); + }); + afterAll(async () => { + await pool.query('TRUNCATE TABLE messages, pods RESTART IDENTITY CASCADE'); + await closePgDb(); + await pool.end(); + }); + + test('orders by created_at, ties by id, and puts an undated row last', async () => { + await expect(PGMessage.recentContentByUser(LIN)).resolves.toEqual([ + 'written live this week', + 'copied from months ago', + 'same instant, higher id', + 'same instant, lower id', + 'undated, highest id', + ]); + }); + + test('the row cap keeps the newest dated rows', async () => { + await expect(PGMessage.recentContentByUser(LIN, 1)).resolves.toEqual(['written live this week']); + }); +});