Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions backend/__tests__/unit/models/PgMessageCreateAfterInsert.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// TASK-313: once the INSERT has committed, a failure in the pod touch or the
// attention bookkeeping must not reject create(). The controller's Mongo
// fallback would otherwise write a second copy of a message that is already
// in Postgres.
jest.mock('../../../config/db-pg', () => ({
pool: {
query: jest.fn(),
},
}));

const mockRecordMentionedUsers = jest.fn();
const mockResolveMentionAttentionForReply = jest.fn();
jest.mock('../../../services/attentionItemService', () => ({
recordMentionedUsers: (...args) => mockRecordMentionedUsers(...args),
resolveMentionAttentionForReply: (...args) => mockResolveMentionAttentionForReply(...args),
resolve: jest.fn(),
resolveMany: jest.fn(),
}));

const { pool } = require('../../../config/db-pg');
const Message = require('../../../models/pg/Message');

const insertedRow = {
id: '7',
pod_id: 'p',
user_id: 'u',
content: 'hello',
message_type: 'text',
created_at: new Date('2026-10-23T00:00:00Z'),
thread_root_id: null,
reply_to_message_id: null,
};

describe('PG Message create after the insert has committed', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRecordMentionedUsers.mockResolvedValue(undefined);
mockResolveMentionAttentionForReply.mockResolvedValue(0);
});

it('resolves with the inserted row when the pod touch fails', async () => {
pool.query
.mockResolvedValueOnce({ rows: [insertedRow] })
.mockRejectedValueOnce(new Error('pod update failed'));

const row = await Message.create('p', 'u', 'hello', 'text', null, null, null);

expect(row.id).toBe('7');
expect(mockRecordMentionedUsers).toHaveBeenCalledTimes(1);
});

it('resolves with the inserted row when attention bookkeeping fails', async () => {
pool.query
.mockResolvedValueOnce({ rows: [insertedRow] })
.mockResolvedValueOnce({ rows: [] });
mockRecordMentionedUsers.mockRejectedValueOnce(new Error('attention write failed'));

const row = await Message.create('p', 'u', 'hello', 'text', null, null, null);

expect(row.id).toBe('7');
});

it('still throws before the insert when the reply target is not in the pod', async () => {
pool.query.mockResolvedValueOnce({ rows: [] });

await expect(Message.create('p', 'u', 'hello', 'text', '99', null, null))
.rejects.toThrow(/not in this pod|reply_target_unavailable|ThreadRoot/i);
expect(pool.query).toHaveBeenCalledTimes(1);
});
});
50 changes: 31 additions & 19 deletions backend/models/pg/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,26 +176,38 @@ class Message {
const { ThreadRootError } = require('../../services/threadRootResolver');
throw new ThreadRootError('reply_target_unavailable', 'the message being replied to is not in this pod');
}
await (pool as PgPool).query(
'UPDATE pods SET updated_at = CURRENT_TIMESTAMP WHERE id = $1',
[podId],
);
const row = result.rows[0] as unknown as MessageRow;
// eslint-disable-next-line global-require
const {
recordMentionedUsers,
resolveMentionAttentionForReply,
} = require('../../services/attentionItemService');
await recordMentionedUsers({
...row, podId, userId, content, threadRootId: resolvedThreadRootId, answeringMessageId,
});
await resolveMentionAttentionForReply({
podId,
recipientUserId: userId,
repliedAt: row.created_at,
threadRootId: row.thread_root_id,
replyToMessageId: row.reply_to_message_id,
});
// The INSERT has committed. Nothing below may reject back to the caller:
// the controller's Mongo fallback would then write a second copy of a
// message that is already in Postgres (TASK-313). Each step is best
// effort and logged, the same way the post-write read is.
try {
await (pool as PgPool).query(
'UPDATE pods SET updated_at = CURRENT_TIMESTAMP WHERE id = $1',
[podId],
);
} catch (touchErr) {
console.warn('[Message.create] pod touch failed after insert:', (touchErr as Error).message);
}
try {
// eslint-disable-next-line global-require
const {
recordMentionedUsers,
resolveMentionAttentionForReply,
} = require('../../services/attentionItemService');
await recordMentionedUsers({
...row, podId, userId, content, threadRootId: resolvedThreadRootId, answeringMessageId,
});
await resolveMentionAttentionForReply({
podId,
recipientUserId: userId,
repliedAt: row.created_at,
threadRootId: row.thread_root_id,
replyToMessageId: row.reply_to_message_id,
});
} catch (attentionErr) {
console.warn('[Message.create] attention bookkeeping failed after insert:', (attentionErr as Error).message);
}
return row;
} catch (error) {
const e = error as { message?: string };
Expand Down
Loading