Files
kill-the-news/src/infrastructure/feed-repository.test.ts
T
Julien HerrandClaude Opus 4.7 06c436c36a refactor: separate Feed domain state from persistence DTO
Move four DDD tensions on the Feed aggregate to ground:

- #1 The aggregate now holds a domain FeedState (camelCase) instead of the
  snake_case FeedConfig DTO; infrastructure/feed-mapper.ts owns the
  FeedState<->FeedConfig/FeedListItem translation as the sole snake_case site
  outside the HTTP edge.
- #3 Replace the edit() recomputeExpiry control flag with a Lifetime VO:
  passing a lifetime recomputes expiry, omitting it preserves the current one
  (the dashboard quick-edit path).
- #4 Domain events carry their own feedId; dispatchFeedEvents centralizes the
  drain+dispatch in the application layer (no more manual pullEvents at call
  sites), keeping infra->application dependency direction intact.
- #6 Rename FeedId.fromTrusted to FeedId.unchecked to make the absence of
  revalidation explicit.

Adds Lifetime + feed-mapper round-trip tests. 353 tests green, tsc clean,
wrangler dry-run OK. Docs (CLAUDE.md) synced.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:10:04 +02:00

157 lines
5.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { createMockEnv } from "../test/setup";
import { FeedRepository } from "./feed-repository";
import { Feed } from "../domain/feed.aggregate";
import { FeedId } from "../domain/value-objects/feed-id";
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
const mockEnv = () => createMockEnv() as unknown as Env;
const fid = (value: string) => FeedId.unchecked(value);
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
title: "Test Feed",
language: "en",
created_at: 1000,
...overrides,
});
const sampleEmail = (overrides: Partial<EmailData> = {}): EmailData => ({
subject: "Hello",
from: "[email protected]",
content: "<p>hi</p>",
receivedAt: 1234,
headers: {},
...overrides,
});
describe("FeedRepository key schema", () => {
it("builds the canonical KV keys via the public API", () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(repo.feedKeyPrefix(fid("a.b.42"))).toBe("feed:a.b.42:");
expect(repo.newEmailKey(fid("a.b.42"))).toMatch(/^feed:a\.b\.42:\d+$/);
});
it("recognises email keys vs config/metadata keys", () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:config")).toBe(false);
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:metadata")).toBe(false);
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:1700000000000")).toBe(
true,
);
});
it("recovers the feed id from an email key", () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(repo.feedIdFromEmailKey("feed:a.b.42:1700000000000")).toBe("a.b.42");
});
});
describe("FeedRepository config & metadata", () => {
it("round-trips and deletes a feed config", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(await repo.getConfig(fid("a.b.42"))).toBeNull();
await repo.putConfig(fid("a.b.42"), sampleConfig());
expect(await repo.getConfig(fid("a.b.42"))).toMatchObject({
title: "Test Feed",
});
await repo.deleteConfig(fid("a.b.42"));
expect(await repo.getConfig(fid("a.b.42"))).toBeNull();
});
it("round-trips and deletes feed metadata", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const meta: FeedMetadata = { emails: [] };
await repo.putMetadata(fid("a.b.42"), meta);
expect(await repo.getMetadata(fid("a.b.42"))).toEqual(meta);
await repo.deleteMetadata(fid("a.b.42"));
expect(await repo.getMetadata(fid("a.b.42"))).toBeNull();
});
});
describe("FeedRepository emails", () => {
it("stores and reads an email under a minted key", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const key = repo.newEmailKey(fid("a.b.42"));
await repo.putEmail(key, sampleEmail());
expect(await repo.getEmail(key)).toMatchObject({ subject: "Hello" });
await repo.deleteEmail(key);
expect(await repo.getEmail(key)).toBeNull();
});
it("lists every key under a feed prefix", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
await repo.putConfig(fid("a.b.42"), sampleConfig());
await repo.putMetadata(fid("a.b.42"), { emails: [] });
const emailKey = repo.newEmailKey(fid("a.b.42"));
await repo.putEmail(emailKey, sampleEmail());
const listed = await repo.listFeedKeys(fid("a.b.42"));
expect(listed.names).toContain("feed:a.b.42:config");
expect(listed.names).toContain("feed:a.b.42:metadata");
expect(listed.names).toContain(emailKey);
expect(
listed.names.filter((k) => repo.isEmailKey(fid("a.b.42"), k)),
).toEqual([emailKey]);
});
});
describe("FeedRepository feed list", () => {
const feedWith = (
id: string,
title: string,
opts: { description?: string; expires_at?: number } = {},
) =>
Feed.reconstitute(
fid(id),
{
title,
language: "en",
allowedSenders: [],
blockedSenders: [],
createdAt: 1000,
description: opts.description,
expiresAt: opts.expires_at,
},
{ emails: [] },
);
it("upserts the list entry from the aggregate on save/saveConfig", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
await repo.save(
feedWith("a.b.42", "One", { description: "desc", expires_at: 5000 }),
);
await repo.save(feedWith("c.d.99", "Two"));
let feeds = await repo.listFeeds();
expect(feeds).toHaveLength(2);
expect(feeds.find((f) => f.id === "a.b.42")).toMatchObject({
title: "One",
expires_at: 5000,
});
// saveConfig refreshes the same entry in place (no duplicate, expiry cleared).
await repo.saveConfig(feedWith("a.b.42", "One-updated"));
feeds = await repo.listFeeds();
expect(feeds.filter((f) => f.id === "a.b.42")).toHaveLength(1);
const updated = feeds.find((f) => f.id === "a.b.42");
expect(updated).toMatchObject({ title: "One-updated" });
expect(updated?.expires_at).toBeUndefined();
expect(await repo.removeFromList(fid("a.b.42"))).toBe(true);
expect(await repo.removeFromList(fid("missing"))).toBe(false);
feeds = await repo.listFeeds();
expect(feeds.map((f) => f.id)).toEqual(["c.d.99"]);
});
it("bulk-removes only the matching ids", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
await repo.save(feedWith("a.b.42", "One"));
await repo.save(feedWith("c.d.99", "Two"));
await repo.save(feedWith("e.f.10", "Three"));
const removed = await repo.removeFromListBulk(["a.b.42", "e.f.10", "nope"]);
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]);
});
});