/** * Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly. * * These prove that a Cloudflare Worker (Edge) can sit in front of a * TypeScript backend and behave identically to sitting in front of Django. */ import { describe, test, expect, beforeEach } from 'bun:test' import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest } from '../src' const UserCtx = new ReactContext('user') function setupUserContext() { const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) { return { name: `user_${userId}`, email: `user${userId}@test.com` } }) const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) { return { count: userId * 10 } }) const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) { return { name, email: `user${userId}@test.com` } }) client({ affects: 'userProfile' }, async function updateName(userId: number, name: string) { return { name, email: `user${userId}@test.com` } }) } describe('Edge Compatibility', () => { beforeEach(() => { clearRegistry() setupUserContext() }) // ── Deterministic JSON ────────────────────────────────────────────── test('deterministic JSON output', async () => { const r1 = await handleContextFetch('user', { userId: '5' }) const r2 = await handleContextFetch('user', { userId: '5' }) expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body)) }) test('different params produce different responses', async () => { const r1 = await handleContextFetch('user', { userId: '5' }) const r2 = await handleContextFetch('user', { userId: '6' }) expect(JSON.stringify(r1.body)).not.toBe(JSON.stringify(r2.body)) expect(r1.body.userProfile.name).toBe('user_5') expect(r2.body.userProfile.name).toBe('user_6') }) // ── Cache-Control correctness ─────────────────────────────────────── test('context GET is cacheable', async () => { const r = await handleContextFetch('user', { userId: '5' }) expect(r.headers['Cache-Control']).toContain('public') expect(r.headers['Cache-Control']).toContain('s-maxage') expect(r.headers['Cache-Control']).not.toContain('no-store') }) test('mutation POST not cacheable', async () => { const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' }) expect(r.headers['Cache-Control']).toBe('no-store') }) test('error response not cacheable', async () => { const r = await handleContextFetch('nonexistent', {}) expect(r.status).toBe(404) expect(r.headers['Cache-Control']).toBe('no-store') }) // ── X-Mizan-Invalidate header ────────────────────────────────────── test('mutation response includes invalidation header', async () => { const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' }) expect(r.headers['X-Mizan-Invalidate']).toBeDefined() expect(r.headers['X-Mizan-Invalidate']).toContain('user') }) test('auto-scoped invalidation in header', async () => { const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' }) expect(r.headers['X-Mizan-Invalidate']).toBe('user;userId=5') }) test('invalidation header matches JSON body', async () => { const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' }) const body = r.body expect(body.invalidate[0].context).toBe('user') expect(body.invalidate[0].params.userId).toBe(5) expect(r.headers['X-Mizan-Invalidate']).toContain('user;userId=5') }) test('function-level invalidation in header', async () => { const r = await handleMutationCall('updateName', { userId: 7, name: 'X' }) expect(r.headers['X-Mizan-Invalidate']).toContain('userProfile') }) test('no invalidation header on context GET', async () => { const r = await handleContextFetch('user', { userId: '5' }) expect(r.headers['X-Mizan-Invalidate']).toBeUndefined() }) // ── Header format edge cases ─────────────────────────────────────── test('special characters in param values are URL-encoded', () => { const header = formatInvalidateHeader([ { context: 'search', params: { q: 'a;b' } }, ]) expect(header).not.toContain(';b') expect(header).toContain('a%3Bb') }) test('spaces in param values are URL-encoded', () => { const header = formatInvalidateHeader([ { context: 'search', params: { q: 'hello world' } }, ]) expect(header).not.toContain(' ') expect(header).toContain('hello%20world') }) test('header round-trip with special chars', () => { const header = formatInvalidateHeader([ { context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } }, ]) // Parse (what Edge does) const segments = header.split(';') const ctx = segments[0] const params: Record = {} for (const seg of segments.slice(1)) { const [k, v] = seg.split('=', 2) params[decodeURIComponent(k)] = decodeURIComponent(v) } expect(ctx).toBe('data') expect(params.name).toBe("O'Brien") expect(params.tag).toBe('a;b;c') }) // ── Empty invalidation ───────────────────────────────────────────── test('no affects = no header, no body key', async () => { client({ context: new ReactContext('plain') }, async function plainFn() { return { ok: true } }) // A context function called via mutation dispatch (shouldn't have invalidation) // Actually test a function without affects clearRegistry() client({}, async function noAffects() { return { ok: true } }) const r = await handleMutationCall('noAffects', {}) expect(r.body.invalidate).toBeUndefined() expect(r.headers['X-Mizan-Invalidate']).toBeUndefined() }) // ── Private functions ────────────────────────────────────────────── test('private functions rejected from RPC', async () => { clearRegistry() client({ affects: 'subscription', private: true }, async function webhook() { return { ok: true } }) const r = await handleMutationCall('webhook', {}) expect(r.status).toBe(403) }) // ── Unknown function ─────────────────────────────────────────────── test('unknown function returns 404', async () => { const r = await handleMutationCall('doesNotExist', {}) expect(r.status).toBe(404) }) test('unknown context returns 404', async () => { const r = await handleContextFetch('doesNotExist', {}) expect(r.status).toBe(404) }) }) describe('Manifest', () => { beforeEach(() => { clearRegistry() setupUserContext() }) test('manifest matches expected structure', () => { const m = generateManifest() expect(m.version).toBe(1) expect(m.contexts.user).toBeDefined() expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/']) expect(m.contexts.user.params).toContain('userId') expect(m.contexts.user.user_scoped).toBe(true) expect(m.contexts.user.render_strategy).toBe('dynamic_cached') }) test('mutations section includes auto-scoped params', () => { const m = generateManifest() expect(m.mutations.updateProfile).toBeDefined() expect(m.mutations.updateProfile.affects).toEqual(['user']) expect(m.mutations.updateProfile.auto_scoped_params).toContain('userId') }) test('PSR strategy for non-user-scoped context', () => { clearRegistry() const ProductCtx = new ReactContext('products') client({ context: ProductCtx }, async function productDetail(productId: number) { return { id: productId } }) const m = generateManifest() expect(m.contexts.products.user_scoped).toBe(false) expect(m.contexts.products.render_strategy).toBe('psr') }) test('private mutation in manifest', () => { clearRegistry() client( { affects: 'subscription', private: true, route: '/webhooks/stripe/', methods: ['POST'] }, async function stripeWebhook() { return new Response('ok') }, ) const m = generateManifest() expect(m.mutations.stripeWebhook).toBeDefined() expect(m.mutations.stripeWebhook.private).toBe(true) expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/') expect(m.mutations.stripeWebhook.methods).toEqual(['POST']) }) test('rev appears in manifest', () => { clearRegistry() const Ctx = new ReactContext('data') client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) { return { value: itemId } }) const m = generateManifest() const fn = m.contexts.data.functions[0] expect(fn.rev).toBe(3) }) test('cache TTL appears in manifest', () => { clearRegistry() const Ctx = new ReactContext('trending') client({ context: Ctx, cache: 60 }, async function trendingFn() { return { items: [] } }) const m = generateManifest() const fn = m.contexts.trending.functions[0] expect(fn.cache).toBe(60) }) test('cache=60 sets s-maxage=60', async () => { clearRegistry() const Ctx = new ReactContext('live') client({ context: Ctx, cache: 60 }, async function liveFn() { return { score: 42 } }) const r = await handleContextFetch('live', {}) expect(r.headers['Cache-Control']).toBe('public, max-age=0, s-maxage=60') }) test('cache=false sets no-store', async () => { clearRegistry() const Ctx = new ReactContext('random') client({ context: Ctx, cache: false }, async function randomFn() { return { value: Math.random() } }) const r = await handleContextFetch('random', {}) expect(r.headers['Cache-Control']).toBe('no-store') }) })