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
103 changes: 103 additions & 0 deletions src/__tests__/signal-reviewed-since.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* GET /api/signals — `reviewed_since` filter semantics (issue #819)
*
* `since` filters on `created_at`. `reviewed_since` filters on `reviewed_at`.
* Callers that compute review-window metrics (editor activity, queue velocity)
* must use `reviewed_since`, not `since`, or they silently miss signals created
* before the window but reviewed inside it.
*/
import { describe, it, expect } from "vitest";
import { SELF } from "cloudflare:test";

const REPORTER = "bc1qreviewed819testaddr000000000000000000000";
const BEAT = "agent-social";

async function seed(body: Record<string, unknown>) {
const res = await SELF.fetch("http://example.com/api/test-seed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
}

async function listSignals(query: string) {
const res = await SELF.fetch(`http://example.com/api/signals${query}`);
expect(res.status).toBe(200);
const body = await res.json<{ signals: { id: string }[] }>();
return body.signals.map((s) => s.id);
}

describe("GET /api/signals — reviewed_since filter", () => {
it("includes a signal created before the window but reviewed inside it", async () => {
// Signal created 2 days ago, reviewed 1 hour ago.
// `since=yesterday` should NOT include it; `reviewed_since=yesterday` should.
await seed({
signals: [
{
id: "rs-819-old-created-new-reviewed",
beat_slug: BEAT,
btc_address: REPORTER,
headline: "Created old, reviewed new",
sources: "[]",
created_at: "2026-05-06T10:00:00.000Z",
status: "approved",
reviewed_at: "2026-05-08T10:00:00.000Z",
},
{
id: "rs-819-new-created-new-reviewed",
beat_slug: BEAT,
btc_address: REPORTER,
headline: "Created new, reviewed new",
sources: "[]",
created_at: "2026-05-08T09:00:00.000Z",
status: "approved",
reviewed_at: "2026-05-08T10:30:00.000Z",
},
],
});

const windowStart = "2026-05-07T00:00:00.000Z";

// `since` (created_at lower-bound): only the new-created signal is visible
const bySince = await listSignals(`?since=${windowStart}&status=approved&beat=${BEAT}`);
expect(bySince).toContain("rs-819-new-created-new-reviewed");
expect(bySince).not.toContain("rs-819-old-created-new-reviewed");

// `reviewed_since` (reviewed_at lower-bound): both signals are visible
const byReviewedSince = await listSignals(
`?reviewed_since=${windowStart}&status=approved&beat=${BEAT}`
);
expect(byReviewedSince).toContain("rs-819-old-created-new-reviewed");
expect(byReviewedSince).toContain("rs-819-new-created-new-reviewed");
});

it("excludes a signal reviewed before the window even if created inside it", async () => {
await seed({
signals: [
{
id: "rs-819-new-created-old-reviewed",
beat_slug: BEAT,
btc_address: REPORTER,
headline: "Created new, reviewed old",
sources: "[]",
created_at: "2026-05-08T08:00:00.000Z",
status: "approved",
reviewed_at: "2026-05-06T12:00:00.000Z",
},
],
});

const windowStart = "2026-05-07T00:00:00.000Z";

// Appears under `since` (created in window)
const bySince = await listSignals(`?since=${windowStart}&status=approved&beat=${BEAT}`);
expect(bySince).toContain("rs-819-new-created-old-reviewed");

// Absent under `reviewed_since` (reviewed before window)
const byReviewedSince = await listSignals(
`?reviewed_since=${windowStart}&status=approved&beat=${BEAT}`
);
expect(byReviewedSince).not.toContain("rs-819-new-created-old-reviewed");
});
});
7 changes: 7 additions & 0 deletions src/lib/do-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ export interface SignalFilters {
agent?: string;
tag?: string;
since?: string;
/**
* ISO timestamp lower bound on `reviewed_at`. Use when callers want signals
* reviewed inside a time window, regardless of when they were created.
* Distinct from `since` which filters on `created_at`.
*/
reviewed_since?: string;
/** UTC calendar day (YYYY-MM-DD) — filters to signals within that day's UTC boundaries. */
date?: string;
status?: string;
Expand Down Expand Up @@ -204,6 +210,7 @@ export async function listSignalsPage(
if (filters.agent) params.set("agent", filters.agent);
if (filters.tag) params.set("tag", filters.tag);
if (filters.since) params.set("since", filters.since);
if (filters.reviewed_since) params.set("reviewed_since", filters.reviewed_since);
if (filters.date) params.set("date", filters.date);
if (filters.status) params.set("status", filters.status);
if (filters.include_pending) params.set("include_pending", "true");
Expand Down
8 changes: 8 additions & 0 deletions src/objects/news-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ interface SignalListFilters {
beat: string | null;
agent: string | null;
since: string | null;
/** Lower bound on `reviewed_at` (ISO string). Filters to signals reviewed after this timestamp. */
reviewed_since: string | null;
tag: string | null;
status: string | null;
dateStart: string | null;
Expand Down Expand Up @@ -124,6 +126,10 @@ function buildSignalListWhere(filters: SignalListFilters): { whereSql: string; p
clauses.push("s.created_at > ?");
params.push(filters.since);
}
if (filters.reviewed_since) {
clauses.push("s.reviewed_at > ?");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] This clause silently excludes rows where reviewed_at IS NULL — correct behavior for the "signals reviewed in window" semantic, but the test suite doesn't lock it in. SQL s.reviewed_at > ? against a NULL column evaluates to NULL (falsy), so pending/submitted signals never appear under reviewed_since regardless of since. Worth a third test case (sample in the top-level review) to make this contract explicit before consumers wire up.

params.push(filters.reviewed_since);
}
if (filters.tag) {
clauses.push("s.id IN (SELECT signal_id FROM signal_tags WHERE tag = ?)");
params.push(filters.tag);
Expand Down Expand Up @@ -2571,6 +2577,7 @@ export class NewsDO extends DurableObject<Env> {
const beat = c.req.query("beat") ?? null;
const agent = c.req.query("agent") ?? null;
const since = c.req.query("since") ?? null;
const reviewed_since = c.req.query("reviewed_since") ?? null;
const tag = c.req.query("tag") ?? null;
const status = c.req.query("status") ?? null;
const includePending = c.req.query("include_pending") === "true";
Expand All @@ -2595,6 +2602,7 @@ export class NewsDO extends DurableObject<Env> {
beat,
agent,
since: dateParam ? null : since,
reviewed_since,
tag,
status,
dateStart,
Expand Down
Loading