Files
tessera-ctl/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts
T
schalli 1be6b15249 feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13)
Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.

TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).

RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.

EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.

tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:45:11 +02:00

380 lines
13 KiB
TypeScript

import { readFileSync } from 'fs';
import { join } from 'path';
import { describe, expect, it, vi } from 'vitest';
import {
EmailAlertAdapter,
extractCandidateLinks,
sourceNoticeIdFor,
titleFromEmail,
} from './email-alert.adapter';
/**
* email-alert.adapter.spec — Task 1 (test-first, TDD) proof for the generic
* link/subject extraction (D-04): pure functions only, no I/O, mirroring
* cosinex.adapter.spec.ts's pure-function spec style. Task 2 adds
* fetchTenders() fan-out coverage (mocked PrismaService/CalendarCryptoService/
* inbox providers — no live DB/network I/O).
*/
/** Fake CalendarCryptoService — deterministic reversible encode, not real AES. */
function makeFakeCrypto() {
return {
encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`),
decrypt: vi.fn((stored: string) => {
if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format');
return Buffer.from(stored.slice(4), 'base64').toString('utf8');
}),
};
}
function makeAdapter(overrides: {
configs?: any[];
imapFetchMessages?: ReturnType<typeof vi.fn>;
exchangeFetchMessages?: ReturnType<typeof vi.fn>;
} = {}) {
const crypto = makeFakeCrypto();
const prisma = {
tenderEmailConfig: {
findMany: vi.fn(async () => overrides.configs ?? []),
},
};
const imapProvider = { fetchMessages: overrides.imapFetchMessages ?? vi.fn(async () => []) };
const exchangeProvider = {
fetchMessages: overrides.exchangeFetchMessages ?? vi.fn(async () => []),
};
const adapter = new EmailAlertAdapter(
prisma as any,
crypto as any,
imapProvider as any,
exchangeProvider as any,
);
return { adapter, prisma, crypto, imapProvider, exchangeProvider };
}
describe('EmailAlertAdapter', () => {
it('declares sourceType email-alert and the single email-alert portal', () => {
const { adapter } = makeAdapter();
expect(adapter.sourceType).toBe('email-alert');
expect(adapter.portals).toEqual(['email-alert']);
});
describe('extractCandidateLinks (pure)', () => {
it('extracts absolute http(s) hrefs from an HTML body via cheerio', () => {
const html = `
<html><body>
<p>Neue Ausschreibung verfügbar:</p>
<a href="https://portal.example/detail/123">Zur Ausschreibung</a>
<a href="https://portal.example/detail/456">Weiterer Link</a>
</body></html>
`;
const links = extractCandidateLinks(html, '');
expect(links).toEqual([
'https://portal.example/detail/123',
'https://portal.example/detail/456',
]);
});
it('falls back to a plaintext URL regex when bodyHtml is null', () => {
const text =
'Neue Ausschreibung: https://portal.example/detail/789 — bitte prüfen.';
const links = extractCandidateLinks(null, text);
expect(links).toEqual(['https://portal.example/detail/789']);
});
it('drops footer-noise links (unsubscribe/abmelden/einstellungen/preferences)', () => {
const html = `
<a href="https://portal.example/detail/1">Detail</a>
<a href="https://portal.example/unsubscribe?id=1">Abbestellen</a>
<a href="https://portal.example/abmelden">Abmelden</a>
<a href="https://portal.example/einstellungen">Einstellungen</a>
<a href="https://mail.example/preferences">Preferences</a>
`;
const links = extractCandidateLinks(html, '');
expect(links).toEqual(['https://portal.example/detail/1']);
});
it('dedupes identical hrefs', () => {
const html = `
<a href="https://portal.example/detail/1">Detail</a>
<a href="https://portal.example/detail/1">Nochmal</a>
`;
const links = extractCandidateLinks(html, '');
expect(links).toEqual(['https://portal.example/detail/1']);
});
it('caps extracted links at MAX_LINKS_PER_EMAIL (link-spam safety valve)', () => {
const html = Array.from(
{ length: 10 },
(_, i) => `<a href="https://portal.example/detail/${i}">Link ${i}</a>`,
).join('\n');
const links = extractCandidateLinks(html, '');
expect(links.length).toBe(5);
});
it('ignores non-http(s) hrefs (mailto:, relative paths, javascript:)', () => {
const html = `
<a href="mailto:info@portal.example">Kontakt</a>
<a href="/relative/path">Relativ</a>
<a href="javascript:void(0)">JS</a>
<a href="https://portal.example/detail/1">Detail</a>
`;
const links = extractCandidateLinks(html, '');
expect(links).toEqual(['https://portal.example/detail/1']);
});
it('returns [] for an empty HTML body and empty plaintext', () => {
expect(extractCandidateLinks('', '')).toEqual([]);
expect(extractCandidateLinks(null, '')).toEqual([]);
});
});
describe('titleFromEmail (pure)', () => {
it('returns the trimmed subject when present', () => {
expect(titleFromEmail(' Neue Ausschreibung Nr. 123 ', 'Body text')).toBe(
'Neue Ausschreibung Nr. 123',
);
});
it('falls back to the first non-empty body line when subject is blank', () => {
const text = '\n\n Erste inhaltliche Zeile \nZweite Zeile';
expect(titleFromEmail('', text)).toBe('Erste inhaltliche Zeile');
expect(titleFromEmail(' ', text)).toBe('Erste inhaltliche Zeile');
});
it('falls back to "Unbenannte Ausschreibung" when subject and body are both empty', () => {
expect(titleFromEmail('', '')).toBe('Unbenannte Ausschreibung');
expect(titleFromEmail('', '\n\n \n')).toBe('Unbenannte Ausschreibung');
});
});
describe('sourceNoticeIdFor (pure)', () => {
it('returns a stable 40-char hex id for the same link', () => {
const link = 'https://portal.example/detail/123';
const id1 = sourceNoticeIdFor(link);
const id2 = sourceNoticeIdFor(link);
expect(id1).toBe(id2);
expect(id1).toMatch(/^[a-f0-9]{40}$/);
});
it('returns different ids for different links', () => {
const id1 = sourceNoticeIdFor('https://portal.example/detail/1');
const id2 = sourceNoticeIdFor('https://portal.example/detail/2');
expect(id1).not.toBe(id2);
});
});
it('never branches on a specific sender email/domain (D-04 restraint — no portal-specific parser)', () => {
const source = readFileSync(join(__dirname, 'email-alert.adapter.ts'), 'utf8');
// No hardcoded sender-domain/portal-name conditional logic — generic
// extraction only, mirroring the RESEARCH.md Anti-Pattern guard.
expect(source).not.toMatch(/senderDomain|from\.includes\(|sender\.includes\(/);
});
describe('fetchTenders (per-tenant fan-out, Task 2, INGEST-05/D-13)', () => {
const MSG = {
uid: 1,
messageId: '<msg-1>',
subject: 'Neue Ausschreibung Nr. 42',
from: 'alerts@portal.example',
date: new Date('2026-07-23T09:00:00.000Z'),
bodyHtml: '<a href="https://portal.example/detail/42">Details</a>',
bodyText: '',
};
it('only queries isActive TenderEmailConfig rows (cross-tenant read, deliberate)', async () => {
const { adapter, prisma } = makeAdapter({ configs: [] });
await adapter.fetchTenders('2026-07-23');
expect(prisma.tenderEmailConfig.findMany).toHaveBeenCalledWith({
where: { isActive: true },
});
});
it('tags each extracted record with ownerTenantId = the configuring tenant (D-13)', async () => {
const imapFetchMessages = vi.fn(async () => [MSG]);
const { adapter } = makeAdapter({
configs: [
{
tenantId: 'tenant-a',
protocol: 'imap',
host: 'imap.example.test',
port: 993,
encryption: 'ssl-tls',
folder: 'INBOX',
senderFilter: null,
domain: null,
encryptedInboxCreds: null,
},
],
imapFetchMessages,
});
const records = await adapter.fetchTenders('2026-07-23');
expect(records).toHaveLength(1);
expect(records[0]?.ownerTenantId).toBe('tenant-a');
expect(records[0]?.sourceType).toBe('email-alert');
expect(records[0]?.sourcePortal).toBe('email-alert');
expect(records[0]?.sourceUrl).toBe('https://portal.example/detail/42');
});
it('routes protocol=exchange configs to the ExchangeInboxProvider, imap to ImapProvider', async () => {
const imapFetchMessages = vi.fn(async () => []);
const exchangeFetchMessages = vi.fn(async () => [MSG]);
const { adapter } = makeAdapter({
configs: [
{
tenantId: 'tenant-ews',
protocol: 'exchange',
host: 'https://mail.example.test/EWS/Exchange.asmx',
port: null,
encryption: 'ssl-tls',
folder: 'INBOX',
senderFilter: null,
domain: 'CONTOSO',
encryptedInboxCreds: null,
},
],
imapFetchMessages,
exchangeFetchMessages,
});
const records = await adapter.fetchTenders('2026-07-23');
expect(imapFetchMessages).not.toHaveBeenCalled();
expect(exchangeFetchMessages).toHaveBeenCalledTimes(1);
expect(records).toHaveLength(1);
expect(records[0]?.ownerTenantId).toBe('tenant-ews');
});
it('decrypts encryptedInboxCreds and passes username/password into InboxConfig', async () => {
const crypto = makeFakeCrypto();
const encrypted = crypto.encrypt(
JSON.stringify({ username: 'alerts@example.test', password: 'secret' }),
);
const imapFetchMessages = vi.fn(async () => []);
const prisma = {
tenderEmailConfig: {
findMany: vi.fn(async () => [
{
tenantId: 'tenant-a',
protocol: 'imap',
host: 'imap.example.test',
port: 993,
encryption: 'ssl-tls',
folder: 'INBOX',
senderFilter: null,
domain: null,
encryptedInboxCreds: encrypted,
},
]),
},
};
const adapter = new EmailAlertAdapter(
prisma as any,
crypto as any,
{ fetchMessages: imapFetchMessages } as any,
{ fetchMessages: vi.fn(async () => []) } as any,
);
await adapter.fetchTenders('2026-07-23');
expect(imapFetchMessages).toHaveBeenCalledWith(
expect.objectContaining({ username: 'alerts@example.test', password: 'secret' }),
);
});
it('catch-per-tenant: one mocked mailbox throws, others still return records tagged with their own ownerTenantId', async () => {
const throwingFetch = vi.fn(async () => {
throw new Error('connection refused');
});
const workingFetch = vi.fn(async () => [MSG]);
const crypto = makeFakeCrypto();
const prisma = {
tenderEmailConfig: {
findMany: vi.fn(async () => [
{
tenantId: 'tenant-broken',
protocol: 'imap',
host: 'imap.broken.test',
port: 993,
encryption: 'ssl-tls',
folder: 'INBOX',
senderFilter: null,
domain: null,
encryptedInboxCreds: null,
},
{
tenantId: 'tenant-ok',
protocol: 'imap',
host: 'imap.ok.test',
port: 993,
encryption: 'ssl-tls',
folder: 'INBOX',
senderFilter: null,
domain: null,
encryptedInboxCreds: null,
},
]),
},
};
// First mailbox throws, second succeeds — same imapProvider instance,
// called twice (once per tenant), mockImplementationOnce per call.
const imapProvider = {
fetchMessages: vi
.fn()
.mockImplementationOnce(throwingFetch)
.mockImplementationOnce(workingFetch),
};
const adapter = new EmailAlertAdapter(
prisma as any,
crypto as any,
imapProvider as any,
{ fetchMessages: vi.fn(async () => []) } as any,
);
const records = await adapter.fetchTenders('2026-07-23');
expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2);
expect(records).toHaveLength(1);
expect(records[0]?.ownerTenantId).toBe('tenant-ok');
});
it('returns [] when there are no active configs', async () => {
const { adapter } = makeAdapter({ configs: [] });
const records = await adapter.fetchTenders('2026-07-23');
expect(records).toEqual([]);
});
it('produces one RawTenderRecord per extracted candidate link (multiple links in one message)', async () => {
const multiLinkMsg = {
...MSG,
bodyHtml:
'<a href="https://portal.example/detail/1">A</a><a href="https://portal.example/detail/2">B</a>',
};
const { adapter } = makeAdapter({
configs: [
{
tenantId: 'tenant-a',
protocol: 'imap',
host: 'imap.example.test',
port: 993,
encryption: 'ssl-tls',
folder: 'INBOX',
senderFilter: null,
domain: null,
encryptedInboxCreds: null,
},
],
imapFetchMessages: vi.fn(async () => [multiLinkMsg]),
});
const records = await adapter.fetchTenders('2026-07-23');
expect(records).toHaveLength(2);
expect(records.every((r) => r.ownerTenantId === 'tenant-a')).toBe(true);
});
});
});