Skip to content
Merged
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
127 changes: 127 additions & 0 deletions apps/labeler/console/src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ describe("fixture client", () => {
expect(ids).toEqual(ids.toSorted((a, b) => b - a));
expect(page.items.some((d) => d.status === "new")).toBe(true);
});

it("lists reconsiderations with an open and a resolved case", async () => {
const page = await apiClient.listReconsiderations();
expect(page.items.length).toBeGreaterThan(0);
expect(page.items.some((r) => r.state === "open")).toBe(true);
expect(page.items.some((r) => r.state === "resolved" && r.outcome !== null)).toBe(true);
});

it("returns a reconsideration case with its oldest-first note thread", async () => {
const [first] = (await apiClient.listReconsiderations()).items;
expect(first).toBeDefined();
const detail = await apiClient.getReconsideration(first!.id);
expect(detail?.reconsideration.id).toBe(first!.id);
expect(detail!.notes.length).toBeGreaterThan(0);
const timestamps = detail!.notes.map((n) => Date.parse(n.createdAt));
expect(timestamps).toEqual(timestamps.toSorted((a, b) => a - b));
});

it("returns null for an unknown reconsideration id", async () => {
expect(await apiClient.getReconsideration("recon_missing")).toBeNull();
});
});

describe("fetch client label actions", () => {
Expand Down Expand Up @@ -279,4 +300,110 @@ describe("fetch client label actions", () => {
}),
).rejects.toThrow("key already used");
});

it("GETs the reconsideration list", async () => {
stubFetch(() => Response.json({ data: { items: [{ id: "recon_1", state: "open" }] } }));
const page = await fetchClient.listReconsiderations({ cursor: "c1", limit: 25 });
expect(page.items).toEqual([{ id: "recon_1", state: "open" }]);
const url = calls[0]!.url;
expect(url).toContain("/admin/api/reconsiderations?");
expect(url).toContain("cursor=c1");
expect(url).toContain("limit=25");
});

it("GETs one reconsideration, mapping 404 to null", async () => {
stubFetch(() => new Response(null, { status: 404 }));
expect(await fetchClient.getReconsideration("recon_missing")).toBeNull();
expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_missing");
});

it("POSTs an open with the CSRF header, JSON content type, and threaded key", async () => {
stubFetch(() => Response.json({ data: { actionId: "oact_1", reconsiderationId: "recon_1" } }));
const result = await fetchClient.openReconsideration({
assessmentId: "asmt_target",
note: "publisher appealed",
reason: "opening",
idempotencyKey: input.idempotencyKey,
});
expect(result).toMatchObject({ reconsiderationId: "recon_1" });
const call = calls[0]!;
expect(call.url).toBe("/admin/api/reconsiderations/open");
expect(call.init.method).toBe("POST");
const headers = new Headers(call.init.headers);
expect(headers.get("X-EmDash-Request")).toBe("1");
expect(headers.get("Content-Type")).toBe("application/json");
expect(JSON.parse(call.init.body as string)).toMatchObject({
assessmentId: "asmt_target",
note: "publisher appealed",
reason: "opening",
idempotencyKey: input.idempotencyKey,
});
});

it("POSTs a note to the case note route", async () => {
stubFetch(() => Response.json({ data: { actionId: "oact_1", noteId: "rnote_1" } }));
await fetchClient.addReconsiderationNote("recon_1", {
note: "another note",
reason: "context",
idempotencyKey: input.idempotencyKey,
});
expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_1/note");
expect(calls[0]!.init.method).toBe("POST");
expect(JSON.parse(calls[0]!.init.body as string)).toMatchObject({ note: "another note" });
});

it("POSTs a resolve to the resolve route with the outcome", async () => {
stubFetch(() => Response.json({ data: { actionId: "oact_1", outcome: "granted" } }));
await fetchClient.resolveReconsideration("recon_1", {
outcome: "granted",
reason: "false positive",
idempotencyKey: input.idempotencyKey,
});
expect(calls[0]!.url).toBe("/admin/api/reconsiderations/recon_1/resolve");
expect(calls[0]!.init.method).toBe("POST");
expect(JSON.parse(calls[0]!.init.body as string).outcome).toBe("granted");
});

it("surfaces the server 409 open-exists message", async () => {
stubFetch(() =>
Response.json(
{
error: {
code: "RECONSIDERATION_OPEN_EXISTS",
message: "An open reconsideration already exists for this subject",
},
},
{ status: 409 },
),
);
await expect(
fetchClient.openReconsideration({
assessmentId: "asmt_target",
note: "n",
reason: "r",
idempotencyKey: input.idempotencyKey,
}),
).rejects.toThrow("An open reconsideration already exists for this subject");
});

it("surfaces the server 409 already-resolved message on a resolve", async () => {
stubFetch(() =>
Response.json(
{
error: {
code: "RECONSIDERATION_RESOLVED",
message: "Reconsideration is already resolved",
},
},
{ status: 409 },
),
);
await expect(
fetchClient.resolveReconsideration("recon_1", {
outcome: "denied",
reason: "r",
idempotencyKey: input.idempotencyKey,
}),
).rejects.toThrow("Reconsideration is already resolved");
});
});
85 changes: 85 additions & 0 deletions apps/labeler/console/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
FIXTURE_FINDINGS_BY_ASSESSMENT,
FIXTURE_LABELS_BY_ASSESSMENT,
FIXTURE_OPERATOR_ACTIONS,
FIXTURE_RECONSIDERATION_DETAIL,
FIXTURE_RECONSIDERATIONS,
FIXTURE_SUBJECT_HISTORY,
FIXTURE_SYSTEM_STATUS,
} from "../fixtures/index.js";
Expand All @@ -25,13 +27,22 @@ import type {
ListAssessmentsParams,
ListAuditLogParams,
ListDeadLettersParams,
ListReconsiderationsParams,
OperatorAction,
OverrideActionInput,
OverrideEffectPreviewParams,
OverrideResult,
OverrideRetractResult,
OperatorFinding,
Page,
ReconsiderationDetail,
ReconsiderationNoteInput,
ReconsiderationNoteResult,
ReconsiderationOpenInput,
ReconsiderationOpenResult,
ReconsiderationResolveInput,
ReconsiderationResolveResult,
ReconsiderationView,
RerunResult,
SubjectHistoryView,
SubjectLabel,
Expand All @@ -54,6 +65,8 @@ export interface LabelerConsoleClient {
getSubjectLabels(uri: string, cid?: string): Promise<SubjectLabel[]>;
listAuditLog(params?: ListAuditLogParams): Promise<Page<OperatorAction>>;
listDeadLetters(params?: ListDeadLettersParams): Promise<Page<DeadLetter>>;
listReconsiderations(params?: ListReconsiderationsParams): Promise<Page<ReconsiderationView>>;
getReconsideration(id: string): Promise<ReconsiderationDetail | null>;
getSystemStatus(): Promise<SystemStatusSnapshot>;
whoami(): Promise<WhoamiIdentity>;
previewEffect(params: EffectPreviewParams): Promise<EffectPreview>;
Expand All @@ -72,6 +85,15 @@ export interface LabelerConsoleClient {
resumeAutomation(input: AutomationToggleInput): Promise<AutomationToggleResult>;
retryDeadLetter(id: number, input: DeadLetterActionInput): Promise<DeadLetterActionResult>;
quarantineDeadLetter(id: number, input: DeadLetterActionInput): Promise<DeadLetterActionResult>;
openReconsideration(input: ReconsiderationOpenInput): Promise<ReconsiderationOpenResult>;
addReconsiderationNote(
id: string,
input: ReconsiderationNoteInput,
): Promise<ReconsiderationNoteResult>;
resolveReconsideration(
id: string,
input: ReconsiderationResolveInput,
): Promise<ReconsiderationResolveResult>;
}

/** The admin-only emergency endpoints, keyed by action and direction. */
Expand Down Expand Up @@ -182,6 +204,18 @@ export function createFetchClient(): LabelerConsoleClient {
const response = await consoleApiFetch(`/dead-letters?${search.toString()}`);
return parseJson(response, "Failed to load dead letters");
},
async listReconsiderations(params = {}) {
const search = new URLSearchParams();
if (params.cursor) search.set("cursor", params.cursor);
if (params.limit) search.set("limit", String(params.limit));
const response = await consoleApiFetch(`/reconsiderations?${search.toString()}`);
return parseJson(response, "Failed to load reconsiderations");
},
async getReconsideration(id) {
const response = await consoleApiFetch(`/reconsiderations/${encodeURIComponent(id)}`);
if (response.status === 404) return null;
return parseJson(response, "Failed to load reconsideration");
},
async getSystemStatus() {
const response = await consoleApiFetch("/status");
return parseJson(response, "Failed to load system status");
Expand Down Expand Up @@ -259,6 +293,23 @@ export function createFetchClient(): LabelerConsoleClient {
"Failed to quarantine dead letter",
);
},
async openReconsideration(input) {
return postAction("/reconsiderations/open", input, "Failed to open reconsideration");
},
async addReconsiderationNote(id, input) {
return postAction(
`/reconsiderations/${encodeURIComponent(id)}/note`,
input,
"Failed to add note",
);
},
async resolveReconsideration(id, input) {
return postAction(
`/reconsiderations/${encodeURIComponent(id)}/resolve`,
input,
"Failed to resolve reconsideration",
);
},
};
}

Expand Down Expand Up @@ -294,6 +345,12 @@ export function createFixtureClient(): LabelerConsoleClient {
async listDeadLetters() {
return { items: [...FIXTURE_DEAD_LETTERS] };
},
async listReconsiderations() {
return { items: [...FIXTURE_RECONSIDERATIONS] };
},
async getReconsideration(id) {
return FIXTURE_RECONSIDERATION_DETAIL[id] ?? null;
},
async getSystemStatus() {
return FIXTURE_SYSTEM_STATUS;
},
Expand Down Expand Up @@ -411,6 +468,34 @@ export function createFixtureClient(): LabelerConsoleClient {
cts: new Date().toISOString(),
};
},
async openReconsideration(input) {
return {
actionId: "oact_fixture",
reconsiderationId: "recon_fixture",
uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1",
cid: "bafyfixture",
triggeringAssessmentId: input.assessmentId,
cts: new Date().toISOString(),
};
},
async addReconsiderationNote(id) {
return {
actionId: "oact_fixture",
reconsiderationId: id,
noteId: "rnote_fixture",
cts: new Date().toISOString(),
};
},
async resolveReconsideration(id, input) {
return {
actionId: "oact_fixture",
reconsiderationId: id,
outcome: input.outcome,
uri: "at://did:plc:x/com.emdashcms.experimental.package.release/rk1",
cid: "bafyfixture",
cts: new Date().toISOString(),
};
},
};
}

Expand Down
Loading
Loading