feat(18-03): Task 1 — Fetch-Helfer und Download-Link auf der Anmeldeseite
- apps/web/src/lib/desktop.ts: loadDesktopLatest (memoisiert, still bei Fehler), desktopDownloadUrl, formatFileSize (lokalisierte MB-Werte) - DesktopDownloadLinks: unauffaelliger Link-Block, rendert nichts ohne Daten; Windows fuehrt, Linux als Kurzlink wenn beide Pakete vorliegen - Anmeldeseite bindet den Block nach dem Formular ein (D-12) - de/en: auth.desktopDownload.* ergaenzt Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* desktop.test — Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18-03).
|
||||
*
|
||||
* `loadDesktopLatest()` memoisiert die Antwort von `GET /desktop/latest` —
|
||||
* deshalb setzt jeder Test die Module zurueck und importiert dynamisch,
|
||||
* nachdem `fetch` gestubbt ist (Muster: app-version.test.ts).
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function importFresh() {
|
||||
vi.resetModules();
|
||||
return import('./desktop');
|
||||
}
|
||||
|
||||
describe('desktop (18-03)', () => {
|
||||
it('Test 1 (Laden, memoisiert): zwei Aufrufe liefern dasselbe Objekt, fetch laeuft genau einmal ohne credentials', async () => {
|
||||
const payload = {
|
||||
version: '1.1.0',
|
||||
channel: 'beta',
|
||||
commit: 'abc1234',
|
||||
buildTime: 'x',
|
||||
files: {
|
||||
windows: { name: 'Tessera-Setup-1.1.0.exe', size: 6123456, sha256: 'x', url: '/desktop/download/windows' },
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const mod = await importFresh();
|
||||
|
||||
const first = await mod.loadDesktopLatest();
|
||||
const second = await mod.loadDesktopLatest();
|
||||
|
||||
expect(first).toEqual(payload);
|
||||
expect(second).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined];
|
||||
expect(url.endsWith('/desktop/latest')).toBe(true);
|
||||
expect(options?.credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 2 (still bei ok=false): eine Nicht-2xx-Antwort liefert null, nichts wird geworfen', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })));
|
||||
const mod = await importFresh();
|
||||
await expect(mod.loadDesktopLatest()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('Test 3 (still bei Netzfehler): ein abgelehntes fetch-Promise liefert null, nichts wird geworfen', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
|
||||
const mod = await importFresh();
|
||||
await expect(mod.loadDesktopLatest()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('Test 4 (desktopDownloadUrl): API_URL plus relatives url-Feld', async () => {
|
||||
const mod = await importFresh();
|
||||
expect(
|
||||
mod.desktopDownloadUrl({ name: 'Tessera-Setup-1.1.0.exe', size: 1, sha256: 'x', url: '/desktop/download/windows' }),
|
||||
).toBe('http://localhost:3001/desktop/download/windows');
|
||||
});
|
||||
|
||||
it('Test 5 (formatFileSize): lokalisierte MB-Werte', async () => {
|
||||
const mod = await importFresh();
|
||||
expect(mod.formatFileSize(6123456, 'de')).toBe('5,8 MB');
|
||||
expect(mod.formatFileSize(6123456, 'en')).toBe('5.8 MB');
|
||||
expect(mod.formatFileSize(106461688, 'de')).toBe('101,5 MB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18, D-10/D-12).
|
||||
*
|
||||
* Quelle fuer den unauffaelligen Download-Link auf der Anmeldeseite
|
||||
* (`DesktopDownloadLinks`) UND fuer die Einstellungsseite
|
||||
* "Desktop-App" (`DesktopAppSettings`): beide lesen `GET /desktop/latest`
|
||||
* (oeffentlich, `@Public()` in `apps/api/src/desktop/desktop.controller.ts`)
|
||||
* und blenden sich aus, wenn der Server keine Pakete traegt (404).
|
||||
*
|
||||
* 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]` (siehe `app-version.ts`).
|
||||
* Im Betrieb laeuft die Anfrage ueber den Rewrite `/api-proxy` auf die API.
|
||||
*/
|
||||
|
||||
export type DesktopPlatform = 'windows' | 'linux';
|
||||
|
||||
/**
|
||||
* Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus
|
||||
* `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab
|
||||
* (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in
|
||||
* `apps/api/src/desktop/desktop.service.ts`.
|
||||
*/
|
||||
export interface DesktopFileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DesktopLatestInfo {
|
||||
version: string;
|
||||
channel: string;
|
||||
commit: string;
|
||||
buildTime: string;
|
||||
files: Partial<Record<DesktopPlatform, DesktopFileInfo>>;
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
let desktopLatestPromise: Promise<DesktopLatestInfo | null> | null = null;
|
||||
|
||||
/**
|
||||
* Laedt `GET /desktop/latest` genau einmal je Modulinstanz (memoisiert);
|
||||
* jeder Fehler (Netz, Nicht-2xx — insbesondere 404 ohne Manifest) ist still
|
||||
* und liefert `null`. Kein `credentials: 'include'`: die Anmeldeseite zeigt
|
||||
* den Link, bevor ein Cookie existiert, und der Endpunkt ist oeffentlich.
|
||||
*/
|
||||
export function loadDesktopLatest(): Promise<DesktopLatestInfo | null> {
|
||||
if (!desktopLatestPromise) {
|
||||
desktopLatestPromise = fetch(`${API_URL}/desktop/latest`)
|
||||
.then((res) => (res.ok ? (res.json() as Promise<DesktopLatestInfo>) : null))
|
||||
.catch(() => null);
|
||||
}
|
||||
return desktopLatestPromise;
|
||||
}
|
||||
|
||||
/** Baut die absolute Download-Adresse aus `API_URL` plus dem relativen `url`-Feld (T-18-07). */
|
||||
export function desktopDownloadUrl(file: DesktopFileInfo): string {
|
||||
return `${API_URL}${file.url}`;
|
||||
}
|
||||
|
||||
/** Formatiert Dateigroessen in MB, lokalisiert (z. B. `5,8 MB` / `5.8 MB`). */
|
||||
export function formatFileSize(bytes: number, locale: string): string {
|
||||
const mb = bytes / 1048576;
|
||||
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(mb)} MB`;
|
||||
}
|
||||
Reference in New Issue
Block a user