Skip to content
File

Blob: tests/worker/queues/site-cover.workers.test.ts

typescript201 lines
1import { env } from "cloudflare:workers";
2import { ulid } from "ulid";
3import { beforeEach, describe, expect, it } from "vitest";
4 
5import { GRADIENT_PRESETS } from "@/shared/page-cover";
6import { handleSiteCover } from "@/worker/queues/site-cover";
7import {
8 createSiteCoverHash,
9 SITE_COVER_FORMAT,
10 SITE_COVER_HEIGHT,
11 SITE_COVER_WIDTH,
12 siteCoverKey,
13} from "@/worker/sites/cover";
14import { resetD1Tables } from "@tests/worker/helpers/db";
15import { seedPage, seedUpload, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
16 
17const TINY_PNG_BASE64 =
18 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
19const UPLOAD_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
20 
21function bytesFromBase64(input: string): Uint8Array {
22 const binary = atob(input);
23 const bytes = new Uint8Array(binary.length);
24 for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
25 return bytes;
26}
27 
28function tinyPngBytes(): Uint8Array {
29 return bytesFromBase64(TINY_PNG_BASE64);
30}
31 
32async function clearBuckets(): Promise<void> {
33 const [r2Listing, sitesListing] = await Promise.all([env.R2.list(), env.SITES.list()]);
34 await Promise.all([
35 ...r2Listing.objects.map((object) => env.R2.delete(object.key)),
36 ...sitesListing.objects.map((object) => env.SITES.delete(object.key)),
37 ]);
38}
39 
40describe("handleSiteCover", () => {
41 beforeEach(async () => {
42 await resetD1Tables();
43 await clearBuckets();
44 });
45 
46 it("returns retry when the page is not yet visible in D1", async () => {
47 await expect(handleSiteCover(ulid(), env)).resolves.toEqual({ kind: "retry", delaySeconds: 2 });
48 });
49 
50 it("renders a supported preset gradient to the fixed Sites cover key", async () => {
51 const owner = await seedUser();
52 const ws = await seedWorkspace({ owner_id: owner.id });
53 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: GRADIENT_PRESETS[0] });
54 
55 await expect(handleSiteCover(page.id, env)).resolves.toEqual({ kind: "ok" });
56 
57 const object = await env.SITES.get(siteCoverKey(ws.id, page.id));
58 expect(object).not.toBeNull();
59 expect(object?.httpMetadata?.contentType).toBe("image/png");
60 expect(object?.customMetadata).toMatchObject({
61 cover_hash: await createSiteCoverHash(GRADIENT_PRESETS[0]),
62 cover_url: GRADIENT_PRESETS[0],
63 width: String(SITE_COVER_WIDTH),
64 height: String(SITE_COVER_HEIGHT),
65 source: "gradient",
66 });
67 });
68 
69 it("skips fresh generated cover objects", async () => {
70 const owner = await seedUser();
71 const ws = await seedWorkspace({ owner_id: owner.id });
72 const cover = GRADIENT_PRESETS[0];
73 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: cover });
74 const key = siteCoverKey(ws.id, page.id);
75 await env.SITES.put(key, "already-rendered", {
76 httpMetadata: { contentType: "image/png" },
77 customMetadata: {
78 cover_hash: await createSiteCoverHash(cover),
79 cover_url: cover,
80 width: String(SITE_COVER_WIDTH),
81 height: String(SITE_COVER_HEIGHT),
82 source: "gradient",
83 },
84 });
85 
86 await expect(handleSiteCover(page.id, env)).resolves.toEqual({ kind: "ok" });
87 await expect(env.SITES.get(key).then((object) => object?.text())).resolves.toBe("already-rendered");
88 });
89 
90 it("no-ops unsupported current covers without retry", async () => {
91 const owner = await seedUser();
92 const ws = await seedWorkspace({ owner_id: owner.id });
93 const pages = [
94 await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: null }),
95 await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/upload_1" }),
96 await seedPage({
97 workspace_id: ws.id,
98 created_by: owner.id,
99 cover_url: "linear-gradient(90deg, #000000 0%, #ffffff 100%)",
100 }),
101 await seedPage({
102 workspace_id: ws.id,
103 created_by: owner.id,
104 cover_url: "linear-gradient(to right, #000000 0%, #ffffff 100%)",
105 }),
106 await seedPage({ workspace_id: ws.id, created_by: owner.id, kind: "canvas", cover_url: GRADIENT_PRESETS[0] }),
107 ];
108 
109 for (const page of pages) {
110 await expect(handleSiteCover(page.id, env)).resolves.toEqual({ kind: "ok" });
111 await expect(env.SITES.get(siteCoverKey(ws.id, page.id))).resolves.toBeNull();
112 }
113 });
114 
115 it("transforms safe uploaded covers to fixed Sites PNG artifacts", async () => {
116 const owner = await seedUser();
117 const ws = await seedWorkspace({ owner_id: owner.id });
118 
119 for (const contentType of UPLOAD_IMAGE_TYPES) {
120 const uploadId = `upload-${contentType.split("/")[1]}`;
121 const coverUrl = `/uploads/${uploadId}`;
122 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: coverUrl });
123 const upload = await seedUpload({
124 id: uploadId,
125 workspace_id: ws.id,
126 uploaded_by: owner.id,
127 page_id: page.id,
128 content_type: contentType,
129 r2_key: `${ws.id}/${page.id}/${uploadId}`,
130 });
131 await env.R2.put(upload.r2_key, tinyPngBytes(), { httpMetadata: { contentType } });
132 
133 await expect(handleSiteCover(page.id, env)).resolves.toEqual({ kind: "ok" });
134 
135 const object = await env.SITES.get(siteCoverKey(ws.id, page.id));
136 expect(object).not.toBeNull();
137 expect(object?.httpMetadata?.contentType).toBe(SITE_COVER_FORMAT);
138 expect(object?.customMetadata).toMatchObject({
139 cover_hash: await createSiteCoverHash(coverUrl),
140 cover_url: coverUrl,
141 width: String(SITE_COVER_WIDTH),
142 height: String(SITE_COVER_HEIGHT),
143 source: "upload",
144 });
145 }
146 });
147 
148 it("skips doomed uploaded covers without writing Sites artifacts", async () => {
149 const owner = await seedUser();
150 const ws = await seedWorkspace({ owner_id: owner.id });
151 const otherWs = await seedWorkspace({ owner_id: owner.id });
152 const otherPage = await seedPage({ workspace_id: ws.id, created_by: owner.id });
153 const otherWorkspacePage = await seedPage({ workspace_id: otherWs.id, created_by: owner.id });
154 const cases = [
155 await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/missing-row" }),
156 await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/wrong-page" }),
157 await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/wrong-workspace" }),
158 await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/unsafe-upload" }),
159 await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/missing-object" }),
160 ];
161 
162 await seedUpload({
163 id: "wrong-page",
164 workspace_id: ws.id,
165 uploaded_by: owner.id,
166 page_id: otherPage.id,
167 r2_key: `${ws.id}/${otherPage.id}/wrong-page.png`,
168 });
169 await seedUpload({
170 id: "wrong-workspace",
171 workspace_id: otherWs.id,
172 uploaded_by: owner.id,
173 page_id: otherWorkspacePage.id,
174 r2_key: `${otherWs.id}/${otherWorkspacePage.id}/wrong-workspace.png`,
175 });
176 await seedUpload({
177 id: "unsafe-upload",
178 workspace_id: ws.id,
179 uploaded_by: owner.id,
180 page_id: cases[3].id,
181 content_type: "application/pdf",
182 r2_key: `${ws.id}/${cases[3].id}/unsafe.pdf`,
183 });
184 await seedUpload({
185 id: "missing-object",
186 workspace_id: ws.id,
187 uploaded_by: owner.id,
188 page_id: cases[4].id,
189 r2_key: `${ws.id}/${cases[4].id}/missing.png`,
190 });
191 await env.R2.put(`${ws.id}/${otherPage.id}/wrong-page.png`, "PNG");
192 await env.R2.put(`${otherWs.id}/${otherWorkspacePage.id}/wrong-workspace.png`, "PNG");
193 await env.R2.put(`${ws.id}/${cases[3].id}/unsafe.pdf`, "PDF");
194 
195 for (const page of cases) {
196 await expect(handleSiteCover(page.id, env)).resolves.toEqual({ kind: "ok" });
197 await expect(env.SITES.get(siteCoverKey(page.workspace_id, page.id))).resolves.toBeNull();
198 }
199 });
200});