import { afterEach, describe, expect, it, vi } from 'vitest';
import { ACTUATE_TAG_ROOT, actuateTag, cacheKeyFor, cachedRead, invalidationTagsForDocumentWrite, invalidationTagsForGlobalWrite, publicReadCacheControl, } from '../http.js';
import { setActuateConfig } from '../../config/runtime.js';
// Reset the per-process config global between cases so a publicReads
// override in one test doesn't leak into the next. The runtime module
// stores config on globalThis (see runtime.ts header) so vitest's
// per-file isolation alone isn't enough.
afterEach(() => {
    setActuateConfig({});
});
describe('actuateTag / tag taxonomy', () => {
    it('produces canonical tag strings that mirror the SDK output', () => {
        expect(ACTUATE_TAG_ROOT).toBe('actuate');
        expect(actuateTag('collection', 'posts')).toBe('actuate:collection:posts');
        expect(actuateTag('doc', 'abc')).toBe('actuate:doc:abc');
        expect(actuateTag('global', 'settings')).toBe('actuate:global:settings');
        expect(actuateTag('path', '/blog/foo')).toBe('actuate:path:/blog/foo');
    });
    it('invalidationTagsForDocumentWrite includes root + collection + doc tags', () => {
        expect(invalidationTagsForDocumentWrite('posts', 'abc')).toEqual([
            'actuate',
            'actuate:collection:posts',
            'actuate:doc:abc',
        ]);
    });
    it('invalidationTagsForGlobalWrite includes root + global tag', () => {
        expect(invalidationTagsForGlobalWrite('settings')).toEqual([
            'actuate',
            'actuate:global:settings',
        ]);
    });
});
describe('publicReadCacheControl', () => {
    const apiKeyRequest = () => new Request('https://cms.example.com/api/cms/collections/posts', {
        headers: { authorization: 'Bearer act_sk_abc123' },
    });
    const sessionRequest = () => new Request('https://cms.example.com/api/cms/collections/posts', {
        // The cookie name is what triggers the session-auth branch — value
        // and validity don't matter for header-emission rules.
        headers: { cookie: 'actuate_session=anything; other=x' },
    });
    it('returns null when cache config is missing entirely', () => {
        expect(publicReadCacheControl(apiKeyRequest(), undefined)).toBeNull();
    });
    it('returns null when publicReads is not opted in', () => {
        expect(publicReadCacheControl(apiKeyRequest(), { publicReads: { enabled: false } })).toBeNull();
    });
    it('emits public s-maxage when enabled and the request has no session cookie', () => {
        const value = publicReadCacheControl(apiKeyRequest(), {
            publicReads: { enabled: true, maxAge: 60, staleWhileRevalidate: 300 },
        });
        expect(value).toBe('public, s-maxage=60, stale-while-revalidate=300');
    });
    it('applies the documented defaults when maxAge/swr are omitted', () => {
        const value = publicReadCacheControl(apiKeyRequest(), { publicReads: { enabled: true } });
        expect(value).toBe('public, s-maxage=60, stale-while-revalidate=300');
    });
    it('emits private/no-store for session-cookie requests even when caching is enabled', () => {
        // This is the security-critical invariant: a shared CDN tier MUST NOT
        // cache a response that may contain per-user state. Refusing the
        // public cache here is what makes the rest of the feature safe.
        const value = publicReadCacheControl(sessionRequest(), {
            publicReads: { enabled: true, maxAge: 60 },
        });
        expect(value).toBe('private, no-store');
    });
    it('does not match a cookie whose VALUE happens to contain actuate_session=', () => {
        const req = new Request('https://cms.example.com/api/cms/collections/posts', {
            headers: {
                // Defense-in-depth: an attacker setting another cookie whose value
                // contains the substring must not flip us into session-cookie mode.
                cookie: 'analytics=actuate_session=fake; tenant=x',
            },
        });
        expect(publicReadCacheControl(req, { publicReads: { enabled: true } })).toBe('public, s-maxage=60, stale-while-revalidate=300');
    });
    it('clamps negative maxAge/swr values to 0 instead of emitting nonsense', () => {
        const value = publicReadCacheControl(apiKeyRequest(), {
            publicReads: { enabled: true, maxAge: -10, staleWhileRevalidate: -5 },
        });
        expect(value).toBe('public, s-maxage=0, stale-while-revalidate=0');
    });
});
// ─── Runtime cache (layer 3) ─────────────────────────────────────────────
describe('cacheKeyFor', () => {
    it('returns the pathname when there is no query string', () => {
        const req = new Request('https://cms.example.com/api/cms/collections/posts');
        expect(cacheKeyFor(req)).toBe('/api/cms/collections/posts');
    });
    it('appends sorted query params so order does not fragment the cache', () => {
        const a = new Request('https://cms.example.com/api/cms/collections/posts?page=2&pageSize=20');
        const b = new Request('https://cms.example.com/api/cms/collections/posts?pageSize=20&page=2');
        expect(cacheKeyFor(a)).toBe(cacheKeyFor(b));
        expect(cacheKeyFor(a)).toBe('/api/cms/collections/posts?page=2&pageSize=20');
    });
    it('keys populated and non-populated reads independently', () => {
        const plain = new Request('https://cms.example.com/api/cms/collections/posts/abc');
        const populated = new Request('https://cms.example.com/api/cms/collections/posts/abc?populate=author');
        expect(cacheKeyFor(plain)).not.toBe(cacheKeyFor(populated));
    });
});
describe('cachedRead', () => {
    function makeAdapter(initial) {
        const store = new Map(Object.entries(initial ?? {}));
        return {
            store,
            calls: { get: [], set: [] },
            async get(key) {
                this.calls.get.push(key);
                return store.get(key);
            },
            async set(key, value, options) {
                this.calls.set.push({ key, ttl: options.ttl, tags: options.tags });
                store.set(key, value);
            },
        };
    }
    function jsonWrap(payload) {
        return new Response(JSON.stringify(payload), {
            status: 200,
            headers: { 'Content-Type': 'application/json' },
        });
    }
    const anonRequest = () => new Request('https://cms.example.com/api/cms/collections/posts');
    it('bypasses the cache when no adapter is configured', async () => {
        const build = vi.fn(async () => ({ data: ['a', 'b'] }));
        const res = await cachedRead(anonRequest(), {
            cache: { publicReads: { enabled: true } },
            tags: ['actuate'],
            wrap: jsonWrap,
            build,
        });
        expect(res.headers.get('X-Actuate-Cache')).toBeNull();
        expect(build).toHaveBeenCalledTimes(1);
        expect(await res.json()).toEqual({ data: ['a', 'b'] });
    });
    it('bypasses the cache for session-cookie requests', async () => {
        const adapter = makeAdapter();
        const req = new Request('https://cms.example.com/api/cms/collections/posts', {
            headers: { cookie: 'actuate_session=anything' },
        });
        const build = vi.fn(async () => ({ data: 'priv' }));
        await cachedRead(req, {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate', 'actuate:collection:posts'],
            wrap: jsonWrap,
            build,
        });
        expect(adapter.calls.get).toEqual([]); // never touched the cache
        expect(adapter.calls.set).toEqual([]);
        expect(build).toHaveBeenCalledTimes(1);
    });
    it('bypasses the cache when the request opts out via Cache-Control: no-cache', async () => {
        const adapter = makeAdapter({ '/api/cms/collections/posts': { data: ['stale'] } });
        const req = new Request('https://cms.example.com/api/cms/collections/posts', {
            headers: { 'cache-control': 'no-cache' },
        });
        const build = vi.fn(async () => ({ data: ['fresh'] }));
        const res = await cachedRead(req, {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate'],
            wrap: jsonWrap,
            build,
        });
        expect(adapter.calls.get).toEqual([]);
        expect(build).toHaveBeenCalledTimes(1);
        expect(await res.json()).toEqual({ data: ['fresh'] });
    });
    it('returns the cached payload on hit without calling build', async () => {
        const cached = { data: ['hit'] };
        const adapter = makeAdapter({ '/api/cms/collections/posts': cached });
        const build = vi.fn();
        const res = await cachedRead(anonRequest(), {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate', 'actuate:collection:posts'],
            wrap: jsonWrap,
            build,
        });
        expect(build).not.toHaveBeenCalled();
        expect(res.headers.get('X-Actuate-Cache')).toBe('HIT');
        expect(await res.json()).toEqual(cached);
    });
    it('runs build on miss and stores the result with tags + ttl', async () => {
        const adapter = makeAdapter();
        const payload = { data: ['fresh'] };
        const build = vi.fn(async () => payload);
        const res = await cachedRead(anonRequest(), {
            cache: { runtimeCache: { adapter, ttl: 120 } },
            tags: ['actuate', 'actuate:collection:posts'],
            wrap: jsonWrap,
            build,
        });
        expect(build).toHaveBeenCalledTimes(1);
        expect(adapter.calls.set).toEqual([
            {
                key: '/api/cms/collections/posts',
                ttl: 120,
                tags: ['actuate', 'actuate:collection:posts'],
            },
        ]);
        expect(res.headers.get('X-Actuate-Cache')).toBe('MISS');
        expect(await res.json()).toEqual(payload);
    });
    it('falls back to defaults when ttl is omitted', async () => {
        const adapter = makeAdapter();
        await cachedRead(anonRequest(), {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate'],
            wrap: jsonWrap,
            build: async () => ({ data: 1 }),
        });
        expect(adapter.calls.set[0]?.ttl).toBe(60);
    });
    it('never caches a build that returns a Response (e.g. 404)', async () => {
        const adapter = makeAdapter();
        const notFound = new Response(JSON.stringify({ error: 'gone' }), { status: 404 });
        const build = vi.fn(async () => notFound);
        const res = await cachedRead(anonRequest(), {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate'],
            wrap: jsonWrap,
            build,
        });
        expect(res.status).toBe(404);
        expect(adapter.calls.set).toEqual([]);
    });
    it('degrades to a fresh fetch when adapter.get throws', async () => {
        const adapter = {
            async get() {
                throw new Error('redis down');
            },
            async set() {
                /* unused */
            },
        };
        const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
        const build = vi.fn(async () => ({ data: 'fresh' }));
        const res = await cachedRead(anonRequest(), {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate'],
            wrap: jsonWrap,
            build,
        });
        expect(build).toHaveBeenCalledTimes(1);
        expect(await res.json()).toEqual({ data: 'fresh' });
        expect(errSpy).toHaveBeenCalled();
        errSpy.mockRestore();
    });
    it('swallows adapter.set errors and still returns the fresh result', async () => {
        const adapter = {
            async get() {
                return undefined;
            },
            async set() {
                throw new Error('write fail');
            },
        };
        const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
        const res = await cachedRead(anonRequest(), {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate'],
            wrap: jsonWrap,
            build: async () => ({ data: 'fresh' }),
        });
        expect(await res.json()).toEqual({ data: 'fresh' });
        expect(res.headers.get('X-Actuate-Cache')).toBe('MISS');
        expect(errSpy).toHaveBeenCalled();
        errSpy.mockRestore();
    });
    it('skips storing payloads larger than ~1.5 MB to stay under the Vercel ceiling', async () => {
        const adapter = makeAdapter();
        // Build a > 1.5 MB payload — ~2 MB worth of repeated string.
        const huge = { data: { body: 'x'.repeat(2_000_000) } };
        await cachedRead(anonRequest(), {
            cache: { runtimeCache: { adapter } },
            tags: ['actuate'],
            wrap: jsonWrap,
            build: async () => huge,
        });
        expect(adapter.calls.set).toEqual([]);
        // ... but the response still returns the fresh data
        expect(adapter.store.size).toBe(0);
    });
});
//# sourceMappingURL=http.test.js.map