chahinebrini 34491ad220 feat(backend): denormalize domain_submissions.type for admin + lyra + notifications
User asked for the admin review tooling — and the lyra-bot community
post / notification text that goes out with each submission — to know
whether a submission is a website-domain or a mail-sender-domain. Until
now the type lived only on user_custom_domains and the submission
inherited it implicitly via the foreign key. Reading it back for the
admin list or the lyra prompt meant joining the source row every time.

- migration 20260516_domain_submission_type adds a type column to
  rebreak.domain_submissions with a default of 'web' and backfills
  every existing row from its linked user_custom_domains.type. The
  backfill is idempotent (UPDATE … FROM with the type comparison).
- Composite index (type, status) so the admin pending-list can scope
  by category without scanning the whole table.
- submitDomainForReview now copies the source row's type into the new
  submission. The submit endpoint picks it up to vary the auto-generated
  community-vote post copy: a website framing for type='web' and an
  "Mail-Absender"-framing for type='mail_domain'. The user's nickname
  is the only PII referenced.
- adminApproveSubmission returns the type alongside the domain so the
  approve endpoint's Lyra-bot Groq prompt can swap its subject/action
  labels per category. Reject path unchanged — the notification just
  carries the bare domain string, no type framing needed.
- BlocklistDomain stays type-agnostic on purpose. The mail-daemon's
  getBlocklistedDomainsSet is a flat string-set match against sender
  domain or URL host, and works for both categories without splitting.
  Adding a type there would be redundant work in v1.0 — revisit only
  if we ever need a UI to surface what category each global entry
  came from.

38/38 backend tests pass (8 admin/domains, 30 plan-limits including
5 new for the type-copy semantics and community-post text variants).
2026-05-16 02:24:42 +02:00

152 lines
4.7 KiB
TypeScript

/**
* Tests für getPendingSubmissions (Domain-Approval-Queue).
*
* Schwerpunkt: Sort-Order-Garantie + Deadline-Computation.
* - Legend > Pro > Free Plan-Priority
* - Innerhalb gleicher Priority: älteste createdAt zuerst (FIFO)
* - deadlineAt = createdAt + 24h, msUntilDeadline negativ wenn überfällig
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const prismaMock = vi.hoisted(() => ({
domainSubmission: {
findMany: vi.fn(),
},
}));
vi.mock("../../server/utils/prisma", () => ({
usePrisma: () => prismaMock,
}));
import {
getPendingSubmissions,
ADMIN_APPROVAL_SLA_MS,
} from "../../server/db/domains";
beforeEach(() => {
vi.clearAllMocks();
});
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "sub-id",
domain: "example.com",
type: "web",
yesVotes: 0,
noVotes: 0,
status: "in_review",
createdAt: new Date("2026-05-09T10:00:00Z"),
userId: "user-id",
postId: null,
customDomain: { id: "cd-id" },
user: { id: "user-id", nickname: "nick", plan: "free" },
...overrides,
};
}
describe("getPendingSubmissions — Plan-Priority + Deadline", () => {
it("sortiert Legend vor Pro vor Free (Plan-Priority)", async () => {
const sameTime = new Date("2026-05-09T10:00:00Z");
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({
id: "free-1",
createdAt: sameTime,
user: { id: "u1", nickname: "a", plan: "free" },
}),
makeRow({
id: "legend-1",
createdAt: sameTime,
user: { id: "u2", nickname: "b", plan: "legend" },
}),
makeRow({
id: "pro-1",
createdAt: sameTime,
user: { id: "u3", nickname: "c", plan: "pro" },
}),
]);
const result = await getPendingSubmissions();
expect(result.map((r) => r.id)).toEqual(["legend-1", "pro-1", "free-1"]);
});
it("innerhalb gleicher Plan-Priority: älteste zuerst (FIFO)", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({
id: "legend-newer",
createdAt: new Date("2026-05-09T12:00:00Z"),
user: { id: "u1", nickname: "x", plan: "legend" },
}),
makeRow({
id: "legend-older",
createdAt: new Date("2026-05-09T08:00:00Z"),
user: { id: "u2", nickname: "y", plan: "legend" },
}),
]);
const result = await getPendingSubmissions();
expect(result.map((r) => r.id)).toEqual(["legend-older", "legend-newer"]);
});
it("berechnet deadlineAt = createdAt + 24h pro row", async () => {
const created = new Date("2026-05-09T10:00:00Z");
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ createdAt: created }),
]);
const result = await getPendingSubmissions();
const expectedDeadline = new Date(
created.getTime() + ADMIN_APPROVAL_SLA_MS,
);
expect(result[0]!.deadlineAt.toISOString()).toBe(
expectedDeadline.toISOString(),
);
});
it("msUntilDeadline ist negativ wenn Submission überfällig (>24h alt)", async () => {
// 30h alte Submission → 6h überfällig → ms negativ
const created = new Date(Date.now() - 30 * 60 * 60 * 1000);
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ createdAt: created }),
]);
const result = await getPendingSubmissions();
expect(result[0]!.msUntilDeadline).toBeLessThan(0);
});
it("planPriority fällt auf 0 zurück wenn user.plan unbekannt / null", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ user: null }),
]);
const result = await getPendingSubmissions();
expect(result[0]!.planPriority).toBe(0);
});
it("returned type='web' für Web-Submission", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ id: "web-sub", domain: "casino.de", type: "web" }),
]);
const result = await getPendingSubmissions();
expect(result[0]!.type).toBe("web");
});
it("returned type='mail_domain' für Mail-Submission", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({
id: "mail-sub",
domain: "mailing.casino-affiliate.com",
type: "mail_domain",
}),
]);
const result = await getPendingSubmissions();
expect(result[0]!.type).toBe("mail_domain");
});
it("type ist im Response-Objekt vorhanden (passthrough)", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ type: "mail_domain" }),
]);
const result = await getPendingSubmissions();
expect(Object.prototype.hasOwnProperty.call(result[0], "type")).toBe(true);
});
});