mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
refactor(domain): consolidate Feed aggregate invariants in domain/feed.ts
Gather the feed's scattered business rules — expiry, sender allow/block policy, and the email byte-size budget — into one framework-agnostic module. Expiry was duplicated across feed-service, email-processor and the rss/atom/entries routes; the sender policy and trim loop lived inline in email-processor. Each now calls a single function (isExpired, applySenderPolicy, trimToByteBudget, resolveExpiresAt). Drops the now-unused MAX_METADATA_EMAILS constant. Behaviour-preserving; adds feed.test.ts covering every invariant. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveExpiresAt,
|
||||
isExpired,
|
||||
applySenderPolicy,
|
||||
trimToByteBudget,
|
||||
} from "./feed";
|
||||
import type { Env, FeedMetadata, EmailMetadata } from "../types";
|
||||
|
||||
const env = (overrides: Partial<Env> = {}): Env =>
|
||||
({ FEED_TTL_HOURS: undefined, ...overrides }) as Env;
|
||||
|
||||
describe("resolveExpiresAt", () => {
|
||||
it("returns undefined when no lifetime applies", () => {
|
||||
expect(resolveExpiresAt(env())).toBeUndefined();
|
||||
expect(resolveExpiresAt(env(), 0)).toBeUndefined();
|
||||
expect(resolveExpiresAt(env(), -5)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("computes expiry from a supplied lifetime", () => {
|
||||
const before = Date.now();
|
||||
const result = resolveExpiresAt(env(), 2)!;
|
||||
expect(result).toBeGreaterThanOrEqual(before + 2 * 3_600_000);
|
||||
});
|
||||
|
||||
it("lets a server-side FEED_TTL_HOURS override the client value", () => {
|
||||
const before = Date.now();
|
||||
const result = resolveExpiresAt(env({ FEED_TTL_HOURS: "1" }), 999)!;
|
||||
// Uses 1h (server), not 999h (client).
|
||||
expect(result).toBeLessThan(before + 2 * 3_600_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExpired", () => {
|
||||
it("is false when no expiry is set", () => {
|
||||
expect(isExpired({ expires_at: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it("is true at or past the expiry instant", () => {
|
||||
expect(isExpired({ expires_at: 1000 }, 1000)).toBe(true);
|
||||
expect(isExpired({ expires_at: 1000 }, 1001)).toBe(true);
|
||||
expect(isExpired({ expires_at: 1000 }, 999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySenderPolicy", () => {
|
||||
it("accepts everything when no lists are configured", () => {
|
||||
expect(applySenderPolicy({}, ["anyone@example.com"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("requires an allowlist match when an allowlist is set", () => {
|
||||
const config = { allowed_senders: ["news@example.com"] };
|
||||
expect(applySenderPolicy(config, ["news@example.com"])).toBe("accepted");
|
||||
expect(applySenderPolicy(config, ["other@example.com"])).toBe("blocked");
|
||||
});
|
||||
|
||||
it("matches an allowlist by domain", () => {
|
||||
const config = { allowed_senders: ["example.com"] };
|
||||
expect(applySenderPolicy(config, ["anyone@example.com"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("blocks a blocklisted sender even when allowlisted", () => {
|
||||
const config = {
|
||||
allowed_senders: ["example.com"],
|
||||
blocked_senders: ["spam@example.com"],
|
||||
};
|
||||
expect(applySenderPolicy(config, ["spam@example.com"])).toBe("blocked");
|
||||
expect(applySenderPolicy(config, ["ok@example.com"])).toBe("accepted");
|
||||
});
|
||||
|
||||
it("with only a blocklist, accepts everything else", () => {
|
||||
const config = { blocked_senders: ["bad.com"] };
|
||||
expect(applySenderPolicy(config, ["x@bad.com"])).toBe("blocked");
|
||||
expect(applySenderPolicy(config, ["x@good.com"])).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