1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { beforeEach, describe, expect, it } from "vite-plus/test";
4import {
5 DraftId,
6 useComposerDraftStore,
7 type ComposerFileAttachment,
8 type ComposerImageAttachment,
9} from "../../composerDraftStore";
10import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./queuedMessageEdit";
11
12const environmentId = EnvironmentId.make("remote-environment");
13const threadTarget = scopeThreadRef(environmentId, ThreadId.make("thread:edit"));
14const editTarget = DraftId.make("queued-edit:test");
15const file: ComposerFileAttachment = {
16 type: "file",
17 id: "file:report",
18 name: "report.pdf",
19 mimeType: "application/pdf",
20 sizeBytes: 6,
21 file: new File(["report"], "report.pdf", { type: "application/pdf" }),
22};
23const image: ComposerImageAttachment = {
24 type: "image",
25 id: "image:screen",
26 name: "screen.png",
27 mimeType: "image/png",
28 sizeBytes: 5,
29 file: new File(["image"], "screen.png", { type: "image/png" }),
30 previewUrl: "blob:screen",
31};
32const uploadedFile = {
33 type: "file" as const,
34 id: "upload:report",
35 name: file.name,
36 mimeType: file.mimeType,
37 sizeBytes: file.sizeBytes,
38};
39
40describe("queued message file edits", () => {
41 beforeEach(() => {
42 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
43 });
44
45 it.each([
46 { images: [], label: "file-only" },
47 { images: [image], label: "mixed" },
48 ])("preserves a generic file in a $label save", async ({ images }) => {
49 const attachments = await prepareQueuedEditAttachments({
50 existingAttachments: [],
51 images,
52 files: [file],
53 uploadFiles: async () => [uploadedFile],
54 readImage: async () => "data:image/png;base64,aW1hZ2U=",
55 });
56 expect(attachments.at(-1)).toEqual(uploadedFile);
57 expect(attachments.length).toBe(images.length + 1);
58 if (images.length > 0)
59 expect(attachments[0]).toMatchObject({
60 type: "image",
61 dataUrl: "data:image/png;base64,aW1hZ2U=",
62 });
63 });
64
65 it("retains saved attachments alongside newly uploaded files", async () => {
66 const saved = { ...uploadedFile, id: "saved:earlier" };
67 const attachments = await prepareQueuedEditAttachments({
68 existingAttachments: [saved],
69 images: [],
70 files: [file],
71 uploadFiles: async () => [uploadedFile],
72 readImage: async () => "unused",
73 });
74 expect(attachments).toEqual([saved, uploadedFile]);
75 });
76
77 it("fails a save instead of dropping a file whose upload is missing", async () => {
78 await expect(
79 prepareQueuedEditAttachments({
80 existingAttachments: [],
81 images: [image],
82 files: [file],
83 uploadFiles: async () => [],
84 readImage: async () => "image",
85 }),
86 ).rejects.toThrow("Retry or remove");
87 });
88
89 it("keeps file-only edits when another client starts the queued run", () => {
90 const store = useComposerDraftStore.getState();
91 store.setPrompt(editTarget, "Original message");
92 store.addFiles(editTarget, [file]);
93 expect(
94 recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }),
95 ).toBe("kept");
96 expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Original message");
97 expect(store.getComposerDraft(threadTarget)?.files).toEqual([file]);
98 expect(store.getComposerDraft(editTarget)).toBeNull();
99 });
100
101 it("preserves uploaded file references when a remote queue advance interrupts the edit", () => {
102 const store = useComposerDraftStore.getState();
103 const uploaded = {
104 ...file,
105 file: null,
106 uploadedAttachmentId: uploadedFile.id,
107 uploadEnvironmentId: environmentId,
108 };
109 store.addFiles(editTarget, [uploaded]);
110 expect(recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "" })).toBe("kept");
111 expect(store.getComposerDraft(threadTarget)?.files).toEqual([uploaded]);
112 });
113
114 it("does not overwrite a separate draft when the queued run leaves", () => {
115 const store = useComposerDraftStore.getState();
116 store.setPrompt(threadTarget, "Separate draft");
117 store.addFiles(editTarget, [file]);
118 expect(recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "" })).toBe(
119 "discarded",
120 );
121 expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Separate draft");
122 expect(store.getComposerDraft(editTarget)).toBeNull();
123 });
124});