import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { isAuthorizedCronRequest, processCleanup, processSeoScan } from '../../cron/index.js';
import { setActuateConfig } from '../../config/runtime.js';
describe('cron auth', () => {
    const originalSecret = process.env.CRON_SECRET;
    afterEach(() => {
        if (originalSecret === undefined)
            delete process.env.CRON_SECRET;
        else
            process.env.CRON_SECRET = originalSecret;
    });
    it('rejects when CRON_SECRET is unset (fail-closed)', () => {
        delete process.env.CRON_SECRET;
        expect(isAuthorizedCronRequest('Bearer anything')).toBe(false);
    });
    it('rejects when CRON_SECRET is the empty string (fail-closed)', () => {
        process.env.CRON_SECRET = '';
        expect(isAuthorizedCronRequest('Bearer anything')).toBe(false);
    });
    it('rejects null/undefined header', () => {
        process.env.CRON_SECRET = 'abc123';
        expect(isAuthorizedCronRequest(null)).toBe(false);
        expect(isAuthorizedCronRequest(undefined)).toBe(false);
    });
    it('rejects wrong secret', () => {
        process.env.CRON_SECRET = 'correct-secret';
        expect(isAuthorizedCronRequest('Bearer wrong-secret')).toBe(false);
    });
    it('rejects partially-correct secret of equal length (constant-time)', () => {
        process.env.CRON_SECRET = 'secret-with-fixed-length';
        expect(isAuthorizedCronRequest('Bearer secret-with-fixex-length')).toBe(false);
    });
    it('accepts correct Bearer token', () => {
        process.env.CRON_SECRET = 'correct-secret-value';
        expect(isAuthorizedCronRequest('Bearer correct-secret-value')).toBe(true);
    });
    it('accepts bare secret (for self-hosted schedulers without Bearer prefix)', () => {
        process.env.CRON_SECRET = 'correct-secret-value';
        expect(isAuthorizedCronRequest('correct-secret-value')).toBe(true);
    });
    // Bugbot review #6 (PR #40): the prior `if (a.length !== b.length) return
    // false` early exit leaked the secret length through response timing.
    // The HMAC-based comparison must reject mismatched lengths just as
    // reliably without revealing length information through different code
    // paths.
    it.each([
        ['shorter than secret', 'short'],
        ['longer than secret', 'a-much-longer-string-than-the-secret-value-itself'],
        ['empty', ''],
        ['one char short', 'correct-secret-valu'],
        ['one char long', 'correct-secret-value-x'],
    ])('rejects header %s when length differs from secret', (_label, attempt) => {
        process.env.CRON_SECRET = 'correct-secret-value';
        expect(isAuthorizedCronRequest(`Bearer ${attempt}`)).toBe(false);
    });
});
describe('processCleanup', () => {
    let now;
    beforeEach(() => {
        now = Date.now();
        vi.useFakeTimers();
        vi.setSystemTime(now);
    });
    afterEach(() => {
        vi.useRealTimers();
    });
    it('returns zeros when db is empty / has no models', async () => {
        const result = await processCleanup({});
        expect(result).toEqual({
            sessionsDeleted: 0,
            auditLogsDeleted: 0,
            documentsDeleted: 0,
            passwordResetTokensDeleted: 0,
            formSubmissionsDeleted: 0,
            versionsDeleted: 0,
            webhookDeliveriesDeleted: 0,
            apiRequestMetricsDeleted: 0,
        });
    });
    it('deletes expired/revoked sessions older than the retention window', async () => {
        const deleteMany = vi.fn().mockResolvedValue({ count: 3 });
        const db = { session: { deleteMany } };
        const result = await processCleanup(db, { sessionRetentionMs: 1000 });
        expect(result.sessionsDeleted).toBe(3);
        expect(deleteMany).toHaveBeenCalledTimes(1);
        const where = deleteMany.mock.calls[0][0].where;
        expect(where.OR[0].revokedAt.lt.getTime()).toBe(now - 1000);
        expect(where.OR[1].expiresAt.lt.getTime()).toBe(now - 1000);
    });
    it('continues other cleanups when one model fails', async () => {
        const db = {
            session: { deleteMany: vi.fn().mockRejectedValue(new Error('boom')) },
            auditLog: { deleteMany: vi.fn().mockResolvedValue({ count: 5 }) },
            document: { deleteMany: vi.fn().mockResolvedValue({ count: 2 }) },
        };
        const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
        const result = await processCleanup(db);
        expect(result.sessionsDeleted).toBe(0);
        expect(result.auditLogsDeleted).toBe(5);
        expect(result.documentsDeleted).toBe(2);
        expect(warn).toHaveBeenCalled();
        warn.mockRestore();
    });
    it('uses default retention windows when none provided', async () => {
        const deleteMany = vi.fn().mockResolvedValue({ count: 0 });
        const db = { auditLog: { deleteMany } };
        await processCleanup(db);
        const cutoff = deleteMany.mock.calls[0][0].where.timestamp.lt.getTime();
        // Default audit log retention is 90 days.
        expect(cutoff).toBe(now - 90 * 24 * 60 * 60 * 1000);
    });
    // Bugbot review (PR #40, post-fix re-scan): `modelExists` used
    // `typeof db[name] === 'object'` which is true for `null` because of the
    // historical `typeof null === 'object'` quirk. With `{ session: null }` the
    // guard returned true and the subsequent `db.session.deleteMany(...)` call
    // threw a TypeError that the outer try/catch papered over.
    it('treats explicitly-null model delegates as missing (regression)', async () => {
        const db = {
            session: null,
            auditLog: null,
            document: null,
            passwordResetToken: null,
        };
        const result = await processCleanup(db);
        expect(result).toEqual({
            sessionsDeleted: 0,
            auditLogsDeleted: 0,
            documentsDeleted: 0,
            passwordResetTokensDeleted: 0,
            formSubmissionsDeleted: 0,
            versionsDeleted: 0,
            webhookDeliveriesDeleted: 0,
            apiRequestMetricsDeleted: 0,
        });
    });
    it('does not touch form submissions when retention is disabled (0)', async () => {
        const deleteMany = vi.fn().mockResolvedValue({ count: 9 });
        const db = { formSubmission: { deleteMany } };
        const result = await processCleanup(db);
        expect(result.formSubmissionsDeleted).toBe(0);
        expect(deleteMany).not.toHaveBeenCalled();
    });
    it('hard-deletes form submissions older than the configured retention window', async () => {
        const deleteMany = vi.fn().mockResolvedValue({ count: 4 });
        const db = { formSubmission: { deleteMany } };
        const result = await processCleanup(db, {
            formSubmissionRetentionMs: 30 * 24 * 60 * 60 * 1000,
        });
        expect(result.formSubmissionsDeleted).toBe(4);
        const cutoff = deleteMany.mock.calls[0][0].where.submittedAt.lt.getTime();
        expect(cutoff).toBe(now - 30 * 24 * 60 * 60 * 1000);
    });
    it('hard-deletes document versions older than the default retention window', async () => {
        const deleteMany = vi.fn().mockResolvedValue({ count: 12 });
        const db = { version: { deleteMany } };
        const result = await processCleanup(db);
        expect(result.versionsDeleted).toBe(12);
        const cutoff = deleteMany.mock.calls[0][0].where.createdAt.lt.getTime();
        expect(cutoff).toBe(now - 90 * 24 * 60 * 60 * 1000);
    });
    it('hard-deletes webhook delivery logs and API metrics with default retention', async () => {
        const webhookDeleteMany = vi.fn().mockResolvedValue({ count: 2 });
        const metricDeleteMany = vi.fn().mockResolvedValue({ count: 7 });
        const db = {
            webhookDeliveryLog: { deleteMany: webhookDeleteMany },
            apiRequestMetric: { deleteMany: metricDeleteMany },
        };
        const result = await processCleanup(db);
        expect(result.webhookDeliveriesDeleted).toBe(2);
        expect(result.apiRequestMetricsDeleted).toBe(7);
        expect(webhookDeleteMany.mock.calls[0][0].where.createdAt.lt.getTime()).toBe(now - 30 * 24 * 60 * 60 * 1000);
        expect(metricDeleteMany.mock.calls[0][0].where.bucketStart.lt.getTime()).toBe(now - 30 * 24 * 60 * 60 * 1000);
    });
});
describe('processSeoScan', () => {
    it('returns empty result when document model is missing', async () => {
        const result = await processSeoScan({});
        expect(result).toEqual({ total: 0, pagesWithIssues: 0, totalProblems: 0, issues: [] });
    });
    it('flags missing meta title and description', async () => {
        const findMany = vi.fn().mockResolvedValue([
            {
                id: 'doc1',
                title: 'Hello',
                slug: 'hello',
                collection: 'pages',
                data: {},
                plainText: 'x'.repeat(500),
                publishedAt: null,
            },
        ]);
        const db = { document: { findMany } };
        const result = await processSeoScan(db);
        expect(result.total).toBe(1);
        expect(result.pagesWithIssues).toBe(1);
        const problems = result.issues[0].problems;
        expect(problems).toContain('Missing meta description');
        expect(problems).not.toContain('Missing meta title');
    });
    it('does not flag missing meta when effective title and description resolve', async () => {
        setActuateConfig({
            seo: {
                siteName: 'Acme',
                titleTemplate: '%page% | %site%',
            },
            collections: {
                pages: { slug: 'pages', type: 'page', fields: [] },
            },
        });
        const db = {
            document: {
                findMany: vi.fn().mockResolvedValue([
                    {
                        id: 'doc1',
                        title: 'Hello',
                        slug: 'hello',
                        collection: 'pages',
                        data: {
                            title: 'Hello',
                            keyTakeaway: 'Hello page summary with enough characters to serve as an effective meta description for search engines.',
                        },
                        plainText: 'x'.repeat(500),
                        publishedAt: new Date(),
                    },
                ]),
            },
        };
        const result = await processSeoScan(db);
        expect(result.pagesWithIssues).toBe(0);
        setActuateConfig({});
    });
    it('respects maxDocuments bound', async () => {
        const findMany = vi.fn().mockResolvedValue([]);
        const db = { document: { findMany } };
        await processSeoScan(db, { maxDocuments: 100 });
        expect(findMany.mock.calls[0][0].take).toBe(100);
    });
    it('does not flag well-formed documents', async () => {
        const db = {
            document: {
                findMany: vi.fn().mockResolvedValue([
                    {
                        id: 'doc1',
                        title: 'Hello',
                        slug: 'hello',
                        collection: 'pages',
                        data: {
                            metaTitle: 'Hello',
                            metaDescription: 'A page',
                            canonical: 'https://example.com/hello',
                            schemaType: 'Article',
                            body: '<h1>Hello</h1><img alt="ok" src="x">'.padEnd(400, ' '),
                        },
                        plainText: 'x'.repeat(500),
                    },
                ]),
            },
        };
        const result = await processSeoScan(db);
        expect(result.pagesWithIssues).toBe(0);
        expect(result.totalProblems).toBe(0);
    });
    // Bugbot review (PR #40, post-fix re-scan): the `<h1>` heading detection
    // used `content.includes('<h1')` which is case-sensitive, so documents with
    // valid uppercase `<H1>` headings (or `<H1 class="...">`) were falsely
    // flagged as missing. Now matches the case-insensitive `<img>` detection
    // for consistency with the HTML spec.
    it.each([
        ['lowercase <h1>', '<h1>Hello</h1>'],
        ['uppercase <H1>', '<H1>Hello</H1>'],
        ['mixed-case <H1 class>', '<H1 class="hero">Hello</H1>'],
        ['attribute on lowercase', '<h1 id="x">Hello</h1>'],
    ])('does not flag %s as missing H1 (regression)', async (_label, h1Markup) => {
        const db = {
            document: {
                findMany: vi.fn().mockResolvedValue([
                    {
                        id: 'doc1',
                        title: 'Hello',
                        slug: 'hello',
                        collection: 'pages',
                        data: {
                            metaTitle: 'Hello',
                            metaDescription: 'A page',
                            canonical: 'https://example.com/hello',
                            schemaType: 'Article',
                            body: `${h1Markup}<img alt="ok" src="x">`.padEnd(400, ' '),
                        },
                        plainText: 'x'.repeat(500),
                    },
                ]),
            },
        };
        const result = await processSeoScan(db);
        expect(result.issues[0]?.problems ?? []).not.toContain('No H1 heading found in content');
    });
    // Bugbot review (PR #40, post-fix re-scan #2): the `<img>` regex was
    // case-insensitive (`/gi`) but the `alt=` substring check inside the filter
    // was case-sensitive (`img.includes('alt=')`), so `<IMG ALT="text">` matched
    // the regex and was then falsely counted as missing alt text. Same class of
    // bug as the H1 case-sensitivity fix above.
    it.each([
        ['lowercase img + lowercase alt', '<img src="x" alt="ok">', 0],
        ['uppercase IMG + uppercase ALT', '<IMG SRC="x" ALT="ok">', 0],
        ['mixed-case Img + mixed-case Alt', '<Img src="x" Alt="ok">', 0],
        ['lowercase img with no alt is flagged', '<img src="x">', 1],
        ['uppercase IMG with no alt is flagged', '<IMG SRC="x">', 1],
    ])('alt-text scan is case-insensitive — %s', async (_label, imgMarkup, expectedMissing) => {
        const db = {
            document: {
                findMany: vi.fn().mockResolvedValue([
                    {
                        id: 'doc1',
                        title: 'Hello',
                        slug: 'hello',
                        collection: 'pages',
                        data: {
                            metaTitle: 'Hello',
                            metaDescription: 'A page',
                            canonical: 'https://example.com/hello',
                            schemaType: 'Article',
                            body: `<h1>Hello</h1>${imgMarkup}`.padEnd(400, ' '),
                        },
                        plainText: 'x'.repeat(500),
                    },
                ]),
            },
        };
        const result = await processSeoScan(db);
        const problems = result.issues[0]?.problems ?? [];
        const altMessages = problems.filter((p) => p.endsWith('image(s) missing alt text'));
        if (expectedMissing === 0) {
            expect(altMessages).toHaveLength(0);
        }
        else {
            expect(altMessages).toContain(`${expectedMissing} image(s) missing alt text`);
        }
    });
});
//# sourceMappingURL=cron.test.js.map