feat(quick-260921-pi9): Bilderrahmen-Widget - Diashow mit Grossansicht, Bildverwaltung in den Einstellungen
- picture-frame-config.ts: Eintragstyp als Vereinigung (upload | url) in EINER geordneten Liste, resolvePictureFrameConfig laesst alles ausser https weg (T-PI9-07), Intervall 0 oder 5..3600 s, pickNextIndex (Zufall nie dasselbe) - dashboard-images-api.ts: Upload als FormData-Feld image ohne eigenen Content-Type, 413 -> deutsche Meldung, Proxy-Pfad fuer <img src> - PictureFrameWidget: Leerzustand, <img referrerPolicy=no-referrer> (Browser laedt Fremdbilder, Server nie), object-contain/cover, Unterschrift-Streifen, Wechsel per Timer mit Raeumung, kaputte Bilder verlassen den Umlauf, Grossansicht nur ausserhalb des Bearbeitungsmodus mit Fokus-Rueckgabe und Pause des Wechsels; im Bearbeitungsmodus kein Knopf (Karte bleibt Griff) - PictureFrameConfigForm im WidgetSettingsPanel: Ausschnitt, Intervall, Reihenfolge, Liste mit Vorschau/Unterschrift/Pfeilen/Entfernen (Upload wird auch serverseitig geloescht), Datei hochladen, https-Adresse hinzufuegen - Registry (4x4 min, 8x8 Vorgabe), Katalog, Seite, Uebersetzungen de/en (widgets.pictureFrame, 28 Schluessel), bestehende Tests auf acht Typen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
dashboardImageSrc,
|
||||
deleteDashboardImage,
|
||||
fetchDashboardImages,
|
||||
uploadDashboardImage,
|
||||
} from './dashboard-images-api';
|
||||
|
||||
/**
|
||||
* dashboard-images-api.test — NEU (quick-260921-pi9). Die vier Aufrufe des
|
||||
* Bilderrahmen-Widgets gegen /dashboard/images: FormData-Feld `image` ohne
|
||||
* eigenen Content-Type, Cookie immer mit, 413 und 400 mit deutschen
|
||||
* Meldungen, Proxy-Pfad fuer <img src>.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('dashboard-images-api (quick-260921-pi9)', () => {
|
||||
it('Test 1: uploadDashboardImage sendet POST mit FormData-Feld image, credentials include, ohne Content-Type von Hand', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'img-1', originalName: 'a.png', mimeType: 'image/png', size: 3 }), {
|
||||
status: 201,
|
||||
}),
|
||||
);
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' });
|
||||
const result = await uploadDashboardImage(file);
|
||||
expect(result.id).toBe('img-1');
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/dashboard/images')).toBe(true);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.headers).toBeUndefined();
|
||||
const body = init.body as FormData;
|
||||
expect(body.get('image')).toBeInstanceOf(File);
|
||||
expect((body.get('image') as File).name).toBe('a.png');
|
||||
});
|
||||
|
||||
it('Test 2: Status 413 -> deutsche Meldung "zu gross"', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{"message":"File too large"}', { status: 413 }));
|
||||
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
|
||||
'Die Datei ist zu groß – erlaubt sind höchstens 5 MB.',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 3: Status 400 mit { message } -> genau diese Meldung; sonst allgemeine Meldung', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: 'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.' }), {
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
|
||||
'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.',
|
||||
);
|
||||
|
||||
mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 }));
|
||||
await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow(
|
||||
'Das Bild konnte nicht hochgeladen werden.',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 4: fetchDashboardImages (GET) und deleteDashboardImage (DELETE, Kennung kodiert) mit Cookie', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('[]', { status: 200 }));
|
||||
await expect(fetchDashboardImages()).resolves.toEqual([]);
|
||||
const [listUrl, listInit] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(listUrl).endsWith('/dashboard/images')).toBe(true);
|
||||
expect(listInit.credentials).toBe('include');
|
||||
|
||||
mockFetch.mockResolvedValue(new Response('{"id":"a b"}', { status: 200 }));
|
||||
await deleteDashboardImage('a b');
|
||||
const [delUrl, delInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(String(delUrl).endsWith('/dashboard/images/a%20b')).toBe(true);
|
||||
expect(delInit.method).toBe('DELETE');
|
||||
expect(delInit.credentials).toBe('include');
|
||||
|
||||
mockFetch.mockResolvedValue(new Response('', { status: 404 }));
|
||||
await expect(deleteDashboardImage('weg')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('Test 5: dashboardImageSrc kodiert die Kennung im Proxy-Pfad', () => {
|
||||
expect(dashboardImageSrc('a b')).toBe('/api-proxy/dashboard/images/a%20b');
|
||||
expect(dashboardImageSrc('img-1')).toBe('/api-proxy/dashboard/images/img-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* dashboard-images-api — Aufrufe des Bilderrahmen-Widgets gegen
|
||||
* /dashboard/images (quick-260921-pi9). Muster favorites-api.ts: Cookie
|
||||
* immer mit (`credentials: 'include'`).
|
||||
*
|
||||
* `uploadDashboardImage` schickt `multipart/form-data` mit genau dem Feld
|
||||
* `image` und OHNE eigenen Content-Type-Header — die Multipart-Grenze setzt
|
||||
* der Browser (Muster bug-report-api.ts). Die deutsche Meldung fuer „zu
|
||||
* gross“ entsteht HIER aus dem Status 413 (multer meldet englisch), die
|
||||
* Meldungen fuer 400 kommen von der API (bereits deutsch).
|
||||
*
|
||||
* `dashboardImageSrc` liefert den Proxy-Pfad fuer `<img src>`: der
|
||||
* Next-Rewrite `/api-proxy/:path*` (next.config.ts) reicht Cookies mit,
|
||||
* die Bytes kommen aus Tesseras eigenem Origin (Muster FavoriteIcon).
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export interface DashboardImageMeta {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function fetchDashboardImages(): Promise<DashboardImageMeta[]> {
|
||||
const res = await fetch(`${API_URL}/dashboard/images`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Failed to fetch dashboard images');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function uploadDashboardImage(file: File): Promise<DashboardImageMeta> {
|
||||
const body = new FormData();
|
||||
body.append('image', file, file.name);
|
||||
const res = await fetch(`${API_URL}/dashboard/images`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
|
||||
if (res.status === 413) {
|
||||
throw new Error('Die Datei ist zu groß – erlaubt sind höchstens 5 MB.');
|
||||
}
|
||||
if (res.status === 400) {
|
||||
const message = await readMessage(res);
|
||||
if (message) throw new Error(message);
|
||||
}
|
||||
throw new Error('Das Bild konnte nicht hochgeladen werden.');
|
||||
}
|
||||
|
||||
export async function deleteDashboardImage(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/dashboard/images/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete dashboard image');
|
||||
}
|
||||
|
||||
export function dashboardImageSrc(id: string): string {
|
||||
return `/api-proxy/dashboard/images/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
async function readMessage(res: Response): Promise<string | null> {
|
||||
try {
|
||||
const data: unknown = await res.json();
|
||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||
const message = (data as { message: unknown }).message;
|
||||
if (typeof message === 'string' && message !== '') return message;
|
||||
}
|
||||
} catch {
|
||||
// kein JSON — allgemeine Meldung
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user