feat(quick-260911-cwh): Bereich calendar binden — alle 12 Zugriffe ueber forTenant() (Aufgabe 2)
- calendar.service.spec.ts: NEU, Zwei-Klienten-Nachweis ueber __makeBoundClient
(Muster dkv.service.spec.ts), Attrappen fuer CryptoService und die drei
Provider, 23 Testfaelle: getSources/addSource, updateSource inkl. drei
Erhaltungsfaelle, alle drei Besitzpruefungen je Ausnahmeart, testConnection
Erfolgs-/Fehlerpfad, aggregateEvents/fetchAndCacheEvents inkl. beider
Synchronstatus-Rueckschreibungen, Cache-Verhalten inkl. Nutzer-Trennung,
testConnectionFromConfig ohne DB-Zugriff, Wachhund fuer genau einen
gebundenen Klienten je Aufruf
- calendar.service.ts: alle 12 Zugriffe auf forTenant() umgestellt, ein
tenantPrisma-Klient je Methode (getSources, addSource, updateSource,
deleteSource, testConnection, fetchAndCacheEvents); Cache-Schluessel-Urteil
und die kein-sechster-Hintergrunddienst-Begruendung als Kommentare
festgehalten
- calendar.controller.ts: alle sechs kontextnutzenden Handler reichen
Benutzer- UND Mandantenkennung aus extractContext durch, keine neue
Vertrauensquelle
- Falsifizierungsnachweis durchgefuehrt: eine Rueckschreibung testweise
entbunden, benannter Test ging rot ("aggregateEvents, Erfolgspfad"), Fund
bestaetigt, zurueckgenommen
- docs/mandantentrennung-zugriffsklassifikation.md: Bestandsaufnahme-Zeile
calendar.service.ts/calendarSource auf gebunden gezogen
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,557 @@
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user