Files
kill-the-news/src/test/setup.ts
T
Julien HerrandClaude Opus 4.7 eb12f21894 feat(favicon): per-feed icon from the last sender's domain
Resolve each feed's most recent sender domain and serve its favicon at
GET /favicon/:feedId, falling back to the project icon. Icons are fetched
in the background on ingestion (direct /favicon.ico then a DuckDuckGo
fallback), cached base64 in KV keyed by domain with a 1-week TTL so the
fetch only fires when absent. Exposed via RSS <image> / Atom <icon>/<logo>
and rendered in the admin feed list, plus a landing-page feature card.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:05:14 +02:00

257 lines
6.5 KiB
TypeScript

import { beforeAll, afterAll, afterEach } from "vitest";
import { setupServer } from "msw/node";
// Minimal Node.js built-ins used only in this test setup file.
// Declared locally to avoid pulling in the full @types/node package,
// which would conflict with @cloudflare/workers-types globals.
declare function require(id: string): any;
/**
* Mock KV namespace implementation
* Simulates Cloudflare Workers KV storage using an in-memory Map
*/
class MockKV {
private store: Map<string, any> = new Map();
async get(
key: string,
typeOrOptions:
| "text"
| "json"
| "arrayBuffer"
| "stream"
| { type: "text" | "json" | "arrayBuffer" | "stream" } = "text",
) {
const type =
typeof typeOrOptions === "string" ? typeOrOptions : typeOrOptions.type;
const value = this.store.get(key);
if (!value) return null;
return type === "json" ? JSON.parse(value) : value;
}
async put(
key: string,
value: any,
_options?: { expirationTtl?: number; expiration?: number },
) {
// TTL options are accepted for API parity but not simulated in tests.
this.store.set(
key,
typeof value === "string" ? value : JSON.stringify(value),
);
return undefined; // Match CF Workers KV behavior
}
async delete(key: string) {
this.store.delete(key);
return undefined; // Match CF Workers KV behavior
}
async list(options?: { prefix?: string; cursor?: string; limit?: number }) {
const keys = Array.from(this.store.keys())
.filter((key) => !options?.prefix || key.startsWith(options.prefix))
.slice(0, options?.limit || undefined)
.map((name) => ({ name }));
return {
keys,
list_complete: true,
cursor: "",
};
}
}
/**
* Mock Cache implementation
* Simulates Cloudflare Workers Cache API using an in-memory Map
*/
class MockCache implements Cache {
private store: Map<string, Response> = new Map();
async put(request: RequestInfo, response: Response): Promise<undefined> {
const key = request instanceof Request ? request.url : request;
this.store.set(key, response.clone());
return undefined;
}
async match(
request: RequestInfo,
_options?: CacheQueryOptions,
): Promise<Response | undefined> {
const key = request instanceof Request ? request.url : request;
const response = this.store.get(key);
return response?.clone();
}
async delete(
request: RequestInfo,
_options?: CacheQueryOptions,
): Promise<boolean> {
const key = request instanceof Request ? request.url : request;
return this.store.delete(key);
}
// Required Cache interface methods with minimal implementations
async add(): Promise<void> {
throw new Error("Not implemented");
}
async addAll(): Promise<void> {
throw new Error("Not implemented");
}
async keys(): Promise<Request[]> {
return [];
}
}
// Create MSW server for mocking external requests
export const server = setupServer();
// Setup before tests
beforeAll(() => {
// Setup MSW server
server.listen({ onUnhandledRequest: "error" });
// Type-safe access to Node's global object for setting Workers-like globals in tests
const g = globalThis as any;
// Mock Cloudflare Workers runtime globals
g.caches = {
default: new MockCache(),
open: async () => new MockCache(),
} as unknown as CacheStorage;
// Mock crypto for generating random values
if (!g.crypto) {
g.crypto = require("crypto").webcrypto;
}
// Ensure other required globals are available
if (!g.FormData) {
const { FormData } = require("undici");
g.FormData = FormData;
}
if (!g.Headers) {
const { Headers } = require("undici");
g.Headers = Headers;
}
if (!g.Request) {
const { Request } = require("undici");
g.Request = Request;
}
if (!g.Response) {
const { Response } = require("undici");
g.Response = Response;
}
});
// Clean up after tests
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
});
/**
* Mock R2 bucket implementation
* Simulates Cloudflare Workers R2 storage using an in-memory Map
*/
export class MockR2 {
private store: Map<
string,
{
body: ArrayBuffer;
httpMetadata?: { contentType?: string; contentDisposition?: string };
httpEtag: string;
}
> = new Map();
async put(
key: string,
value:
| ReadableStream
| ArrayBuffer
| ArrayBufferView
| string
| null
| Blob,
options?: {
httpMetadata?: { contentType?: string; contentDisposition?: string };
},
) {
let buffer: ArrayBuffer;
if (value instanceof ArrayBuffer) {
buffer = value;
} else if (typeof value === "string") {
buffer = new TextEncoder().encode(value).buffer as ArrayBuffer;
} else if (ArrayBuffer.isView(value)) {
buffer = value.buffer as ArrayBuffer;
} else {
buffer = new ArrayBuffer(0);
}
this.store.set(key, {
body: buffer,
httpMetadata: options?.httpMetadata,
httpEtag: `"mock-etag-${key}"`,
});
}
async get(key: string) {
const entry = this.store.get(key);
if (!entry) return null;
return {
key,
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(entry.body));
controller.close();
},
}),
httpEtag: entry.httpEtag,
httpMetadata: entry.httpMetadata,
size: entry.body.byteLength,
arrayBuffer: async () => entry.body,
text: async () => new TextDecoder().decode(entry.body),
writeHttpMetadata: (headers: Headers) => {
if (entry.httpMetadata?.contentType) {
headers.set("Content-Type", entry.httpMetadata.contentType);
}
if (entry.httpMetadata?.contentDisposition) {
headers.set(
"Content-Disposition",
entry.httpMetadata.contentDisposition,
);
}
},
};
}
async delete(keys: string | string[]) {
const arr = Array.isArray(keys) ? keys : [keys];
for (const k of arr) {
this.store.delete(k);
}
}
_has(key: string) {
return this.store.has(key);
}
}
/**
* Create a mock environment for testing
* @returns Mock environment with KV storage and configuration
*/
export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
EMAIL_STORAGE: new MockKV(),
DOMAIN: "test.getmynews.app",
ADMIN_PASSWORD: "test-password",
...(options.withR2
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
: {}),
});