Separate the two feed identities so the public read URL never reveals the inbound address and vice-versa: - FeedId becomes an opaque high-entropy token (read id + KV key); MailboxId (noun.noun.NN) owns the inbound address and the untrusted-input boundary via MailboxId.parse. They map only through the inbound:<mailbox> secondary index, resolved solely at reception. - inbound index lifecycle is owned by FeedRepository: written by save/saveConfig, dropped by removeFromList(Bulk) — symmetric, never mirrored by hand (removes the manual delete in feed-service + the cron loop, and a silent empty-catch). - Feed.mailboxId exposes a MailboxId VO (symmetry with Feed.id); the mailbox@domain shape lives on MailboxId.emailAddress(domain). - Distinguish mailbox_unknown (no feed claims the address) from feed_not_found (dangling index) for observability; both forwardable, both 404. - Drop the redundant EmailParser.extractMailbox pass-through so MailboxId.parse is the single parse boundary. Docs (README/INSTALL/CLAUDE.md/landing) and tests updated; 439 tests green, tsc clean, build dry-run OK. Co-Authored-By: Claude Opus 4.7 <[email protected]>
132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { createMockEnv } from "../test/setup";
|
|
import {
|
|
createFeedRecord,
|
|
editFeed,
|
|
deleteFeedRecord,
|
|
deleteFeedFastDetailed,
|
|
} from "./feed-service";
|
|
import { getCounters } from "./stats";
|
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
|
import { FeedId } from "../domain/value-objects/feed-id";
|
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
|
import type { Env } from "../types";
|
|
|
|
const mkEnv = (overrides: Partial<Env> = {}) =>
|
|
({ ...createMockEnv(), ...overrides }) as unknown as Env;
|
|
|
|
const baseInput = {
|
|
title: "N",
|
|
language: "en",
|
|
allowedSenders: [],
|
|
blockedSenders: [],
|
|
};
|
|
|
|
const TWO_HOURS = 2 * 3_600_000;
|
|
|
|
// The lifetime policy (parse env, apply the server-side FEED_TTL_HOURS override)
|
|
// lives here in the application layer; the domain only receives a resolved
|
|
// ttlHours. These tests pin that policy at the public service boundary.
|
|
describe("createFeedRecord — TTL policy", () => {
|
|
it("never expires when neither server nor client lifetime is set", async () => {
|
|
const { config } = await createFeedRecord(mkEnv(), { ...baseInput });
|
|
expect(config.expires_at).toBeUndefined();
|
|
});
|
|
|
|
it("uses the client lifetimeHours when there is no server override", async () => {
|
|
const before = Date.now();
|
|
const { config } = await createFeedRecord(mkEnv(), {
|
|
...baseInput,
|
|
lifetimeHours: 2,
|
|
});
|
|
expect(config.expires_at!).toBeGreaterThanOrEqual(before + TWO_HOURS);
|
|
});
|
|
|
|
it("lets a server FEED_TTL_HOURS override a larger client lifetime", async () => {
|
|
const before = Date.now();
|
|
const { config } = await createFeedRecord(mkEnv({ FEED_TTL_HOURS: "1" }), {
|
|
...baseInput,
|
|
lifetimeHours: 9999,
|
|
});
|
|
// 1h (server) wins over 9999h (client).
|
|
expect(config.expires_at!).toBeLessThan(before + TWO_HOURS);
|
|
});
|
|
|
|
it("bumps the feeds_created counter via the FeedCreated domain event", async () => {
|
|
const env = mkEnv();
|
|
await createFeedRecord(env, { ...baseInput });
|
|
const counters = await getCounters(env.EMAIL_STORAGE);
|
|
expect(counters.feeds_created).toBe(1);
|
|
expect(counters.last_feed_created_at).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("deleting a feed drops its inbound mailbox index", () => {
|
|
it("deleteFeedRecord removes the inbound index so the address stops resolving", async () => {
|
|
const env = mkEnv();
|
|
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
|
|
const repo = FeedRepository.from(env);
|
|
|
|
// Sanity: the address resolves to the feed before deletion.
|
|
expect(
|
|
(await repo.resolveInbound(MailboxId.unchecked(mailboxId)))?.value,
|
|
).toBe(feedId);
|
|
|
|
await deleteFeedRecord(env, FeedId.unchecked(feedId), () => {});
|
|
|
|
expect(
|
|
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("the bulk path (deleteFeedFastDetailed + removeFromListBulk) clears the inbound index", async () => {
|
|
const env = mkEnv();
|
|
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
|
|
const repo = FeedRepository.from(env);
|
|
|
|
// The bulk admin path drops config/metadata, then removes from the list —
|
|
// the latter is what clears the inbound index (symmetric with save()).
|
|
await deleteFeedFastDetailed(env.EMAIL_STORAGE, FeedId.unchecked(feedId));
|
|
await repo.removeFromListBulk([feedId]);
|
|
|
|
expect(
|
|
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("editFeed — TTL policy", () => {
|
|
it("recomputes expiry from the server override on edit", async () => {
|
|
const env = mkEnv({ FEED_TTL_HOURS: "1" });
|
|
const { feedId } = await createFeedRecord(env, { ...baseInput });
|
|
|
|
const before = Date.now();
|
|
const result = await editFeed(env, FeedId.unchecked(feedId), {
|
|
title: "renamed",
|
|
});
|
|
|
|
expect(result.status).toBe("ok");
|
|
if (result.status === "ok") {
|
|
expect(result.config.title).toBe("renamed");
|
|
expect(result.config.expires_at!).toBeLessThan(before + TWO_HOURS);
|
|
}
|
|
});
|
|
|
|
it("preserves expiry when neither server TTL nor client lifetime is given", async () => {
|
|
const env = mkEnv();
|
|
const { feedId, config } = await createFeedRecord(env, {
|
|
...baseInput,
|
|
lifetimeHours: 5,
|
|
});
|
|
|
|
const result = await editFeed(env, FeedId.unchecked(feedId), {
|
|
title: "x",
|
|
});
|
|
|
|
expect(result.status).toBe("ok");
|
|
if (result.status === "ok") {
|
|
expect(result.config.expires_at).toBe(config.expires_at);
|
|
}
|
|
});
|
|
});
|