Skip to content
File

Blob: src/cloudflare/internal/test/images/images-upstream-mock.js

javascript238 lines
1// Copyright (c) 2025 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import { WorkerEntrypoint, RpcTarget } from 'cloudflare:workers';
6 
7/**
8 * @param {FormDataEntryValue | null} blob
9 * @returns {Promise<string | null>}
10 */
11async function imageAsString(blob) {
12 if (blob === null) {
13 return null;
14 }
15 
16 if (typeof blob === 'string') {
17 return null;
18 }
19 
20 return blob.text();
21}
22 
23class ImageHandleMock extends RpcTarget {
24 /** @type {string} */
25 #imageId;
26 
27 /** @param {string} imageId */
28 constructor(imageId) {
29 super();
30 this.#imageId = imageId;
31 }
32 
33 async details() {
34 if (this.#imageId === 'not-found') {
35 return null;
36 }
37 
38 return {
39 id: this.#imageId,
40 filename: 'test.jpg',
41 uploaded: '2024-01-01T00:00:00Z',
42 requireSignedURLs: false,
43 variants: ['public'],
44 meta: {},
45 draft: false,
46 creator: 'test-creator',
47 };
48 }
49 
50 async bytes() {
51 if (this.#imageId === 'not-found') {
52 return null;
53 }
54 
55 const mockData = `MOCK_IMAGE_DATA_${this.#imageId}`;
56 return new Blob([mockData]).stream();
57 }
58 
59 /**
60 * @param {ImageUpdateOptions} body
61 * @returns {Promise<ImageMetadata>}
62 */
63 async update(body) {
64 if (this.#imageId === 'not-found') {
65 throw new Error('Image not found');
66 }
67 
68 return {
69 id: this.#imageId,
70 filename: 'updated.jpg',
71 uploaded: '2024-01-01T00:00:00Z',
72 requireSignedURLs:
73 body.requireSignedURLs !== undefined ? body.requireSignedURLs : false,
74 variants: ['public'],
75 meta: body.metadata || {},
76 draft: false,
77 creator: body.creator,
78 };
79 }
80 
81 /**
82 * @returns {Promise<boolean>}
83 */
84 async delete() {
85 return this.#imageId !== 'not-found';
86 }
87}
88 
89export class ServiceEntrypoint extends WorkerEntrypoint {
90 /**
91 * @param {string} imageId
92 * @returns {ImageHandleMock}
93 */
94 image(imageId) {
95 return new ImageHandleMock(imageId);
96 }
97 
98 async upload(image, options) {
99 // Handle both ReadableStream and ArrayBuffer
100 const buffer =
101 image instanceof ArrayBuffer
102 ? image
103 : await new Response(image).arrayBuffer();
104 
105 const decoder = new TextDecoder();
106 const text = decoder.decode(buffer);
107 
108 if (text === 'INVALID') {
109 throw new Error('Invalid image data');
110 }
111 
112 return {
113 id: options?.id || 'generated-id',
114 filename: options?.filename || 'uploaded.jpg',
115 uploaded: '2024-01-01T00:00:00Z',
116 requireSignedURLs: options?.requireSignedURLs || false,
117 variants: ['public'],
118 meta: options?.metadata || {},
119 draft: false,
120 creator: options?.creator,
121 };
122 }
123 
124 /**
125 * @param {ImageListOptions} [options]
126 * @returns {Promise<ImageList>}
127 */
128 async list(options) {
129 const images = [
130 {
131 id: 'image-1',
132 filename: 'test1.jpg',
133 uploaded: '2024-01-01T00:00:00Z',
134 requireSignedURLs: false,
135 variants: ['public'],
136 meta: {},
137 creator: 'test-creator',
138 },
139 {
140 id: 'image-2',
141 filename: 'test2.jpg',
142 uploaded: '2024-01-02T00:00:00Z',
143 requireSignedURLs: false,
144 variants: ['public'],
145 meta: {},
146 creator: 'test-creator',
147 },
148 ];
149 
150 const limit = options?.limit || 50;
151 const slicedImages = images.slice(0, limit);
152 
153 return {
154 images: slicedImages,
155 listComplete: true,
156 };
157 }
158 
159 /**
160 * Handle HTTP requests for info and transform operations.
161 * In production these go to a separate transformation service,
162 * but in tests we mock both the ServiceEntrypoint and transformation service in one place.
163 * @param {Request} request
164 * @returns {Promise<Response>}
165 */
166 async fetch(request) {
167 const form = await request.formData();
168 const image = (await imageAsString(form.get('image'))) || '';
169 if (image.includes('BAD')) {
170 return new Response('ERROR 123: Bad request', {
171 status: 409,
172 headers: {
173 'cf-images-binding': 'err=123',
174 },
175 });
176 }
177 
178 switch (new URL(request.url).pathname) {
179 case '/info':
180 if (image.includes('<svg')) {
181 return Response.json({
182 format: 'image/svg+xml',
183 });
184 } else {
185 return Response.json({
186 format: 'image/png',
187 file_size: 123,
188 width: 123,
189 height: 123,
190 });
191 }
192 case '/transform': {
193 /** @type {any} */
194 const obj = {
195 image: await imageAsString(form.get('image')),
196 // @ts-expect-error - form.get() returns FormDataEntryValue which is not assignable to string
197 transforms: JSON.parse(form.get('transforms') || '{}'),
198 };
199 for (const x of [
200 'output_format',
201 'output_quality',
202 'background',
203 'anim',
204 ]) {
205 if (form.get(x)) {
206 obj[x] = form.get(x);
207 }
208 }
209 
210 if (form.get('draw_image')) {
211 const drawImages = [];
212 for (const entry of form.getAll('draw_image')) {
213 drawImages.push(await imageAsString(entry));
214 }
215 obj['draw_image'] = drawImages;
216 }
217 
218 return Response.json(obj);
219 }
220 }
221 
222 throw new Error('Unexpected mock invocation');
223 }
224}
225 
226export default {
227 /**
228 * @param {Request} request
229 * @param {*} env
230 * @param {ExecutionContext} ctx
231 * @returns {Promise<Response>}
232 */
233 async fetch(request, env, ctx) {
234 const entrypoint = new ServiceEntrypoint(ctx, env);
235 return entrypoint.fetch(request);
236 },
237};