Ben Senescu 1c74fded7b Site audit P0 (1/3): issue engine, incremental persistence, block detection (#362)
* Site audit P0 (1/3): issue engine, incremental persistence, block detection

Server-side foundation of the P0 feature set from docs/site-audit-pm-research.md:

- Issue engine: shared registry of issue types (severity, explanation,
  how-to-fix). Per-page reporters run inside crawl steps; cross-page checks
  (duplicate titles/descriptions/content, broken internal links, redirect
  chains/loops, orphan pages) run at finalize as SQL over the persisted crawl.
- New audit_links + audit_issues tables, audit_pages columns (depth, content
  hash, header signals, fetch class, sitemap flag); audit tables moved to
  src/db/{,pg/}audit.schema.ts; migrations 0029 (D1) / 0006 (PG).
- Incremental persistence: pages/links/issues written inside each crawl-batch
  step with deterministic row ids + upserts (retry idempotent); slim step
  state; robots.txt checkpointed as step state; merged progress steps keep a
  10k-page crawl within the Workflows step budget.
- Crawler: manual redirect handling with inline follow of normalization-
  equivalent redirects, response header capture (X-Robots-Tag, Link
  rel=canonical), BFS depth, sitemap-last seeding, SSRF check on discovered
  links, honest 'we were blocked' classification (403/429/cf-mitigated/
  challenge).
- MCP: run_site_audit, get_audit_status, get_audit_issues, get_audit_pages;
  limitTier resolved via shared AuditService.resolveAuditLimitTier.
- Lighthouse strategies reduced to auto/none (legacy all/manual map on read).
- Self-healing: getStatus reconciles audits whose workflow instance errored/
  terminated without reaching mark-failed.

The Issues UI and the badseo.dev e2e fixture site stack on top of this PR.

Deploy notes: run db:migrate:prod (additive); terminate running audits before
deploying — the workflow step structure changed and in-flight instances cannot
replay under the new code (a finalize guard fails them loudly instead of
completing empty).

* Store only internal link edges in audit_links

Both consumers (broken-internal-link and orphan checks) filter on
isInternal; per-page external counts already live on audit_pages.
Dropping external rows cuts stored edges on outbound-heavy sites.
Column stays so P1 external-link checks can re-add rows without a
migration.

* Review fixes: failAudit CAS guard, dedupe hash helpers, cheaper checks

- failAudit only transitions running audits, so the getStatus reconciler
  can't flip a just-completed audit to failed when it races finalize
- collapse the duplicate SHA-256 helper into audit/ids.ts
- finalize integrity guard uses a limit-1 existence probe instead of
  fetching every page row
- get_audit_status MCP tool no longer reads the audit row twice when an
  explicit auditId is given
2026-07-07 22:08:14 -04:00

51 lines
1.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
AUDIT_LIMITS,
clampAuditMaxPages,
getEstimatedAuditCapacity,
} from "@/server/features/audit/services/audit-capacity";
describe("audit capacity helpers", () => {
it("clamps max pages into the supported range", () => {
expect(clampAuditMaxPages()).toBe(50);
expect(clampAuditMaxPages(1)).toBe(10);
expect(clampAuditMaxPages(500)).toBe(500);
expect(clampAuditMaxPages(20_000)).toBe(10_000);
});
it("estimates capacity for each lighthouse strategy", () => {
expect(
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "none" }),
).toEqual({
pagesTotal: 100,
lighthouseTotal: 0,
total: 100,
});
expect(
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "auto" }),
).toEqual({
pagesTotal: 100,
lighthouseTotal: 20,
total: 120,
});
});
it("stays within the paid capacity limit for the maximum auto audit", () => {
expect(
getEstimatedAuditCapacity({
maxPages: 10_000,
lighthouseStrategy: "auto",
}).total,
).toBeLessThan(AUDIT_LIMITS.paid.maxCapacityUnits);
});
it("fits a maximum free audit within the free capacity budget", () => {
const freeAudit = getEstimatedAuditCapacity({
maxPages: AUDIT_LIMITS.free.maxPagesPerAudit,
lighthouseStrategy: "auto",
});
expect(freeAudit.pagesTotal).toBe(AUDIT_LIMITS.free.maxPagesPerAudit);
expect(freeAudit.total).toBeLessThan(AUDIT_LIMITS.free.maxCapacityUnits);
});
});