Files
OwnCord/Client/tauri-client/tests/unit/messages.store.test.ts
T
J3vbandClaude Opus 5 8cf019c03f fix: 40 correctness fixes from the 2026-08-19 bug hunt (#1392)
* fix(identity): 1 defect(s) (OC-0151)

* fix(ws): 1 defect(s) (OC-0152)

* fix(admin): 1 defect(s) (OC-0153)

* fix(admin): 1 defect(s) (OC-0154)

* fix(voice): 2 defect(s) (OC-0155, OC-0167)

Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.

- OC-0155: back-to-back rotations (the second run immediately by
  drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
  combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
  entirely, letting a key holder joining a large ongoing call burst every
  queued announce's offer unpaced.

The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).

* fix(client): 1 defect(s) (OC-0156)

createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.

* fix(client): 2 defect(s) (OC-0160, OC-0163)

OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.

OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.

* fix(voice): 1 defect(s) (OC-0162)

updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.

* fix(client): 1 defect(s) (OC-0164)

* fix(plugin): 1 defect(s) (OC-0165)

scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.

* fix(ws): 1 defect(s) (OC-0166)

Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.

* fix(db): 1 defect(s) (OC-0168)

* fix(client): 1 defect(s) (OC-0169)

* fix(client): 1 defect(s) (OC-0171)

addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.

* fix(voice): 1 defect(s) (OC-0172)

* fix(client): 1 defect(s) (OC-0174)

* fix(ws): 1 defect(s) (OC-0175)

* fix(client): 1 defect(s) (OC-0177)

* fix(client): 1 defect(s) (OC-0178)

* fix(voice): 1 defect(s) (OC-0179)

Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.

* fix(client): 1 defect(s) (OC-0182)

* fix(plugin): 1 defect(s) (OC-0183)

* fix(client): 1 defect(s) (OC-0184)

Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.

* fix(client): 1 defect(s) (OC-0185)

Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.

Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): 1 defect(s) (OC-0186)

* fix(client): 1 defect(s) (OC-0187)

The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.

* fix(client): 1 defect(s) (OC-0189)

DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.

* fix(client): 1 defect(s) (OC-0190)

* fix(client): 1 defect(s) (OC-0191)

* fix(client): 2 defect(s) (OC-0157, OC-0176)

* fix(client): 1 defect(s) (OC-0161)

confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.

* fix(admin): 1 defect(s) (OC-0173)

* fix(identity): 1 defect(s) (OC-0180)

* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)

handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.

Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.

Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(admin): role changes commit with no client ever notified (OC-0170)

broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.

Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.

Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): username rename stomps the profile card header (OC-0188)

The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.

Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): settings overlay never focuses when mounted already-open (OC-0181)

mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.

Attach root before syncing initial state so focusDialog() runs against a
connected subtree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* chore: satisfy the CI gates for this fix batch

The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.

golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
  and OC-0151 the last use of api's sanitizer, leaving both package-level
  bluemonday vars unused. Remove them along with the now-unused imports,
  and reword the comments that named them so they still explain why the
  fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.

tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
  surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
  noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
  must supply typingUsers.

prettier: reformat the five files this batch touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* chore(ledger): record the 2026-08-19 hunt and its fixes

Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".

"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.

OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* test(e2e): make the voice-roster join fixture self-consistent

The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.

The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.

Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.

Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 16:32:07 +02:00

1566 lines
52 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import {
messagesStore,
addMessage,
setMessages,
prependMessages,
editMessage,
deleteMessage,
bulkDeleteMessages,
setMessagePinned,
updateReaction,
addOptimisticReaction,
rollbackReaction,
addPendingSend,
confirmSend,
addOptimisticMessage,
markSendFailed,
removeOptimistic,
getChannelMessages,
isChannelLoaded,
hasMoreMessages,
isWindowDetached,
clearChannelMessages,
setChannelLoading,
setChannelLoadError,
getHistoryLoadState,
invalidateLoadedMessageWindows,
invalidateChannelMessageWindow,
setAroundMessages,
} from "../../src/stores/messages.store";
import type {
ChatMessagePayload,
ChatEditedPayload,
ChatDeletedPayload,
ReactionUpdatePayload,
MessageResponse,
MessageUser,
Attachment,
} from "../../src/lib/types";
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const TEST_USER: MessageUser = {
id: 1,
username: "alice",
avatar: "alice.png",
};
const TEST_USER_2: MessageUser = {
id: 2,
username: "bob",
avatar: null,
};
const ATTACHMENT: Attachment = {
id: "att-1",
filename: "screenshot.png",
size: 1024,
mime: "image/png",
url: "/uploads/screenshot.png",
};
function makeChatPayload(overrides?: Partial<ChatMessagePayload>): ChatMessagePayload {
return {
id: 100,
channel_id: 1,
user: TEST_USER,
content: "Hello world",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
...overrides,
};
}
function makeMessageResponse(overrides?: Partial<MessageResponse>): MessageResponse {
return {
id: 200,
channel_id: 1,
user: TEST_USER,
content: "REST message",
reply_to: null,
attachments: [],
reactions: [],
pinned: false,
edited_at: null,
deleted: false,
timestamp: "2026-03-15T09:00:00Z",
...overrides,
};
}
// ---------------------------------------------------------------------------
// Reset helper — clears all channels we might have touched
// ---------------------------------------------------------------------------
function resetStore(): void {
clearChannelMessages(1);
clearChannelMessages(2);
clearChannelMessages(99);
// Clear any leftover pending sends by confirming them
const pending = messagesStore.getState().pendingSends;
for (const [corrId] of pending) {
confirmSend(corrId, 0, "");
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("messages store", () => {
beforeEach(() => {
resetStore();
});
// 1. Initial state is empty
describe("initial state", () => {
it("has empty messagesByChannel", () => {
expect(messagesStore.getState().messagesByChannel.size).toBe(0);
});
it("has empty pendingSends", () => {
expect(messagesStore.getState().pendingSends.size).toBe(0);
});
it("has empty loadedChannels", () => {
expect(messagesStore.getState().loadedChannels.size).toBe(0);
});
it("has empty hasMore", () => {
expect(messagesStore.getState().hasMore.size).toBe(0);
});
});
// 2. addMessage appends to correct channel
describe("addMessage", () => {
it("adds a message to the correct channel", () => {
addMessage(makeChatPayload({ id: 1, channel_id: 1 }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.id).toBe(1);
expect(msgs[0]!.channelId).toBe(1);
});
it("converts snake_case fields to camelCase", () => {
addMessage(
makeChatPayload({
id: 10,
channel_id: 2,
reply_to: 5,
attachments: [ATTACHMENT],
}),
);
const msg = getChannelMessages(2)[0]!;
expect(msg.channelId).toBe(2);
expect(msg.replyTo).toBe(5);
expect(msg.attachments).toEqual([ATTACHMENT]);
expect(msg.editedAt).toBeNull();
expect(msg.deleted).toBe(false);
});
it("appends subsequent messages in order", () => {
addMessage(makeChatPayload({ id: 1, channel_id: 1 }));
addMessage(makeChatPayload({ id: 2, channel_id: 1, content: "Second" }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(2);
expect(msgs[0]!.id).toBe(1);
expect(msgs[1]!.id).toBe(2);
});
it("keeps messages in separate channels isolated", () => {
addMessage(makeChatPayload({ id: 1, channel_id: 1 }));
addMessage(makeChatPayload({ id: 2, channel_id: 2 }));
expect(getChannelMessages(1)).toHaveLength(1);
expect(getChannelMessages(2)).toHaveLength(1);
});
it("produces a new state reference", () => {
const before = messagesStore.getState();
addMessage(makeChatPayload());
const after = messagesStore.getState();
expect(before).not.toBe(after);
});
});
// 3. setMessages bulk sets and marks loaded
describe("setMessages", () => {
it("sets messages for a channel", () => {
// API returns newest-first; store reverses to oldest-first for display.
const responses = [makeMessageResponse({ id: 11 }), makeMessageResponse({ id: 10 })];
setMessages(1, responses, false);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(2);
expect(msgs[0]!.id).toBe(10);
expect(msgs[1]!.id).toBe(11);
});
it("marks channel as loaded", () => {
expect(isChannelLoaded(1)).toBe(false);
setMessages(1, [], false);
expect(isChannelLoaded(1)).toBe(true);
});
it("stores hasMore flag", () => {
setMessages(1, [], true);
expect(messagesStore.getState().hasMore.get(1)).toBe(true);
setMessages(2, [], false);
expect(messagesStore.getState().hasMore.get(2)).toBe(false);
});
it("converts MessageResponse fields to camelCase", () => {
setMessages(
1,
[makeMessageResponse({ edited_at: "2026-03-15T11:00:00Z", reply_to: 3 })],
false,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.editedAt).toBe("2026-03-15T11:00:00Z");
expect(msg.replyTo).toBe(3);
});
it("replaces existing messages for the channel", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
setMessages(1, [makeMessageResponse({ id: 20 })], false);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.id).toBe(20);
});
it("keeps a newer live broadcast that landed while the history fetch was in flight", () => {
// The broadcast arrives over the open WS after the server ran the GET
// query but before the response reaches the client.
addMessage(makeChatPayload({ id: 300, channel_id: 1, content: "live" }));
setMessages(1, [makeMessageResponse({ id: 201 }), makeMessageResponse({ id: 200 })], false);
const msgs = getChannelMessages(1);
expect(msgs.map((m) => m.id)).toEqual([200, 201, 300]);
expect(msgs[2]!.content).toBe("live");
});
it("keeps pending and failed optimistic rows across setMessages", () => {
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "in flight",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
addOptimisticMessage({
correlationId: "c2",
channelId: 1,
user: TEST_USER,
content: "refused",
replyTo: null,
timestamp: "2026-03-15T10:00:01Z",
});
markSendFailed("c2", "SLOW_MODE");
setMessages(1, [makeMessageResponse({ id: 200 })], false);
const msgs = getChannelMessages(1);
expect(msgs.map((m) => m.correlationId)).toEqual([null, "c1", "c2"]);
expect(msgs[1]!.status).toBe("pending");
expect(msgs[2]!.status).toBe("failed");
});
it("dedupes an optimistic row against its own real message after a lost ack + resync", () => {
// The chat_send_ok ack never arrives (dropped by the same disconnect
// that forces a resync), so the row is still "pending" when
// invalidateLoadedMessageWindows carries it through.
setMessages(1, [makeMessageResponse({ id: 10 })], false);
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "ok",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
invalidateLoadedMessageWindows();
expect(getChannelMessages(1)).toHaveLength(1);
expect(getChannelMessages(1)[0]!.status).toBe("pending");
// The resync's history fetch reveals the server DID persist the send —
// id 500 is the real echo of the lost ack.
setMessages(1, [makeMessageResponse({ id: 500, content: "ok", user: TEST_USER })], false);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.id).toBe(500);
expect(msgs[0]!.status).toBe("sent");
});
it("keeps two genuinely distinct same-author, same-text sends distinct across the same resync", () => {
setMessages(1, [], false);
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "ok",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
addOptimisticMessage({
correlationId: "c2",
channelId: 1,
user: TEST_USER,
content: "ok",
replyTo: null,
timestamp: "2026-03-15T10:00:01Z",
});
invalidateLoadedMessageWindows();
expect(getChannelMessages(1)).toHaveLength(2);
// Both sends actually landed server-side; the resync's fetch returns
// both real rows — neither optimistic row may collapse onto the other's.
setMessages(
1,
[
makeMessageResponse({ id: 501, content: "ok", user: TEST_USER }),
makeMessageResponse({ id: 500, content: "ok", user: TEST_USER }),
],
false,
);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(2);
expect(msgs.map((m) => m.id)).toEqual([500, 501]);
expect(msgs.every((m) => m.status === "sent")).toBe(true);
});
});
// 4. prependMessages prepends older messages
describe("prependMessages", () => {
it("prepends older messages before existing ones", () => {
// API returns newest-first; store reverses to oldest-first.
setMessages(1, [makeMessageResponse({ id: 20 })], true);
prependMessages(1, [makeMessageResponse({ id: 15 }), makeMessageResponse({ id: 10 })], false);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(3);
expect(msgs[0]!.id).toBe(10);
expect(msgs[1]!.id).toBe(15);
expect(msgs[2]!.id).toBe(20);
});
it("updates hasMore flag", () => {
setMessages(1, [makeMessageResponse({ id: 20 })], true);
expect(messagesStore.getState().hasMore.get(1)).toBe(true);
prependMessages(1, [makeMessageResponse({ id: 10 })], false);
expect(messagesStore.getState().hasMore.get(1)).toBe(false);
});
it("works on a channel with no existing messages", () => {
prependMessages(1, [makeMessageResponse({ id: 5 })], false);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.id).toBe(5);
});
});
// 5. editMessage updates content and editedAt
describe("editMessage", () => {
it("updates content and editedAt for the target message", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1, content: "Original" }));
const editPayload: ChatEditedPayload = {
message_id: 100,
channel_id: 1,
content: "Edited content",
edited_at: "2026-03-15T12:00:00Z",
};
editMessage(editPayload);
const msg = getChannelMessages(1)[0]!;
expect(msg.content).toBe("Edited content");
expect(msg.editedAt).toBe("2026-03-15T12:00:00Z");
});
it("does not affect other messages in the channel", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1, content: "First" }));
addMessage(makeChatPayload({ id: 101, channel_id: 1, content: "Second" }));
editMessage({
message_id: 100,
channel_id: 1,
content: "Edited",
edited_at: "2026-03-15T12:00:00Z",
});
const msgs = getChannelMessages(1);
expect(msgs[0]!.content).toBe("Edited");
expect(msgs[1]!.content).toBe("Second");
});
it("is a no-op if the channel does not exist", () => {
const before = messagesStore.getState();
editMessage({
message_id: 999,
channel_id: 99,
content: "Nope",
edited_at: "2026-03-15T12:00:00Z",
});
const after = messagesStore.getState();
expect(before).toBe(after);
});
it("produces a new message object (immutable update)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
const original = getChannelMessages(1)[0]!;
editMessage({
message_id: 100,
channel_id: 1,
content: "Edited",
edited_at: "2026-03-15T12:00:00Z",
});
const edited = getChannelMessages(1)[0]!;
expect(original).not.toBe(edited);
});
});
// 6. deleteMessage marks as deleted
describe("deleteMessage", () => {
it("marks the message as deleted", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
const deletePayload: ChatDeletedPayload = {
message_id: 100,
channel_id: 1,
};
deleteMessage(deletePayload);
const msg = getChannelMessages(1)[0]!;
expect(msg.deleted).toBe(true);
});
it("keeps the message in the array (soft delete)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addMessage(makeChatPayload({ id: 101, channel_id: 1 }));
deleteMessage({ message_id: 100, channel_id: 1 });
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(2);
expect(msgs[0]!.deleted).toBe(true);
expect(msgs[1]!.deleted).toBe(false);
});
it("is a no-op if the channel does not exist", () => {
const before = messagesStore.getState();
deleteMessage({ message_id: 999, channel_id: 99 });
const after = messagesStore.getState();
expect(before).toBe(after);
});
});
// 6b. bulkDeleteMessages (channel purge)
describe("bulkDeleteMessages", () => {
it("marks every id as deleted while keeping the rows", () => {
for (const id of [100, 101, 102]) {
addMessage(makeChatPayload({ id, channel_id: 1 }));
}
bulkDeleteMessages({ channel_id: 1, ids: [102, 101] });
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(3);
expect(msgs.find((m) => m.id === 102)!.deleted).toBe(true);
expect(msgs.find((m) => m.id === 101)!.deleted).toBe(true);
expect(msgs.find((m) => m.id === 100)!.deleted).toBe(false);
});
it("ignores ids that are not loaded", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
bulkDeleteMessages({ channel_id: 1, ids: [100, 999] });
expect(getChannelMessages(1)).toHaveLength(1);
expect(getChannelMessages(1)[0]!.deleted).toBe(true);
});
it("is a no-op for an unknown channel, an empty id list, and a repeat purge", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
bulkDeleteMessages({ channel_id: 1, ids: [100] });
const before = messagesStore.getState();
bulkDeleteMessages({ channel_id: 99, ids: [1] });
bulkDeleteMessages({ channel_id: 1, ids: [] });
bulkDeleteMessages({ channel_id: 1, ids: [100] });
expect(messagesStore.getState()).toBe(before);
});
});
// 7. addPendingSend / confirmSend lifecycle
describe("pending send lifecycle", () => {
it("addPendingSend tracks correlationId -> channelId", () => {
addPendingSend("corr-1", 1);
const pending = messagesStore.getState().pendingSends;
expect(pending.get("corr-1")).toBe(1);
});
it("confirmSend removes the pending entry", () => {
addPendingSend("corr-1", 1);
confirmSend("corr-1", 100, "2026-03-15T10:00:00Z");
const pending = messagesStore.getState().pendingSends;
expect(pending.has("corr-1")).toBe(false);
});
it("tracks multiple pending sends independently", () => {
addPendingSend("corr-1", 1);
addPendingSend("corr-2", 2);
expect(messagesStore.getState().pendingSends.size).toBe(2);
confirmSend("corr-1", 100, "2026-03-15T10:00:00Z");
const pending = messagesStore.getState().pendingSends;
expect(pending.size).toBe(1);
expect(pending.has("corr-1")).toBe(false);
expect(pending.get("corr-2")).toBe(2);
});
it("confirmSend is a no-op for unknown correlationId", () => {
const before = messagesStore.getState();
confirmSend("unknown", 100, "2026-03-15T10:00:00Z");
const after = messagesStore.getState();
// State still changes (new Map created), but pending size is 0
expect(after.pendingSends.size).toBe(0);
});
});
// 8. getChannelMessages returns empty for unknown channel
describe("getChannelMessages", () => {
it("returns empty array for a channel with no messages", () => {
const msgs = getChannelMessages(999);
expect(msgs).toEqual([]);
expect(msgs).toHaveLength(0);
});
it("returns the messages after addMessage", () => {
addMessage(makeChatPayload({ id: 1, channel_id: 1 }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
});
});
// 9. clearChannelMessages clears
describe("clearChannelMessages", () => {
it("removes messages for the channel", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], true);
expect(getChannelMessages(1)).toHaveLength(1);
clearChannelMessages(1);
expect(getChannelMessages(1)).toHaveLength(0);
});
it("removes loaded status for the channel", () => {
setMessages(1, [], false);
expect(isChannelLoaded(1)).toBe(true);
clearChannelMessages(1);
expect(isChannelLoaded(1)).toBe(false);
});
it("removes hasMore for the channel", () => {
setMessages(1, [], true);
expect(messagesStore.getState().hasMore.get(1)).toBe(true);
clearChannelMessages(1);
expect(messagesStore.getState().hasMore.has(1)).toBe(false);
});
it("does not affect other channels", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
setMessages(2, [makeMessageResponse({ id: 20, channel_id: 2 })], false);
clearChannelMessages(1);
expect(getChannelMessages(1)).toHaveLength(0);
expect(getChannelMessages(2)).toHaveLength(1);
expect(isChannelLoaded(2)).toBe(true);
});
it("is safe to call on a channel that was never loaded", () => {
clearChannelMessages(999);
expect(getChannelMessages(999)).toHaveLength(0);
});
});
// 10. isChannelLoaded selector
describe("isChannelLoaded", () => {
it("returns false for unknown channel", () => {
expect(isChannelLoaded(999)).toBe(false);
});
it("returns true after setMessages", () => {
setMessages(1, [], false);
expect(isChannelLoaded(1)).toBe(true);
});
it("returns false after clearChannelMessages", () => {
setMessages(1, [], false);
clearChannelMessages(1);
expect(isChannelLoaded(1)).toBe(false);
});
});
// 11. hasMoreMessages selector
describe("hasMoreMessages", () => {
it("returns false for unknown channel", () => {
expect(hasMoreMessages(999)).toBe(false);
});
it("returns true when hasMore is set", () => {
setMessages(1, [], true);
expect(hasMoreMessages(1)).toBe(true);
});
it("returns false when hasMore is false", () => {
setMessages(1, [], false);
expect(hasMoreMessages(1)).toBe(false);
});
});
// 12. setMessagePinned
describe("setMessagePinned", () => {
it("sets a message as pinned", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
expect(getChannelMessages(1)[0]!.pinned).toBe(false);
setMessagePinned(1, 100, true);
expect(getChannelMessages(1)[0]!.pinned).toBe(true);
});
it("sets a message as unpinned", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
setMessagePinned(1, 100, true);
expect(getChannelMessages(1)[0]!.pinned).toBe(true);
setMessagePinned(1, 100, false);
expect(getChannelMessages(1)[0]!.pinned).toBe(false);
});
it("does not affect other messages", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addMessage(makeChatPayload({ id: 101, channel_id: 1 }));
setMessagePinned(1, 100, true);
expect(getChannelMessages(1)[0]!.pinned).toBe(true);
expect(getChannelMessages(1)[1]!.pinned).toBe(false);
});
it("is a no-op if the channel does not exist", () => {
const before = messagesStore.getState();
setMessagePinned(99, 100, true);
const after = messagesStore.getState();
expect(before).toBe(after);
});
it("produces a new message object (immutable update)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
const original = getChannelMessages(1)[0]!;
setMessagePinned(1, 100, true);
const updated = getChannelMessages(1)[0]!;
expect(original).not.toBe(updated);
});
});
// 13. updateReaction
describe("updateReaction", () => {
it("adds a new reaction to a message", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
const payload: ReactionUpdatePayload = {
message_id: 100,
channel_id: 1,
emoji: "👍",
user_id: 2,
action: "add",
};
updateReaction(payload, 1);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toHaveLength(1);
expect(msg.reactions[0]).toEqual({ emoji: "👍", count: 1, me: false });
});
it("marks reaction as 'me' when current user reacts", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
updateReaction(
{
message_id: 100,
channel_id: 1,
emoji: "❤️",
user_id: 1,
action: "add",
},
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions[0]).toEqual({ emoji: "❤️", count: 1, me: true });
});
it("increments count on existing reaction", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
updateReaction(
{
message_id: 100,
channel_id: 1,
emoji: "👍",
user_id: 2,
action: "add",
},
1,
);
updateReaction(
{
message_id: 100,
channel_id: 1,
emoji: "👍",
user_id: 3,
action: "add",
},
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toHaveLength(1);
expect(msg.reactions[0]!.count).toBe(2);
});
it("sets me=true when incrementing existing reaction by current user", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
updateReaction(
{
message_id: 100,
channel_id: 1,
emoji: "👍",
user_id: 2,
action: "add",
},
1,
);
updateReaction(
{
message_id: 100,
channel_id: 1,
emoji: "👍",
user_id: 1,
action: "add",
},
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions[0]!.me).toBe(true);
});
it("removes a reaction (decrements count)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
// Add 2 reactions
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 3, action: "add" }, 1);
// Remove one
updateReaction(
{ message_id: 100, channel_id: 1, emoji: "👍", user_id: 3, action: "remove" },
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toHaveLength(1);
expect(msg.reactions[0]!.count).toBe(1);
});
it("removes reaction entirely when count reaches 0", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
updateReaction(
{ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" },
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toHaveLength(0);
});
it("clears 'me' flag when current user removes their reaction", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1);
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
expect(getChannelMessages(1)[0]!.reactions[0]!.me).toBe(true);
updateReaction(
{ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "remove" },
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions[0]!.me).toBe(false);
expect(msg.reactions[0]!.count).toBe(1);
});
it("is a no-op if the channel does not exist", () => {
const before = messagesStore.getState();
updateReaction(
{ message_id: 999, channel_id: 99, emoji: "👍", user_id: 1, action: "add" },
1,
);
const after = messagesStore.getState();
expect(before).toBe(after);
});
it("does not affect other messages in the channel", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addMessage(makeChatPayload({ id: 101, channel_id: 1 }));
updateReaction({ message_id: 100, channel_id: 1, emoji: "🎉", user_id: 2, action: "add" }, 1);
const msgs = getChannelMessages(1);
expect(msgs[0]!.reactions).toHaveLength(1);
expect(msgs[1]!.reactions).toHaveLength(0);
});
it("preserves 'me' when another user removes (not current user)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1);
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
updateReaction(
{ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" },
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions[0]!.me).toBe(true);
expect(msg.reactions[0]!.count).toBe(1);
});
it("preserves other emoji reactions when incrementing one (non-matching branch)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
// Add two different emoji reactions
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
updateReaction({ message_id: 100, channel_id: 1, emoji: "❤️", user_id: 3, action: "add" }, 1);
// Increment only 👍 — the ❤️ reaction should remain unchanged
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 4, action: "add" }, 1);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toHaveLength(2);
const thumbs = msg.reactions.find((r) => r.emoji === "👍");
const heart = msg.reactions.find((r) => r.emoji === "❤️");
expect(thumbs!.count).toBe(2);
expect(heart!.count).toBe(1);
});
it("preserves other emoji reactions when removing one (non-matching branch)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
// Add two different reactions
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
updateReaction({ message_id: 100, channel_id: 1, emoji: "❤️", user_id: 3, action: "add" }, 1);
// Remove 👍 — ❤️ should remain unchanged
updateReaction(
{ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "remove" },
1,
);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toHaveLength(1);
expect(msg.reactions[0]!.emoji).toBe("❤️");
expect(msg.reactions[0]!.count).toBe(1);
});
});
// 13b. optimistic reactions (ux/messaging §5)
describe("optimistic reactions", () => {
const toggle = (action: "add" | "remove") => ({
channelId: 1,
messageId: 100,
emoji: "👍",
action,
});
it("applies an optimistic add immediately as the current user's pill", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addOptimisticReaction("corr-1", toggle("add"));
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toEqual([{ emoji: "👍", count: 1, me: true }]);
});
it("consumes the self-echo instead of double-counting it", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addOptimisticReaction("corr-1", toggle("add"));
// The server broadcasts the toggle back to its sender too.
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toEqual([{ emoji: "👍", count: 1, me: true }]);
expect(messagesStore.getState().pendingReactions?.size).toBe(0);
});
it("still applies another user's identical reaction while one is pending", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addOptimisticReaction("corr-1", toggle("add"));
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toEqual([{ emoji: "👍", count: 2, me: true }]);
// The pending toggle is NOT consumed by someone else's echo.
expect(messagesStore.getState().pendingReactions?.size).toBe(1);
});
it("rolls back a failed optimistic add (pill disappears)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addOptimisticReaction("corr-1", toggle("add"));
expect(rollbackReaction("corr-1")).toBe(true);
const msg = getChannelMessages(1)[0]!;
expect(msg.reactions).toHaveLength(0);
expect(messagesStore.getState().pendingReactions?.size).toBe(0);
});
it("rolls back a failed optimistic remove (pill restored)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
// Someone else's reaction plus mine.
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 2, action: "add" }, 1);
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1);
addOptimisticReaction("corr-1", toggle("remove"));
expect(getChannelMessages(1)[0]!.reactions).toEqual([{ emoji: "👍", count: 1, me: false }]);
expect(rollbackReaction("corr-1")).toBe(true);
expect(getChannelMessages(1)[0]!.reactions).toEqual([{ emoji: "👍", count: 2, me: true }]);
});
it("rollback of an unknown correlation id reports false and changes nothing", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
const before = messagesStore.getState();
expect(rollbackReaction("nope")).toBe(false);
expect(messagesStore.getState()).toBe(before);
});
it("a late error after the echo was consumed cannot roll back (no ghost revert)", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
addOptimisticReaction("corr-1", toggle("add"));
updateReaction({ message_id: 100, channel_id: 1, emoji: "👍", user_id: 1, action: "add" }, 1);
expect(rollbackReaction("corr-1")).toBe(false);
expect(getChannelMessages(1)[0]!.reactions).toEqual([{ emoji: "👍", count: 1, me: true }]);
});
it("does not register a pending toggle for an unloaded channel", () => {
addOptimisticReaction("corr-1", { channelId: 99, messageId: 1, emoji: "👍", action: "add" });
expect(messagesStore.getState().pendingReactions?.size).toBe(0);
});
});
// 14. addMessage eviction beyond MAX_MESSAGES_PER_CHANNEL
describe("addMessage eviction", () => {
it("evicts oldest messages when exceeding cap (500)", () => {
// Pre-load 500 messages using addMessage (since setMessages reverses)
for (let i = 1; i <= 500; i++) {
addMessage(makeChatPayload({ id: i, channel_id: 1 }));
}
expect(getChannelMessages(1)).toHaveLength(500);
// Adding one more should evict the oldest
addMessage(makeChatPayload({ id: 501, channel_id: 1 }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(500);
expect(msgs[0]!.id).toBe(2); // oldest (id=1) evicted
expect(msgs[msgs.length - 1]!.id).toBe(501);
});
it("sets hasMore to true when eviction occurs", () => {
for (let i = 1; i <= 500; i++) {
addMessage(makeChatPayload({ id: i, channel_id: 1 }));
}
addMessage(makeChatPayload({ id: 501, channel_id: 1 }));
expect(hasMoreMessages(1)).toBe(true);
});
});
// 15. setMessages trimming
describe("setMessages trimming", () => {
it("trims to MAX_MESSAGES_PER_CHANNEL when receiving more", () => {
const responses: MessageResponse[] = [];
for (let i = 1; i <= 510; i++) {
responses.push(makeMessageResponse({ id: i, channel_id: 1 }));
}
setMessages(1, responses, false);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(500);
});
it("sets hasMore to true when trimming occurs", () => {
const responses: MessageResponse[] = [];
for (let i = 1; i <= 510; i++) {
responses.push(makeMessageResponse({ id: i, channel_id: 1 }));
}
setMessages(1, responses, false);
expect(hasMoreMessages(1)).toBe(true);
});
});
// 16. prependMessages trimming
describe("prependMessages trimming", () => {
it("trims combined messages to MAX_MESSAGES_PER_CHANNEL", () => {
// Load 400 messages
const initial: MessageResponse[] = [];
for (let i = 101; i <= 500; i++) {
initial.push(makeMessageResponse({ id: i, channel_id: 1 }));
}
setMessages(1, initial, true);
// Prepend 200 more
const older: MessageResponse[] = [];
for (let i = 1; i <= 200; i++) {
older.push(makeMessageResponse({ id: i, channel_id: 1 }));
}
prependMessages(1, older, false);
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(500);
});
it("keeps the server's hasMore and detaches when trimming on prepend", () => {
// Trimming on prepend drops the live tail (rows BELOW the window), not
// older history, so "more above" stays whatever the server said and the
// channel becomes a detached window instead.
const initial: MessageResponse[] = [];
for (let i = 301; i <= 500; i++) {
initial.push(makeMessageResponse({ id: i, channel_id: 1 }));
}
setMessages(1, initial, false);
const older: MessageResponse[] = [];
for (let i = 1; i <= 400; i++) {
older.push(makeMessageResponse({ id: i, channel_id: 1 }));
}
prependMessages(1, older, false);
expect(hasMoreMessages(1)).toBe(false);
expect(isWindowDetached(1)).toBe(true);
});
});
// 17. editMessage when message ID doesn't match
describe("editMessage edge cases", () => {
it("does not modify messages when message ID not found in channel", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1, content: "Original" }));
editMessage({
message_id: 999,
channel_id: 1,
content: "Should not appear",
edited_at: "2026-03-15T12:00:00Z",
});
const msg = getChannelMessages(1)[0]!;
expect(msg.content).toBe("Original");
expect(msg.editedAt).toBeNull();
});
});
// 18. deleteMessage when message ID doesn't match
describe("deleteMessage edge cases", () => {
it("does not modify messages when message ID not found in channel", () => {
addMessage(makeChatPayload({ id: 100, channel_id: 1 }));
deleteMessage({ message_id: 999, channel_id: 1 });
const msg = getChannelMessages(1)[0]!;
expect(msg.deleted).toBe(false);
});
});
// -------------------------------------------------------------------------
// Optimistic send lifecycle
// -------------------------------------------------------------------------
describe("optimistic send", () => {
it("addOptimisticMessage inserts a pending row and tracks the correlation id", () => {
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.status).toBe("pending");
expect(msgs[0]!.correlationId).toBe("c1");
expect(msgs[0]!.id).toBe(0);
expect(messagesStore.getState().pendingSends.get("c1")).toBe(1);
});
it("confirmSend then the broadcast reconciles into a single sent message", () => {
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
// Ack arrives first (server sends chat_send_ok before the broadcast).
confirmSend("c1", 555, "2026-03-15T10:00:01Z");
let msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.status).toBe("sent");
expect(msgs[0]!.id).toBe(555);
expect(messagesStore.getState().pendingSends.has("c1")).toBe(false);
// The broadcast for our own message arrives with the real id → replace,
// not duplicate, upgrading to the full server row.
addMessage(makeChatPayload({ id: 555, content: "hi", attachments: [ATTACHMENT] }));
msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.id).toBe(555);
expect(msgs[0]!.status).toBe("sent");
expect(msgs[0]!.correlationId).toBeNull();
expect(msgs[0]!.attachments).toHaveLength(1);
});
it("markSendFailed flips the row to failed with the error code", () => {
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
markSendFailed("c1", "SLOW_MODE");
const msg = getChannelMessages(1)[0]!;
expect(msg.status).toBe("failed");
expect(msg.errorCode).toBe("SLOW_MODE");
expect(messagesStore.getState().pendingSends.has("c1")).toBe(false);
});
it("removeOptimistic drops the row (retry / dismiss)", () => {
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
removeOptimistic("c1");
expect(getChannelMessages(1)).toHaveLength(0);
expect(messagesStore.getState().pendingSends.has("c1")).toBe(false);
});
it("removeOptimistic drops an already-failed row, whose channel is no longer in pendingSends", () => {
// markSendFailed deletes the correlationId from pendingSends when it
// flips the row to "failed" — the Retry/Delete buttons only render for
// failed rows, so this is the path every UI-driven removeOptimistic call
// actually takes.
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
markSendFailed("c1", "SLOW_MODE");
expect(messagesStore.getState().pendingSends.has("c1")).toBe(false);
removeOptimistic("c1");
expect(getChannelMessages(1)).toHaveLength(0);
});
it("addMessage is idempotent by real id (replay-safe)", () => {
addMessage(makeChatPayload({ id: 700, content: "once" }));
addMessage(makeChatPayload({ id: 700, content: "once" }));
expect(getChannelMessages(1)).toHaveLength(1);
});
it("does not consume a pending row for a same-author broadcast with different content", () => {
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "mine, still sending",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
// Same author, different content: a replayed message from another
// session of this account, not the echo of the pending send.
addMessage(makeChatPayload({ id: 800, user: TEST_USER, content: "from my other device" }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(2);
expect(msgs.find((m) => m.correlationId === "c1")!.status).toBe("pending");
expect(msgs.find((m) => m.id === 800)!.content).toBe("from my other device");
});
it("defensively reconciles a broadcast that raced ahead of its ack", () => {
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "race",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
// Broadcast arrives before confirmSend; matched by author against the
// oldest pending row → replaced, not duplicated.
addMessage(makeChatPayload({ id: 800, user: TEST_USER, content: "race" }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.id).toBe(800);
expect(msgs[0]!.status).toBe("sent");
});
it("reconciles an OFFLINE-failed row when its echo arrives — no duplicate, no dead Retry", () => {
// The dispatcher's offline sweep flips every pending send to
// failed/OFFLINE on the first reconnecting/disconnected transition —
// it cannot know whether the frame actually reached the server first.
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
markSendFailed("c1", "OFFLINE");
// It did reach the server after all — its broadcast (or a reconnect
// replay) arrives with the real id.
addMessage(makeChatPayload({ id: 900, user: TEST_USER, content: "hi" }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(1);
expect(msgs[0]!.id).toBe(900);
expect(msgs[0]!.status).toBe("sent");
});
it("does not reconcile a server-rejected (non-OFFLINE) failed row into an unrelated broadcast", () => {
// SLOW_MODE/FORBIDDEN etc. are never broadcast by the server, so no echo
// can legitimately arrive for them — widening the reconcile must stay
// scoped to OFFLINE, or a same-author/same-content coincidence would
// silently eat a row the user still needs to retry.
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
markSendFailed("c1", "SLOW_MODE");
addMessage(makeChatPayload({ id: 900, user: TEST_USER, content: "hi" }));
const msgs = getChannelMessages(1);
expect(msgs).toHaveLength(2);
expect(msgs.find((m) => m.correlationId === "c1")!.status).toBe("failed");
});
it("keeps id/time order when another user's message commits while our send is still in flight", () => {
// Channel loaded with [id 100].
addMessage(makeChatPayload({ id: 100, user: TEST_USER_2, content: "seed" }));
// A types and sends -> optimistic pending row appended at the tail.
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "mine",
replyTo: null,
timestamp: "2026-03-15T10:00:01Z",
});
// Before A's send commits server-side, B's message commits as id 101
// and is broadcast. Different author/content, so this cannot reconcile
// against the pending row — it must land as a genuine append, and it
// must land *before* the still-pending row, not after it, or the
// pending row (which will shortly outrank it in id/time) ends up
// sitting ahead of an older message.
addMessage(
makeChatPayload({
id: 101,
user: TEST_USER_2,
content: "bob's message",
timestamp: "2026-03-15T10:00:02Z",
}),
);
// A's chat_send_ok arrives with the real id, stamped in place.
confirmSend("c1", 102, "2026-03-15T10:00:03Z");
// A's own echo of the broadcast arrives and reconciles by real id.
addMessage(
makeChatPayload({
id: 102,
user: TEST_USER,
content: "mine",
timestamp: "2026-03-15T10:00:03Z",
}),
);
const msgs = getChannelMessages(1);
expect(msgs.map((m) => m.id)).toEqual([100, 101, 102]);
});
});
describe("invalidateLoadedMessageWindows", () => {
it("drops sent rows and clears loaded/hasMore/detached for every loaded channel", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], true);
setMessages(2, [makeMessageResponse({ id: 20, channel_id: 2 })], false);
expect(isChannelLoaded(1)).toBe(true);
expect(isChannelLoaded(2)).toBe(true);
invalidateLoadedMessageWindows();
expect(getChannelMessages(1)).toEqual([]);
expect(getChannelMessages(2)).toEqual([]);
expect(isChannelLoaded(1)).toBe(false);
expect(isChannelLoaded(2)).toBe(false);
expect(hasMoreMessages(1)).toBe(false);
expect(isWindowDetached(1)).toBe(false);
});
it("carries pending and failed optimistic rows instead of destroying them", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "still sending",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
addOptimisticMessage({
correlationId: "c2",
channelId: 1,
user: TEST_USER,
content: "refused",
replyTo: null,
timestamp: "2026-03-15T10:00:01Z",
});
markSendFailed("c2", "SLOW_MODE");
invalidateLoadedMessageWindows();
const msgs = getChannelMessages(1);
expect(msgs.map((m) => m.correlationId)).toEqual(["c1", "c2"]);
expect(msgs[0]!.status).toBe("pending");
expect(msgs[1]!.status).toBe("failed");
});
it("is a no-op when no channel is loaded", () => {
const before = messagesStore.getState();
invalidateLoadedMessageWindows();
expect(messagesStore.getState()).toBe(before);
});
});
describe("invalidateChannelMessageWindow", () => {
it("drops only that channel's loaded flag and keeps its rows for instant re-render", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
setMessages(2, [makeMessageResponse({ id: 20, channel_id: 2 })], false);
invalidateChannelMessageWindow(1);
expect(isChannelLoaded(1)).toBe(false);
expect(isChannelLoaded(2)).toBe(true);
// The old rows stay rendered until the refetched tail lands and merges.
expect(getChannelMessages(1).map((m) => m.id)).toEqual([10]);
});
it("lets the next tail fetch land: setMessages refreshes the window and re-marks it loaded", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
invalidateChannelMessageWindow(1);
// Wire order is newest-first; the refetched tail carries a message
// posted while the channel was not focused.
setMessages(1, [makeMessageResponse({ id: 11 }), makeMessageResponse({ id: 10 })], false);
expect(isChannelLoaded(1)).toBe(true);
expect(getChannelMessages(1).map((m) => m.id)).toEqual([10, 11]);
});
it("keeps a failed optimistic row across the invalidate-then-refetch cycle", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "refused",
replyTo: null,
timestamp: "2026-03-15T10:00:01Z",
});
markSendFailed("c1", "SLOW_MODE");
invalidateChannelMessageWindow(1);
setMessages(1, [makeMessageResponse({ id: 11 }), makeMessageResponse({ id: 10 })], false);
const msgs = getChannelMessages(1);
expect(msgs.map((m) => m.id)).toEqual([10, 11, 0]);
expect(msgs[2]!.status).toBe("failed");
});
it("leaves the detached flag alone (setMessages clears it once the tail lands)", () => {
setAroundMessages(1, [makeMessageResponse({ id: 10 })], true, true);
expect(isWindowDetached(1)).toBe(true);
invalidateChannelMessageWindow(1);
expect(isChannelLoaded(1)).toBe(false);
expect(isWindowDetached(1)).toBe(true);
});
it("is a no-op for a channel that is not loaded", () => {
const before = messagesStore.getState();
invalidateChannelMessageWindow(1);
expect(messagesStore.getState()).toBe(before);
});
});
// 10. First-page history load state
describe("history load state", () => {
it("is idle (null) by default", () => {
expect(getHistoryLoadState(1)).toBeNull();
});
it("setChannelLoading and setChannelLoadError set the per-channel state", () => {
setChannelLoading(1);
expect(getHistoryLoadState(1)).toBe("loading");
expect(getHistoryLoadState(2)).toBeNull();
setChannelLoadError(1);
expect(getHistoryLoadState(1)).toBe("error");
});
it("setMessages clears the channel's load state", () => {
setChannelLoading(1);
setMessages(1, [makeMessageResponse({ id: 1 })], false);
expect(getHistoryLoadState(1)).toBeNull();
});
it("clearChannelMessages clears the channel's load state", () => {
setChannelLoadError(1);
clearChannelMessages(1);
expect(getHistoryLoadState(1)).toBeNull();
});
});
});
describe("mention plumbing", () => {
it("carries mentions from a chat_message payload onto the store row", () => {
addMessage({
id: 1,
channel_id: 1,
user: TEST_USER,
content: "hi @bob @everyone",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
mentions: [2],
mentions_everyone: true,
} as ChatMessagePayload);
const msg = messagesStore.getState().messagesByChannel.get(1)![0]!;
expect(msg.mentions).toEqual([2]);
expect(msg.mentionsEveryone).toBe(true);
});
it("leaves them undefined when an older server omits them", () => {
addMessage({
id: 1,
channel_id: 1,
user: TEST_USER,
content: "hi",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
} as ChatMessagePayload);
const msg = messagesStore.getState().messagesByChannel.get(1)![0]!;
expect(msg.mentions).toBeUndefined();
expect(msg.mentionsEveryone).toBeUndefined();
});
it("replaces mentions on edit — an edit re-resolves but never re-notifies", () => {
addMessage({
id: 1,
channel_id: 1,
user: TEST_USER,
content: "hi @bob",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
mentions: [2],
mentions_everyone: false,
} as ChatMessagePayload);
editMessage({
message_id: 1,
channel_id: 1,
content: "hi @carol",
edited_at: "2026-03-15T10:01:00Z",
mentions: [3],
mentions_everyone: false,
} as ChatEditedPayload);
const msg = messagesStore.getState().messagesByChannel.get(1)![0]!;
expect(msg.mentions).toEqual([3]);
});
});