Files
SnapOtter/tests/unit/api/jobs/worker.behavior.test.ts
T
SnapOtterandGitHub b20bca3c3c fix(telemetry): data-quality pass (opt-in noise, onboarding split, file_count, OIDC) (#614)
Five fixes to the PostHog event stream, from an audit of what we actually collect versus what's flowing in. Each one is test-first.

## What changed

**Silenced the `$opt_in` noise.** `initAnalytics` called `opt_in_capturing()` on every page load to clear a stale opt-out flag, and posthog-js emits an `$opt_in` event on every call. That was 10k+ events a month (up to 55 per user) carrying no signal: analytics is on by default with an admin opt-out, so there is no per-user consent to record. Both call sites now pass `captureEventName: false`.

**Split the onboarding survey out of `feedback_submitted`.** The onboarding usage survey rode the same event as real feedback, so about 93% of "feedback" was actually onboarding profiling. It now emits `onboarding_survey_submitted`, so feedback metrics mean feedback again.

**Set `pipeline_executed.file_count`.** It was declared in the properties interface but never populated. A pure `pipelineExecutedProps` helper now derives it (batch size for a batch run, else 1) and is shared by the success and failure paths, which also drops a duplicated payload.

**Tracked OIDC login failures.** All six OIDC callback failure branches bumped the Prometheus counter and wrote an audit log but never emitted `auth_login_failed`. A `recordOidcFailure` helper mirrors the password path.

**Added `TELEMETRY.md`.** A contributor-facing event dictionary: every event, its properties, where it fires, and the privacy invariants, with the allowlists as source of truth. A drift test fails if any `ANALYTICS_EVENTS` value goes undocumented.

I left the published telemetry guide (`apps/docs/guide/telemetry.md`) alone. It is high-level and still accurate, and editing it would pull in the 21-locale stale-gate for no gain.

## Verification

- Unit (63 tests): `analytics-events`, `telemetry-doc-drift`, `api/analytics`, `web/analytics`, `worker.behavior`
- Integration (41 tests): `oidc-auth`, `feedback`
- Full typecheck across all 9 workspaces
- Biome clean on the changed files

All green locally.
2026-07-21 23:36:02 +08:00

292 lines
8.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
const objectStorageMocks = vi.hoisted(() => ({
copyObjectToFile: vi.fn(),
getObjectBuffer: vi.fn(),
getObjectSize: vi.fn(),
putObject: vi.fn(),
}));
async function loadWorker() {
vi.resetModules();
vi.doMock("node:fs/promises", () => ({
mkdir: vi.fn(),
readFile: vi.fn(),
rm: vi.fn(),
}));
vi.doMock("@snapotter/shared", () => ({
ANALYTICS_EVENTS: {},
TOOLS: [],
getBundleForTool: vi.fn(() => null),
getOptionalBundleForTool: vi.fn(() => null),
}));
vi.doMock("bullmq", () => ({
UnrecoverableError: class UnrecoverableError extends Error {},
Worker: vi.fn(() => ({
on: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
})),
}));
vi.doMock("drizzle-orm", () => ({
eq: vi.fn(() => "eq"),
}));
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: {
SCRATCH_PATH: "",
JOB_TIMEOUT_LONG_S: 60,
JOB_TIMEOUT_FAST_S: 15,
},
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
db: {},
schema: { jobs: {} },
}));
vi.doMock("../../../../apps/api/src/lib/analytics.js", () => ({
captureException: vi.fn(),
trackEvent: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/lib/analytics-gate.js", () => ({
analyticsEnabled: vi.fn(() => false),
}));
vi.doMock("../../../../apps/api/src/lib/env.js", () => ({
resolveConcurrency: vi.fn(() => 2),
}));
vi.doMock("../../../../apps/api/src/lib/errors.js", () => ({
friendlyError: vi.fn((message: string) => message),
}));
vi.doMock("../../../../apps/api/src/lib/logger.js", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.doMock("../../../../apps/api/src/lib/metrics.js", () => ({
jobDuration: { observe: vi.fn() },
jobsTotal: { inc: vi.fn() },
}));
vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({
...objectStorageMocks,
}));
vi.doMock("../../../../apps/api/src/routes/progress.js", () => ({
publishEphemeral: vi.fn(),
updateSingleFileProgress: vi.fn(),
updateSingleFileProgressAtomically: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/routes/tool-factory.js", () => ({
getToolConfig: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/jobs/ai-handlers.js", () => ({
hasAiJobHandler: vi.fn(() => false),
runAiToolJob: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/jobs/batch-progress.js", () => ({
recordChildOutcome: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/jobs/cancel.js", () => ({
registerCancelable: vi.fn(() => new AbortController()),
unregisterCancelable: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/jobs/connection.js", () => ({
createBullMQConnection: vi.fn(() => ({})),
}));
vi.doMock("../../../../apps/api/src/jobs/postprocess.js", () => ({
autoSaveToLibrary: vi.fn(),
buildOutputName: vi.fn(),
generatePreview: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/jobs/system-jobs.js", () => ({
runSystemJob: vi.fn(),
}));
return import("../../../../apps/api/src/jobs/worker.js");
}
describe("worker result payload behavior", () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it("rejects an oversized OCR image object before buffering it", async () => {
objectStorageMocks.getObjectSize.mockResolvedValueOnce(512 * 1024 * 1024 + 1);
const { loadToolInputBuffer } = await loadWorker();
await expect(loadToolInputBuffer("ocr", "uploads/job-1/input.bin")).rejects.toMatchObject({
name: "InputValidationError",
statusCode: 413,
});
expect(objectStorageMocks.getObjectBuffer).not.toHaveBeenCalled();
});
it("maps an oversized streamed OCR PDF object to the OCR input limit", async () => {
objectStorageMocks.copyObjectToFile.mockRejectedValueOnce(
Object.assign(new Error("too large"), { statusCode: 413 }),
);
const { loadToolInputs } = await loadWorker();
await expect(
loadToolInputs(
"ocr-pdf",
["uploads/job-1/scan.pdf"],
"scan.pdf",
"/tmp/job-1",
new AbortController().signal,
),
).rejects.toMatchObject({ name: "InputValidationError", statusCode: 413 });
expect(objectStorageMocks.getObjectBuffer).not.toHaveBeenCalled();
});
it("loads OCR PDF input as a bounded scratch path without buffering it", async () => {
objectStorageMocks.copyObjectToFile.mockResolvedValueOnce(42);
const controller = new AbortController();
const { loadToolInputs } = await loadWorker();
await expect(
loadToolInputs(
"ocr-pdf",
["uploads/job-1/scan.pdf"],
"scan.pdf",
"/tmp/job-1",
controller.signal,
),
).resolves.toEqual({
inputs: [],
pathInput: { path: "/tmp/job-1/input.pdf", size: 42 },
originalSize: 42,
});
expect(objectStorageMocks.copyObjectToFile).toHaveBeenCalledWith(
"uploads/job-1/scan.pdf",
"/tmp/job-1/input.pdf",
{
maxBytes: 512 * 1024 * 1024,
signal: controller.signal,
},
);
expect(objectStorageMocks.getObjectBuffer).not.toHaveBeenCalled();
});
it("loads OCR objects at the encoded-size boundary and leaves other tools unchanged", async () => {
objectStorageMocks.getObjectSize.mockResolvedValueOnce(512 * 1024 * 1024);
objectStorageMocks.getObjectBuffer.mockResolvedValue(Buffer.from("ocr"));
const { loadToolInputBuffer } = await loadWorker();
await expect(loadToolInputBuffer("ocr", "uploads/job-1/scan.tiff")).resolves.toEqual(
Buffer.from("ocr"),
);
await expect(loadToolInputBuffer("compress", "uploads/job-2/photo.png")).resolves.toEqual(
Buffer.from("ocr"),
);
expect(objectStorageMocks.getObjectSize).toHaveBeenCalledTimes(1);
expect(objectStorageMocks.getObjectBuffer).toHaveBeenCalledTimes(2);
});
it("builds legacy download, preview, saved-file, and tool payload fields", async () => {
const { buildLegacyResultPayload } = await loadWorker();
expect(
buildLegacyResultPayload(
{
outputRefs: ["outputs/job-1/report final.pdf"],
filename: "report final.pdf",
contentType: "application/pdf",
originalSize: 100,
processedSize: 80,
previewRef: "outputs/job-1/preview.png",
savedFileId: "file-2",
resultPayload: { pageCount: 3 },
},
"job-1",
),
).toEqual({
jobId: "job-1",
downloadUrl: "/api/v1/download/job-1/report%20final.pdf",
previewUrl: "/api/v1/download/job-1/preview.png",
originalSize: 100,
processedSize: 80,
savedFileId: "file-2",
pageCount: 3,
});
});
it("omits optional legacy payload fields when the job result does not include them", async () => {
const { buildLegacyResultPayload } = await loadWorker();
expect(
buildLegacyResultPayload(
{
outputRefs: ["outputs/job-2/out.png"],
filename: "out.png",
contentType: "image/png",
originalSize: 10,
processedSize: 8,
},
"job-2",
),
).toEqual({
jobId: "job-2",
downloadUrl: "/api/v1/download/job-2/out.png",
originalSize: 10,
processedSize: 8,
});
});
});
describe("pipelineExecutedProps", () => {
it("reports the batch file count for a batch-finalize pipeline", async () => {
const { pipelineExecutedProps } = await loadWorker();
expect(
pipelineExecutedProps(
{ kind: "batch-finalize", totalFiles: 5 },
3,
["resize", "compress", "watermark"],
1200,
"completed",
),
).toEqual({
step_count: 3,
tool_ids: ["resize", "compress", "watermark"],
is_batch: true,
file_count: 5,
duration_ms: 1200,
status: "completed",
});
});
it("defaults file_count to 1 for a single-file pipeline-finalize", async () => {
const { pipelineExecutedProps } = await loadWorker();
expect(
pipelineExecutedProps(
{ kind: "pipeline-finalize" },
2,
["grayscale", "resize"],
800,
"failed",
),
).toMatchObject({ is_batch: false, file_count: 1, status: "failed" });
});
});