From b35edd5f313c4602410c97ed059d56f0a877be6c Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 25 Sep 2026 08:42:11 +0200 Subject: [PATCH] test(260925-bow): Versionsvergleich und Was-ist-neu-Endpunkte (rot) - parseReleaseVersion/compareReleaseVersions/getRunningRelease - GET me/release-notice, POST me/release-seen: Format, nicht ueber laufend, nie absenken, Mandanten- und Benutzerbindung, ReleaseSeenDto Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/health/release-version.spec.ts | 122 +++++++++++++ apps/api/src/user/user.controller.spec.ts | 187 +++++++++++++++++++- 2 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/health/release-version.spec.ts diff --git a/apps/api/src/health/release-version.spec.ts b/apps/api/src/health/release-version.spec.ts new file mode 100644 index 0000000..2b04842 --- /dev/null +++ b/apps/api/src/health/release-version.spec.ts @@ -0,0 +1,122 @@ +import { compareReleaseVersions, parseReleaseVersion } from '@tessera/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getRunningRelease } from './app-version'; + +/** + * Freigegebene Versionen (quick-260925-bow, D-02/D-06). + * + * `parseReleaseVersion` und `compareReleaseVersions` stehen EINMAL in + * `packages/shared/src/index.ts` und werden von API und Web benutzt. Getestet + * wird hier in der API-Suite, weil `packages/shared` keinen eigenen Testlauf + * hat (Vorbild `widget-module-map.spec.ts`). + * + * `getRunningRelease()` ist die einzige Quelle der laufenden Version fuer das + * "Was ist neu"-Fenster: `APP_VERSION` der API, auf X.Y.Z gekuerzt. + */ + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('parseReleaseVersion (quick-260925-bow)', () => { + it.each([ + ['v10.2.3', '10.2.3'], + ['10.2.3', '10.2.3'], + ['v10.2.3-5-gabc1234', '10.2.3'], + ['10.2.3-12-g0123456789abcdef', '10.2.3'], + ['010.02.3', '10.2.3'], + ['1.4.0', '1.4.0'], + ])('%s → %s', (raw, expected) => { + expect(parseReleaseVersion(raw)).toBe(expected); + }); + + it.each([ + ['dev'], + [''], + ['abc1234'], + ['10.2'], + ['10.2.3-rc.1'], + ['10.2.3-dirty'], + [' 10.2.3'], + ['10.2.3 '], + ['V10.2.3'], + ['vv10.2.3'], + ['10.2.3.4'], + ['1234567.0.0'], + ['10.2.3-5-gxyz1234'], + ['10.2.3-5-gabc'], + ['10.2.3-g0123456'], + ])('%j → null', (raw) => { + expect(parseReleaseVersion(raw)).toBeNull(); + }); + + it('Eingaben ueber 64 Zeichen ergeben null, auch wenn das Muster sonst passen wuerde', () => { + const long = `1.2.3-5-g${'a'.repeat(40)}`; + expect(long.length).toBeLessThanOrEqual(64); + expect(parseReleaseVersion(long)).toBe('1.2.3'); + expect(parseReleaseVersion(`${'1'.repeat(70)}.0.0`)).toBeNull(); + expect(parseReleaseVersion('x'.repeat(10_000))).toBeNull(); + }); + + it('Nicht-Zeichenketten ergeben null statt eines Fehlers', () => { + expect(parseReleaseVersion(undefined as unknown as string)).toBeNull(); + expect(parseReleaseVersion(null as unknown as string)).toBeNull(); + expect(parseReleaseVersion(123 as unknown as string)).toBeNull(); + }); +}); + +describe('compareReleaseVersions (quick-260925-bow)', () => { + it('vergleicht numerisch, nicht lexikografisch', () => { + expect(compareReleaseVersions('1.10.0', '1.9.0')).toBe(1); + expect(compareReleaseVersions('1.9.0', '1.10.0')).toBe(-1); + }); + + it('die hoehere Hauptversion gewinnt', () => { + expect(compareReleaseVersions('2.0.0', '1.99.99')).toBe(1); + expect(compareReleaseVersions('1.99.99', '2.0.0')).toBe(-1); + }); + + it('Patch-Stelle entscheidet bei gleicher Haupt- und Nebenversion', () => { + expect(compareReleaseVersions('1.3.1', '1.3.0')).toBe(1); + }); + + it('gleiche Versionen ergeben 0, auch mit v-Praefix und Describe-Anhang', () => { + expect(compareReleaseVersions('1.4.0', '1.4.0')).toBe(0); + expect(compareReleaseVersions('v10.2.3', '10.2.3')).toBe(0); + expect(compareReleaseVersions('v10.2.3-5-gabc1234', '10.2.3')).toBe(0); + }); + + it('wirft bei nicht parsebarer Eingabe', () => { + expect(() => compareReleaseVersions('dev', '1.0.0')).toThrow(); + expect(() => compareReleaseVersions('1.0.0', 'abc1234')).toThrow(); + }); +}); + +describe('getRunningRelease (quick-260925-bow)', () => { + it('liest APP_VERSION und kuerzt den Describe-Stand auf X.Y.Z', () => { + vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234'); + expect(getRunningRelease()).toBe('10.2.3'); + }); + + it('ein Tag-Stand ergibt die Version selbst', () => { + vi.stubEnv('APP_VERSION', 'v1.4.0'); + expect(getRunningRelease()).toBe('1.4.0'); + }); + + it('dev ergibt null', () => { + vi.stubEnv('APP_VERSION', 'dev'); + expect(getRunningRelease()).toBeNull(); + }); + + it('ungesetzt oder leer ergibt null', () => { + vi.stubEnv('APP_VERSION', undefined); + expect(getRunningRelease()).toBeNull(); + vi.stubEnv('APP_VERSION', ''); + expect(getRunningRelease()).toBeNull(); + }); + + it('ein blosser Commit-Stempel ergibt null', () => { + vi.stubEnv('APP_VERSION', 'abc1234'); + expect(getRunningRelease()).toBeNull(); + }); +}); diff --git a/apps/api/src/user/user.controller.spec.ts b/apps/api/src/user/user.controller.spec.ts index 63796e0..fd2ab74 100644 --- a/apps/api/src/user/user.controller.spec.ts +++ b/apps/api/src/user/user.controller.spec.ts @@ -1,6 +1,9 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Role } from '@prisma/client'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ReleaseSeenDto } from './dto/release-seen.dto'; import { UserController } from './user.controller'; /** @@ -397,4 +400,184 @@ describe('UserController', () => { expect(userService.delete).not.toHaveBeenCalled(); }); }); + /** + * quick-260925-bow: "Was ist neu"-Fenster. Beide Wege lesen und schreiben + * ausschliesslich die Zeile des angemeldeten Benutzers ueber den an dessen + * Mandanten gebundenen Klienten; es gibt keinen Kennungsparameter. + * Die laufende Version kommt aus APP_VERSION (getRunningRelease()). + */ + describe('Was-ist-neu-Fenster (quick-260925-bow)', () => { + const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false }; + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function seedMe(lastSeenReleaseVersion: string | null, tenantId = 't1', id = 'u-me') { + prisma.__seedUser({ id, username: id, tenantId, lastSeenReleaseVersion }); + } + + describe('GET me/release-notice', () => { + it('liefert laufende Version und gemerkten Stand, gelesen ueber den gebundenen Klienten des eigenen Mandanten', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234'); + seedMe('10.1.0'); + + const result = await controller.getReleaseNotice(me); + + expect(result).toEqual({ currentRelease: '10.2.3', lastSeenReleaseVersion: '10.1.0' }); + expectBoundCall(prisma, 't1', 'user', 'findUnique'); + }); + + it('Bestandsbenutzer ohne gemerkten Stand: lastSeenReleaseVersion ist null; dev-Stand: currentRelease ist null', async () => { + vi.stubEnv('APP_VERSION', 'dev'); + seedMe(null); + + const result = await controller.getReleaseNotice(me); + + expect(result).toEqual({ currentRelease: null, lastSeenReleaseVersion: null }); + }); + + it('ein Benutzer derselben Kennung in einem fremden Mandanten ist unsichtbar: NotFoundException', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe('10.1.0', 't2'); + + await expect(controller.getReleaseNotice(me)).rejects.toBeInstanceOf(NotFoundException); + expectBoundCall(prisma, 't1', 'user', 'findUnique'); + }); + }); + + describe('POST me/release-seen', () => { + it('merkt die laufende Version und nennt den gespeicherten Stand', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe(null); + + const result = await controller.markReleaseSeen({ version: '10.2.3' }, me); + + expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' }); + const after = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } }); + expect(after.lastSeenReleaseVersion).toBe('10.2.3'); + expectBoundCall(prisma, 't1', 'user', 'findUnique'); + expectBoundCall(prisma, 't1', 'user', 'update'); + }); + + it('eine aeltere, aber nicht zu hohe Version wird angenommen, wenn sie ueber dem gemerkten Stand liegt', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234'); + seedMe('10.0.0'); + + const result = await controller.markReleaseSeen({ version: '10.1.0' }, me); + + expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.1.0' }); + }); + + it.each([ + ['v10.2.3'], + ['10.2'], + ['abc'], + ['10.2.3-5-gabc1234'], + ['010.2.3'], + [''], + [' 10.2.3'], + ])('nicht kanonisches Format %j → BadRequestException, nichts gespeichert', async (version) => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe(null); + + await expect(controller.markReleaseSeen({ version }, me)).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false); + }); + + it.each([[123], [null], [undefined], [{ version: '10.2.3' }], [['10.2.3']]])( + 'Nicht-Zeichenkette %j → BadRequestException', + async (version) => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe(null); + + await expect( + controller.markReleaseSeen({ version } as unknown as ReleaseSeenDto, me), + ).rejects.toBeInstanceOf(BadRequestException); + }, + ); + + it('eine Version ueber der laufenden → BadRequestException', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe(null); + + await expect(controller.markReleaseSeen({ version: '10.2.4' }, me)).rejects.toBeInstanceOf(BadRequestException); + await expect(controller.markReleaseSeen({ version: '11.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false); + }); + + it.each([['dev'], ['abc1234'], [undefined]])( + 'laufende Version nicht freigegeben (%j) → BadRequestException', + async (appVersion) => { + vi.stubEnv('APP_VERSION', appVersion); + seedMe(null); + + await expect(controller.markReleaseSeen({ version: '1.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException); + }, + ); + + it('ein gemerkter Stand wird nie abgesenkt', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe('10.2.3'); + + const result = await controller.markReleaseSeen({ version: '10.1.0' }, me); + + expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' }); + expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false); + }); + + it('ein unparsebarer gemerkter Wert wird ueberschrieben', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe('kaputt'); + + const result = await controller.markReleaseSeen({ version: '10.2.3' }, me); + + expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' }); + }); + + it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe(null, 't1', 'u-me'); + seedMe(null, 't2', 'u-other'); + + await controller.markReleaseSeen({ version: '10.2.3' }, me); + + const mine = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } }); + const other = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-other' } }); + expect(mine.lastSeenReleaseVersion).toBe('10.2.3'); + expect(other.lastSeenReleaseVersion).toBeNull(); + expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true); + }); + + it('fehlt die eigene Zeile im eigenen Mandanten (Kennung nur in fremdem Mandanten) → NotFoundException, nichts geaendert', async () => { + vi.stubEnv('APP_VERSION', 'v10.2.3'); + seedMe(null, 't2', 'u-me'); + + await expect(controller.markReleaseSeen({ version: '10.2.3' }, me)).rejects.toBeInstanceOf(NotFoundException); + const foreign = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-me' } }); + expect(foreign.lastSeenReleaseVersion).toBeNull(); + }); + }); + + describe('ReleaseSeenDto (globale ValidationPipe)', () => { + async function errorsFor(body: unknown) { + return validate(plainToInstance(ReleaseSeenDto, body)); + } + + it('nimmt die kanonische Form X.Y.Z an', async () => { + expect(await errorsFor({ version: '1.4.0' })).toHaveLength(0); + }); + + it.each([ + [{ version: 'v1.4.0' }], + [{ version: '1.4' }], + [{ version: '1.4.0-5-gabc1234' }], + [{ version: 123 }], + [{}], + [{ version: `1.4.0${'0'.repeat(40)}` }], + ])('lehnt %j ab', async (body) => { + expect((await errorsFor(body)).length).toBeGreaterThan(0); + }); + }); + }); });