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,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" />,
|
||||
}));
|
||||
|
||||
// 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 = [
|
||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
||||
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
||||
@@ -178,4 +184,15 @@ describe('Sidebar', () => {
|
||||
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 { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
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';
|
||||
|
||||
@@ -198,6 +199,13 @@ export function Sidebar() {
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user