Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ function collectReferencedEvents(): Set<string> {
}

describe("buildAnalyticsDashboardBody — drift prevention", () => {
it("emits 28 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => {
it("emits 29 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 4 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => {
const body = buildBody();
expect(body.widgets).toHaveLength(28);
expect(body.widgets).toHaveLength(29);
});

it("the homepage A/B widget counts pageviews on the experiment campaign, grouped by variant (utm_content)", () => {
Expand Down
14 changes: 14 additions & 0 deletions projects/hutch/src/runtime/observability/analytics-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,20 @@ export function buildAnalyticsDashboardBody(deps: BuildAnalyticsDashboardDeps):
x: 0, y: 122, width: 12, height: 8,
view: "bar",
}),
logWidget({
region,
title: "Save errors by surface (view_save_intent outcome=error)",
logGroupNames: [hutchLogGroupName],
query: [
`fields coalesce(surface, "${SAVE_SURFACES.readerView}") as surface`,
`| filter stream = "${STREAMS.analytics}" and event = "${ANALYTICS_EVENTS.viewSaveIntent}" and outcome = "${SAVE_OUTCOMES.error}"`,
...exclude,
"| stats count(*) as save_errors by surface",
"| sort save_errors desc",
].join(" "),
x: 0, y: 130, width: 12, height: 8,
view: "bar",
}),
);

// --- Blog traffic ---
Expand Down
9 changes: 9 additions & 0 deletions projects/hutch/src/runtime/web/pages/queue/queue.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {
const validation = deps.validateSaveableUrl(submittedUrl);

if (validation.status === "ERROR") {
emitSaveIntent({ req, url: submittedUrl, path: SAVE_INTENT_PATH.saveArticle, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
if (validation.error.code === "malformed_url") {
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({ code: "invalid-url", message: validation.error.message }),
Expand Down Expand Up @@ -904,6 +905,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {
res.status(201).type(SIREN_MEDIA_TYPE).json(toArticleEntity(result.saved));
return;
}
emitSaveIntent({ req, url: typeof req.body?.url === "string" ? req.body.url : "", path: SAVE_INTENT_PATH.saveHtml, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({ code: "invalid-save-html", message: "Invalid save-html request" }),
);
Expand All @@ -912,6 +914,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {

const urlValidation = deps.validateSaveableUrl(parsed.data.url);
if (urlValidation.status === "ERROR") {
emitSaveIntent({ req, url: parsed.data.url, path: SAVE_INTENT_PATH.saveHtml, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({ code: "invalid-save-html", message: urlValidation.error.message }),
);
Expand Down Expand Up @@ -999,6 +1002,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {
const title = titlePart ? titlePart.content.toString("utf8") : undefined;

if (!contentBytes || contentBytes.length === 0) {
emitSaveIntent({ req, url: submittedUrl, path: SAVE_INTENT_PATH.saveContent, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({
code: "invalid-save-content",
Expand All @@ -1010,6 +1014,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {
}

if (!mediaType) {
emitSaveIntent({ req, url: submittedUrl, path: SAVE_INTENT_PATH.saveContent, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({
code: "invalid-save-content",
Expand All @@ -1022,6 +1027,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {

const validation = deps.validateSaveableUrl(submittedUrl);
if (validation.status === "ERROR") {
emitSaveIntent({ req, url: submittedUrl, path: SAVE_INTENT_PATH.saveContent, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({
code: "invalid-save-content",
Expand All @@ -1039,6 +1045,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {
const handler = saveContentHandlers[normalized];

if (!handler) {
emitSaveIntent({ req, url: articleUrl, path: SAVE_INTENT_PATH.saveContent, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({
code: "unsupported-media-type",
Expand All @@ -1051,6 +1058,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {

const handlerResult = await handler({ url: articleUrl, bytes: contentBytes, title, userId });
if (!handlerResult.ok) {
emitSaveIntent({ req, url: articleUrl, path: SAVE_INTENT_PATH.saveContent, surface: SAVE_SURFACES.extension, outcome: SAVE_OUTCOMES.error });
res.status(422).type(SIREN_MEDIA_TYPE).json(
sirenError({
code: handlerResult.code,
Expand Down Expand Up @@ -1082,6 +1090,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router {
const validation = deps.validateSaveableUrl(submittedUrl);

if (validation.status === "ERROR") {
emitSaveIntent({ req, url: submittedUrl, path: SAVE_INTENT_PATH.save, surface: SAVE_SURFACES.queueSaveBar, outcome: SAVE_OUTCOMES.error });
const urlState = parseQueueUrl({});
const result = await deps.findArticlesByUser({ userId });
const unreadCount = await deps.countArticlesByUser({ userId, status: "unread" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,40 @@ describe("view_save_intent — authenticated save surfaces", () => {
expect(response.headers.location).toBe("/queue?error_code=save_failed");
expect(saveIntents(harness)[0]).toMatchObject({ surface: "queue_save_bar", outcome: "error", is_authenticated: 1 });
});

it("emits queue_save_bar / error with a null host when the URL is malformed (422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const agent = await loginAgent(harness.server, harness.auth);

const response = await agent.post("/queue/save").type("form").send({ url: "not-a-url" });

expect(response.status).toBe(422);
const intents = saveIntents(harness);
assert.equal(intents.length, 1, "exactly one view_save_intent");
expect(intents[0]).toMatchObject({
path: "/queue/save",
surface: "queue_save_bar",
outcome: "error",
article_host: null,
content_class: null,
is_authenticated: 1,
});
});

it("emits queue_save_bar / error keeping the parseable host of an unsaveable private-network URL", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const agent = await loginAgent(harness.server, harness.auth);

const response = await agent.post("/queue/save").type("form").send({ url: "http://localhost/x" });

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({
surface: "queue_save_bar",
outcome: "error",
article_host: "localhost",
content_class: "third_party",
});
});
});

describe("POST /queue (extension save-article)", () => {
Expand Down Expand Up @@ -132,6 +166,22 @@ describe("view_save_intent — authenticated save surfaces", () => {
expect(response.status).toBe(500);
expect(saveIntents(harness)[0]).toMatchObject({ surface: "extension", outcome: "error" });
});

it("emits extension / error when the URL is malformed (422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.send({ url: "not-a-url" });

expect(response.status).toBe(422);
const intents = saveIntents(harness);
assert.equal(intents.length, 1, "exactly one view_save_intent");
expect(intents[0]).toMatchObject({ path: "/queue", surface: "extension", outcome: "error", article_host: null });
});
});

describe("POST /queue/save-html (extension)", () => {
Expand Down Expand Up @@ -162,6 +212,34 @@ describe("view_save_intent — authenticated save surfaces", () => {
expect(response.status).toBe(500);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-html", surface: "extension", outcome: "error" });
});

it("emits extension / error when the request fails schema validation (invalid url, 422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue/save-html")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.send({ url: "not-a-url", rawHtml: "<html>captured</html>" });

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-html", surface: "extension", outcome: "error", article_host: null });
});

it("emits extension / error when a schema-valid URL is unsaveable (private network, 422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue/save-html")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.send({ url: "http://localhost/x", rawHtml: "<html>captured</html>" });

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-html", surface: "extension", outcome: "error", article_host: "localhost" });
});
});

describe("POST /queue/save-content (extension)", () => {
Expand Down Expand Up @@ -196,6 +274,84 @@ describe("view_save_intent — authenticated save surfaces", () => {
expect(response.status).toBe(500);
expect(saveIntents(harness)[0]).toMatchObject({ surface: "extension", outcome: "error" });
});

it("emits extension / error when the URL is malformed (422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue/save-content")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.field("url", "not-a-url")
.field("mediaType", "text/html")
.attach("content", Buffer.from("<html>captured</html>"), "content");

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-content", surface: "extension", outcome: "error", article_host: null });
});

it("emits extension / error when the content field is missing (422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue/save-content")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.field("url", "https://example.com/article")
.field("mediaType", "text/html");

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-content", surface: "extension", outcome: "error", article_host: "example.com" });
});

it("emits extension / error when the mediaType field is missing (422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue/save-content")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.field("url", "https://example.com/article")
.attach("content", Buffer.from("<html>captured</html>"), "content");

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-content", surface: "extension", outcome: "error", article_host: "example.com" });
});

it("emits extension / error on an unsupported media type (422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue/save-content")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.field("url", "https://example.com/article")
.field("mediaType", "application/xml")
.attach("content", Buffer.from("<note>hi</note>"), "content");

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-content", surface: "extension", outcome: "error", article_host: "example.com" });
});

it("emits extension / error when the uploaded bytes are not a PDF (422)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
const token = await bearerToken(harness);

const response = await request(harness.server)
.post("/queue/save-content")
.set("Accept", SIREN_MEDIA_TYPE)
.set("Authorization", `Bearer ${token}`)
.field("url", "https://example.com/article")
.field("mediaType", "application/pdf")
.attach("content", Buffer.from("<html>not a pdf</html>"), "content");

expect(response.status).toBe(422);
expect(saveIntents(harness)[0]).toMatchObject({ path: "/queue/save-content", surface: "extension", outcome: "error", article_host: "example.com" });
});
});

describe("POST /queue/save-articles (extension bulk)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ function pendingArticle(url: string): Article {
};
}

function crawlFailedArticle(url: string): Article {
return {
...pendingArticle(url),
crawl: { kind: "failed", reason: { kind: "fetch-failed" } },
};
}

function crawlUnsupportedArticle(url: string): Article {
return {
...pendingArticle(url),
crawl: { kind: "unsupported", reason: { kind: "paywall" } },
};
}

type HandlerDeps = Parameters<typeof initGenerateSummaryHandler>[0];

const NOW = new Date("2026-05-30T12:00:00.000Z");
Expand Down Expand Up @@ -227,6 +241,53 @@ describe("initGenerateSummaryHandler", () => {
);
});

it("marks the summary skipped (reason='crawl-failed') when content is absent and the crawl terminally failed, instead of DLQing forever", async () => {
const URL = "https://example.com/crawl-failed";
const { handler, deps } = createHandler({
findArticleContent: jest.fn<ReturnType<FindArticleContent>, Parameters<FindArticleContent>>().mockResolvedValue(undefined),
loadArticle: jest.fn().mockResolvedValue(crawlFailedArticle(URL)),
});

const result = await handler(createSqsEvent({ url: URL }), buildLambdaContext(), () => {});

expect(result).toEqual({ batchItemFailures: [] });
expect(deps.transitionAndPersist).toHaveBeenCalledWith(markSummarySkipped, {
url: URL,
input: { reason: "crawl-failed", now: NOW.toISOString() },
});
expect(deps.summarizeArticle).not.toHaveBeenCalled();
});

it("marks the summary skipped (reason='crawl-unsupported') when content is absent and the crawl is unsupported", async () => {
const URL = "https://example.com/crawl-unsupported";
const { handler, deps } = createHandler({
findArticleContent: jest.fn<ReturnType<FindArticleContent>, Parameters<FindArticleContent>>().mockResolvedValue(undefined),
loadArticle: jest.fn().mockResolvedValue(crawlUnsupportedArticle(URL)),
});

const result = await handler(createSqsEvent({ url: URL }), buildLambdaContext(), () => {});

expect(result).toEqual({ batchItemFailures: [] });
expect(deps.transitionAndPersist).toHaveBeenCalledWith(markSummarySkipped, {
url: URL,
input: { reason: "crawl-unsupported", now: NOW.toISOString() },
});
expect(deps.summarizeArticle).not.toHaveBeenCalled();
});

it("keeps the assert → DLQ path when content is absent and no aggregate row exists (unknown row, not a terminal crawl)", async () => {
const URL = "https://example.com/no-row";
const { handler, deps } = createHandler({
findArticleContent: jest.fn<ReturnType<FindArticleContent>, Parameters<FindArticleContent>>().mockResolvedValue(undefined),
loadArticle: jest.fn().mockResolvedValue(undefined),
});

const result = await handler(createSqsEvent({ url: URL }), buildLambdaContext(), () => {});

expect(result).toEqual({ batchItemFailures: [{ itemIdentifier: "msg-1" }] });
expect(deps.transitionAndPersist).not.toHaveBeenCalled();
});

it("reports batchItemFailures on invalid command schema (Zod failure)", async () => {
const { handler, deps } = createHandler();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ export function initGenerateSummaryHandler(deps: GenerateSummaryHandlerDeps): Ha
}

const article = await findArticleContent(command.url);
/* Terminal crawl without canonical content is not a race — the crawl
* gave up, so no content will ever arrive. Collapse the summary to
* skipped (the same terminal outcome markCrawlBlocked/markCrawlNotFound
* write) so the auto-heal loop stops repriming this row forever. A
* pending crawl (content still landing) or a ready crawl (content
* genuinely missing) keeps the assert → retry → DLQ path below. */
if (
!article &&
existing &&
(existing.crawl.kind === "failed" || existing.crawl.kind === "unsupported")
) {
await transitionAndPersist(markSummarySkipped, {
url: command.url,
input: {
reason: existing.crawl.kind === "failed" ? "crawl-failed" : "crawl-unsupported",
now: now().toISOString(),
},
});
logger.info("[GenerateSummary] crawl terminal without canonical content — marking summary skipped", {
url: command.url,
crawl: existing.crawl.kind,
});
continue;
}
assert(article, `Article content not found: ${command.url}`);

const result = await summarizeArticle({
Expand Down
Loading
Loading