Files
tessera-ctl/apps/api/src/calendar/calendar.service.spec.ts
T
schalli 07fc653f52 feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht
- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6,
  dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2,
  tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument
  durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst,
  Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein
  Controller angefasst, keine anwendungsseitige userId-Filterung entfernt.
- rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration
  umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/
  TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in
  runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/
  WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates).
  SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln).
  runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue
  Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen
  Tabellen inkl. gemeinsame-Zeile-Pruefungen).
- Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance,
  searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) —
  alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT
  Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung.
- Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug
  203/203 bestanden (vorher 146).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 17:39:17 +02:00

560 lines
24 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
/**
* CalendarService.spec — Zwei-Klienten-Nachweis fuer die Bindung an
* forTenant() (260911-cwh, Aufgabe 2). Dieser Bereich hatte VOR diesem
* Durchlauf KEINE einzige Testdatei (Befund C) — dieser Fake ist deshalb
* die Voraussetzung dafuer, dass irgendeine Aussage dieses Plans
* nachpruefbar ist, nicht eine Zugabe.
*
* Muster wie `dkv.service.spec.ts` (260909-mir): `__makeBoundClient(tenantId)`
* wrappt DIESELBEN In-Memory-Zeilen mit einer protokollierenden Schicht fuer
* `calendarSource`. Der ungebundene Fake protokolliert NICHT, der gebundene
* schon — eine vergessene Bindung wird dadurch sichtbar, ein reiner
* Identitaets-Mock (`(p) => p`) wuerde das nicht leisten. Der Wachhund
* "genau ein Klient je Aufruf" folgt `dashboard.service.spec.ts` (Zeile
* 545): `vi.mocked(forTenant).mock.calls.length` wird nach jedem Aufruf
* geprueft.
*
* Die drei Provider (ICS/CalDAV/Exchange) reden mit echten Servern und
* werden NICHT ausgeuebt — nur als `vi.fn()`-Attrappen fuer
* `fetchEvents`/`testConnection` eingebunden.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CalendarService } from './calendar.service';
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (select[key]) out[key] = (row as any)[key];
}
return out;
}
function makeDefaultSourceFields() {
const now = new Date('2026-01-01T00:00:00.000Z');
return {
name: 'Testquelle',
type: 'ics',
exchangeMode: null,
domain: null,
url: 'https://example.invalid/cal.ics',
username: null,
encryptedPassword: null,
color: '#3B82F6',
isVisible: true,
syncIntervalMin: 15,
lastSyncAt: null,
lastSyncError: null,
createdAt: now,
updatedAt: now,
};
}
/**
* Handgerollter Prisma-Nachbau mit In-Memory-Zeilen fuer `calendarSource`
* (`findMany`, `findUnique`, `create`, `update`, `delete`). Die ungebundene
* Form protokolliert NICHT; `__makeBoundClient(tenantId)` liefert eine
* ZWEITE, protokollierende Schicht ueber denselben Zeilen.
*/
function makeFakePrisma() {
const sources = new Map<string, any>(); // key: id
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
let autoId = 0;
const calendarSource = {
findMany: vi.fn(
async ({
where,
select,
orderBy,
}: { where?: Record<string, unknown>; select?: Record<string, boolean>; orderBy?: { createdAt?: string } } = {}) => {
let rows = Array.from(sources.values());
if (where) {
rows = rows.filter((r) =>
Object.entries(where).every(([k, v]) => (r as any)[k] === v),
);
}
if (orderBy?.createdAt === 'asc') {
rows = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}
return rows.map((r) => _applySelect(r, select));
},
),
findUnique: vi.fn(
async ({ where, select }: { where: { id: string }; select?: Record<string, boolean> }) => {
const row = sources.get(where.id);
return row ? _applySelect(row, select) : null;
},
),
create: vi.fn(
async ({
data,
select,
}: { data: Record<string, unknown>; select?: Record<string, boolean> }) => {
const id = (data.id as string) ?? `src-${++autoId}`;
const now = new Date();
const record = { ...makeDefaultSourceFields(), id, createdAt: now, updatedAt: now, ...data };
sources.set(id, record);
return _applySelect(record, select);
},
),
update: vi.fn(
async ({
where,
data,
select,
}: { where: { id: string }; data: Record<string, unknown>; select?: Record<string, boolean> }) => {
const existing = sources.get(where.id);
if (!existing) {
// Nachbau des in Aufgabe 1 gemessenen Wettlauf-Ergebnisses
// (`calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut`,
// 260911-cwh): PrismaClientKnownRequestError mit code P2025.
const err: any = new Error('An operation failed because it depends on one or more records that were required but not found.');
err.code = 'P2025';
throw err;
}
const record = { ...existing, ...data, updatedAt: new Date() };
sources.set(where.id, record);
return _applySelect(record, select);
},
),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
const existing = sources.get(where.id);
sources.delete(where.id);
return existing;
}),
};
const fake: any = {
calendarSource,
__boundCallLog: boundCallLog,
__seedSource(row: { id: string; userId: string; tenantId: string } & Partial<ReturnType<typeof makeDefaultSourceFields>>) {
sources.set(row.id, { ...makeDefaultSourceFields(), ...row });
},
__makeBoundClient(tenantId: string) {
const wrapModel = (model: Record<string, any>, modelName: string, methods: string[]) => {
const wrapped: any = {};
for (const method of methods) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: modelName, method });
return model[method](...args);
};
}
return wrapped;
};
return {
calendarSource: wrapModel(calendarSource, 'calendarSource', [
'findMany',
'findUnique',
'create',
'update',
'delete',
]),
};
},
};
return fake;
}
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => 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);
}
/** Umkehrbare Attrappen fuer CryptoService — kein echtes Verschluesseln. */
function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) {
return {
encrypt: overrides.encrypt ?? vi.fn((plain: string) => `enc(${plain})`),
decrypt: overrides.decrypt ?? vi.fn((stored: string) => stored.replace(/^enc\(/, '').replace(/\)$/, '')),
};
}
/** Attrappen fuer die drei Provider — NIE ausgeuebt, nur `vi.fn()`. */
function makeFakeProviders(
overrides: Partial<{ ics: any; caldav: any; exchange: any }> = {},
) {
const makeProvider = () => ({
fetchEvents: vi.fn(async () => []),
testConnection: vi.fn(async () => true),
});
return {
icsProvider: overrides.ics ?? makeProvider(),
caldavProvider: overrides.caldav ?? makeProvider(),
exchangeProvider: overrides.exchange ?? makeProvider(),
};
}
function makeCalendarService(
prisma: any,
overrides: Partial<{
encrypt: any;
decrypt: any;
icsProvider: any;
caldavProvider: any;
exchangeProvider: any;
}> = {},
) {
const crypto = makeFakeCrypto(overrides);
const { icsProvider, caldavProvider, exchangeProvider } = makeFakeProviders({
ics: overrides.icsProvider,
caldav: overrides.caldavProvider,
exchange: overrides.exchangeProvider,
});
const service = new CalendarService(
prisma,
crypto as any,
icsProvider as any,
caldavProvider as any,
exchangeProvider as any,
);
return { service, crypto, icsProvider, caldavProvider, exchangeProvider };
}
describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => {
// ─── getSources ────────────────────────────────────────────────────────
it('getSources: laeuft gebunden mit der uebergebenen Mandantenkennung im Protokoll; die Antwort traegt hasCredentials und NIE encryptedPassword', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim)' });
const { service } = makeCalendarService(prisma);
const result = await service.getSources('user-a1', 't1');
expectBoundCall(prisma, 't1', 'calendarSource', 'findMany');
expect(result).toHaveLength(1);
expect(result[0].hasCredentials).toBe(true);
expect((result[0] as any).encryptedPassword).toBeUndefined();
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
});
it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' });
prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1' });
const { service } = makeCalendarService(prisma);
const result = await service.getSources('user-a1', 't1');
expect(result.map((s: any) => s.id)).toEqual(['src-a1']);
});
// ─── addSource ─────────────────────────────────────────────────────────
it('addSource: gebunden, Mandantenkennung als Pflichtwert geschrieben, Passwort verschluesselt, Antwort ohne encryptedPassword', async () => {
const prisma = makeFakePrisma();
const { service, crypto } = makeCalendarService(prisma);
const created = await service.addSource('user-a1', 't1', {
name: 'Neue Quelle',
type: 'ics',
url: 'https://example.invalid/neu.ics',
password: 'geheim-123',
} as any);
expectBoundCall(prisma, 't1', 'calendarSource', 'create');
expect(prisma.calendarSource.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ tenantId: 't1', userId: 'user-a1' }) }),
);
expect(created.hasCredentials).toBe(true);
expect((created as any).encryptedPassword).toBeUndefined();
expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('geheim-123');
});
// ─── updateSource ──────────────────────────────────────────────────────
it('updateSource: BEIDE Abfragen (Nachschlagen und Aendern) stehen ueber DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung im Protokoll', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' });
const { service } = makeCalendarService(prisma);
await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Neuer Name' } as any);
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
});
it('updateSource, Zugangsdaten-Erhaltung — Feld FEHLT: encryptedPassword bleibt unveraendert, kein Lesezugriff laedt ein Passwort (Befund E)', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' });
const { service, crypto } = makeCalendarService(prisma);
const result = await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Umbenannt' } as any);
expect(result.hasCredentials).toBe(true);
expect(vi.mocked(crypto.decrypt)).not.toHaveBeenCalled();
expect(vi.mocked(crypto.encrypt)).not.toHaveBeenCalled();
const updateCall = vi.mocked(prisma.calendarSource.update).mock.calls[0][0] as any;
expect(updateCall.data).not.toHaveProperty('encryptedPassword');
});
it('updateSource, Zugangsdaten-Erhaltung — Feld LEER: encryptedPassword wird null', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' });
const { service } = makeCalendarService(prisma);
const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: '' } as any);
expect(result.hasCredentials).toBe(false);
});
it('updateSource, Zugangsdaten-Erhaltung — Feld GESETZT: encryptedPassword wird neu verschluesselt', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' });
const { service, crypto } = makeCalendarService(prisma);
const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: 'neu-geheim' } as any);
expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('neu-geheim');
expect(result.hasCredentials).toBe(true);
});
// ─── Besitzpruefungen (updateSource/deleteSource/testConnection) ──────
it('updateSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' });
const { service } = makeCalendarService(prisma);
await expect(
service.updateSource('src-b1', 'user-a1', 't1', { name: 'X' } as any),
).rejects.toThrow(ForbiddenException);
});
it('updateSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => {
const prisma = makeFakePrisma();
const { service } = makeCalendarService(prisma);
await expect(
service.updateSource('src-unbekannt', 'user-a1', 't1', { name: 'X' } as any),
).rejects.toThrow(NotFoundException);
});
it('deleteSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' });
const { service } = makeCalendarService(prisma);
await expect(service.deleteSource('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException);
});
it('deleteSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => {
const prisma = makeFakePrisma();
const { service } = makeCalendarService(prisma);
await expect(service.deleteSource('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException);
});
it('testConnection: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' });
const { service } = makeCalendarService(prisma);
await expect(service.testConnection('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException);
});
it('testConnection: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => {
const prisma = makeFakePrisma();
const { service } = makeCalendarService(prisma);
await expect(service.testConnection('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException);
});
// ─── deleteSource, positiver Pfad ──────────────────────────────────────
it('deleteSource: beide Abfragen (Nachschlagen und Loeschen) stehen ueber denselben gebundenen Klienten im Protokoll', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' });
const { service } = makeCalendarService(prisma);
await service.deleteSource('src-a1', 'user-a1', 't1');
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
expectBoundCall(prisma, 't1', 'calendarSource', 'delete');
});
// ─── testConnection, positive Pfade ────────────────────────────────────
it('testConnection, Erfolgspfad: alle Abfragen (Nachschlagen, Rueckschreiben bei Erfolg) stehen ueber denselben gebundenen Klienten; der Provider erhaelt das ENTSCHLUESSELTE Passwort', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' });
const { service, icsProvider } = makeCalendarService(prisma);
const result = await service.testConnection('src-a1', 'user-a1', 't1');
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
expect(result.success).toBe(true);
expect(vi.mocked(icsProvider.testConnection)).toHaveBeenCalledWith(
expect.objectContaining({ password: 'geheim-123' }),
);
});
it('testConnection, Fehlerpfad: alle Abfragen (Nachschlagen, Rueckschreiben im catch) stehen ueber denselben gebundenen Klienten; die Antwort ist generisch (kein Passwort, keine Serverdetails)', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' });
const icsProvider = {
fetchEvents: vi.fn(async () => []),
testConnection: vi.fn(async () => {
throw new Error('ECONNREFUSED mail.example.invalid:993 password=geheim-123');
}),
};
const { service } = makeCalendarService(prisma, { icsProvider });
const result = await service.testConnection('src-a1', 'user-a1', 't1');
expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique');
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
expect(result.success).toBe(false);
expect(result.error).toBe('Connection failed');
expect(result.error).not.toContain('geheim-123');
expect(result.error).not.toContain('mail.example.invalid');
});
// ─── aggregateEvents / fetchAndCacheEvents ────────────────────────────
it('aggregateEvents, Erfolgspfad: das Laden der Quellen UND die Synchronstatus-Rueckschreibung bei Erfolg stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
const { service } = makeCalendarService(prisma);
await service.aggregateEvents('user-a1', 't1');
expectBoundCall(prisma, 't1', 'calendarSource', 'findMany');
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
});
it('aggregateEvents, Fehlerpfad (Providerfehler): das Laden der Quellen UND die Synchronstatus-Rueckschreibung im catch stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
const icsProvider = {
fetchEvents: vi.fn(async () => {
throw new Error('Provider nicht erreichbar');
}),
testConnection: vi.fn(async () => true),
};
const { service } = makeCalendarService(prisma, { icsProvider });
const events = await service.aggregateEvents('user-a1', 't1');
expect(events).toEqual([]);
expectBoundCall(prisma, 't1', 'calendarSource', 'findMany');
expectBoundCall(prisma, 't1', 'calendarSource', 'update');
const updateCalls = prisma.__boundCallLog.filter(
(c: any) => c.tenantId === 't1' && c.model === 'calendarSource' && c.method === 'update',
);
expect(updateCalls.length).toBeGreaterThanOrEqual(1);
});
it('aggregateEvents ohne Quellen: Rueckgabe ist eine leere Liste, kein Fehler, und es wird KEIN Cache-Eintrag angelegt — die Deutung von Leere als Abwesenheit als heutiges Verhalten festgehalten', async () => {
const prisma = makeFakePrisma();
const { service } = makeCalendarService(prisma);
const first = await service.aggregateEvents('user-ohne-quellen', 't1');
expect(first).toEqual([]);
// Zweiter Aufruf misst erneut ueber die Datenbank — waere ein
// Cache-Eintrag angelegt worden, bliebe der Aufrufzaehler von
// findMany bei 1 stehen.
await service.aggregateEvents('user-ohne-quellen', 't1');
const findManyCalls = prisma.__boundCallLog.filter(
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
);
expect(findManyCalls.length).toBe(2);
});
// ─── Cache ──────────────────────────────────────────────────────────────
it('Cache: ein zweiter Aufruf innerhalb der Lebensdauer erzeugt keinen weiteren Datenbankzugriff', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
const { service } = makeCalendarService(prisma);
await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
const findManyCallsAfterFirst = prisma.__boundCallLog.filter(
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
).length;
await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
const findManyCallsAfterSecond = prisma.__boundCallLog.filter(
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
).length;
expect(findManyCallsAfterSecond).toBe(findManyCallsAfterFirst);
});
it('Cache: zwei VERSCHIEDENE Benutzerkennungen teilen sich keinen Eintrag — das Urteil aus Aufgabe 1 zum Cache-Schluessel, festgenagelt', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1', isVisible: true, type: 'ics' });
const { service } = makeCalendarService(prisma);
await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
const findManyCallsAfterUserA = prisma.__boundCallLog.filter(
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
).length;
await service.aggregateEvents('user-a2', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z');
const findManyCallsAfterUserB = prisma.__boundCallLog.filter(
(c: any) => c.model === 'calendarSource' && c.method === 'findMany',
).length;
expect(findManyCallsAfterUserB).toBe(findManyCallsAfterUserA + 1);
});
// ─── testConnectionFromConfig ───────────────────────────────────────────
it('testConnectionFromConfig: kein Datenbankzugriff, weder gebunden noch ungebunden — der Nachbau bleibt unberuehrt', async () => {
const prisma = makeFakePrisma();
const { service } = makeCalendarService(prisma);
await service.testConnectionFromConfig({
type: 'ics',
url: 'https://example.invalid/cal.ics',
} as any);
expect(prisma.__boundCallLog).toEqual([]);
expect(vi.mocked(prisma.calendarSource.findMany)).not.toHaveBeenCalled();
expect(vi.mocked(prisma.calendarSource.findUnique)).not.toHaveBeenCalled();
expect(vi.mocked(prisma.calendarSource.create)).not.toHaveBeenCalled();
});
// ─── Wachhund ────────────────────────────────────────────────────────
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' });
const { service } = makeCalendarService(prisma);
for (const call of [
() => service.getSources('user-a1', 't1'),
() => service.addSource('user-a1', 't1', { name: 'X', type: 'ics', url: 'https://example.invalid/x.ics' } as any),
() => service.updateSource('src-a1', 'user-a1', 't1', { name: 'Y' } as any),
() => service.testConnection('src-a1', 'user-a1', 't1'),
() => service.aggregateEvents('user-a1', 't1', '2026-02-01T00:00:00.000Z', '2026-02-02T00:00:00.000Z'),
() => service.deleteSource('src-a1', 'user-a1', 't1'),
]) {
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);
}
});
});