cm_whatsapp_bot_v1/apps/web/src/lib/reminder-messages.test.ts
yiekheng b71dbadef1 feat(reminders): multi-message stack with mid-stream media swap
Reminders can now deliver a stack of message parts in send order. The
DB and bot's fire-reminder loop already supported this — only the UI
and the server action's input shape were single-message. This change
makes the whole flow stack-aware end-to-end.

What's new
----------
A reminder is now a list of MessagePart objects:

    { kind: "text",  textContent: "Hi",   mediaId: null  }
    { kind: "media", textContent: "cap",  mediaId: uuid  }
    { kind: "media", textContent: null,   mediaId: uuid  }

The bot fires them in order with ~1.5 s spacing (already the case in
fire-reminder.ts).

Cap of 10 parts per reminder. Anything more clutters the URL beyond
the 2KB practical budget for the wizard's encoded `messages=…` param.

Where this shows up
-------------------
1. `<MessageStack>` — new shared component (apps/web/src/components/
   message-stack.tsx). Each block is either a text Textarea or a
   media block (file picker + preview + caption Input). Per-block
   move-up / move-down / delete buttons. "+ Add text" / "+ Add file"
   buttons at the bottom. Reused by both the wizard's compose step
   AND the per-section Edit Message page.

2. Edit Message page — was a single Textarea + read-only attachment
   indicator with a "Replacing it isn't supported" note. Now uses
   MessageStack and lets the operator add/remove/reorder parts AND
   swap the file on a media block, fixing
   the asked-for "should let user change media files too" gap.

3. Wizard — Compose / When / Groups / Review pass a single
   `messages=<urlencoded JSON>` param instead of three separate
   text/mediaId/caption fields. The Review step renders one row per
   part, with file names resolved from the DB so users see "menu.pdf"
   not an opaque uuid. Every step accepts the legacy fields too and
   folds them into the new shape on entry, so older bookmarked URLs
   keep working.

4. Server actions (createReminder / updateReminder) accept either:
     - The new `messages: MessagePart[]` field, OR
     - The legacy `text` / `mediaId` / `caption` triple,
   and resolve to a flat parts list before doing anything else. Both
   actions then write one row per part into `reminder_messages` with
   a sequential `position` column, replacing the old "always 1 row"
   logic in updateReminderAction.

5. The reminder name (visible in lists, detail header, etc.) is
   sourced from the first part with a non-empty text body — falling
   back to the literal "Reminder" if every part is media-without-
   caption. Capped at 50 chars to fit the existing column.

Wire-format helpers
-------------------
New `lib/reminder-messages.ts`:
- `MessagePart` interface (the canonical shape)
- `isValidMessagePart` — reject empty texts and orphan-mediaId rows
- `encodeMessages` / `decodeMessages` — URI-encoded JSON, drops
  invalid entries, returns null when nothing valid is left
- `legacyMessageToParts` — synthesise a one-element stack from the
  old text/mediaId/caption fields (used by step pages on entry)

Tests (15 + 5 = 20 new; 146 total, was 132 + adjustment)
--------------------------------------------------------
- `lib/reminder-messages.test.ts`: round-trip a non-trivial stack;
  survive URL-unsafe characters in text (\\n, & = % #); reject
  null / empty / garbage; drop invalid entries; legacy-fallback paths.
- `edit-message-form.test.tsx`: rewrites for the new prop shape
  (initialMessages instead of initialText/initialMediaId/initialCaption);
  asserts the form renders one block per initial part and that media
  filename appears in the SSR markup.
- `no-render-warnings.test.tsx`: same prop-shape update for the two
  EditMessageForm hydration / button-nesting guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:15:37 +08:00

140 lines
4.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
decodeMessages,
encodeMessages,
isValidMessagePart,
legacyMessageToParts,
type MessagePart,
} from "./reminder-messages";
describe("isValidMessagePart", () => {
it("accepts a non-empty text part", () => {
expect(
isValidMessagePart({ kind: "text", textContent: "hi", mediaId: null }),
).toBe(true);
});
it("rejects an empty/whitespace text part", () => {
expect(
isValidMessagePart({ kind: "text", textContent: " ", mediaId: null }),
).toBe(false);
expect(
isValidMessagePart({ kind: "text", textContent: null, mediaId: null }),
).toBe(false);
});
it("accepts a media part with mediaId, with or without caption", () => {
expect(
isValidMessagePart({ kind: "media", textContent: null, mediaId: "uuid" }),
).toBe(true);
expect(
isValidMessagePart({ kind: "media", textContent: "cap", mediaId: "uuid" }),
).toBe(true);
});
it("rejects a media part without mediaId", () => {
expect(
isValidMessagePart({ kind: "media", textContent: "cap", mediaId: null }),
).toBe(false);
});
it("rejects unknown kinds and non-objects", () => {
expect(isValidMessagePart({ kind: "weird", mediaId: null, textContent: "" })).toBe(false);
expect(isValidMessagePart(null)).toBe(false);
expect(isValidMessagePart("hi")).toBe(false);
});
});
describe("encodeMessages / decodeMessages round-trip", () => {
const sample: MessagePart[] = [
{ kind: "text", textContent: "Hello there", mediaId: null },
{ kind: "media", textContent: "caption", mediaId: "11111111-1111-1111-1111-111111111111" },
{ kind: "media", textContent: null, mediaId: "22222222-2222-2222-2222-222222222222" },
];
it("round-trips a non-trivial stack", () => {
const encoded = encodeMessages(sample);
expect(typeof encoded).toBe("string");
expect(decodeMessages(encoded)).toEqual(sample);
});
it("survives URL-unsafe characters in text content (newlines, &, =, %, #)", () => {
const trickier: MessagePart[] = [
{
kind: "text",
textContent: "Line 1\nLine 2 & more = special % # values",
mediaId: null,
},
];
const encoded = encodeMessages(trickier);
// Encoded form must be safely embeddable as a URL param value.
expect(encoded).not.toContain("\n");
expect(encoded).not.toContain("&");
expect(decodeMessages(encoded)).toEqual(trickier);
});
it("decodeMessages returns null on null/undefined/empty/garbage input", () => {
expect(decodeMessages(null)).toBeNull();
expect(decodeMessages(undefined)).toBeNull();
expect(decodeMessages("")).toBeNull();
expect(decodeMessages("not-json")).toBeNull();
expect(decodeMessages(encodeURIComponent("not an array"))).toBeNull();
});
it("decodeMessages drops invalid entries and returns null when nothing valid is left", () => {
const mixed = encodeURIComponent(
JSON.stringify([
{ kind: "text", textContent: "", mediaId: null }, // invalid
{ kind: "media", textContent: null, mediaId: null }, // invalid
]),
);
expect(decodeMessages(mixed)).toBeNull();
});
it("decodeMessages keeps valid entries when some are invalid", () => {
const mixed = encodeURIComponent(
JSON.stringify([
{ kind: "text", textContent: "good", mediaId: null },
{ kind: "media", textContent: null, mediaId: null }, // invalid
{ kind: "media", textContent: null, mediaId: "uuid" },
]),
);
expect(decodeMessages(mixed)).toEqual([
{ kind: "text", textContent: "good", mediaId: null },
{ kind: "media", textContent: null, mediaId: "uuid" },
]);
});
});
describe("legacyMessageToParts back-compat fallback", () => {
it("text-only legacy URL → one text part", () => {
expect(legacyMessageToParts("hi", null, null)).toEqual([
{ kind: "text", textContent: "hi", mediaId: null },
]);
});
it("media + caption legacy URL → one media part with caption", () => {
expect(legacyMessageToParts(null, "uuid", "cap")).toEqual([
{ kind: "media", textContent: "cap", mediaId: "uuid" },
]);
});
it("media without caption → media part with null textContent", () => {
expect(legacyMessageToParts(null, "uuid", null)).toEqual([
{ kind: "media", textContent: null, mediaId: "uuid" },
]);
});
it("media + legacy text (no caption) reuses text as caption", () => {
expect(legacyMessageToParts("text-too", "uuid", null)).toEqual([
{ kind: "media", textContent: "text-too", mediaId: "uuid" },
]);
});
it("nothing useful returns null", () => {
expect(legacyMessageToParts(null, null, null)).toBeNull();
expect(legacyMessageToParts("", null, null)).toBeNull();
expect(legacyMessageToParts(" ", null, null)).toBeNull();
});
});