refactor: tighten DDD boundaries on the Feed aggregate
Address five modeling tensions in one pass: - Encapsulation: the Feed aggregate no longer exposes raw config/metadata (a shallow Readonly still leaked mutable arrays). It now offers intention-revealing accessors that return copies, plus toConfigSnapshot/toMetadataSnapshot for the repository and summary() for the global registry. - feeds:list consistency: FeedRepository.save/saveConfig upsert the registry entry from feed.summary(), so services no longer mirror title/description/ expiry by hand (the old add/updateInList footgun is gone). - domain/feed.ts: drop the dead applySenderPolicy, internalise resolveExpiresAt and trimToByteBudget into the aggregate; feed.ts keeps only the shared isExpired predicate used by the read-model routes. - Single edit path: remove editDetails; edit(patch, deps) is the sole config mutation, with a systematic expired guard. Renaming an expired feed now 403s. - FeedId flows through the application and infrastructure signatures; fromTrusted/parse happen once at the edge, .value only at the serialisation boundaries (urls, feed-generator, feed-keys, logs, JSON). 347 tests green, tsc clean, Worker bundle builds. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
b3d42f6c50
commit
ad196f1761
24 files changed
+406
-398
No files matched your search
+1
-86
@@ -1,26 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveExpiresAt,
|
||||
isExpired,
|
||||
applySenderPolicy,
|
||||
trimToByteBudget,
|
||||
} from "./feed";
|
||||
import type { FeedMetadata, EmailMetadata } from "../types";
|
||||
|
||||
describe("resolveExpiresAt", () => {
|
||||
const NOW = 1_000_000;
|
||||
|
||||
it("returns undefined when no positive lifetime applies", () => {
|
||||
expect(resolveExpiresAt(undefined, NOW)).toBeUndefined();
|
||||
expect(resolveExpiresAt(0, NOW)).toBeUndefined();
|
||||
expect(resolveExpiresAt(-5, NOW)).toBeUndefined();
|
||||
expect(resolveExpiresAt(NaN, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("computes expiry from a supplied lifetime relative to now", () => {
|
||||
expect(resolveExpiresAt(2, NOW)).toBe(NOW + 2 * 3_600_000);
|
||||
});
|
||||
});
|
||||
import { isExpired } from "./feed";
|
||||
|
||||
describe("isExpired", () => {
|
||||
it("is false when no expiry is set", () => {
|
||||
@@ -33,67 +12,3 @@ describe("isExpired", () => {
|
||||
expect(isExpired({ expires_at: 1000 }, 999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySenderPolicy", () => {
|
||||
it("accepts everything when no lists are configured", () => {
|
||||
expect(applySenderPolicy({}, ["[email protected]"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("requires an allowlist match when an allowlist is set", () => {
|
||||
const config = { allowed_senders: ["[email protected]"] };
|
||||
expect(applySenderPolicy(config, ["[email protected]"])).toBe("accepted");
|
||||
expect(applySenderPolicy(config, ["[email protected]"])).toBe("blocked");
|
||||
});
|
||||
|
||||
it("matches an allowlist by domain", () => {
|
||||
const config = { allowed_senders: ["example.com"] };
|
||||
expect(applySenderPolicy(config, ["[email protected]"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("blocks a blocklisted sender even when allowlisted", () => {
|
||||
const config = {
|
||||
allowed_senders: ["example.com"],
|
||||
blocked_senders: ["[email protected]"],
|
||||
};
|
||||
expect(applySenderPolicy(config, ["[email protected]"])).toBe("blocked");
|
||||
expect(applySenderPolicy(config, ["[email protected]"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("with only a blocklist, accepts everything else", () => {
|
||||
const config = { blocked_senders: ["bad.com"] };
|
||||
expect(applySenderPolicy(config, ["[email protected]"])).toBe("blocked");
|
||||
expect(applySenderPolicy(config, ["[email protected]"])).toBe("accepted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimToByteBudget", () => {
|
||||
const entry = (key: string, size: number): EmailMetadata => ({
|
||||
key,
|
||||
subject: key,
|
||||
receivedAt: 1,
|
||||
size,
|
||||
});
|
||||
|
||||
it("keeps everything within budget", () => {
|
||||
const meta: FeedMetadata = { emails: [entry("a", 10), entry("b", 10)] };
|
||||
const { dropped } = trimToByteBudget(meta, 100);
|
||||
expect(dropped).toEqual([]);
|
||||
expect(meta.emails).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops the oldest entries (from the tail) until within budget", () => {
|
||||
const meta: FeedMetadata = {
|
||||
emails: [entry("new", 30), entry("mid", 30), entry("old", 30)],
|
||||
};
|
||||
const { dropped } = trimToByteBudget(meta, 50);
|
||||
expect(dropped.map((e) => e.key)).toEqual(["old", "mid"]);
|
||||
expect(meta.emails.map((e) => e.key)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("always keeps at least one entry, even when oversized", () => {
|
||||
const meta: FeedMetadata = { emails: [entry("only", 999)] };
|
||||
const { dropped } = trimToByteBudget(meta, 1);
|
||||
expect(dropped).toEqual([]);
|
||||
expect(meta.emails).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user