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
24 changes: 24 additions & 0 deletions backend/__tests__/unit/models/threadRootResolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,30 @@ describe('explicit, the in-thread post shape', () => {
test('explicit wins over derivation when both agree', async () => {
expect(await resolveThreadRoot({ podId: POD, replyToMessageId: 101, threadRootId: 100 })).toBe(100);
});

// @sprint-review 56879. "Agree" is a set with two members and this file only
// held one: above, the parent is a reply INSIDE the thread. The other member
// is the parent BEING the root, and it is the one V2PodChat's handleSend
// comment is written about, because it is the only shape where the reply
// edge pings the ROOT's author rather than some mid-thread author.
//
// It is also the one the resolver structurally cannot refuse. derived is
// COALESCE(parent.thread_root_id, parent.id), and a root's thread_root_id is
// NULL, so derived falls through to parent.id — which IS the named root. The
// two statements are equal by construction; thread_root_mismatch can never
// fire here no matter what the client sends. Nothing server-side stops it,
// so the client rule is the only thing that does, and this test is what
// makes that dependency visible rather than assumed.
test('replying to the root while aimed at its thread: accepted, and unrefusable', async () => {
expect(await resolveThreadRoot({ podId: POD, replyToMessageId: 100, threadRootId: 100 })).toBe(100);
});

test('CONTROL: the same pair one message over DOES disagree and 400s', async () => {
// Proves the acceptance above is the COALESCE fall-through and not a
// resolver that waves through any pair naming the same pod.
await expect(resolveThreadRoot({ podId: POD, replyToMessageId: 100, threadRootId: 200 }))
.rejects.toMatchObject({ code: 'thread_root_mismatch' });
});
});

describe('validation — each of these is a 400, not a silent choice', () => {
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/v2/components/V2PodChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -860,8 +860,20 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ detail, firstRunVisible = false,
// It would not: resolveThreadRoot 400s only when the two DISAGREE
// (thread_root_mismatch) and accepts them when they agree. An in-thread
// post must carry no addressing edge because a reply edge pings the
// root's author (@ux-lead 56879) — that is the rule this enforces, and
// it is the client's to keep.
// author of whatever it points at (@ux-lead 56879) — that is the rule
// this enforces, and it is the client's to keep.
//
// "Agree" is a set, not a point: the backend accepts the pair whenever
// explicit === COALESCE(parent.thread_root_id, parent.id), i.e. whenever
// the parent is anywhere IN the thread being aimed at. Two members, and
// they differ in WHO gets pinged, because resolveImplicitReplyTarget
// resolves the author of replyToMessageId — the parent, not the root:
// parent mid-thread (reply 101 in thread 100) -> pings 101's author
// parent IS the root (replyTo 100, root 100) -> pings the root's author
// Only the second collapses onto "the root's author", which is why that
// is the shape this comment is really about — and it is structurally
// unrefusable: a root's COALESCE falls through to its own id, so the two
// statements can never disagree. @sprint-review 56879.
const created = await sendMessage(
text,
'text',
Expand Down
Loading