feat(260923-lrr): Web — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf
- favorites-api.ts: FavoriteRequestError (Grund iconUrlUnreachable/iconTooLarge/ iconInvalidType/iconUploadFailed), uploadFavoriteIcon/removeFavoriteIcon, FAVORITE_ICON_MAX_BYTES, Felder uploadedIconMime/iconVersion - favorites-widget.tsx: Proxy-Bild traegt ?v=<iconVersion> (Cache-Bust nach Aenderung), Remount-Key um iconVersion/uploadedIconMime erweitert, eigenes Symbol im Hinzufuegen- und Bearbeitungsformular waehlen, Entfernen-Knopf, Fehlermeldung (role=alert) im Formular, Platzhalter jetzt uebersetzbar - de.json/en.json: neun neue Texte unter widgets.favorites - CHANGELOG.md und Anwenderhandbuch ergaenzt Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
FavoriteRequestError,
|
||||
createFavorite,
|
||||
removeFavoriteIcon,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
} from './favorites-api';
|
||||
|
||||
/**
|
||||
* favorites-api.test — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
|
||||
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
|
||||
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
|
||||
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit
|
||||
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische
|
||||
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('favorites-api (quick-260923-lrr)', () => {
|
||||
describe('uploadFavoriteIcon', () => {
|
||||
it('schickt POST an /favorites/<id>/icon mit credentials include, FormData-Feld icon, OHNE eigenen Content-Type; 200 -> Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ id: 'fav-1', uploadedIconMime: 'image/png', iconVersion: 1 }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const result = await uploadFavoriteIcon('fav-1', file);
|
||||
|
||||
expect(result.id).toBe('fav-1');
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.headers).toBeUndefined();
|
||||
const body = init.body as FormData;
|
||||
expect([...body.keys()]).toEqual(['icon']);
|
||||
expect(body.get('icon')).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it('Datei groesser 512 KB -> FavoriteRequestError iconTooLarge, fetch NICHT aufgerufen', async () => {
|
||||
const big = new File([new Uint8Array(FAVORITE_ICON_MAX_BYTES + 1)], 'gross.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', big).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('413 -> iconTooLarge', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 413 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
});
|
||||
|
||||
it('400 -> iconInvalidType', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.txt', { type: 'text/plain' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconInvalidType');
|
||||
});
|
||||
|
||||
it('500 -> iconUploadFailed', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUploadFailed');
|
||||
});
|
||||
});
|
||||
|
||||
it('removeFavoriteIcon schickt DELETE an /favorites/<id>/icon mit credentials include, liefert die Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'fav-1', uploadedIconMime: null, iconVersion: 2 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await removeFavoriteIcon('fav-1');
|
||||
|
||||
expect(result.uploadedIconMime).toBeNull();
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => {
|
||||
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await createFavorite({
|
||||
widgetId: 'w1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
}).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await createFavorite({ widgetId: 'w1', title: 'X', url: 'https://x.invalid' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { title: 'Neu' }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,22 @@
|
||||
* Mirrors the NestJS FavoritesController routes (08-03).
|
||||
* All calls use credentials: 'include' for cookie-based auth.
|
||||
* PUT /favorites/order (reorderFavorites) added 260917-jdd.
|
||||
*
|
||||
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
||||
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
|
||||
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
|
||||
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API
|
||||
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit
|
||||
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen
|
||||
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
|
||||
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/** Hoechstgroesse eines hochgeladenen Symbols — muss der API-Konstante entsprechen. */
|
||||
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
|
||||
|
||||
export interface FavoriteLink {
|
||||
id: string;
|
||||
widgetId: string;
|
||||
@@ -14,6 +26,33 @@ export interface FavoriteLink {
|
||||
url: string;
|
||||
iconUrl: string | null;
|
||||
position: number;
|
||||
/** 260923-lrr — Typ eines hochgeladenen eigenen Symbols, null = keins. */
|
||||
uploadedIconMime?: string | null;
|
||||
/** 260923-lrr — Zaehler fuer die Symbol-Adresse (`?v=`), macht den 24h-Zwischenspeicher nach einer Aenderung sofort ungueltig. */
|
||||
iconVersion?: number;
|
||||
}
|
||||
|
||||
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
|
||||
export type FavoriteErrorReason =
|
||||
| 'iconUrlUnreachable'
|
||||
| 'iconTooLarge'
|
||||
| 'iconInvalidType'
|
||||
| 'iconUploadFailed';
|
||||
|
||||
/**
|
||||
* Ein abgewiesener Favoriten-Aufruf mit einem uebersetzbaren Grund
|
||||
* (260923-lrr) — das Widget bildet `reason` auf `t('favorites.' + reason)`
|
||||
* ab, damit die Meldung sprachrichtig ist statt einer festen deutschen
|
||||
* Zeichenkette aus dem Klienten.
|
||||
*/
|
||||
export class FavoriteRequestError extends Error {
|
||||
public readonly reason: FavoriteErrorReason;
|
||||
|
||||
constructor(reason: FavoriteErrorReason) {
|
||||
super(reason);
|
||||
this.name = 'FavoriteRequestError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +72,8 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
|
||||
/**
|
||||
* Create a new favorite link.
|
||||
* Server-side icon discovery runs automatically if iconUrl is not provided.
|
||||
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden
|
||||
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
*/
|
||||
export async function createFavorite(payload: {
|
||||
widgetId: string;
|
||||
@@ -46,6 +87,7 @@ export async function createFavorite(payload: {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to create favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -54,6 +96,8 @@ export async function createFavorite(payload: {
|
||||
/**
|
||||
* Update an existing favorite link.
|
||||
* Pass iconUrl: null to clear a stored icon.
|
||||
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 ->
|
||||
* `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
*/
|
||||
export async function updateFavorite(
|
||||
id: string,
|
||||
@@ -65,6 +109,7 @@ export async function updateFavorite(
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to update favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -100,3 +145,43 @@ export async function deleteFavorite(id: string): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete favorite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt ein eigenes Symbol fuer einen Favoriten hoch (260923-lrr). Muster
|
||||
* `uploadDashboardImage` (dashboard-images-api.ts): FormData ohne eigenen
|
||||
* Content-Type-Header — die Multipart-Grenze setzt der Browser. Die
|
||||
* Groessenpruefung laeuft VOR dem Netzwerkaufruf (kein Aufruf fuer eine
|
||||
* offensichtlich zu grosse Datei).
|
||||
*/
|
||||
export async function uploadFavoriteIcon(id: string, file: File): Promise<FavoriteLink> {
|
||||
if (file.size > FAVORITE_ICON_MAX_BYTES) {
|
||||
throw new FavoriteRequestError('iconTooLarge');
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('icon', file, file.name);
|
||||
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
|
||||
if (res.status === 413) throw new FavoriteRequestError('iconTooLarge');
|
||||
if (res.status === 400) throw new FavoriteRequestError('iconInvalidType');
|
||||
throw new FavoriteRequestError('iconUploadFailed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein zuvor hochgeladenes Symbol wieder (260923-lrr); die Kachel
|
||||
* faellt danach auf `iconUrl` bzw. automatische Erkennung zurueck.
|
||||
*/
|
||||
export async function removeFavoriteIcon(id: string): Promise<FavoriteLink> {
|
||||
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove favorite icon');
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user