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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user