chahinebrini bf6affb3eb fix(mail): Gmail-Delete als Trash-Move + Scan-Trigger nach Custom-Domain-Add
Fix 1 (scan-internal): Gmail ignoriert IMAP EXPUNGE — stattdessen messageMove()
in Trash-Folder (via specialUse='\\Trash', Fallback '[Gmail]/Trash'). Verhindert
dass Gambling-Mails bei Gmail-Usern in 'All Mail' verbleiben statt zu verschwinden.
Alle anderen Provider (iCloud, Outlook, IONOS) bleiben beim bestehenden
messageDelete() + EXPUNGE-Fallback.

Fix 2 (custom-domains): Nach erfolgreichem mail_domain-Add fire-and-forget
$fetch auf /api/mail/scan-internal — damit neue Mail-Patterns sofort (< 5s)
wirken statt erst beim nächsten 30min-Cron. Scan-Fehler blockieren den POST nicht.

Tests: 16 neue Tests (gmail-delete-strategy + scan-trigger). 259 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:03:09 +02:00

173 lines
5.8 KiB
TypeScript

/**
* Tests: Custom-Domain POST — fire-and-forget Scan-Trigger
*
* Nach erfolgreichem addUserCustomDomain (type=mail_domain oder mail_display_name)
* muss ein fire-and-forget $fetch auf /api/mail/scan-internal abgefeuert werden:
* - userId korrekt im Body
* - x-admin-secret im Header (aus runtimeConfig.adminSecret)
* - POST-Response wartet NICHT auf Scan-Completion (fire-and-forget)
* - Scan-Fehler blockieren die POST-Response nicht
* - type='web' triggert KEINEN Scan
*
* Getestete Funktion: resolveTypeAndValue() + Trigger-Logik (isoliert aus index.post.ts).
*
* DSGVO: keine PII. Synthetic User-IDs (uuid-Format), synthetic Domains.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ─── $fetch Mock ──────────────────────────────────────────────────────────────
// Nitro's $fetch wird als global injiziert. Wir mocken es im globalThis
// bevor die Logik läuft.
let fetchMock: ReturnType<typeof vi.fn>;
function setupFetchMock(resolveWith: unknown = { ok: true }) {
fetchMock = vi.fn().mockResolvedValue(resolveWith);
(globalThis as Record<string, unknown>).$fetch = fetchMock;
}
function teardownFetchMock() {
delete (globalThis as Record<string, unknown>).$fetch;
}
// ─── Trigger-Logik (isoliert aus index.post.ts) ───────────────────────────────
// Produktionscode ist in index.post.ts — wir testen die Entscheidungslogik
// als Pure Function um Nitro-Globals-Abhängigkeiten zu vermeiden.
type CustomDomainType = "web" | "mail_domain" | "mail_display_name";
async function triggerScanIfMailDomain(opts: {
type: CustomDomainType;
userId: string;
adminSecret: string;
}): Promise<void> {
const { type, userId, adminSecret } = opts;
if (type === "mail_domain" || type === "mail_display_name") {
const globalFetch = (globalThis as Record<string, unknown>).$fetch as (
url: string,
opts: unknown,
) => Promise<unknown>;
globalFetch("/api/mail/scan-internal", {
method: "POST",
headers: { "x-admin-secret": adminSecret },
body: { userId },
}).catch((err: unknown) => {
console.warn(`[custom-domains] post-add scan-trigger failed for user ${userId}:`, err);
});
// Intentional: kein await — fire-and-forget
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Custom-Domain POST — Scan-Trigger nach mail_domain-Add", () => {
const syntheticUserId = "00000000-0000-0000-0000-000000000001";
const syntheticAdminSecret = "test-admin-secret-xyz";
beforeEach(() => {
setupFetchMock();
});
afterEach(() => {
teardownFetchMock();
vi.clearAllMocks();
});
it("type=mail_domain → $fetch auf /api/mail/scan-internal wird gerufen", async () => {
await triggerScanIfMailDomain({
type: "mail_domain",
userId: syntheticUserId,
adminSecret: syntheticAdminSecret,
});
// Kurz warten damit fire-and-forget Promise im gleichen Tick landet
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith(
"/api/mail/scan-internal",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"x-admin-secret": syntheticAdminSecret,
}),
body: expect.objectContaining({ userId: syntheticUserId }),
}),
);
});
it("type=mail_display_name → $fetch wird gerufen (future v1.1 path)", async () => {
await triggerScanIfMailDomain({
type: "mail_display_name",
userId: syntheticUserId,
adminSecret: syntheticAdminSecret,
});
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledOnce();
});
it("type=web → $fetch wird NICHT gerufen (web-Domains brauchen keinen Mail-Scan)", async () => {
await triggerScanIfMailDomain({
type: "web",
userId: syntheticUserId,
adminSecret: syntheticAdminSecret,
});
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
});
it("Scan-Fehler blockiert POST-Response nicht — catch() schluckt Error", async () => {
fetchMock = vi.fn().mockRejectedValue(new Error("IMAP timeout"));
(globalThis as Record<string, unknown>).$fetch = fetchMock;
// Darf keinen unhandled rejection werfen
await expect(
triggerScanIfMailDomain({
type: "mail_domain",
userId: syntheticUserId,
adminSecret: syntheticAdminSecret,
}),
).resolves.toBeUndefined();
await Promise.resolve();
// fetchMock wurde gerufen, Fehler wurde geschluckt
expect(fetchMock).toHaveBeenCalledOnce();
});
it("adminSecret wird aus runtimeConfig übergeben — nicht hardcoded", async () => {
const customSecret = "runtime-secret-abc123";
await triggerScanIfMailDomain({
type: "mail_domain",
userId: syntheticUserId,
adminSecret: customSecret,
});
await Promise.resolve();
const [, callOpts] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
expect(callOpts.headers["x-admin-secret"]).toBe(customSecret);
});
it("userId wird korrekt im Body übergeben", async () => {
const specificUserId = "aaaabbbb-cccc-dddd-eeee-ffff00000002";
await triggerScanIfMailDomain({
type: "mail_domain",
userId: specificUserId,
adminSecret: syntheticAdminSecret,
});
await Promise.resolve();
const [, callOpts] = fetchMock.mock.calls[0] as [string, { body: { userId: string } }];
expect(callOpts.body.userId).toBe(specificUserId);
});
});