feat(api): Favoriten — Symbol trotz Zertifikatsfehler holen, Reihenfolge per PUT /favorites/order speichern
- icon-discovery.service.ts: undicis eigenes fetch mit Modul-Singleton
LENIENT_TLS_AGENT (Agent({ connect: { rejectUnauthorized: false } }))
als dispatcher in fetchWithRedirectGuard, der einzigen Ausgangsstelle
fuer HTML-Ermittlung und Icon-Byte-Holen; SSRF-Schutz unveraendert
- undici 7.28.0 (bereits im Lockfile aufgeloest) als direkte Abhaengigkeit
von @tessera/api via pnpm add --offline
- PUT /favorites/order (ReorderFavoritesDto) vor den :id-Routen;
FavoritesService.reorder() setzt position=index fuer die Favoriten
eines Widgets in EINER withTenantTransaction, userId+widgetId in jeder
Bedingung (zweites Netz), eine BadRequestException fuer alle
Abweichungen (T-JDD-06)
- getIcon: X-Content-Type-Options nosniff + restriktive CSP (T-JDD-02)
- 10 neue Tests (3 Dispatcher, 7 reorder); volle API-Suite 68 Dateien/
1101 Tests und type-check gruen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
|
||||
@@ -14,9 +14,18 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
* 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`.
|
||||
*
|
||||
* 260917-jdd: `withTenantTransaction` kommt zum Mock hinzu (Muster
|
||||
* groups.service.spec.ts Z. 30-35/296-299) — `prisma.__withTenantTransaction`
|
||||
* reicht den gebundenen Klienten als `tx` durch und protokolliert den
|
||||
* Aufruf. Der Fake bekommt zusaetzlich `updateMany` auf `favoriteLink` fuer
|
||||
* `reorder()`.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||
withTenantTransaction: vi.fn((unboundClient: any, tenantId: string, fn: (tx: any) => any) =>
|
||||
unboundClient.__withTenantTransaction(tenantId, fn),
|
||||
),
|
||||
}));
|
||||
|
||||
interface FakeFavoriteRow {
|
||||
@@ -40,7 +49,7 @@ interface FakeWidgetRow {
|
||||
|
||||
interface BoundCall {
|
||||
tenantId: string;
|
||||
model: 'favoriteLink' | 'widgetInstance';
|
||||
model: 'favoriteLink' | 'widgetInstance' | '$transaction';
|
||||
method: string;
|
||||
}
|
||||
|
||||
@@ -117,6 +126,20 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
favorites.delete(where.id);
|
||||
return row;
|
||||
},
|
||||
// 260917-jdd: reorder() — filtert nach tenantId sowie, falls in
|
||||
// `where` vorhanden, id/userId/widgetId; wendet `data` auf jede
|
||||
// Treffer-Zeile an; liefert { count }.
|
||||
updateMany: async ({ where, data }: any) => {
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'updateMany' });
|
||||
let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
|
||||
if (where?.id) rows = rows.filter((f) => f.id === where.id);
|
||||
if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
|
||||
if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||
for (const row of rows) {
|
||||
favorites.set(row.id, { ...row, ...data, updatedAt: new Date() });
|
||||
}
|
||||
return { count: rows.length };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +169,10 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
widgetInstance: makeScopedWidgetInstance(tenantId),
|
||||
};
|
||||
},
|
||||
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
|
||||
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
|
||||
return fn(fake.__makeBoundClient(tenantId));
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
@@ -505,4 +532,97 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder (260917-jdd)', () => {
|
||||
const makeAltbestand = () =>
|
||||
makeFakePrisma([
|
||||
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f3', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
|
||||
{ id: 'f9', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
|
||||
]);
|
||||
|
||||
it('setzt position 0/1/2 in der uebergebenen Reihenfolge und liefert die Liste so sortiert', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const result = await service.reorder('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
ids: ['f3', 'f1', 'f2'],
|
||||
} as any);
|
||||
|
||||
expect(result.map((r: any) => r.id)).toEqual(['f3', 'f1', 'f2']);
|
||||
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f1').position).toBe(1);
|
||||
expect(prisma.__favorites.get('f2').position).toBe(2);
|
||||
expect(prisma.__favorites.get('f9').position).toBe(0);
|
||||
expect(vi.mocked(withTenantTransaction)).toHaveBeenCalledWith(prisma, 't1', expect.any(Function));
|
||||
expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany');
|
||||
});
|
||||
|
||||
it('fremde id (user-a2) -> BadRequestException, KEINE Position geaendert', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f9'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__favorites.get('f1').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f2').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||
});
|
||||
|
||||
it('unbekannte id -> BadRequestException', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f-fehlt'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('Teilmenge (2 von 3) -> BadRequestException', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('doppelte ids -> BadRequestException OHNE withTenantTransaction-Aufruf', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
vi.mocked(withTenantTransaction).mockClear();
|
||||
await expect(
|
||||
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f1', 'f2'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('fremder Mandant (t2 auf t1-Zeilen) -> BadRequestException, Positionen unveraendert', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__favorites.get('f1').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f2').position).toBe(0);
|
||||
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||
});
|
||||
|
||||
it('Wachhund: 0 forTenant-Aufrufe, genau 1 withTenantTransaction-Aufruf fuer den Happy Path', async () => {
|
||||
const prisma = makeAltbestand();
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
vi.mocked(withTenantTransaction).mockClear();
|
||||
await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] } as any);
|
||||
|
||||
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user