07fc653f52
- 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
560 lines
24 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|