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) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user