/**
* testPersistence.js — Persist validated tests to SQLite
*
* Extracts the duplicated "Store in db" block that appeared in both
* generateSingleTest and crawlAndGenerateTests.
*
* Exports:
* persistGeneratedTests(validatedTests, project, run, defaults) → testIds[]
* buildPipelineStats({ pagesFound, rawTests, removed, enhancedCount, rejected, journeys, dedupStats }) → object
*/
import { generateTestId } from "../utils/idGenerator.js";
import { getProviderName } from "../aiProvider.js";
import { PROMPT_VERSION } from "./prompts/outputSchema.js";
import * as testRepo from "../database/repositories/testRepo.js";
import { logActivity } from "../utils/activityLogger.js";
import { ACTIVITY_TYPES } from "../constants/activityTypes.js";
import { APPROVAL_SOURCE } from "../services/approvalService.js";
import { normalizeQualityToConfidence } from "./deduplicator.js";
/**
* Pseudo-user attributed to machine-made approvals in `tests.approvedBy` and
* `activities.userName`. The literal `"auto-approver"` is pinned by the
* audit-trail contract in ROADMAP.md (AUTO-003b) and NEXT.md, so consumers
* (UI badges, activity log filters, route handlers) should reference this
* constant rather than re-typing the string.
*/
export const AUTO_APPROVER_USER = "auto-approver";
/**
* Write validated test objects into SQLite and update the run record.
*
* @param {object[]} validatedTests — tests that passed validation
* @param {object} project — project record (id, name, url)
* @param {object} run — mutable run record
* @param {object} [defaults] — fallback values for name/description/sourceUrl/pageTitle
* @returns {string[]} array of created test IDs
*/
/**
* Global kill-switch for auto-approval (AUTO-003b). Read on every persist
* call from `DISABLE_AUTO_APPROVAL` — any truthy value (`"1"`, `"true"`,
* `"yes"`, case-insensitive) forces every generated test to land in Draft
* regardless of the project-level `autoApproveThreshold`.
*
* Intended for ops incidents: if an AI provider starts producing bad tests
* faster than reviewers can revoke them, setting this env var is a
* one-step rollback that doesn't require a code deploy or per-project
* threshold reset. Per-project thresholds stay intact and take effect
* again as soon as the env var is removed.
*
* The check runs per-call (one string compare and a `process.env` read,
* neither measurable at the persist hot path) so operators don't have to
* restart the backend to flip the switch — and so test fixtures can drive
* the behaviour by mutating `process.env` between cases. Matches the
* convention used by other env-var gates in the codebase (e.g.
* `ALLOW_PRIVATE_URLS` in `routes/system.js`).
*
* Exported so the test suite can call it directly without round-tripping
* through `persistGeneratedTests`.
*/
export function isAutoApprovalDisabled() {
const v = String(process.env.DISABLE_AUTO_APPROVAL || "").trim().toLowerCase();
return v === "1" || v === "true" || v === "yes";
}
export function persistGeneratedTests(validatedTests, project, run, defaults = {}) {
const createdTestIds = [];
// Global kill-switch (DISABLE_AUTO_APPROVAL) overrides every per-project
// threshold — setting the env var pins `threshold = null`, which pins
// `autoApproved = false` below, regardless of the project's configuration.
// Read per-call (not cached at module scope) so operators can flip the
// switch without restarting the backend.
const threshold = isAutoApprovalDisabled()
? null
: (Number.isFinite(project?.autoApproveThreshold) ? project.autoApproveThreshold : null);
for (const t of validatedTests) {
const testId = generateTestId();
// `confidenceScore` is 0–1 (normalized by `deduplicateTests` and the
// orchestrator's re-score step); `_quality` is 0–100. Normalize the
// fallback so the `>= threshold` comparison below always compares on
// the same scale — a bare `(t._quality || 0)` would read `75 >= 0.8`
// as true and silently auto-approve every test if the fallback ever
// activates. `threshold` is validated to (0, 1] on the route.
const confidenceScore = Number.isFinite(t?.confidenceScore)
? t.confidenceScore
: normalizeQualityToConfidence(t?._quality);
const autoApproved = threshold !== null && confidenceScore >= threshold;
// approvedAt is epoch ms (INTEGER per migration 017 + NEXT.md spec) so the
// approvals timeline can do straight arithmetic ranges; reviewedAt stays
// ISO-string to match the rest of the codebase's review timestamp convention.
const now = new Date();
const approvedAt = autoApproved ? now.getTime() : null;
const reviewedAt = autoApproved ? now.toISOString() : null;
const test = {
// Spread AI-generated fields first so our critical fields below always win.
// This prevents the AI from accidentally overriding id, projectId, reviewStatus, etc.
...t,
id: testId,
projectId: project.id,
name: t.name || defaults.name || "",
description: t.description || defaults.description || "",
sourceUrl: t.sourceUrl || defaults.sourceUrl || project.url,
pageTitle: t.pageTitle || defaults.pageTitle || project.name,
createdAt: new Date().toISOString(),
lastResult: null,
lastRunAt: null,
qualityScore: t._quality || 0,
confidenceScore,
// Per-factor breakdown that produced `qualityScore` — surfaced as the
// "why was this drafted?" explainer in the Review Queue. `_qualityFactors`
// is set by `deduplicateTests`; we coerce missing data to `[]` so the
// column is never `undefined` (SQLite would store it as `null` then
// `rowToTest` already round-trips `null` → `[]`, but being explicit here
// means the test record matches what the API returns).
qualityScoreFactors: Array.isArray(t._qualityFactors) ? t._qualityFactors : [],
isJourneyTest: t.isJourneyTest || false,
journeyType: t.journeyType || null,
assertionEnhanced: t._assertionEnhanced || false,
// All generated tests start as draft — humans must approve before regression
reviewStatus: autoApproved ? "approved" : "draft",
reviewedAt,
approvalSource: autoApproved ? APPROVAL_SOURCE.AUTO : null,
approvalThreshold: autoApproved ? threshold : null,
approvedAt,
approvedBy: autoApproved ? AUTO_APPROVER_USER : null,
// Traceability — which prompt version and AI model produced this test
promptVersion: PROMPT_VERSION,
modelUsed: getProviderName(),
// Requirement traceability — linked Jira/issue key (set via API or Import Issue)
linkedIssueKey: t.linkedIssueKey || null,
// Tags for filtering and traceability matrix grouping
tags: Array.isArray(t.tags) ? t.tags : [],
// API test marker — "api_har_capture" when generated from captured network traffic
generatedFrom: t._generatedFrom || null,
// ACL-001: Workspace scope — inherit from the project
workspaceId: project.workspaceId || null,
};
testRepo.create(test);
if (autoApproved) {
logActivity({
type: ACTIVITY_TYPES.TEST_AUTO_APPROVE,
projectId: project.id,
projectName: project.name,
testId,
testName: test.name,
detail: `Auto-approved at confidence ${confidenceScore.toFixed(2)} (threshold ${threshold.toFixed(2)})`,
userName: AUTO_APPROVER_USER,
workspaceId: project.workspaceId || null,
// Structured provenance per ROADMAP.md / NEXT.md AUTO-003b spec —
// detail is for humans; meta is for analytics joins (calibration UI).
meta: { score: confidenceScore, threshold },
});
}
run.tests.push(testId);
createdTestIds.push(testId);
}
return createdTestIds;
}
/**
* Build the pipelineStats summary object attached to run records.
*
* @param {object} params
* @returns {object}
*/
export function buildPipelineStats({ pagesFound = 0, rawTests = [], removed = 0, enhancedCount = 0, rejected = 0, journeys = [], dedupStats = {}, apiEndpointsDiscovered = 0 }) {
const apiTestCount = rawTests.filter(t => t._generatedFrom === "api_har_capture" || t._generatedFrom === "api_user_described").length;
return {
pagesFound,
rawTestsGenerated: rawTests.length,
duplicatesRemoved: removed,
assertionsEnhanced: enhancedCount,
validationRejected: rejected,
journeysDetected: journeys.length,
averageQuality: dedupStats.averageQuality || 0,
apiEndpointsDiscovered,
apiTestsGenerated: apiTestCount,
};
}