// Message lifecycle tests cover channel message state transitions and notifications. import { describe, expect, it, vi } from "vitest"; import { createLiveMessageState, defineFinalizableLivePreviewAdapter, deliverFinalizableLivePreview, deliverWithFinalizableLivePreviewAdapter, markLiveMessagePreviewUpdated, } from "./live.js"; import { createMessageReceiveContext } from "./receive.js"; type LivePreviewMediaPayload = { text?: string; mediaUrl: string }; type LivePreviewMediaEdit = { text?: string }; function requireMockCall( mock: { mock: { calls: unknown[][] } }, callIndex: number, label: string, ): unknown[] { const resolvedIndex = callIndex < 0 ? mock.mock.calls.length + callIndex : callIndex; const call = mock.mock.calls[resolvedIndex]; if (!call) { throw new Error(`expected ${label} call ${callIndex}`); } return call; } describe("message lifecycle primitives", () => { it("tracks live preview rendered batch updates", () => { const preview = createLiveMessageState(); const rendered = { payloads: [{ text: "draft" }], plan: { payloadCount: 1, textCount: 1, mediaCount: 0, voiceCount: 0, presentationCount: 0, interactiveCount: 0, channelDataCount: 0, items: [{ index: 0, kinds: ["text"] as const, text: "draft", mediaUrls: [] }], }, }; const updated = markLiveMessagePreviewUpdated(preview, rendered); expect(updated.phase).toBe("previewing"); expect(updated.lastRendered).toBe(rendered); }); it("finalizes live previews in place with preview receipts", async () => { const editFinal = vi.fn(async () => undefined); const deliverNormally = vi.fn(async () => undefined); const onPreviewFinalized = vi.fn(async () => undefined); const result = await deliverFinalizableLivePreview({ kind: "final", payload: { text: "done" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-1", seal: vi.fn(async () => undefined), clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload) => ({ text: payload.text }), editFinal, deliverNormally, onPreviewFinalized, }); expect(result.kind).toBe("preview-finalized"); expect(editFinal).toHaveBeenCalledWith("preview-1", { text: "done" }); expect(deliverNormally).not.toHaveBeenCalled(); const liveState = result.liveState; if (!liveState) { throw new Error("expected finalized live state"); } expect(liveState.phase).toBe("finalized"); expect(liveState.canFinalizeInPlace).toBe(false); expect(liveState.receipt?.primaryPlatformMessageId).toBe("preview-1"); expect(liveState.receipt?.platformMessageIds).toEqual(["preview-1"]); expect(onPreviewFinalized).toHaveBeenCalledTimes(1); const [previewId, receiptArg, stateArg] = requireMockCall( onPreviewFinalized, 0, "preview finalized", ) as [string, { primaryPlatformMessageId?: string }, unknown]; expect(previewId).toBe("preview-1"); expect(receiptArg.primaryPlatformMessageId).toBe("preview-1"); expect(stateArg).toBe(liveState); }); it("delivers supplemental payloads after finalizing live previews", async () => { const editFinal = vi.fn(async () => undefined); const deliverNormally = vi.fn(async () => undefined); const deliverSupplemental = vi.fn(async () => true); const result = await deliverFinalizableLivePreview< LivePreviewMediaPayload, string, LivePreviewMediaEdit >({ kind: "final", payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-1", seal: vi.fn(async () => undefined), clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload) => ({ text: payload.text }), buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }), editFinal, deliverNormally, deliverSupplemental, }); expect(result.kind).toBe("preview-finalized"); expect(editFinal).toHaveBeenCalledWith("preview-1", { text: "done" }); expect(deliverNormally).not.toHaveBeenCalled(); expect(deliverSupplemental).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" }); }); it("falls back to normal supplemental delivery when its dedicated sender reports no send", async () => { const deliverNormally = vi.fn(async () => true); const deliverSupplemental = vi.fn(async () => false); const result = await deliverFinalizableLivePreview< LivePreviewMediaPayload, string, LivePreviewMediaEdit >({ kind: "final", payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-supplement-fallback", clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload) => ({ text: payload.text }), editFinal: vi.fn(async () => undefined), buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }), deliverSupplemental, deliverNormally, }); expect(result.kind).toBe("preview-finalized"); expect(deliverSupplemental).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" }); expect(deliverNormally).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" }); }); it("uses normal delivery when a finalized preview has no supplemental sender", async () => { const deliverNormally = vi.fn(async () => true); const result = await deliverFinalizableLivePreview< LivePreviewMediaPayload, string, LivePreviewMediaEdit >({ kind: "final", payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-no-supplement-sender", clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload) => ({ text: payload.text }), editFinal: vi.fn(async () => undefined), buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }), deliverNormally, }); expect(result.kind).toBe("preview-finalized"); expect(deliverNormally).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" }); }); it("surfaces supplemental delivery failure after both sender paths report no send", async () => { await expect( deliverFinalizableLivePreview({ kind: "final", payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-supplement-unsent", clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload) => ({ text: payload.text }), editFinal: vi.fn(async () => undefined), buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }), deliverSupplemental: vi.fn(async () => false), deliverNormally: vi.fn(async () => false), }), ).rejects.toThrow("Live preview supplemental payload was not delivered"); }); it("treats live preview fallback delivery as terminal state", async () => { const discardPending = vi.fn(async () => undefined); const clear = vi.fn(async () => undefined); const deliverNormally = vi.fn(async () => true); const onNormalDelivered = vi.fn(async () => undefined); const result = await deliverFinalizableLivePreview({ kind: "final", payload: { text: "with media" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-2", discardPending, clear, }, buildFinalEdit: () => undefined, editFinal: vi.fn(async () => undefined), deliverNormally, onNormalDelivered, }); expect(result.kind).toBe("normal-delivered"); expect(discardPending).toHaveBeenCalledTimes(1); expect(deliverNormally).toHaveBeenCalledWith({ text: "with media" }); expect(onNormalDelivered).toHaveBeenCalledTimes(1); expect(clear).toHaveBeenCalledTimes(1); const liveState = result.liveState; if (!liveState) { throw new Error("expected fallback live state"); } expect(liveState.phase).toBe("cancelled"); expect(liveState.canFinalizeInPlace).toBe(false); }); it.each(["shared finalizer", "exported adapter"] as const)( "preserves committed normal delivery when preview cleanup fails through the %s", async (entrypoint) => { const events: string[] = []; const cleanupError = new Error("recipient text and fake-secret must stay private"); const draft = { flush: vi.fn(async () => undefined), id: () => "preview-cleanup-failure", discardPending: vi.fn(async () => { events.push("discard"); }), clear: vi.fn(async () => { events.push("clear"); throw cleanupError; }), }; const deliverNormally = vi.fn(async () => { events.push("deliver"); return true; }); const onNormalDelivered = vi.fn(async () => { events.push("commit"); }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const result = entrypoint === "shared finalizer" ? await deliverFinalizableLivePreview({ kind: "final", payload: { text: "already delivered" }, draft, buildFinalEdit: () => undefined, editFinal: vi.fn(async () => undefined), deliverNormally, onNormalDelivered, }) : await deliverWithFinalizableLivePreviewAdapter({ kind: "final", payload: { text: "already delivered" }, adapter: defineFinalizableLivePreviewAdapter({ draft, buildFinalEdit: () => undefined, editFinal: vi.fn(async () => undefined), }), deliverNormally, onNormalDelivered, }); expect(result.kind).toBe("normal-delivered"); expect(events).toEqual(["discard", "deliver", "commit", "clear"]); expect(deliverNormally).toHaveBeenCalledTimes(1); expect(onNormalDelivered).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledExactlyOnceWith( "Live preview cleanup failed after delivery; a stale preview may remain", ); } finally { warn.mockRestore(); } }, ); it("keeps intentionally suppressed fallback delivery skipped without post-delivery cleanup", async () => { const clear = vi.fn(async () => { throw new Error("suppressed reply must not be cleaned up as delivered"); }); const onNormalDelivered = vi.fn(async () => undefined); const result = await deliverFinalizableLivePreview({ kind: "final", payload: { text: "suppressed" }, draft: { flush: vi.fn(async () => undefined), id: () => "suppressed-preview", discardPending: vi.fn(async () => undefined), clear, }, buildFinalEdit: () => undefined, editFinal: vi.fn(async () => undefined), deliverNormally: vi.fn(async () => false), onNormalDelivered, }); expect(result.kind).toBe("normal-skipped"); expect(onNormalDelivered).not.toHaveBeenCalled(); expect(clear).not.toHaveBeenCalled(); }); it("keeps preview cleanup failures fatal before normal delivery starts", async () => { const deliverNormally = vi.fn(async () => true); const onNormalDelivered = vi.fn(async () => undefined); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect( deliverFinalizableLivePreview({ kind: "final", payload: { text: "not delivered" }, draft: { flush: vi.fn(async () => undefined), id: () => "pre-delivery-preview", clear: vi.fn(async () => { throw new Error("pre-delivery cleanup failed"); }), }, buildFinalEdit: () => undefined, editFinal: vi.fn(async () => undefined), deliverNormally, onNormalDelivered, }), ).rejects.toThrow("pre-delivery cleanup failed"); expect(deliverNormally).not.toHaveBeenCalled(); expect(onNormalDelivered).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled(); } finally { warn.mockRestore(); } }); it("preserves a delivery-commit failure when later preview cleanup also fails", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect( deliverFinalizableLivePreview({ kind: "final", payload: { text: "already accepted" }, draft: { flush: vi.fn(async () => undefined), id: () => "commit-failure-preview", discardPending: vi.fn(async () => undefined), clear: vi.fn(async () => { throw new Error("preview cleanup must not replace delivery failure"); }), }, buildFinalEdit: () => undefined, editFinal: vi.fn(async () => undefined), deliverNormally: vi.fn(async () => true), onNormalDelivered: vi.fn(async () => { throw new Error("delivery commit failed"); }), }), ).rejects.toThrow("delivery commit failed"); expect(warn).toHaveBeenCalledTimes(1); } finally { warn.mockRestore(); } }); it("does not complete live preview fallback state when normal delivery throws", async () => { const discardPending = vi.fn(async () => undefined); const clear = vi.fn(async () => undefined); const onNormalDelivered = vi.fn(async () => undefined); await expect( deliverFinalizableLivePreview({ kind: "final", payload: { text: "with media" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-2", discardPending, clear, }, buildFinalEdit: () => undefined, editFinal: vi.fn(async () => undefined), deliverNormally: vi.fn(async () => { throw new Error("send failed"); }), onNormalDelivered, }), ).rejects.toThrow("send failed"); expect(discardPending).toHaveBeenCalledTimes(1); expect(onNormalDelivered).not.toHaveBeenCalled(); expect(clear).not.toHaveBeenCalled(); }); it("delivers through finalizable live preview adapters", async () => { const editFinal = vi.fn(async () => undefined); const adapter = defineFinalizableLivePreviewAdapter({ draft: { flush: vi.fn(async () => undefined), id: () => "preview-adapter-1", clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload: { text: string }) => ({ text: payload.text.toUpperCase() }), editFinal, }); const result = await deliverWithFinalizableLivePreviewAdapter({ kind: "final", payload: { text: "done" }, adapter, deliverNormally: vi.fn(async () => undefined), }); expect(result.kind).toBe("preview-finalized"); expect(editFinal).toHaveBeenCalledWith("preview-adapter-1", { text: "DONE" }); }); it("lets live preview adapters resolve the committed platform id after final edit", async () => { const adapter = defineFinalizableLivePreviewAdapter({ draft: { flush: vi.fn(async () => undefined), id: () => "preview-before-edit", clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }), editFinal: vi.fn(async () => undefined), resolveFinalizedId: () => "message-after-edit", }); const result = await deliverWithFinalizableLivePreviewAdapter({ kind: "final", payload: { text: "done" }, adapter, deliverNormally: vi.fn(async () => undefined), }); expect(result.liveState?.receipt?.primaryPlatformMessageId).toBe("message-after-edit"); }); it("falls back to normal delivery when no live preview adapter is available", async () => { const deliverNormally = vi.fn(async () => undefined); const result = await deliverWithFinalizableLivePreviewAdapter({ kind: "final", payload: { text: "plain" }, deliverNormally, }); expect(result.kind).toBe("normal-delivered"); expect(deliverNormally).toHaveBeenCalledWith({ text: "plain" }); }); it("lets live preview adapters retain ambiguous failed final edits without fallback send", async () => { const deliverNormally = vi.fn(async () => undefined); const handlePreviewEditError = vi.fn(() => "retain" as const); const editError = new Error("timeout after request"); const adapter = defineFinalizableLivePreviewAdapter({ draft: { flush: vi.fn(async () => undefined), id: () => "preview-maybe-final", clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }), editFinal: vi.fn(async () => { throw editError; }), handlePreviewEditError, }); const result = await deliverWithFinalizableLivePreviewAdapter({ kind: "final", payload: { text: "done" }, adapter, deliverNormally, }); expect(result.kind).toBe("preview-retained"); expect(result.liveState?.phase).toBe("previewing"); expect(deliverNormally).not.toHaveBeenCalled(); expect(handlePreviewEditError).toHaveBeenCalledTimes(1); const [editErrorContext] = requireMockCall(handlePreviewEditError, 0, "preview edit error") as [ { error: unknown; id?: string; edit?: unknown; payload?: unknown }, ]; expect(editErrorContext.error).toBe(editError); expect(editErrorContext.id).toBe("preview-maybe-final"); expect(editErrorContext.edit).toEqual({ text: "done" }); expect(editErrorContext.payload).toEqual({ text: "done" }); }); it("does not fallback-send after a successful preview edit when finalization hooks fail", async () => { const deliverNormally = vi.fn(async () => undefined); const onPreviewFinalized = vi.fn(async () => { throw new Error("receipt side effect failed"); }); const editFinal = vi.fn(async () => undefined); await expect( deliverFinalizableLivePreview({ kind: "final", payload: { text: "done" }, draft: { flush: vi.fn(async () => undefined), id: () => "preview-finalized-before-hook", seal: vi.fn(async () => undefined), clear: vi.fn(async () => undefined), }, buildFinalEdit: (payload) => ({ text: payload.text }), editFinal, deliverNormally, onPreviewFinalized, }), ).rejects.toThrow("receipt side effect failed"); expect(editFinal).toHaveBeenCalledWith("preview-finalized-before-hook", { text: "done" }); expect(deliverNormally).not.toHaveBeenCalled(); }); it("creates receive contexts with explicit ack policy defaults", () => { const ctx = createMessageReceiveContext({ id: "rx-1", channel: "telegram", message: { text: "hello" }, receivedAt: 123, }); expect(ctx.id).toBe("rx-1"); expect(ctx.channel).toBe("telegram"); expect(ctx.message).toEqual({ text: "hello" }); expect(ctx.ackPolicy).toBe("after_receive_record"); expect(ctx.ackState).toBe("pending"); expect(ctx.receivedAt).toBe(123); }); it("acks and nacks receive contexts through explicit hooks", async () => { const onAck = vi.fn(async () => undefined); const onNack = vi.fn(async () => undefined); const ctx = createMessageReceiveContext({ id: "rx-ack", channel: "telegram", message: { text: "hello" }, ackPolicy: "after_durable_send", onAck, onNack, }); expect(ctx.shouldAckAfter("receive_record")).toBe(false); expect(ctx.shouldAckAfter("durable_send")).toBe(true); const beforeAck = Date.now(); await ctx.ack(); await ctx.ack(); expect(onAck).toHaveBeenCalledTimes(1); expect(ctx.ackState).toBe("acked"); expect(ctx.ackedAt).toBeGreaterThanOrEqual(beforeAck); const nackError = new Error("offset failed"); await ctx.nack(nackError); await ctx.nack(new Error("duplicate failure")); expect(onNack).toHaveBeenCalledTimes(1); expect(onNack).toHaveBeenCalledWith(nackError); expect(ctx.ackState).toBe("nacked"); expect(ctx.nackErrorMessage).toBe("offset failed"); }); it("retries nack callbacks after a failed attempt", async () => { const onNack = vi .fn<(error: unknown) => Promise>() .mockRejectedValueOnce(new Error("temporary failure")) .mockResolvedValueOnce(); const ctx = createMessageReceiveContext({ id: "rx-nack-retry", channel: "telegram", message: { text: "hello" }, onNack, }); await expect(ctx.nack(new Error("first failure"))).rejects.toThrow("temporary failure"); expect(ctx.ackState).toBe("pending"); const retryError = new Error("retry failure"); await ctx.nack(retryError); expect(onNack).toHaveBeenCalledTimes(2); expect(ctx.ackState).toBe("nacked"); expect(ctx.nackErrorMessage).toBe("retry failure"); }); it("coalesces overlapping nack callbacks and retains the first error", async () => { let resolveNack: (() => void) | undefined; const onNack = vi.fn( () => new Promise((resolve) => { resolveNack = resolve; }), ); const ctx = createMessageReceiveContext({ id: "rx-nack-overlap", channel: "telegram", message: { text: "hello" }, onNack, }); const firstError = new Error("first failure"); const first = ctx.nack(firstError); const duplicate = ctx.nack(new Error("duplicate failure")); await vi.waitFor(() => expect(onNack).toHaveBeenCalledOnce()); resolveNack?.(); await Promise.all([first, duplicate]); expect(onNack).toHaveBeenCalledWith(firstError); expect(ctx.ackState).toBe("nacked"); expect(ctx.nackErrorMessage).toBe("first failure"); }); });