feat(quick-260914-ku1): Versionsstempel — GET /health/version aus APP_*, VersionResponse, app-version.ts und Abzeichen v<Version> · <Kanal> in der Seitenleiste
- packages/shared: VersionResponse { name, version, channel, commit, buildTime }
- apps/api: health/app-version.ts (getAppVersion mit ||-Vorgaben dev/dev, formatAppVersionLine),
HealthController.getVersion delegiert (bleibt @Public, T-KU1-03), main.ts protokolliert
"Tessera API <version> (<channel>) <commit>" beim Start; neuer Spec mit 6 Tests
- apps/web: lib/app-version.ts (NEXT_PUBLIC_APP_* mit vollem Literalnamen, loadApiVersion
memoisiert und still bei Fehler), AppVersionBadge unten in der Seitenleiste (auch mobil,
nicht eingeklappt), sidebar.channel.{beta,live,dev} in de/en; 5 + 4 + 1 neue Tests
- Suiten: API 65 Dateien / 1060 Tests, Web 40 / 243, tsc in shared/api/web Exit 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
import type { VersionResponse } from '@tessera/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versionsstempel der API (quick-260914-ku1).
|
||||||
|
*
|
||||||
|
* Woher die Werte kommen: das CI-Skript `.gitea/scripts/publish-images.sh`
|
||||||
|
* berechnet `APP_VERSION` (`git describe --tags --always`), `APP_CHANNEL`
|
||||||
|
* (`beta` fuer main, `live` fuer Tags v*), `APP_COMMIT` und `APP_BUILD_TIME`
|
||||||
|
* und uebergibt sie als `--build-arg`; die Runner-Stufe beider Dockerfiles
|
||||||
|
* setzt sie als `ENV`, sodass sie hier zur Laufzeit lesbar sind. Lokal ohne
|
||||||
|
* Build-Args greifen die Vorgaben `dev`/`dev`/``/``.
|
||||||
|
*
|
||||||
|
* Warum `||` statt `??`: Docker Compose reicht unbelegte Variablen als
|
||||||
|
* Leerstring weiter — ein leerer Wert muss wie ein fehlender zaehlen.
|
||||||
|
*
|
||||||
|
* `main.ts` protokolliert `formatAppVersionLine()` beim Start direkt nach der
|
||||||
|
* Port-Zeile, `HealthController.getVersion()` liefert `getAppVersion()`.
|
||||||
|
*/
|
||||||
|
export function getAppVersion(): VersionResponse {
|
||||||
|
return {
|
||||||
|
name: 'tessera',
|
||||||
|
version: process.env.APP_VERSION || 'dev',
|
||||||
|
channel: process.env.APP_CHANNEL || 'dev',
|
||||||
|
commit: process.env.APP_COMMIT || '',
|
||||||
|
buildTime: process.env.APP_BUILD_TIME || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAppVersionLine(v: VersionResponse = getAppVersion()): string {
|
||||||
|
const base = `Tessera API ${v.version} (${v.channel})`;
|
||||||
|
return v.commit ? `${base} ${v.commit}` : base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
|
||||||
|
import { formatAppVersionLine } from './app-version';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HealthController.spec — legt die Testlage fuer den Health-Bereich aus dem
|
||||||
|
* Nichts an (quick-260914-ku1, Aufgabe 1: vorher gab es keinen Spec).
|
||||||
|
*
|
||||||
|
* Gegenstand ist der Versionsstempel: `GET /health/version` liest
|
||||||
|
* `APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` aus der
|
||||||
|
* Laufzeit-Umgebung (gesetzt als `ENV` in der Runner-Stufe beider
|
||||||
|
* Dockerfiles, befuellt vom CI-Skript `.gitea/scripts/publish-images.sh`).
|
||||||
|
* Leere Zeichenketten zaehlen wie ungesetzt, weil Docker Compose unbelegte
|
||||||
|
* Variablen als Leerstring weiterreicht (siehe `migrate-and-start.sh`).
|
||||||
|
*
|
||||||
|
* Der Controller hat keine Abhaengigkeiten und wird direkt instanziiert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALL_VARS = ['APP_VERSION', 'APP_CHANNEL', 'APP_COMMIT', 'APP_BUILD_TIME'] as const;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubAll(value: string) {
|
||||||
|
for (const name of ALL_VARS) {
|
||||||
|
vi.stubEnv(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubSample() {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v1.2.3');
|
||||||
|
vi.stubEnv('APP_CHANNEL', 'live');
|
||||||
|
vi.stubEnv('APP_COMMIT', 'abc1234');
|
||||||
|
vi.stubEnv('APP_BUILD_TIME', '2026-09-14T12:00:00Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HealthController — Versionsstempel (quick-260914-ku1)', () => {
|
||||||
|
it('Test 1 (check): liefert status ok und einen Zeitstempel, den Date.parse versteht', () => {
|
||||||
|
const controller = new HealthController();
|
||||||
|
const result = controller.check();
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
expect(Number.isNaN(Date.parse(result.timestamp))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2 (Vorgaben): ohne die vier Variablen liefert getVersion() dev/dev und leere Kennungen', () => {
|
||||||
|
for (const name of ALL_VARS) {
|
||||||
|
vi.stubEnv(name, undefined);
|
||||||
|
}
|
||||||
|
const controller = new HealthController();
|
||||||
|
expect(controller.getVersion()).toEqual({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'dev',
|
||||||
|
channel: 'dev',
|
||||||
|
commit: '',
|
||||||
|
buildTime: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3 (durchgereicht): die vier Variablen kommen woertlich an, name bleibt tessera', () => {
|
||||||
|
stubSample();
|
||||||
|
const controller = new HealthController();
|
||||||
|
expect(controller.getVersion()).toEqual({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
channel: 'live',
|
||||||
|
commit: 'abc1234',
|
||||||
|
buildTime: '2026-09-14T12:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (Compose-Semantik): leere Zeichenketten zaehlen wie ungesetzt', () => {
|
||||||
|
stubAll('');
|
||||||
|
const controller = new HealthController();
|
||||||
|
expect(controller.getVersion()).toEqual({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'dev',
|
||||||
|
channel: 'dev',
|
||||||
|
commit: '',
|
||||||
|
buildTime: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5 (formatAppVersionLine): mit Commit angehaengt, ohne Commit kein Leerzeichen am Ende', () => {
|
||||||
|
stubSample();
|
||||||
|
expect(formatAppVersionLine()).toBe('Tessera API v1.2.3 (live) abc1234');
|
||||||
|
|
||||||
|
stubAll('');
|
||||||
|
expect(formatAppVersionLine()).toBe('Tessera API dev (dev)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6 (bewusst oeffentlich, T-KU1-03): getVersion und check tragen @Public()', () => {
|
||||||
|
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)).toBe(true);
|
||||||
|
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.check)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import type { HealthResponse } from '@tessera/shared';
|
import type { HealthResponse, VersionResponse } from '@tessera/shared';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
import { getAppVersion } from './app-version';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
@@ -13,12 +14,12 @@ export class HealthController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bewusst oeffentlich (T-KU1-03): Betreiber-Kontrolle per `curl` auf dem
|
||||||
|
// Server ohne Anmeldung. Es werden keine Versionen von Systemkomponenten
|
||||||
|
// preisgegeben; das Repository ist privat. Gepinnt durch Spec-Test 6.
|
||||||
@Public()
|
@Public()
|
||||||
@Get('version')
|
@Get('version')
|
||||||
getVersion() {
|
getVersion(): VersionResponse {
|
||||||
return {
|
return getAppVersion();
|
||||||
version: process.env.npm_package_version ?? '0.0.1',
|
|
||||||
name: 'tessera',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { formatAppVersionLine } from './health/app-version';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@@ -31,6 +32,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
await app.listen(3001);
|
await app.listen(3001);
|
||||||
console.log('Tessera API running on port 3001');
|
console.log('Tessera API running on port 3001');
|
||||||
|
console.log(formatAppVersionLine());
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppVersionBadge.test — die Versionszeile unten in der Seitenleiste
|
||||||
|
* (quick-260914-ku1). Vorlage: sidebar.test.tsx (next-intl-Mock, cleanup,
|
||||||
|
* dynamischer Import nach dem Setzen der Mocks).
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: (ns: string) => (key: string) => {
|
||||||
|
const map: Record<string, Record<string, string>> = {
|
||||||
|
sidebar: {
|
||||||
|
'channel.beta': 'Beta',
|
||||||
|
'channel.live': 'Live',
|
||||||
|
'channel.dev': 'Entwicklung',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return map[ns]?.[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockAppVersion = { version: 'v1.2.3', channel: 'beta' as 'beta' | 'live' | 'dev', commit: 'abc1234' };
|
||||||
|
const mockLoad = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/app-version', () => ({
|
||||||
|
get appVersion() {
|
||||||
|
return mockAppVersion;
|
||||||
|
},
|
||||||
|
loadApiVersion: () => mockLoad(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mockLoad.mockReset();
|
||||||
|
mockAppVersion = { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importBadge() {
|
||||||
|
const mod = await import('./app-version-badge');
|
||||||
|
return mod.AppVersionBadge;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppVersionBadge (quick-260914-ku1)', () => {
|
||||||
|
it('Test 1 (Zeile): zeigt Version und uebersetzten Kanal', async () => {
|
||||||
|
mockLoad.mockResolvedValue(null);
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('app-version')).toHaveTextContent('v1.2.3 · Beta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2 (Tooltip mit API): title nennt Commit und API-Version samt Kanal', async () => {
|
||||||
|
mockLoad.mockResolvedValue({
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
channel: 'beta',
|
||||||
|
commit: 'abc1234',
|
||||||
|
buildTime: '',
|
||||||
|
});
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const title = screen.getByTestId('app-version').getAttribute('title') ?? '';
|
||||||
|
expect(title).toContain('Commit abc1234');
|
||||||
|
expect(title).toContain('API v1.2.3 (beta)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3 (Tooltip ohne API): title nennt den Commit, aber keinen API-Teil', async () => {
|
||||||
|
mockLoad.mockResolvedValue(null);
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoad).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const title = screen.getByTestId('app-version').getAttribute('title') ?? '';
|
||||||
|
expect(title).toContain('Commit abc1234');
|
||||||
|
expect(title).not.toContain('API');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (Kanal dev, kein Commit): zeigt dev · Entwicklung und hat kein title-Attribut', async () => {
|
||||||
|
mockAppVersion = { version: 'dev', channel: 'dev', commit: '' };
|
||||||
|
mockLoad.mockResolvedValue(null);
|
||||||
|
const AppVersionBadge = await importBadge();
|
||||||
|
render(<AppVersionBadge />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoad).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const el = screen.getByTestId('app-version');
|
||||||
|
expect(el).toHaveTextContent('dev · Entwicklung');
|
||||||
|
expect(el.hasAttribute('title')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type ApiVersionInfo, appVersion, loadApiVersion } from '@/lib/app-version';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versionszeile unten in der Seitenleiste: `<Version> · <Kanal>`, z. B.
|
||||||
|
* `v1.0.0 · Beta` oder lokal `dev · Entwicklung` (quick-260914-ku1).
|
||||||
|
* Der Tooltip nennt den Web-Commit und, sobald geladen, die API-Version
|
||||||
|
* samt Kanal. "Commit" und "API" sind in beiden Sprachen gleich.
|
||||||
|
*/
|
||||||
|
export function AppVersionBadge() {
|
||||||
|
const t = useTranslations('sidebar');
|
||||||
|
const [api, setApi] = useState<ApiVersionInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
loadApiVersion().then((info) => {
|
||||||
|
if (active) setApi(info);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (appVersion.commit) parts.push(`Commit ${appVersion.commit}`);
|
||||||
|
if (api) parts.push(`API ${api.version} (${api.channel})`);
|
||||||
|
const title = parts.length > 0 ? parts.join(' · ') : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span data-testid="app-version" className="block truncate text-xs text-muted-foreground" title={title}>
|
||||||
|
{appVersion.version} · {t(`channel.${appVersion.channel}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,12 @@ vi.mock('@/components/layout/sidebar-footer', () => ({
|
|||||||
SidebarFooter: () => <div data-testid="sidebar-footer" />,
|
SidebarFooter: () => <div data-testid="sidebar-footer" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
|
||||||
|
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1).
|
||||||
|
vi.mock('@/components/layout/app-version-badge', () => ({
|
||||||
|
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
||||||
|
}));
|
||||||
|
|
||||||
const mockActiveModules = [
|
const mockActiveModules = [
|
||||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
||||||
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
||||||
@@ -178,4 +184,15 @@ describe('Sidebar', () => {
|
|||||||
expect(newCallCount).toBeGreaterThan(initialCallCount);
|
expect(newCallCount).toBeGreaterThan(initialCallCount);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the version badge below the navigation', async () => {
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('app-version-badge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TesseraLogo } from '@/components/brand/tessera-logo';
|
|||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||||
|
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
@@ -198,6 +199,13 @@ export function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
|
||||||
|
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="border-t border-sidebar-border px-4 py-2">
|
||||||
|
<AppVersionBadge />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app-version.test — Versionsstempel der Web-Oberflaeche (quick-260914-ku1).
|
||||||
|
*
|
||||||
|
* `appVersion` wird beim Laden des Moduls aus `NEXT_PUBLIC_APP_*` gelesen und
|
||||||
|
* `loadApiVersion()` memoisiert die Antwort von `GET /health/version` —
|
||||||
|
* deshalb setzt jeder Test die Module zurueck und importiert dynamisch,
|
||||||
|
* nachdem Umgebung und `fetch` gestubbt sind (Muster: module-access-gate.test.tsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importFresh() {
|
||||||
|
vi.resetModules();
|
||||||
|
return import('./app-version');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('app-version (quick-260914-ku1)', () => {
|
||||||
|
it('Test 1 (Vorgaben): ohne NEXT_PUBLIC_APP_* ist appVersion dev/dev ohne Commit', async () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', undefined);
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', undefined);
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', undefined);
|
||||||
|
const mod = await importFresh();
|
||||||
|
expect(mod.appVersion).toEqual({ version: 'dev', channel: 'dev', commit: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2 (Umgebung): NEXT_PUBLIC_APP_VERSION/_CHANNEL/_COMMIT werden woertlich durchgereicht', async () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', 'v1.2.3');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', 'beta');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', 'abc1234');
|
||||||
|
const mod = await importFresh();
|
||||||
|
expect(mod.appVersion).toEqual({ version: 'v1.2.3', channel: 'beta', commit: 'abc1234' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3 (Normalisierung): ein unbekannter Kanal wird zu dev', async () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', 'v1.2.3');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', 'gamma');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', 'abc1234');
|
||||||
|
const mod = await importFresh();
|
||||||
|
expect(mod.appVersion.channel).toBe('dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (Laden, memoisiert): zwei Aufrufe liefern das Objekt, fetch laeuft genau einmal mit Cookie', async () => {
|
||||||
|
const payload = {
|
||||||
|
name: 'tessera',
|
||||||
|
version: 'v1.2.3',
|
||||||
|
channel: 'beta',
|
||||||
|
commit: 'abc1234',
|
||||||
|
buildTime: 'x',
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const mod = await importFresh();
|
||||||
|
|
||||||
|
const first = await mod.loadApiVersion();
|
||||||
|
const second = await mod.loadApiVersion();
|
||||||
|
|
||||||
|
expect(first).toEqual(payload);
|
||||||
|
expect(second).toEqual(payload);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url.endsWith('/health/version')).toBe(true);
|
||||||
|
expect(options).toMatchObject({ credentials: 'include' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5 (still bei Fehler): Netzfehler und ok=false liefern null, nichts wird geworfen', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
|
||||||
|
const rejected = await importFresh();
|
||||||
|
await expect(rejected.loadApiVersion()).resolves.toBeNull();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
|
||||||
|
);
|
||||||
|
const notOk = await importFresh();
|
||||||
|
await expect(notOk.loadApiVersion()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Versionsstempel der Web-Oberflaeche (quick-260914-ku1).
|
||||||
|
*
|
||||||
|
* Quelle fuer das Abzeichen unten in der Seitenleiste (`AppVersionBadge`)
|
||||||
|
* UND fuer den kommenden Fehler-melden-Knopf: beide brauchen die Fassung,
|
||||||
|
* die der Anwender gerade benutzt.
|
||||||
|
*
|
||||||
|
* Wichtig: Next.js ersetzt `process.env.NEXT_PUBLIC_*` nur dann zur Bauzeit
|
||||||
|
* im Browser-Bundle, wenn der Ausdruck woertlich mit vollem Namen im Code
|
||||||
|
* steht — kein Destructuring, kein `process.env[name]`. Sonst ist der Wert
|
||||||
|
* im Browser leer. Die Werte setzt `apps/web/Dockerfile` (builder-Stufe)
|
||||||
|
* aus den Build-Args des CI-Skripts `.gitea/scripts/publish-images.sh`;
|
||||||
|
* lokal greifen die Vorgaben `dev`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AppChannel = 'beta' | 'live' | 'dev';
|
||||||
|
|
||||||
|
export interface AppVersionInfo {
|
||||||
|
version: string;
|
||||||
|
channel: AppChannel;
|
||||||
|
commit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spiegel von `VersionResponse` aus `packages/shared`: `apps/web` haengt
|
||||||
|
* nicht von `@tessera/shared` ab, und ein neuer Import wuerde Lockfile und
|
||||||
|
* die deps-Stufe des Dockerfiles aendern. Die API-Wahrheit bleibt dort.
|
||||||
|
*/
|
||||||
|
export interface ApiVersionInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
channel: string;
|
||||||
|
commit: string;
|
||||||
|
buildTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
function normalizeChannel(raw: string | undefined): AppChannel {
|
||||||
|
if (raw === 'beta' || raw === 'live') return raw;
|
||||||
|
return 'dev';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appVersion: AppVersionInfo = {
|
||||||
|
version: process.env.NEXT_PUBLIC_APP_VERSION || 'dev',
|
||||||
|
channel: normalizeChannel(process.env.NEXT_PUBLIC_APP_CHANNEL),
|
||||||
|
commit: process.env.NEXT_PUBLIC_APP_COMMIT || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt `GET /health/version` genau einmal je Seitenladung (memoisiert);
|
||||||
|
* jeder Fehler (Netz, Nicht-2xx) ist still und liefert `null`.
|
||||||
|
*/
|
||||||
|
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
|
||||||
|
if (!apiVersionPromise) {
|
||||||
|
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
|
||||||
|
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
return apiVersionPromise;
|
||||||
|
}
|
||||||
@@ -104,7 +104,12 @@
|
|||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"tenants": "Mandanten",
|
"tenants": "Mandanten",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"modules": "Module"
|
"modules": "Module",
|
||||||
|
"channel": {
|
||||||
|
"beta": "Beta",
|
||||||
|
"live": "Live",
|
||||||
|
"dev": "Entwicklung"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -104,7 +104,12 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"tenants": "Tenants",
|
"tenants": "Tenants",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"modules": "Modules"
|
"modules": "Modules",
|
||||||
|
"channel": {
|
||||||
|
"beta": "Beta",
|
||||||
|
"live": "Live",
|
||||||
|
"dev": "Development"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -4,3 +4,16 @@ export interface HealthResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antwort von `GET /health/version` (quick-260914-ku1).
|
||||||
|
* `channel` ist in der Praxis `beta` | `live` | `dev`; die Wahrheit der
|
||||||
|
* Version ist der Git-Tag (`git describe`), nicht eine package.json.
|
||||||
|
*/
|
||||||
|
export interface VersionResponse {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
channel: string;
|
||||||
|
commit: string;
|
||||||
|
buildTime: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user