fix(community): Pixelgrenze beim Anzeigen, exakter Tag-Filter, unsichtbare Zeichen
- Gemeinsamer Helfer loadGalleryImageChecked/checkGalleryImage (gallery.ts): lädt das Bild über new Image(), lehnt über 4 Megapixel oder nicht dekodierbare Dateien ab und liefert eine fertige Object-URL (der Aufrufer gibt sie frei). Raster -> „Keine Vorschau“ (auch bei <img>-Fehler), Hero -> Beispielmotiv, Detailseite -> „Das Bild konnte nicht geladen werden.“ Verspätet eintreffende URLs werden sofort freigegeben. - Tag-Filter sucht ",<tag>," (der Server speichert ",a,b,"): „gold“ trifft nicht mehr „marigold“. - Veröffentlichen-Dialog lehnt dieselben unsichtbaren Zeichen ab wie die API (C1, Nullbreite, Bidi, BOM). - Das Limit der API (429 „schon 20 Karten veröffentlicht“) gilt als eindeutige Ablehnung: die hochgeladenen Dateien werden aufgeräumt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5500495443
commit
c62075f2d4
12 files changed
+435
-66
No files matched your search
@@ -513,6 +513,26 @@ describe('publishCard', () => {
|
||||
expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true);
|
||||
});
|
||||
|
||||
it('429 „schon 20 Karten veröffentlicht“ (Limit des Dienstes) behält den Text und ist eindeutig: es wurde nichts angelegt', async () => {
|
||||
const api = await freshApi();
|
||||
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||
fetchMock.mockResolvedValue(json(429, { error: text }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 429, message: text });
|
||||
expect(api.isAlreadyPublished(err)).toBe(false);
|
||||
// damit räumt der Client die zwei hochgeladenen Dateien auf (ein anderes 429, z. B. vom Proxy, bleibt offen)
|
||||
expect(api.isDefinitiveFailure(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('ein anderes 429 beim Veröffentlichen bleibt ein Fehler mit offenem Ausgang', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Anfragen.' }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 429, message: 'Zu viele Anfragen.' });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
|
||||
|
||||
@@ -23,6 +23,8 @@ const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet
|
||||
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
|
||||
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
||||
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
|
||||
const TOO_MANY_PUBLICATIONS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
|
||||
const JWT_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
@@ -189,7 +191,8 @@ export function isAlreadyPublished(err: unknown): boolean {
|
||||
|
||||
/**
|
||||
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
|
||||
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an; alle anderen Fehler unverändert.
|
||||
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist
|
||||
* eine eindeutige Ablehnung (es wurde nichts angelegt, die Dateien dürfen aufgeräumt werden); alle anderen Fehler unverändert.
|
||||
*/
|
||||
export async function publishCard(input: PublishInput): Promise<{ id: string }> {
|
||||
let res: Json;
|
||||
@@ -199,6 +202,9 @@ export async function publishCard(input: PublishInput): Promise<{ id: string }>
|
||||
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
|
||||
throw new ApiError(409, ALREADY_PUBLISHED);
|
||||
}
|
||||
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
|
||||
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GalleryCard } from './gallery';
|
||||
import type { CheckedImage, GalleryCard } from './gallery';
|
||||
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
|
||||
|
||||
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
|
||||
@@ -18,14 +18,15 @@ const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
|
||||
...patch,
|
||||
});
|
||||
|
||||
const blob = () => new Blob(['x'], { type: 'image/webp' });
|
||||
let counter: number;
|
||||
/** Ein geprüftes Bild, wie es `loadGalleryImageChecked` liefert: Blob plus fertige Object-URL. */
|
||||
const image = (): CheckedImage => ({ blob: new Blob(['x'], { type: 'image/webp' }), url: `blob:t-${++counter}` });
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
let host: HTMLElement;
|
||||
let grid: GalleryGrid | null;
|
||||
let counter: number;
|
||||
const revoked: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,7 +35,6 @@ beforeEach(() => {
|
||||
grid = null;
|
||||
counter = 0;
|
||||
revoked.length = 0;
|
||||
URL.createObjectURL = vi.fn(() => `blob:t-${++counter}`);
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
@@ -117,7 +117,7 @@ describe('mountGalleryGrid: Kacheln', () => {
|
||||
|
||||
describe('mountGalleryGrid: Vorschaubilder', () => {
|
||||
it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => {
|
||||
const load = vi.fn(async () => blob());
|
||||
const load = vi.fn(async () => image());
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
const cards = [card(1), card(2)];
|
||||
grid.append(cards);
|
||||
@@ -129,7 +129,7 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
|
||||
});
|
||||
|
||||
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
|
||||
grid = mountGalleryGrid(host, { load: async () => blob(), onTag: () => {} });
|
||||
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
|
||||
grid.append([card(1), card(2), card(3)]);
|
||||
await flush();
|
||||
expect(counter).toBe(3);
|
||||
@@ -138,28 +138,29 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
|
||||
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']);
|
||||
});
|
||||
|
||||
it('legt nach dem Aufräumen keine URL mehr an (späte Antwort) und stößt keine neuen Ladungen an', async () => {
|
||||
const resolvers: Array<(b: Blob) => void> = [];
|
||||
const load = vi.fn(() => new Promise<Blob>((r) => resolvers.push(r)));
|
||||
it('eine nach dem Aufräumen eintreffende URL wird sofort freigegeben (späte Antwort), neue Ladungen starten nicht mehr', async () => {
|
||||
const resolvers: Array<(i: CheckedImage) => void> = [];
|
||||
const load = vi.fn(() => new Promise<CheckedImage>((r) => resolvers.push(r)));
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append([card(1), card(2)]);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
grid.dispose();
|
||||
for (const resolve of resolvers) resolve(blob());
|
||||
for (const resolve of resolvers) resolve(image());
|
||||
await flush();
|
||||
expect(counter).toBe(0);
|
||||
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2']);
|
||||
expect(host.querySelector('img')).toBeNull();
|
||||
grid.append([card(3)]);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
|
||||
});
|
||||
|
||||
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
|
||||
const pending: Array<(b: Blob) => void> = [];
|
||||
const load = vi.fn(() => new Promise<Blob>((r) => pending.push(r)));
|
||||
const pending: Array<(i: CheckedImage) => void> = [];
|
||||
const load = vi.fn(() => new Promise<CheckedImage>((r) => pending.push(r)));
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
|
||||
expect(load).toHaveBeenCalledTimes(6);
|
||||
pending[0](blob());
|
||||
pending[0](image());
|
||||
await flush();
|
||||
expect(load).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
@@ -167,12 +168,36 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
|
||||
it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => {
|
||||
const load = vi.fn(async (c: GalleryCard) => {
|
||||
if (c.id === 'g1') throw new Error('404');
|
||||
return blob();
|
||||
return image();
|
||||
});
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append([card(1), card(2)]);
|
||||
await flush();
|
||||
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
||||
expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull();
|
||||
expect(tiles()[0].querySelector('.gallery-thumb')?.hasAttribute('aria-busy')).toBe(false);
|
||||
});
|
||||
|
||||
it('eine abgelehnte Vorschau (zu viele Pixel, nicht dekodierbar) zeigt „Keine Vorschau“, nie ein defektes Bild', async () => {
|
||||
const load = vi.fn(async () => {
|
||||
throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab
|
||||
});
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append([card(1)]);
|
||||
await flush();
|
||||
expect(host.querySelector('.gallery-thumb img')).toBeNull();
|
||||
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
||||
});
|
||||
|
||||
it('zeigt das Bild nur, wenn es im Browser wirklich lädt: Ladefehler des <img> ersetzt es durch „Keine Vorschau“ und gibt die URL frei', async () => {
|
||||
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
|
||||
grid.append([card(1)]);
|
||||
await flush();
|
||||
host.querySelector('.gallery-thumb img')?.dispatchEvent(new Event('error'));
|
||||
expect(host.querySelector('.gallery-thumb img')).toBeNull();
|
||||
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
||||
expect(revoked).toEqual(['blob:t-1']);
|
||||
grid.dispose();
|
||||
expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
|
||||
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket) und werden als Object-URL angezeigt;
|
||||
// `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
|
||||
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked)
|
||||
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
|
||||
import { el } from '../editor/dom';
|
||||
import type { GalleryCard } from './gallery';
|
||||
import type { CheckedImage, GalleryCard } from './gallery';
|
||||
|
||||
export interface GalleryGridOptions {
|
||||
/** Lädt die Vorschau einer Karte. */
|
||||
load: (card: GalleryCard) => Promise<Blob>;
|
||||
/** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */
|
||||
load: (card: GalleryCard) => Promise<CheckedImage>;
|
||||
/** Ein Tag-Knopf wurde angeklickt. */
|
||||
onTag: (tag: string) => void;
|
||||
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
|
||||
@@ -39,16 +39,28 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
|
||||
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
|
||||
let active = 0;
|
||||
|
||||
const showEmpty = (thumb: HTMLElement) =>
|
||||
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
|
||||
|
||||
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
|
||||
try {
|
||||
const blob = await opts.load(card);
|
||||
if (disposed) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
|
||||
const url = URL.createObjectURL(blob);
|
||||
const { url } = await opts.load(card);
|
||||
if (disposed) {
|
||||
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
|
||||
return;
|
||||
}
|
||||
urls.add(url);
|
||||
thumb.replaceChildren(el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' }));
|
||||
const img = el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' });
|
||||
// Die Datei kam an und wurde geprüft, lässt sich aber nicht darstellen: lieber der Hinweis als ein defektes Bild.
|
||||
img.addEventListener('error', () => {
|
||||
if (disposed) return;
|
||||
urls.delete(url);
|
||||
URL.revokeObjectURL(url);
|
||||
showEmpty(thumb);
|
||||
});
|
||||
thumb.replaceChildren(img);
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
|
||||
if (!disposed) showEmpty(thumb);
|
||||
} finally {
|
||||
thumb.removeAttribute('aria-busy');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppwriteException, Query } from 'appwrite';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listRows: vi.fn(),
|
||||
@@ -30,12 +30,15 @@ vi.mock('../account/cards', async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
MAX_GALLERY_PIXELS,
|
||||
PAGE_SIZE,
|
||||
checkGalleryImage,
|
||||
galleryQueries,
|
||||
getGalleryCard,
|
||||
listGallery,
|
||||
listReports,
|
||||
loadGalleryImage,
|
||||
loadGalleryImageChecked,
|
||||
myPublications,
|
||||
toGalleryCard,
|
||||
type GalleryCard,
|
||||
@@ -119,14 +122,24 @@ describe('galleryQueries', () => {
|
||||
expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö'));
|
||||
});
|
||||
|
||||
it('Tag: contains auf tags, getrimmt und klein; die Galerie bleibt auf sichtbare Karten beschränkt', () => {
|
||||
it('Tag: exakter Treffer auf ",tag," in tags (der Server speichert ",a,b,"), getrimmt und klein; nur sichtbare Karten', () => {
|
||||
expect(galleryQueries({ tag: ' Visa ' })).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.contains('tags', 'visa'),
|
||||
Query.contains('tags', ',visa,'),
|
||||
]);
|
||||
expect(JSON.stringify(Query.contains('tags', 'visa'))).toContain('contains');
|
||||
expect(JSON.stringify(Query.contains('tags', ',visa,'))).toContain('contains');
|
||||
});
|
||||
|
||||
it('Tag: "gold" trifft nicht "marigold" (Trennzeichen an beiden Seiten)', () => {
|
||||
const [, , , tagQuery] = galleryQueries({ tag: 'gold' });
|
||||
expect(JSON.parse(tagQuery)).toEqual({ method: 'contains', attribute: 'tags', values: [',gold,'] });
|
||||
const stored = (tags: string) => tags.includes(JSON.parse(tagQuery).values[0] as string);
|
||||
expect(stored(',visa,gold,')).toBe(true);
|
||||
expect(stored(',gold,')).toBe(true);
|
||||
expect(stored(',marigold,')).toBe(false);
|
||||
expect(stored(',goldig,')).toBe(false);
|
||||
});
|
||||
|
||||
it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => {
|
||||
@@ -140,7 +153,7 @@ describe('galleryQueries', () => {
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.cursorAfter('g24'),
|
||||
Query.contains('tags', 'gold'),
|
||||
Query.contains('tags', ',gold,'),
|
||||
Query.search('searchText', 'visa'),
|
||||
]);
|
||||
});
|
||||
@@ -183,7 +196,7 @@ describe('galleryQueries', () => {
|
||||
Query.equal('hidden', true),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.contains('tags', 'visa'),
|
||||
Query.contains('tags', ',visa,'),
|
||||
Query.contains('searchText', 'x'),
|
||||
]);
|
||||
});
|
||||
@@ -252,6 +265,12 @@ describe('toGalleryCard', () => {
|
||||
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('Tags im Speicherformat ",a,b," (Komma an beiden Enden) werden zur sauberen Liste', () => {
|
||||
expect(toGalleryCard(row(1, { tags: ',visa,gold,' })).tags).toEqual(['visa', 'gold']);
|
||||
expect(toGalleryCard(row(1, { tags: ',blau,' })).tags).toEqual(['blau']);
|
||||
expect(toGalleryCard(row(1, { tags: ',' })).tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('reportCount: nur endliche, nicht negative Zahlen', () => {
|
||||
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
|
||||
expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0);
|
||||
@@ -297,7 +316,7 @@ describe('listGallery', () => {
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.contains('tags', 'visa'),
|
||||
Query.contains('tags', ',visa,'),
|
||||
Query.contains('searchText', 'ab'),
|
||||
]);
|
||||
});
|
||||
@@ -457,3 +476,128 @@ describe('loadGalleryImage', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('checkGalleryImage (Pixelgrenze beim Anzeigen)', () => {
|
||||
interface Behaviour {
|
||||
load: 'ok' | 'fail';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
let behaviour: Behaviour;
|
||||
let created: FakeImage[];
|
||||
let urlCounter: number;
|
||||
let revoked: string[];
|
||||
|
||||
class FakeImage {
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#src = '';
|
||||
|
||||
constructor() {
|
||||
created.push(this);
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this.#src = value;
|
||||
queueMicrotask(() => {
|
||||
if (behaviour.load === 'ok') {
|
||||
this.naturalWidth = behaviour.width;
|
||||
this.naturalHeight = behaviour.height;
|
||||
this.onload?.();
|
||||
} else {
|
||||
this.onerror?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get src(): string {
|
||||
return this.#src;
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob(['x'], { type: 'image/png' });
|
||||
|
||||
beforeEach(() => {
|
||||
behaviour = { load: 'ok', width: 1536, height: 969 };
|
||||
created = [];
|
||||
urlCounter = 0;
|
||||
revoked = [];
|
||||
vi.stubGlobal('Image', FakeImage);
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(URL, {
|
||||
createObjectURL: vi.fn(() => `blob:check-${++urlCounter}`),
|
||||
revokeObjectURL: vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('Grenze: 4 Megapixel (die Karte hat 1536 x 969, rund 1,5)', () => {
|
||||
expect(MAX_GALLERY_PIXELS).toBe(4_000_000);
|
||||
});
|
||||
|
||||
it('eine Karte in Normalgröße: liefert Blob und fertige Object-URL, die nicht freigegeben wird (der Aufrufer macht das)', async () => {
|
||||
const image = await checkGalleryImage(blob);
|
||||
expect(image).toEqual({ blob, url: 'blob:check-1' });
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].src).toBe('blob:check-1');
|
||||
expect(revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it('genau 4 000 000 Pixel gehen noch durch', async () => {
|
||||
behaviour = { ...behaviour, width: 2000, height: 2000 };
|
||||
await expect(checkGalleryImage(blob)).resolves.toMatchObject({ url: 'blob:check-1' });
|
||||
});
|
||||
|
||||
it('mehr als 4 000 000 Pixel: abgelehnt, die URL wird freigegeben (Dekompressionsbombe)', async () => {
|
||||
behaviour = { ...behaviour, width: 2000, height: 2001 };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('riesige Maße (60 000 x 60 000): abgelehnt, ohne dass das Bild je angezeigt würde', async () => {
|
||||
behaviour = { ...behaviour, width: 60_000, height: 60_000 };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('nicht dekodierbar (Ladefehler): abgelehnt, URL freigegeben', async () => {
|
||||
behaviour = { ...behaviour, load: 'fail' };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('Maße 0 (kein echtes Bild): abgelehnt, URL freigegeben', async () => {
|
||||
behaviour = { ...behaviour, width: 0, height: 0 };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('loadGalleryImageChecked lädt die Datei und prüft sie; Vorschau und Vollbild nutzen dieselbe Prüfung', async () => {
|
||||
const card = toGalleryCard(row(3));
|
||||
mocks.fetchFile.mockResolvedValue(blob);
|
||||
await expect(loadGalleryImageChecked(card, 'preview')).resolves.toEqual({ blob, url: 'blob:check-1' });
|
||||
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'pre3');
|
||||
await expect(loadGalleryImageChecked(card, 'full')).resolves.toEqual({ blob, url: 'blob:check-2' });
|
||||
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'img3');
|
||||
|
||||
behaviour = { ...behaviour, width: 8000, height: 8000 };
|
||||
await expect(loadGalleryImageChecked(card, 'full')).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-3']);
|
||||
});
|
||||
|
||||
it('ein Fehler beim Laden der Datei geht durch, ohne dass eine URL angelegt wird', async () => {
|
||||
const card = toGalleryCard(row(3));
|
||||
mocks.fetchFile.mockRejectedValue(notFound('storage_file_not_found'));
|
||||
await expect(loadGalleryImageChecked(card, 'full')).rejects.toMatchObject({ code: 404 });
|
||||
expect(urlCounter).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ export type GalleryFilter = 'all' | 'reported' | 'hidden';
|
||||
export interface GalleryOptions {
|
||||
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
|
||||
q?: string;
|
||||
/** Nur Karten mit diesem Tag (Teilstring in `tags`, klein). */
|
||||
/** Nur Karten mit genau diesem Tag (klein; der Server speichert `tags` als `,a,b,`, gesucht wird `,<tag>,`). */
|
||||
tag?: string;
|
||||
cursor?: string;
|
||||
filter?: GalleryFilter;
|
||||
@@ -90,7 +90,7 @@ export function galleryQueries(opts: GalleryOptions): string[] {
|
||||
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
|
||||
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
|
||||
const tag = (opts.tag ?? '').trim().toLowerCase();
|
||||
if (tag) queries.push(Query.contains('tags', tag));
|
||||
if (tag) queries.push(Query.contains('tags', `,${tag},`)); // mit Trennzeichen: „gold“ trifft nicht „marigold“
|
||||
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (q) {
|
||||
queries.push(
|
||||
@@ -171,3 +171,47 @@ export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'previe
|
||||
if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
|
||||
return fetchFile(IDS.galleryBucket, fileId);
|
||||
}
|
||||
|
||||
/** Obergrenze für Breite × Höhe eines Galerie-Bildes: die Karte hat 1536 × 969 (rund 1,5 Megapixel). */
|
||||
export const MAX_GALLERY_PIXELS = 4_000_000;
|
||||
const IMAGE_UNUSABLE = 'Das Bild konnte nicht geladen werden.';
|
||||
|
||||
/** Ein geprüftes Galerie-Bild; `url` ist eine Object-URL auf `blob`, die der Aufrufer mit `URL.revokeObjectURL` freigibt. */
|
||||
export interface CheckedImage {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein Galerie-Bild, bevor es angezeigt wird: Das Bild wird über eine Object-URL in ein `Image` geladen (dann sind
|
||||
* die Maße bekannt, es ist aber noch nicht dekodiert) und abgelehnt, wenn das nicht klappt oder Breite × Höhe über
|
||||
* `MAX_GALLERY_PIXELS` liegt. Ein kleines, stark komprimiertes PNG/WebP kann sonst Gigabytes an Pixeln enthalten und den
|
||||
* Tab einfrieren (Startseite und Galerie zeigen fremde Bilder). Die Server-Prüfung beim Veröffentlichen ersetzt das nicht.
|
||||
* Bei Erfolg liefert es dieselbe Object-URL, die dann angezeigt wird; bei Ablehnung ist sie schon freigegeben.
|
||||
*/
|
||||
export async function checkGalleryImage(blob: Blob): Promise<CheckedImage> {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = new Image();
|
||||
const loaded = new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(IMAGE_UNUSABLE));
|
||||
});
|
||||
img.src = url;
|
||||
await loaded;
|
||||
const { naturalWidth: w, naturalHeight: h } = img;
|
||||
if (!w || !h || w * h > MAX_GALLERY_PIXELS) throw new Error(IMAGE_UNUSABLE);
|
||||
return { blob, url };
|
||||
} catch (err) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie `loadGalleryImage`, aber geprüft (`checkGalleryImage`) und mit fertiger Object-URL: der gemeinsame Weg für Raster,
|
||||
* Hero und Detailseite. Wirft bei Ladefehler, zu großen Maßen und nicht dekodierbaren Dateien.
|
||||
*/
|
||||
export async function loadGalleryImageChecked(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage> {
|
||||
return checkGalleryImage(await loadGalleryImage(card, size));
|
||||
}
|
||||
@@ -151,6 +151,42 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||||
});
|
||||
|
||||
// Dasselbe Muster wie deploy/api/app.js: C1-Steuerzeichen, Nullbreiten-, Bidi-Zeichen und BOM sind unsichtbar oder ordnen Text um.
|
||||
it.each([
|
||||
['Nullbreiten-Leerzeichen', 'a\u200bb'],
|
||||
['Rechts-nach-links-Überschreibung', 'foto\u202egnp.exe'],
|
||||
['Isolate', 'a\u2066b'],
|
||||
['Wortverbinder', 'a\u2060b'],
|
||||
['C1-Steuerzeichen', 'a\u0085b'],
|
||||
['BOM mitten im Titel', 'a\ufeffb'],
|
||||
])('unsichtbare Zeichen werden abgelehnt: %s (Titel)', async (_name, title) => {
|
||||
await invalid({ title }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||||
});
|
||||
|
||||
it('ein Titel nur aus unsichtbaren Zeichen gilt als ungültig', async () => {
|
||||
await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||||
});
|
||||
|
||||
it('unsichtbare Zeichen in Herausgeber, Tags und Anzeigename', async () => {
|
||||
await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer');
|
||||
});
|
||||
|
||||
it('unsichtbares Zeichen in einem Tag', async () => {
|
||||
await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags');
|
||||
});
|
||||
|
||||
it('unsichtbares Zeichen im Anzeigenamen', async () => {
|
||||
await invalid({ displayName: 'Ken\u2060so' }, 'Der Anzeigename enthält ungültige Zeichen.', 'displayName');
|
||||
});
|
||||
|
||||
it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', displayName: 'Zoë', tags: 'grün, ✨' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
});
|
||||
|
||||
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
|
||||
@@ -22,7 +22,11 @@ const ISSUER_MAX = 40;
|
||||
const DISPLAY_NAME_MAX = 30;
|
||||
const TAGS_MAX = 5;
|
||||
const TAG_MAX = 20;
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
/**
|
||||
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
|
||||
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
|
||||
*/
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||||
|
||||
export interface PublishFormValues extends PublishForm {
|
||||
consent: boolean;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { User } from '../account/auth';
|
||||
import { ApiError, type ReportReason } from '../community/api';
|
||||
import type { GalleryCard, GalleryReport } from '../community/gallery';
|
||||
import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery';
|
||||
|
||||
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
|
||||
vi.mock('../account/appwrite', () => ({
|
||||
@@ -43,6 +43,8 @@ const settle = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
const blob = () => new Blob(['png'], { type: 'image/png' });
|
||||
/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */
|
||||
const image = (b: Blob = blob()): CheckedImage => ({ blob: b, url: `blob:detail-${++urlCounter}` });
|
||||
|
||||
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
|
||||
const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
|
||||
@@ -58,7 +60,7 @@ let urlCounter: number;
|
||||
const revoked: string[] = [];
|
||||
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
|
||||
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
|
||||
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
|
||||
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<CheckedImage>>();
|
||||
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
|
||||
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
|
||||
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
|
||||
@@ -115,7 +117,7 @@ beforeEach(() => {
|
||||
}
|
||||
getCard.mockResolvedValue(card());
|
||||
listReports.mockResolvedValue([]);
|
||||
loadImage.mockImplementation(async () => blob());
|
||||
loadImage.mockImplementation(async () => image());
|
||||
withdrawCard.mockResolvedValue(undefined);
|
||||
setVisibility.mockResolvedValue(undefined);
|
||||
getUser.mockResolvedValue(null);
|
||||
@@ -123,7 +125,6 @@ beforeEach(() => {
|
||||
authListeners = new Set();
|
||||
urlCounter = 0;
|
||||
revoked.length = 0;
|
||||
URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`);
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
@@ -276,6 +277,31 @@ describe('Detailseite: Karte', () => {
|
||||
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ein von der Pixelprüfung abgelehntes Bild (zu groß, nicht dekodierbar) zeigt den Hinweis statt eines Bildes', async () => {
|
||||
// so lehnt loadGalleryImageChecked ab: es wirft, noch bevor ein <img> entsteht
|
||||
loadImage.mockRejectedValue(new Error('Das Bild konnte nicht geladen werden.'));
|
||||
await mount();
|
||||
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
|
||||
expect(q('.detail-card img')).toBeNull();
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau'); // die Angaben und Knöpfe (Melden!) bleiben bedienbar
|
||||
expect(buttonByText('Melden')).toBeDefined();
|
||||
// auch Herunterladen bekommt das Bild nicht
|
||||
buttonByText('Herunterladen')!.click();
|
||||
await settle();
|
||||
expect(saveBlobAs).not.toHaveBeenCalled();
|
||||
expect(errorText()).toBe('Das Bild konnte nicht geladen werden.');
|
||||
});
|
||||
|
||||
it('das Bild zum Herunterladen kommt aus der Prüfung; eine dabei angelegte URL wird sofort freigegeben', async () => {
|
||||
await mount();
|
||||
q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher
|
||||
revoked.length = 0;
|
||||
buttonByText('Herunterladen')!.click();
|
||||
await settle();
|
||||
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||||
expect(revoked).toEqual(['blob:detail-2']);
|
||||
});
|
||||
|
||||
it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => {
|
||||
loadImage.mockRejectedValueOnce(new TypeError('offline'));
|
||||
await mount();
|
||||
@@ -311,17 +337,18 @@ describe('Detailseite: Karte', () => {
|
||||
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
|
||||
|
||||
// Bild kommt erst nach dem Verlassen
|
||||
let finishImage!: (b: Blob) => void;
|
||||
let finishImage!: (i: CheckedImage) => void;
|
||||
getCard.mockResolvedValue(card());
|
||||
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
|
||||
unmount = mountCommunityPage(root, 'g1', deps());
|
||||
await settle();
|
||||
unmount();
|
||||
unmount = null;
|
||||
const created = urlCounter;
|
||||
finishImage(blob());
|
||||
revoked.length = 0;
|
||||
finishImage({ blob: blob(), url: 'blob:detail-late' });
|
||||
await settle();
|
||||
expect(urlCounter).toBe(created);
|
||||
expect(revoked).toEqual(['blob:detail-late']); // die verspätete URL wird sofort freigegeben
|
||||
expect(q('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,7 +446,7 @@ describe('Detailseite: Aufräumen', () => {
|
||||
describe('Detailseite: Herunterladen und Vorlage', () => {
|
||||
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
|
||||
const full = blob();
|
||||
loadImage.mockResolvedValue(full);
|
||||
loadImage.mockImplementation(async () => image(full));
|
||||
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
|
||||
await mount();
|
||||
buttonByText('Herunterladen')!.click();
|
||||
|
||||
@@ -10,7 +10,8 @@ import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason }
|
||||
import {
|
||||
getGalleryCard,
|
||||
listReports,
|
||||
loadGalleryImage,
|
||||
loadGalleryImageChecked,
|
||||
type CheckedImage,
|
||||
type GalleryCard,
|
||||
type GalleryReport,
|
||||
} from '../community/gallery';
|
||||
@@ -25,7 +26,8 @@ import { navigate, titleFor } from './router';
|
||||
export interface CommunityDeps {
|
||||
getCard(id: string): Promise<GalleryCard | null>;
|
||||
listReports(galleryId: string): Promise<GalleryReport[]>;
|
||||
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
|
||||
/** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */
|
||||
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage>;
|
||||
reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
|
||||
withdrawCard(id: string): Promise<void>;
|
||||
setVisibility(id: string, visible: boolean): Promise<void>;
|
||||
@@ -42,7 +44,7 @@ export interface CommunityDeps {
|
||||
export const defaultDeps: CommunityDeps = {
|
||||
getCard: getGalleryCard,
|
||||
listReports,
|
||||
loadImage: loadGalleryImage,
|
||||
loadImage: loadGalleryImageChecked,
|
||||
reportCard,
|
||||
withdrawCard,
|
||||
setVisibility,
|
||||
@@ -132,10 +134,13 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
imageSlot.setAttribute('aria-busy', 'true');
|
||||
imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING)));
|
||||
deps.loadImage(forCard, 'full').then(
|
||||
(blob) => {
|
||||
if (disposed || token !== imageToken) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
|
||||
imageBlob = blob;
|
||||
imageUrl = URL.createObjectURL(blob);
|
||||
(loaded) => {
|
||||
if (disposed || token !== imageToken) {
|
||||
URL.revokeObjectURL(loaded.url); // zu spät: niemand gibt diese URL mehr frei
|
||||
return;
|
||||
}
|
||||
imageBlob = loaded.blob;
|
||||
imageUrl = loaded.url;
|
||||
const img = el('img', {
|
||||
src: imageUrl,
|
||||
alt: `Community-Karte „${forCard.title}“`,
|
||||
@@ -355,7 +360,12 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
setBusy('download');
|
||||
try {
|
||||
// Normalerweise liegt das Bild schon vor (es ist die große Karte); sonst wird es jetzt geholt.
|
||||
const blob = imageBlob ?? (await deps.loadImage(c, 'full'));
|
||||
let blob = imageBlob;
|
||||
if (!blob) {
|
||||
const loaded = await deps.loadImage(c, 'full');
|
||||
URL.revokeObjectURL(loaded.url); // hier zählt nur die Datei, nicht die Anzeige-URL
|
||||
blob = loaded.blob;
|
||||
}
|
||||
if (disposed || mine !== generation) return;
|
||||
const created = new Date(c.createdAt);
|
||||
deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { User } from '../account/auth';
|
||||
import type { GalleryCard, GalleryOptions } from '../community/gallery';
|
||||
import type { CheckedImage, GalleryCard, GalleryOptions } from '../community/gallery';
|
||||
|
||||
// gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage.
|
||||
vi.mock('../account/appwrite', () => ({
|
||||
@@ -37,6 +37,8 @@ const settle = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
const blob = () => new Blob(['x'], { type: 'image/png' });
|
||||
/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */
|
||||
const image = (): CheckedImage => ({ blob: blob(), url: `blob:home-${++urlCounter}` });
|
||||
|
||||
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
|
||||
const member: User = { $id: 'u1', email: 'u@example.com', labels: [] };
|
||||
@@ -48,7 +50,7 @@ let authListeners: Set<(u: User | null) => void>;
|
||||
let urlCounter: number;
|
||||
const revoked: string[] = [];
|
||||
const listGallery = vi.fn<(opts: GalleryOptions) => Promise<Page>>();
|
||||
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
|
||||
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<CheckedImage>>();
|
||||
const navigate = vi.fn<(path: string) => void>();
|
||||
const scrollToHash = vi.fn<(hash: string) => void>();
|
||||
|
||||
@@ -81,13 +83,12 @@ beforeEach(() => {
|
||||
loadImage.mockReset();
|
||||
navigate.mockReset();
|
||||
scrollToHash.mockReset();
|
||||
loadImage.mockImplementation(async () => blob());
|
||||
loadImage.mockImplementation(async () => image());
|
||||
listGallery.mockResolvedValue(page([]));
|
||||
user = null;
|
||||
authListeners = new Set();
|
||||
urlCounter = 0;
|
||||
revoked.length = 0;
|
||||
URL.createObjectURL = vi.fn(() => `blob:home-${++urlCounter}`);
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
@@ -143,12 +144,41 @@ describe('Startseite: Hero', () => {
|
||||
listGallery.mockResolvedValue(page([card(1)]));
|
||||
loadImage.mockImplementation(async (_c, size) => {
|
||||
if (size === 'full') throw new Error('404');
|
||||
return blob();
|
||||
return image();
|
||||
});
|
||||
await mount();
|
||||
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
|
||||
});
|
||||
|
||||
it('fällt auf das Beispielmotiv zurück, wenn die Pixelprüfung das Kartenbild ablehnt (Dekompressionsbombe)', async () => {
|
||||
listGallery.mockResolvedValue(page([card(1), card(2)]));
|
||||
loadImage.mockImplementation(async (c, size) => {
|
||||
if (size === 'full') throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab
|
||||
return image();
|
||||
});
|
||||
await mount();
|
||||
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
|
||||
expect(q('.hero-card a')).toBeNull();
|
||||
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).not.toMatch(/^blob:/);
|
||||
// die Kacheln darunter laden weiter, jede Vorschau mit eigener Prüfung
|
||||
expect(qa('.gallery-tile')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('eine nach dem Verlassen eintreffende Hero-URL wird sofort freigegeben', async () => {
|
||||
listGallery.mockResolvedValue(page([card(1)]));
|
||||
let finish!: (i: CheckedImage) => void;
|
||||
loadImage.mockImplementation((_c, size) =>
|
||||
size === 'full' ? new Promise<CheckedImage>((r) => (finish = r)) : Promise.resolve(image()),
|
||||
);
|
||||
await mount();
|
||||
unmount!();
|
||||
unmount = null;
|
||||
revoked.length = 0;
|
||||
finish({ blob: blob(), url: 'blob:home-late' });
|
||||
await settle();
|
||||
expect(revoked).toEqual(['blob:home-late']);
|
||||
});
|
||||
|
||||
it('Ladefehler der Galerie: Hero zeigt das Beispielmotiv', async () => {
|
||||
listGallery.mockRejectedValue(new Error('offline'));
|
||||
await mount();
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import '../community/community.css';
|
||||
import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
||||
import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid';
|
||||
import { listGallery, loadGalleryImage, type GalleryCard, type GalleryFilter, type GalleryOptions } from '../community/gallery';
|
||||
import {
|
||||
listGallery,
|
||||
loadGalleryImageChecked,
|
||||
type CheckedImage,
|
||||
type GalleryCard,
|
||||
type GalleryFilter,
|
||||
type GalleryOptions,
|
||||
} from '../community/gallery';
|
||||
import { el } from '../editor/dom';
|
||||
import { navigate, scrollToHash } from './router';
|
||||
|
||||
@@ -12,7 +19,8 @@ type Page = { cards: GalleryCard[]; nextCursor: string | null };
|
||||
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
||||
export interface HomeDeps {
|
||||
listGallery(opts: GalleryOptions): Promise<Page>;
|
||||
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
|
||||
/** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */
|
||||
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage>;
|
||||
currentUser(): User | null;
|
||||
onAuthChange(fn: (user: User | null) => void): () => void;
|
||||
navigate(path: string): void;
|
||||
@@ -21,7 +29,7 @@ export interface HomeDeps {
|
||||
|
||||
export const defaultDeps: HomeDeps = {
|
||||
listGallery,
|
||||
loadImage: loadGalleryImage,
|
||||
loadImage: loadGalleryImageChecked,
|
||||
currentUser,
|
||||
onAuthChange,
|
||||
navigate,
|
||||
@@ -112,10 +120,13 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
|
||||
if (disposed) return;
|
||||
if (!card) return showExample();
|
||||
try {
|
||||
const blob = await deps.loadImage(card, 'full');
|
||||
if (disposed) return;
|
||||
heroUrl = URL.createObjectURL(blob);
|
||||
setHeroImage(heroUrl, `Community-Karte „${card.title}“`, card);
|
||||
const { url } = await deps.loadImage(card, 'full');
|
||||
if (disposed) {
|
||||
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
|
||||
return;
|
||||
}
|
||||
heroUrl = url;
|
||||
setHeroImage(url, `Community-Karte „${card.title}“`, card);
|
||||
} catch {
|
||||
if (!disposed) showExample();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user