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
17 changes: 17 additions & 0 deletions docs/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -23881,6 +23881,7 @@
"required": [
"ledgerId",
"seriesId",
"seriesTitle",
"sourceId",
"pluginId",
"language",
Expand Down Expand Up @@ -23911,6 +23912,10 @@
"type": "string",
"format": "uuid"
},
"seriesTitle": {
"type": "string",
"description": "Series display title (`series_metadata.title`, falling back to the\nseries directory name). Carried in the event so notifications can\nrender a clickable series link without a second round-trip."
},
"sourceId": {
"type": "string",
"format": "uuid"
Expand Down Expand Up @@ -34859,6 +34864,18 @@
"$ref": "#/components/schemas/BoolOperator"
}
}
},
{
"type": "object",
"description": "Filter by whether release tracking is enabled for the series.\n\n`IsTrue` returns only series whose `series_tracking.tracked` flag is\n`true`. `IsFalse` returns everything else, including series with no\n`series_tracking` row at all (the common case for a fresh library).",
"required": [
"isTracked"
],
"properties": {
"isTracked": {
"$ref": "#/components/schemas/BoolOperator"
}
}
}
],
"description": "Series-level search conditions\n\nConditions can be composed using `allOf` (AND) and `anyOf` (OR).\nUses untagged enum for cleaner JSON without explicit type field."
Expand Down
213 changes: 211 additions & 2 deletions plugins/release-mangaupdates/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HostRpcClient, type TrackedSeriesEntry } from "@ashdev/codex-plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { pollSeries } from "./index.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { _resetState, poll, pollSeries } from "./index.js";
import { EXTERNAL_ID_SOURCE_MANGAUPDATES } from "./manifest.js";

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -218,4 +218,213 @@ describe("pollSeries", () => {
expect(out.parsed).toBe(3);
expect(out.recorded).toBe(0); // every record returned deduped:true
});

it("uses the channel-level <link> as payloadUrl on the v1 RSS feed shape", async () => {
// The current MU v1 feed has no per-item <link>. The plugin should
// fall through to the channel-level link (the series page) rather
// than emitting an opaque `urn:mu:` URN, which is useless for the
// user clicking through from the inbox.
const v1Feed = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<link>https://www.mangaupdates.com/series/uu4rl66/series-slug</link>
<item>
<title>Series v.13 c.116</title>
<description>Galaxy Degen Scans</description>
</item>
<item>
<title>Series c.113a</title>
<description>Comikey</description>
</item>
<item>
<title>Series</title>
<description>OneshotGroup</description>
</item>
</channel>
</rss>`;
const { rpc, calls } = makeMockRpc(() => ({ ledgerId: "ld", deduped: false }));
const out = await pollSeries(rpc, "src-1", trackedEntry("series-1"), {
blockedGroups: [],
timeoutMs: 1000,
fetchImpl: mockFetchOk(v1Feed),
});
// Title-only item (no chapter/volume) is dropped before reaching record().
expect(out.parsed).toBe(2);
expect(out.recorded).toBe(2);

const recordCalls = calls.filter((c) => c.method === "releases/record");
expect(recordCalls).toHaveLength(2);
for (const call of recordCalls) {
const params = call.params as { candidate: { payloadUrl: string } };
expect(params.candidate.payloadUrl).toBe(
"https://www.mangaupdates.com/series/uu4rl66/series-slug",
);
}
});

it("emits distinct candidates for the same chapter from different groups", async () => {
// Three groups releasing the same chapter must surface as three
// ledger rows. The externalReleaseId hash includes the group so the
// host's `(source_id, external_release_id)` dedup doesn't collapse
// them into one.
const sameChapterDifferentGroups = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<link>https://www.mangaupdates.com/series/abc/series</link>
<item>
<title>Series c.200</title>
<description>Asura</description>
</item>
<item>
<title>Series c.200</title>
<description>FLAME-SCANS</description>
</item>
<item>
<title>Series c.200</title>
<description>LeviatanScans</description>
</item>
</channel>
</rss>`;
const { rpc, calls } = makeMockRpc(() => ({ ledgerId: "ld", deduped: false }));
const out = await pollSeries(rpc, "src-1", trackedEntry("series-1"), {
blockedGroups: [],
timeoutMs: 1000,
fetchImpl: mockFetchOk(sameChapterDifferentGroups),
});
expect(out.parsed).toBe(3);
expect(out.recorded).toBe(3);

const ids = calls
.filter((c) => c.method === "releases/record")
.map(
(c) =>
(c.params as { candidate: { externalReleaseId: string } }).candidate.externalReleaseId,
);
expect(new Set(ids).size).toBe(3);
});
});

// -----------------------------------------------------------------------------
// poll (top-level): count_tracked + report_progress integration
// -----------------------------------------------------------------------------

describe("poll", () => {
beforeEach(() => {
_resetState();
});

it("calls count_tracked once and report_progress per series with the right denominator", async () => {
// Two tracked series, both with MU IDs, both upstream-200 with one item.
const tracked: TrackedSeriesEntry[] = [
{
seriesId: "series-1",
externalIds: { [EXTERNAL_ID_SOURCE_MANGAUPDATES]: "11111" } as Record<string, string>,
},
{
seriesId: "series-2",
externalIds: { [EXTERNAL_ID_SOURCE_MANGAUPDATES]: "22222" } as Record<string, string>,
},
];
const v1Feed = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<link>https://www.mangaupdates.com/series/abc/series</link>
<item>
<title>Series c.1</title>
<description>SomeGroup</description>
</item>
</channel>
</rss>`;

const { rpc, calls } = makeMockRpc((method) => {
if (method === "releases/count_tracked") return { total: tracked.length };
if (method === "releases/list_tracked") return { tracked, nextOffset: undefined };
if (method === "releases/record") return { ledgerId: "ld", deduped: false };
if (method === "releases/report_progress") return { emitted: true };
throw new Error(`unexpected method: ${method}`);
});

// Initialize plugin state directly (bypass createReleaseSourcePlugin).
// The SDK normally injects state through `onInitialize`; for this test
// we only need the RPC client wired up, since `poll` reads `state.*`
// for blocked groups + timeout but works fine with the defaults.
//
// `Response.text()` consumes the body, so each `fetch` call needs a
// fresh `Response` — `mockImplementation` returns a new instance each
// invocation.
const fetchImpl = vi
.fn()
.mockImplementation(
async () => new Response(v1Feed, { status: 200, headers: { etag: '"e"' } }),
) as unknown as typeof fetch;
// Replace global fetch for this test so pollSeries -> fetcher uses it.
const origFetch = globalThis.fetch;
globalThis.fetch = fetchImpl;
try {
await poll({ sourceId: "src-1", sourceKey: "default", config: null, etag: null }, rpc);
} finally {
globalThis.fetch = origFetch;
}

const countCalls = calls.filter((c) => c.method === "releases/count_tracked");
expect(countCalls).toHaveLength(1);
expect((countCalls[0]?.params as { sourceId: string }).sourceId).toBe("src-1");

const progressCalls = calls.filter((c) => c.method === "releases/report_progress");
// One emit per tracked series: 2 total. Denominator equals count.
expect(progressCalls).toHaveLength(2);
expect(progressCalls[0]?.params).toMatchObject({ current: 1, total: 2 });
expect(progressCalls[1]?.params).toMatchObject({ current: 2, total: 2 });
});

it("falls back to progressive denominator when count_tracked is unsupported", async () => {
// Older host: count_tracked returns METHOD_NOT_FOUND. The plugin
// should keep working and emit progress with `total = current`.
const tracked: TrackedSeriesEntry[] = [
{
seriesId: "series-1",
externalIds: { [EXTERNAL_ID_SOURCE_MANGAUPDATES]: "11111" } as Record<string, string>,
},
];
const v1Feed = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<link>https://www.mangaupdates.com/series/abc/series</link>
<item><title>Series c.1</title><description>G</description></item>
</channel>
</rss>`;

const { rpc, calls } = makeMockRpc((method) => {
if (method === "releases/count_tracked") {
// Synthesize a JSON-RPC METHOD_NOT_FOUND error.
const err = Object.assign(new Error("Method not found"), { code: -32_601 });
// Throwing inside `respond` is captured by the mock writeFn and
// surfaced as an error response; HostRpcClient wraps it in
// HostRpcError which the plugin catches.
throw err;
}
if (method === "releases/list_tracked") return { tracked, nextOffset: undefined };
if (method === "releases/record") return { ledgerId: "ld", deduped: false };
if (method === "releases/report_progress") return { emitted: true };
throw new Error(`unexpected method: ${method}`);
});

const fetchImpl = vi
.fn()
.mockResolvedValue(
new Response(v1Feed, { status: 200, headers: { etag: '"e"' } }),
) as unknown as typeof fetch;
const origFetch = globalThis.fetch;
globalThis.fetch = fetchImpl;
try {
await poll({ sourceId: "src-1", sourceKey: "default", config: null, etag: null }, rpc);
} finally {
globalThis.fetch = origFetch;
}

const progressCalls = calls.filter((c) => c.method === "releases/report_progress");
expect(progressCalls).toHaveLength(1);
// No total known => current == total.
expect(progressCalls[0]?.params).toMatchObject({ current: 1, total: 1 });
});
});
Loading
Loading