test(260911-gwh): RED — neue Testdateien fuer favorites/settings mit dem Zwei-Klienten-Nachbau
- favorites.service.spec.ts (NEU, 23 Faelle): ungebundener Nachbau hat KEIN favoriteLink/widgetInstance-Modell; erwartet die Zielsignatur list/create/update/remove/getIconBytes(tenantId, ...) und den Widget-Besitzriegel in create -- scheitert erwartungsgemaess an "Cannot read properties of undefined" gegen den heutigen Dienst (22/23 rot) - settings.service.spec.ts (NEU, 20 Faelle): ungebundener Nachbau bietet fuer smtpConfig NUR findFirst, gebundener Klient NUR findUnique/upsert; erwartet loadAnySmtpConfigForStartupTransport() (Startpfad-Umbenennung) und den Null-Klienten-Nachweis fuer den Startpfad -- 18/20 rot Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -0,0 +1,506 @@
|
|||||||
|
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FavoritesService } from './favorites.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
|
||||||
|
* VOR diesem Lauf KEINE Testdatei fuer den Dienst (Befund J; nur
|
||||||
|
* `icon-discovery.service.spec.ts` existierte). Zwei-Klienten-Nachbau
|
||||||
|
* (Muster `dkv.service.spec.ts`/`auth.service.spec.ts`): `forTenant()` wird
|
||||||
|
* auf `unboundClient.__makeBoundClient(tenantId)` umgeleitet. Der
|
||||||
|
* UNGEBUNDENE Nachbau (der Fake selbst) hat KEINES der Anfrage-Modelle
|
||||||
|
* (`favoriteLink`, `widgetInstance`) — ein versehentlich ungebundener
|
||||||
|
* Modellzugriff scheitert mit "Cannot read properties of undefined" (die
|
||||||
|
* dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der
|
||||||
|
* GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`.
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface FakeFavoriteRow {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
widgetId: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
position: number;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeWidgetRow {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoundCall {
|
||||||
|
tenantId: string;
|
||||||
|
model: 'favoriteLink' | 'widgetInstance';
|
||||||
|
method: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwP2025(action: 'update' | 'delete'): never {
|
||||||
|
const err: any = new Error(
|
||||||
|
action === 'update'
|
||||||
|
? 'An operation failed because it depends on one or more records that were required but not found. No record was found for an update.'
|
||||||
|
: 'An operation failed because it depends on one or more records that were required but not found. No record was found for a delete.',
|
||||||
|
);
|
||||||
|
err.code = 'P2025';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei-Klienten-Nachbau: `favorites`/`widgets` sind das gemeinsame
|
||||||
|
* Gedaechtnis, der gebundene Klient (`__makeBoundClient`) protokolliert
|
||||||
|
* jeden Zugriff im `boundCallLog` — der ungebundene Basisclient (der Fake
|
||||||
|
* selbst) traegt KEIN `favoriteLink`/`widgetInstance` und protokolliert
|
||||||
|
* deshalb strukturell nie.
|
||||||
|
*/
|
||||||
|
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
||||||
|
const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }]));
|
||||||
|
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
||||||
|
const boundCallLog: BoundCall[] = [];
|
||||||
|
let autoId = favoriteRows.length;
|
||||||
|
|
||||||
|
function makeScopedFavoriteLink(tenantId: string) {
|
||||||
|
return {
|
||||||
|
findMany: async ({ where, orderBy }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findMany' });
|
||||||
|
let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
|
||||||
|
if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
|
||||||
|
if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||||
|
if (orderBy) {
|
||||||
|
rows = [...rows].sort((a, b) => {
|
||||||
|
for (const clause of orderBy) {
|
||||||
|
const [key, dir] = Object.entries(clause as Record<string, string>)[0];
|
||||||
|
const av = (a as any)[key];
|
||||||
|
const bv = (b as any)[key];
|
||||||
|
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||||||
|
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
findUnique: async ({ where }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findUnique' });
|
||||||
|
const row = favorites.get(where.id);
|
||||||
|
if (!row || row.tenantId !== tenantId) return null;
|
||||||
|
return { ...row };
|
||||||
|
},
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
||||||
|
const id = data.id ?? `fav-${++autoId}`;
|
||||||
|
const now = new Date();
|
||||||
|
const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id };
|
||||||
|
favorites.set(id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
update: async ({ where, data }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
||||||
|
const row = favorites.get(where.id);
|
||||||
|
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
||||||
|
const updated = { ...row, ...data, updatedAt: new Date() };
|
||||||
|
favorites.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
delete: async ({ where }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'delete' });
|
||||||
|
const row = favorites.get(where.id);
|
||||||
|
if (!row || row.tenantId !== tenantId) throwP2025('delete');
|
||||||
|
favorites.delete(where.id);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeScopedWidgetInstance(tenantId: string) {
|
||||||
|
return {
|
||||||
|
findUnique: async ({ where, select }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'widgetInstance', method: 'findUnique' });
|
||||||
|
const row = widgets.get(where.id);
|
||||||
|
if (!row || row.tenantId !== tenantId) return null;
|
||||||
|
if (!select) return { ...row };
|
||||||
|
const picked: any = {};
|
||||||
|
for (const key of Object.keys(select)) {
|
||||||
|
if (select[key]) picked[key] = (row as any)[key];
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fake: any = {
|
||||||
|
__favorites: favorites,
|
||||||
|
__widgets: widgets,
|
||||||
|
__boundCallLog: boundCallLog,
|
||||||
|
__makeBoundClient(tenantId: string) {
|
||||||
|
return {
|
||||||
|
favoriteLink: makeScopedFavoriteLink(tenantId),
|
||||||
|
widgetInstance: makeScopedWidgetInstance(tenantId),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBoundCall(
|
||||||
|
prisma: any,
|
||||||
|
tenantId: string,
|
||||||
|
model: 'favoriteLink' | 'widgetInstance',
|
||||||
|
method: string,
|
||||||
|
) {
|
||||||
|
const found = prisma.__boundCallLog.some(
|
||||||
|
(c: BoundCall) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIconDiscovery(
|
||||||
|
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
discoverFavoriteIconUrl:
|
||||||
|
overrides.discoverFavoriteIconUrl ??
|
||||||
|
vi.fn(async (url: string) => `https://icons.invalid/${encodeURIComponent(url)}`),
|
||||||
|
fetchIconBytes:
|
||||||
|
overrides.fetchIconBytes ??
|
||||||
|
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||||
|
it('scheitert an "Cannot read properties of undefined", wenn ein Favoritenzugriff versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
expect(prisma.favoriteLink).toBeUndefined();
|
||||||
|
expect(prisma.widgetInstance).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('liefert nur die Zeilen von user-a1 fuer widget-a1, sortiert nach position, dann title', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 1 },
|
||||||
|
{ id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
|
||||||
|
{ id: 'f3', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
|
||||||
|
{ id: 'f4', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a2', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
|
||||||
|
]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
const result = await service.list('t1', 'user-a1', 'widget-a1');
|
||||||
|
|
||||||
|
expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']);
|
||||||
|
expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
|
||||||
|
]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
const result = await service.list('t2', 'user-a1', 'widget-a1');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft BadRequestException ohne widgetId, OHNE einen Klienten zu erzeugen', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.list('t1', 'user-a1', '')).rejects.toThrow(BadRequestException);
|
||||||
|
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('normalisiert die URL, sucht das Icon mit der NORMALISIERTEN URL, und legt mit tenantId/userId/widgetId/position=0 an, wenn iconUrl fehlt', async () => {
|
||||||
|
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const created = await service.create('t1', 'user-a1', {
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'CTL',
|
||||||
|
url: 'ctl.de',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith('https://ctl.de');
|
||||||
|
expect(created.url).toBe('https://ctl.de');
|
||||||
|
expect(created.userId).toBe('user-a1');
|
||||||
|
expect(created.tenantId).toBe('t1');
|
||||||
|
expect(created.position).toBe(0);
|
||||||
|
expectBoundCall(prisma, 't1', 'favoriteLink', 'create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sucht KEIN Icon, wenn iconUrl uebergeben wird — gespeicherter Wert bleibt wie uebergeben', async () => {
|
||||||
|
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const created = await service.create('t1', 'user-a1', {
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'CTL',
|
||||||
|
url: 'https://ctl.de',
|
||||||
|
iconUrl: 'https://ctl.de/favicon.ico',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||||
|
expect(created.iconUrl).toBe('https://ctl.de/favicon.ico');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T-GWH-05: widgetId gehoert einem ANDEREN Benutzer desselben Mandanten -> NotFoundException "Widget not found", KEIN create, KEINE Icon-Suche', async () => {
|
||||||
|
const prisma = makeFakePrisma([], [{ id: 'widget-a2', userId: 'user-a2', tenantId: 't1' }]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'user-a1', { widgetId: 'widget-a2', title: 'X', url: 'https://x.invalid' } as any),
|
||||||
|
).rejects.toThrow('Widget not found');
|
||||||
|
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__favorites.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T-GWH-05: widgetId gehoert einem Widget unter FREMDEM Mandanten -> dieselbe NotFoundException, nennt weder Halter noch Mandant', async () => {
|
||||||
|
const prisma = makeFakePrisma([], [{ id: 'widget-b1', userId: 'user-b1', tenantId: 't2' }]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any),
|
||||||
|
).rejects.toThrow('Widget not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T-GWH-05: unbekannte widgetId -> dieselbe NotFoundException', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'user-a1', { widgetId: 'widget-fehlt', title: 'X', url: 'https://x.invalid' } as any),
|
||||||
|
).rejects.toThrow('Widget not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Wachhund: genau EIN gebundener Klient je create-Aufruf, Widget-Pruefung UND Schreibzugriff auf DEMSELBEN Klienten', async () => {
|
||||||
|
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid' } as any);
|
||||||
|
|
||||||
|
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
|
||||||
|
expect(prisma.__boundCallLog.filter((c: BoundCall) => c.tenantId === 't1')).toEqual([
|
||||||
|
{ tenantId: 't1', model: 'widgetInstance', method: 'findUnique' },
|
||||||
|
{ tenantId: 't1', model: 'favoriteLink', method: 'create' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
const baseRow: FakeFavoriteRow = {
|
||||||
|
id: 'f1',
|
||||||
|
userId: 'user-a1',
|
||||||
|
tenantId: 't1',
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'Alt',
|
||||||
|
url: 'https://alt.invalid',
|
||||||
|
iconUrl: 'https://alt.invalid/icon.png',
|
||||||
|
position: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('mergt Titel/URL/Position fuer die eigene Zeile, ueber DEMSELBEN gebundenen Klienten', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu', position: 3 } as any);
|
||||||
|
|
||||||
|
expect(updated.title).toBe('Neu');
|
||||||
|
expect(updated.position).toBe(3);
|
||||||
|
expectBoundCall(prisma, 't1', 'favoriteLink', 'findUnique');
|
||||||
|
expectBoundCall(prisma, 't1', 'favoriteLink', 'update');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('iconUrl explizit null im DTO loest eine Icon-Suche gegen die EFFEKTIVE URL aus', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await service.update('t1', 'f1', 'user-a1', { iconUrl: null } as any);
|
||||||
|
|
||||||
|
expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith(baseRow.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('iconUrl gesetzt im DTO -> KEINE Icon-Suche', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any);
|
||||||
|
|
||||||
|
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||||
|
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Zeile eines ANDEREN Benutzers desselben Mandanten -> NotFoundException, KEIN Schreibzugriff', async () => {
|
||||||
|
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.update('t1', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow(
|
||||||
|
'FavoriteLink not found',
|
||||||
|
);
|
||||||
|
expect(prisma.__favorites.get('f1')?.title).toBe('Alt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Zeile unter FREMDEM Mandanten -> NotFoundException, KEIN Schreibzugriff — der gebundene findUnique liefert null, bevor irgendetwas geschrieben wird', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.update('t2', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
expect(prisma.__favorites.get('f1')?.title).toBe('Alt');
|
||||||
|
expect(
|
||||||
|
prisma.__boundCallLog.some((c: BoundCall) => c.tenantId === 't2' && c.method === 'update'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
const baseRow: FakeFavoriteRow = {
|
||||||
|
id: 'f1',
|
||||||
|
userId: 'user-a1',
|
||||||
|
tenantId: 't1',
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'X',
|
||||||
|
url: 'https://x.invalid',
|
||||||
|
iconUrl: null,
|
||||||
|
position: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('loescht die eigene Zeile', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await service.remove('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Benutzer -> NotFoundException, Zeile bleibt', async () => {
|
||||||
|
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.remove('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found');
|
||||||
|
expect(prisma.__favorites.has('f1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Mandant -> NotFoundException, Zeile bleibt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.remove('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||||
|
expect(prisma.__favorites.has('f1')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIconBytes', () => {
|
||||||
|
const baseRow: FakeFavoriteRow = {
|
||||||
|
id: 'f1',
|
||||||
|
userId: 'user-a1',
|
||||||
|
tenantId: 't1',
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'X',
|
||||||
|
url: 'https://x.invalid',
|
||||||
|
iconUrl: 'https://x.invalid/icon.png',
|
||||||
|
position: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('ruft fetchIconBytes GENAU mit der GESPEICHERTEN URL auf und reicht die Rueckgabe durch', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
|
||||||
|
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Zeile ohne iconUrl -> NotFoundException, fetchIconBytes NICHT aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([{ ...baseRow, iconUrl: null }]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found');
|
||||||
|
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Mandant -> NotFoundException', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
fetchIconBytes: vi.fn(async () => {
|
||||||
|
throw new Error('upstream unreachable');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(HttpException);
|
||||||
|
try {
|
||||||
|
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
} catch (err) {
|
||||||
|
expect((err as HttpException).getStatus()).toBe(502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Wachhund je Methode', () => {
|
||||||
|
it('genau EIN gebundener Klient je Aufruf von list/update/remove/getIconBytes', async () => {
|
||||||
|
const baseRow: FakeFavoriteRow = {
|
||||||
|
id: 'f1',
|
||||||
|
userId: 'user-a1',
|
||||||
|
tenantId: 't1',
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'X',
|
||||||
|
url: 'https://x.invalid',
|
||||||
|
iconUrl: 'https://x.invalid/icon.png',
|
||||||
|
position: 0,
|
||||||
|
};
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
for (const call of [
|
||||||
|
() => service.list('t1', 'user-a1', 'widget-a1'),
|
||||||
|
() => service.update('t1', 'f1', 'user-a1', { title: 'Y' } as any),
|
||||||
|
() => service.getIconBytes('t1', 'f1', 'user-a1'),
|
||||||
|
() => service.remove('t1', 'f1', 'user-a1'),
|
||||||
|
]) {
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await call().catch(() => undefined);
|
||||||
|
expect(
|
||||||
|
vi.mocked(forTenant).mock.calls.length,
|
||||||
|
`Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`,
|
||||||
|
).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as nodemailer from 'nodemailer';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR
|
||||||
|
* diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit
|
||||||
|
* einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau
|
||||||
|
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) —
|
||||||
|
* KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet
|
||||||
|
* AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener
|
||||||
|
* Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg
|
||||||
|
* ("X is not a function" statt eines stillen Fallbacks).
|
||||||
|
*
|
||||||
|
* `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal
|
||||||
|
* gibt es keinen `mailhog`).
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockVerify = vi.fn(async () => true);
|
||||||
|
let mockSendMail = vi.fn(async () => ({}));
|
||||||
|
vi.mock('nodemailer', () => ({
|
||||||
|
createTransport: vi.fn(() => ({
|
||||||
|
verify: (...args: unknown[]) => (mockVerify as any)(...args),
|
||||||
|
sendMail: (...args: unknown[]) => (mockSendMail as any)(...args),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface FakeSmtpRow {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
encryption: string;
|
||||||
|
username: string | null;
|
||||||
|
encryptedPassword: string | null;
|
||||||
|
fromAddress: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoundCall {
|
||||||
|
tenantId: string;
|
||||||
|
model: 'smtpConfig';
|
||||||
|
method: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelect(row: Record<string, unknown>, select?: Record<string, boolean>) {
|
||||||
|
if (!select) return { ...row };
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(select)) {
|
||||||
|
if (select[key]) out[key] = row[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakePrisma(rows: FakeSmtpRow[] = []) {
|
||||||
|
const configs = new Map(rows.map((r) => [r.tenantId, { ...r }]));
|
||||||
|
const boundCallLog: BoundCall[] = [];
|
||||||
|
let autoId = rows.length;
|
||||||
|
|
||||||
|
const unboundSmtpConfig = {
|
||||||
|
findFirst: vi.fn(async () => {
|
||||||
|
const first = configs.values().next().value;
|
||||||
|
return first ? { ...first } : null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeScopedSmtpConfig(tenantId: string) {
|
||||||
|
return {
|
||||||
|
findUnique: async ({ where, select }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'findUnique' });
|
||||||
|
const row = configs.get(where.tenantId);
|
||||||
|
if (!row || row.tenantId !== tenantId) return null;
|
||||||
|
return applySelect(row, select);
|
||||||
|
},
|
||||||
|
upsert: async ({ where, create, update, select }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'upsert' });
|
||||||
|
if (where.tenantId !== tenantId) return null;
|
||||||
|
const existing = configs.get(tenantId);
|
||||||
|
const record = existing
|
||||||
|
? { ...existing, ...update }
|
||||||
|
: { id: `smtp-${++autoId}`, createdAt: new Date(), ...create };
|
||||||
|
record.updatedAt = new Date();
|
||||||
|
configs.set(tenantId, record);
|
||||||
|
return applySelect(record, select);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fake: any = {
|
||||||
|
smtpConfig: unboundSmtpConfig,
|
||||||
|
__configs: configs,
|
||||||
|
__boundCallLog: boundCallLog,
|
||||||
|
__makeBoundClient(tenantId: string) {
|
||||||
|
return { smtpConfig: makeScopedSmtpConfig(tenantId) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBoundCall(prisma: any, tenantId: string, method: string) {
|
||||||
|
const found = prisma.__boundCallLog.some(
|
||||||
|
(c: BoundCall) => c.tenantId === tenantId && c.model === 'smtpConfig' && c.method === method,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
found,
|
||||||
|
`erwarteter gebundener Aufruf smtpConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeCrypto() {
|
||||||
|
return {
|
||||||
|
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||||
|
decrypt: vi.fn((s: string) => s.slice(4, -1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockVerify = vi.fn(async () => true);
|
||||||
|
mockSendMail = vi.fn(async () => ({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
|
||||||
|
it('scheitert an "X is not a function", wenn getSmtpConfig versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
expect((prisma.smtpConfig as any).findUnique).toBeUndefined();
|
||||||
|
expect((prisma.smtpConfig as any).upsert).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSmtpConfig', () => {
|
||||||
|
it('liefert die Zeile mit encryptedPassword (fuer hasPassword), ohne Felder ausserhalb des select', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'user-a',
|
||||||
|
encryptedPassword: 'enc(geheim)',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new SettingsService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.getSmtpConfig('t1');
|
||||||
|
|
||||||
|
expect(result?.encryptedPassword).toBe('enc(geheim)');
|
||||||
|
expect(result?.host).toBe('smtp-a.example.invalid');
|
||||||
|
expectBoundCall(prisma, 't1', 'findUnique');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Mandant: null', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: null,
|
||||||
|
encryptedPassword: null,
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
const result = await service.getSmtpConfig('t2');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveSmtpConfig', () => {
|
||||||
|
it('mit Kennwort: encrypt wird GENAU mit dem Klartext aufgerufen, upsert traegt encryptedPassword, Rueckgabe OHNE encryptedPassword', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new SettingsService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.saveSmtpConfig('t1', {
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'user-a',
|
||||||
|
password: 'klartext-geheim',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(crypto.encrypt).toHaveBeenCalledWith('klartext-geheim');
|
||||||
|
expect((result as any).encryptedPassword).toBeUndefined();
|
||||||
|
const stored = prisma.__configs.get('t1');
|
||||||
|
expect(stored.encryptedPassword).toBe('enc(klartext-geheim)');
|
||||||
|
expectBoundCall(prisma, 't1', 'upsert');
|
||||||
|
const logged = JSON.stringify(prisma.__boundCallLog);
|
||||||
|
expect(logged).not.toContain('klartext-geheim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Kennwort (leer oder fehlend): encrypt NICHT aufgerufen, update traegt KEINEN Schluessel encryptedPassword, username fehlend -> null', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'alt.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'alt-user',
|
||||||
|
encryptedPassword: 'enc(alt-geheim)',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new SettingsService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
await service.saveSmtpConfig('t1', {
|
||||||
|
host: 'neu.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
password: '',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(crypto.encrypt).not.toHaveBeenCalled();
|
||||||
|
const stored = prisma.__configs.get('t1');
|
||||||
|
expect(stored.encryptedPassword).toBe('enc(alt-geheim)'); // bestehendes bleibt
|
||||||
|
expect(stored.username).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unter t2, wenn nur t1 eine Zeile hat: legt fuer t2 an, t1 bleibt unveraendert (Semantik nach der Bindung)', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: null,
|
||||||
|
encryptedPassword: null,
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
await service.saveSmtpConfig('t2', {
|
||||||
|
host: 'b.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
fromAddress: 'b@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.__configs.get('t1').host).toBe('a.example.invalid');
|
||||||
|
expect(prisma.__configs.get('t2').host).toBe('b.example.invalid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDecryptedSmtpConfig', () => {
|
||||||
|
it('entschluesselt encryptedPassword, liefert die Form die tender-mail.service.ts erwartet', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'user-a',
|
||||||
|
encryptedPassword: 'enc(geheim)',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new SettingsService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.getDecryptedSmtpConfig('t1');
|
||||||
|
|
||||||
|
expect(crypto.decrypt).toHaveBeenCalledWith('enc(geheim)');
|
||||||
|
expect(result).toEqual({
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'user-a',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
decryptedPassword: 'geheim',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne encryptedPassword: decryptedPassword null, decrypt NICHT aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: null,
|
||||||
|
encryptedPassword: null,
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new SettingsService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.getDecryptedSmtpConfig('t1');
|
||||||
|
|
||||||
|
expect(result?.decryptedPassword).toBeNull();
|
||||||
|
expect(crypto.decrypt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T-GWH-01: fremder Mandant -> null, decrypt NICHT aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'user-a',
|
||||||
|
encryptedPassword: 'enc(geheim)',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new SettingsService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.getDecryptedSmtpConfig('t2');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(crypto.decrypt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('testSmtpConfig', () => {
|
||||||
|
const storedRow: FakeSmtpRow = {
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'user-a',
|
||||||
|
encryptedPassword: 'enc(geheim)',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('ohne Kennwort/Benutzername im DTO: greift auf die gespeicherten Werte zurueck, ruft verify auf, { success: true }', async () => {
|
||||||
|
const prisma = makeFakePrisma([storedRow]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
const result = await service.testSmtpConfig('t1', {
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(mockVerify).toHaveBeenCalled();
|
||||||
|
expect(mockSendMail).not.toHaveBeenCalled();
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ auth: { user: 'user-a', pass: 'geheim' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit testTo: sendMail statt verify, from/to aus dto', async () => {
|
||||||
|
const prisma = makeFakePrisma([storedRow]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
await service.testSmtpConfig('t1', {
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
testTo: 'ziel@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mockSendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ from: 'a@example.invalid', to: 'ziel@example.invalid' }),
|
||||||
|
);
|
||||||
|
expect(mockVerify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verify wirft -> { success: false }, kein Wurf nach aussen', async () => {
|
||||||
|
mockVerify = vi.fn(async () => {
|
||||||
|
throw new Error('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
const prisma = makeFakePrisma([storedRow]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
const result = await service.testSmtpConfig('t1', {
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Mandant ohne Kennwort im DTO: auth ohne Benutzer, kein Fehler', async () => {
|
||||||
|
const prisma = makeFakePrisma([storedRow]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
await service.testSmtpConfig('t2', {
|
||||||
|
host: 'x.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
fromAddress: 'x@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ auth: undefined }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadAnySmtpConfigForStartupTransport (Startpfad, bewusst ungebunden)', () => {
|
||||||
|
it('laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert secure/requireTLS/entschluesseltes Kennwort', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 465,
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
username: 'user-a',
|
||||||
|
encryptedPassword: 'enc(geheim)',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new SettingsService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.loadAnySmtpConfigForStartupTransport();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
requireTLS: false,
|
||||||
|
username: 'user-a',
|
||||||
|
password: 'geheim',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requireTLS bei starttls', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: null,
|
||||||
|
encryptedPassword: null,
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
const result = await service.loadAnySmtpConfigForStartupTransport();
|
||||||
|
|
||||||
|
expect(result?.secure).toBe(false);
|
||||||
|
expect(result?.requireTLS).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leerer Nachbau -> null', async () => {
|
||||||
|
const prisma = makeFakePrisma([]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
const result = await service.loadAnySmtpConfigForStartupTransport();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Null-Klienten-Nachweis: der Startpfad erzeugt KEINEN gebundenen Klienten (gemessen, nicht behauptet)', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
{
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: null,
|
||||||
|
encryptedPassword: null,
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await service.loadAnySmtpConfigForStartupTransport();
|
||||||
|
|
||||||
|
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Wachhund je Anfrageweg', () => {
|
||||||
|
const storedRow: FakeSmtpRow = {
|
||||||
|
id: 'smtp-a',
|
||||||
|
tenantId: 't1',
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'user-a',
|
||||||
|
encryptedPassword: 'enc(geheim)',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('genau EIN gebundener Klient je Aufruf von getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig', async () => {
|
||||||
|
const prisma = makeFakePrisma([storedRow]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
for (const call of [
|
||||||
|
() => service.getSmtpConfig('t1'),
|
||||||
|
() =>
|
||||||
|
service.saveSmtpConfig('t1', {
|
||||||
|
host: 'x.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
fromAddress: 'x@invalid.de',
|
||||||
|
} as any),
|
||||||
|
() => service.getDecryptedSmtpConfig('t1'),
|
||||||
|
]) {
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await call();
|
||||||
|
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testSmtpConfig erzeugt genau einen gebundenen Klienten, wenn es auf gespeicherte Zugangsdaten zurueckgreift', async () => {
|
||||||
|
const prisma = makeFakePrisma([storedRow]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await service.testSmtpConfig('t1', {
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testSmtpConfig erzeugt KEINEN gebundenen Klienten, wenn Kennwort und Benutzername im DTO stehen', async () => {
|
||||||
|
const prisma = makeFakePrisma([storedRow]);
|
||||||
|
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
|
||||||
|
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await service.testSmtpConfig('t1', {
|
||||||
|
host: 'smtp-a.example.invalid',
|
||||||
|
port: 587,
|
||||||
|
encryption: 'starttls',
|
||||||
|
username: 'anderer-user',
|
||||||
|
password: 'anderes-kennwort',
|
||||||
|
fromAddress: 'a@example.invalid',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user