- Turborepo monorepo structure (packages: api, db, types, jobs; services: darkwatch) - Prisma schema: User, WatchListItem, Exposure, Alert, ScanJob models - WatchListService: CRUD with normalization, dedup, tier-based limits - HIBPService: API integration with severity scoring - MatchingEngine: exact-match with content hash dedup - AlertPipeline: dedup window, email notifications - ScanService: orchestrates watch list -> HIBP -> match -> alert flow - BullMQ job workers for scan and alert processing - Fastify API routes: watchlist, exposures, alerts, scan - Docker Compose: PostgreSQL 16 + Redis 7 - 15 unit tests passing - Implementation plan document uploaded
77 lines
3.0 KiB
TypeScript
77 lines
3.0 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { WatchListService } from "../src/watchlist/WatchListService";
|
|
import prisma from "@shieldai/db";
|
|
import { IdentifierType } from "@shieldai/types";
|
|
|
|
let runId = Date.now();
|
|
|
|
describe("WatchListService", () => {
|
|
let service: WatchListService;
|
|
let userId: string;
|
|
|
|
beforeEach(async () => {
|
|
runId = Date.now();
|
|
service = new WatchListService();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: `test-${runId}@shieldai.local`,
|
|
name: "Test User",
|
|
subscriptionTier: "PREMIUM",
|
|
},
|
|
});
|
|
userId = user.id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await prisma.watchListItem.deleteMany({ where: { userId } });
|
|
await prisma.user.delete({ where: { id: userId } });
|
|
});
|
|
|
|
it("adds an email identifier", async () => {
|
|
const item = await service.addItem(userId, IdentifierType.EMAIL, `test-${runId}@example.com`);
|
|
expect(item.identifierValue).toBe(`test-${runId}@example.com`);
|
|
expect(item.identifierType).toBe(IdentifierType.EMAIL);
|
|
expect(item.identifierHash).toHaveLength(64);
|
|
});
|
|
|
|
it("adds a phone identifier", async () => {
|
|
const digits = String(runId).padStart(10, "0").slice(-10);
|
|
const phone = `${digits.slice(0,3)}-${digits.slice(3,7)}-${digits.slice(7)}`;
|
|
const item = await service.addItem(userId, IdentifierType.PHONE, phone);
|
|
expect(item.identifierType).toBe(IdentifierType.PHONE);
|
|
expect(item.identifierValue).toMatch(/^\+1\d{10}$/);
|
|
});
|
|
|
|
it("deduplicates by hash", async () => {
|
|
await service.addItem(userId, IdentifierType.EMAIL, `dedup-${runId}@example.com`);
|
|
const duplicate = service.addItem(userId, IdentifierType.EMAIL, `DEDUP-${runId}@EXAMPLE.COM`);
|
|
await expect(duplicate).rejects.toThrow("Identifier already watched");
|
|
});
|
|
|
|
it("lists items", async () => {
|
|
await service.addItem(userId, IdentifierType.EMAIL, `list-a-${runId}@example.com`);
|
|
await service.addItem(userId, IdentifierType.EMAIL, `list-b-${runId}@example.com`);
|
|
const items = await service.listItems(userId);
|
|
expect(items).toHaveLength(2);
|
|
});
|
|
|
|
it("removes an item", async () => {
|
|
const item = await service.addItem(userId, IdentifierType.EMAIL, `remove-${runId}@example.com`);
|
|
const result = await service.removeItem(userId, item.id);
|
|
expect(result.count).toBe(1);
|
|
const remaining = await service.listItems(userId);
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
it("enforces BASIC tier limit", async () => {
|
|
const basicUser = await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { subscriptionTier: "BASIC" },
|
|
});
|
|
await service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-1-${runId}@example.com`);
|
|
await service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-2-${runId}@example.com`);
|
|
const third = service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-3-${runId}@example.com`);
|
|
await expect(third).rejects.toThrow("Watch list limit reached");
|
|
});
|
|
});
|