feat(logos): Logo zuschneiden, verkleinern und hochladen
Task 6: Verarbeitung und Hochladen eines Logos als Ablauf ohne Oberfläche (der Dialog folgt in Task 7).
- logos/prepare-logo.ts (neu): validateLogoFile (PNG, JPG, WebP, SVG; ohne Typ zählt die Endung .svg; höchstens 5 MB), die reinen
Helfer trimBox (Alpha ≤ 8 gilt als leer), fitWithin (nie größer, gerundet, mindestens 1), svgRasterSize (längste Seite genau 2048,
ungültiges Seitenverhältnis quadratisch), hasTransparency und planLogo (Ausschnitt und Zielgröße, „leer“ und „zu klein“ nach der
fertigen Größe) sowie sizeSvgRoot. prepareLogo fügt sie zusammen: Raster über createImageBitmap (Breite × Höhe wird vor dem
Zeichnen gegen 40 Megapixel geprüft, das Bild danach freigegeben), SVG nur als Bild aus einer Object-URL (Seitenverhältnis aus
der viewBox, Größe ins Wurzelelement geschrieben), Zuschnitt, Verkleinern auf höchstens 720 × 250, PNG, hasTransparency auf den
Endpixeln. Jeder Fehler beim Dekodieren oder Zeichnen heißt „Das Logo konnte nicht gelesen werden.“; alle Meldungen sind schon
übersetzt (t() beim Auslösen), die deutschen Konstanten LOGO_*_MESSAGE stehen über msg().
- sizeSvgRoot ersetzt eine eigene width/height des Wurzelelements, statt ein zweites Attribut anzuhängen (doppelte Attribute machen
ein SVG ungültig, so scheiterten viele echte SVGs). Es macht genau einen Versuch ab dem ersten „<svg“: ein Suchmuster, das an
jedem „<svg“ neu ansetzt, brauchte bei einer Datei voller offener „<svg x="“ quadratisch viel Zeit (160 kB: 6 s).
- logos/upload-logo.ts (neu): uploadPreparedLogo legt logo.png mit read("any") und delete("user:<ich>") in den Bucket logo-images,
ruft uploadLogo und liefert das neue CommunityLogo (ID = Datei-ID, likeCount 0). mono gilt nur bei durchsichtigen Stellen. Wie
beim Veröffentlichen wird nur bei eindeutigem Scheitern aufgeräumt (genau die Datei dieses Versuchs, ein Fehler dabei überdeckt
nie den eigentlichen), bei offenem Ausgang bleibt die Datei. Ausnahme: 409 mit serverCode logoTaken löscht nichts, ein anderes
Logo trägt diese Datei-ID.
- i18n/en.ts: englische Texte zu den sechs neuen Meldungen.
- Tests: prepare-logo.test.ts (Helfer einzeln, dazu prepareLogo mit gestelltem Dekoder und Canvas: Zuschnitt, Skalierung, Rasterung,
Fehlertexte, 40-Megapixel-Grenze, SVG nie in der Seite) und upload-logo.test.ts (Reihenfolge, Rechte, Felder, mono, Aufräumen je
Status, logoTaken, Standard-Abhängigkeiten gegen den Bucket logo-images). Vitest 47 Dateien, 1452 Tests grün, tsc sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8205f7c37f
commit
0f66136d82
5 files changed
+1211
No files matched your search
@@ -37,6 +37,14 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'This card’s logo is no longer available. Open the card in the editor and choose another one.',
|
'This card’s logo is no longer available. Open the card in the editor and choose another one.',
|
||||||
'Logo oben links': 'Top-left logo',
|
'Logo oben links': 'Top-left logo',
|
||||||
|
|
||||||
|
// Logo auswählen und verarbeiten (logos/prepare-logo.ts)
|
||||||
|
'Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.': 'Please choose a PNG, JPG, WebP or SVG image.',
|
||||||
|
'Die Datei ist größer als 5 MB.': 'The file is larger than 5 MB.',
|
||||||
|
'Das Bild ist zu groß (max. 40 Megapixel).': 'The image is too large (max. 40 megapixels).',
|
||||||
|
'Das Logo konnte nicht gelesen werden.': 'The logo couldn’t be read.',
|
||||||
|
'Das Logo ist leer.': 'The logo is empty.',
|
||||||
|
'Das Logo ist zu klein (mindestens 8 × 8 Pixel).': 'The logo is too small (at least 8 × 8 pixels).',
|
||||||
|
|
||||||
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
||||||
'Bitte melde dich an.': 'Please sign in.',
|
'Bitte melde dich an.': 'Please sign in.',
|
||||||
'Nicht gefunden.': 'Not found.',
|
'Nicht gefunden.': 'Not found.',
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { setLang } from '../i18n/i18n';
|
||||||
|
import {
|
||||||
|
LOGO_ACCEPT,
|
||||||
|
fitWithin,
|
||||||
|
hasTransparency,
|
||||||
|
planLogo,
|
||||||
|
prepareLogo,
|
||||||
|
sizeSvgRoot,
|
||||||
|
svgRasterSize,
|
||||||
|
trimBox,
|
||||||
|
validateLogoFile,
|
||||||
|
} from './prepare-logo';
|
||||||
|
|
||||||
|
// Die Wortlaute der Spec (§ 4, § 11), hier bewusst noch einmal ausgeschrieben.
|
||||||
|
const TYPE = 'Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.';
|
||||||
|
const SIZE = 'Die Datei ist größer als 5 MB.';
|
||||||
|
const PIXELS = 'Das Bild ist zu groß (max. 40 Megapixel).';
|
||||||
|
const READ = 'Das Logo konnte nicht gelesen werden.';
|
||||||
|
const EMPTY = 'Das Logo ist leer.';
|
||||||
|
const SMALL = 'Das Logo ist zu klein (mindestens 8 × 8 Pixel).';
|
||||||
|
|
||||||
|
/** RGBA-Daten eines w × h Bildes. Alle Farbkanäle sind 255 (weiß), `alpha(x, y)` bestimmt die Deckkraft je Pixel. */
|
||||||
|
function pixels(w: number, h: number, alpha: (x: number, y: number) => number): Uint8ClampedArray {
|
||||||
|
const data = new Uint8ClampedArray(w * h * 4).fill(255);
|
||||||
|
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) data[(y * w + x) * 4 + 3] = alpha(x, y);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deckkraft 255 nur im Rechteck (x0, y0, w, h), sonst 0. */
|
||||||
|
const only =
|
||||||
|
(x0: number, y0: number, w: number, h: number) =>
|
||||||
|
(x: number, y: number): number =>
|
||||||
|
x >= x0 && x < x0 + w && y >= y0 && y < y0 + h ? 255 : 0;
|
||||||
|
|
||||||
|
describe('LOGO_ACCEPT', () => {
|
||||||
|
it('PNG, JPG, WebP und SVG (auch über die Endung, falls der Browser den Typ nicht kennt)', () => {
|
||||||
|
expect(LOGO_ACCEPT).toBe('image/png,image/jpeg,image/webp,image/svg+xml,.svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateLogoFile', () => {
|
||||||
|
const check = (type: string, size = 1000, name = 'logo') => validateLogoFile({ type, size, name });
|
||||||
|
|
||||||
|
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'])('nimmt %s an', (type) => {
|
||||||
|
expect(check(type)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt GIF mit der Typ-Meldung der Spec ab', () => {
|
||||||
|
expect(check('image/gif')).toEqual({ ok: false, message: TYPE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['image/heic', 'application/pdf', 'text/plain', 'image/bmp'])('lehnt %s ab', (type) => {
|
||||||
|
expect(check(type)).toEqual({ ok: false, message: TYPE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein SVG ohne Typ (manche Systeme kennen .svg nicht) wird am Namen erkannt, auch groß geschrieben', () => {
|
||||||
|
expect(check('', 1000, 'logo.SVG')).toEqual({ ok: true });
|
||||||
|
expect(check('', 1000, 'mein.logo.svg')).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Typ gilt nur die Endung .svg; ein anderer Typ wird durch den Namen nicht gerettet', () => {
|
||||||
|
expect(check('', 1000, 'logo.png')).toEqual({ ok: false, message: TYPE });
|
||||||
|
expect(check('', 1000, 'logosvg')).toEqual({ ok: false, message: TYPE });
|
||||||
|
expect(check('', 1000, 'logo.svg.exe')).toEqual({ ok: false, message: TYPE });
|
||||||
|
expect(check('text/plain', 1000, 'logo.svg')).toEqual({ ok: false, message: TYPE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5 242 881 Byte sind zu groß, genau 5 242 880 sind erlaubt', () => {
|
||||||
|
expect(check('image/png', 5_242_881)).toEqual({ ok: false, message: SIZE });
|
||||||
|
expect(check('image/png', 5_242_880)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Typ hat Vorrang vor der Größe', () => {
|
||||||
|
expect(check('image/gif', 9_000_000)).toEqual({ ok: false, message: TYPE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine leere Datei lässt sich nicht lesen', () => {
|
||||||
|
expect(check('image/png', 0)).toEqual({ ok: false, message: READ });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Meldung steht in der Sprache der Seite', () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
expect(check('image/gif')).toEqual({ ok: false, message: 'Please choose a PNG, JPG, WebP or SVG image.' });
|
||||||
|
expect(check('image/png', 5_242_881)).toEqual({ ok: false, message: 'The file is larger than 5 MB.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trimBox', () => {
|
||||||
|
it('ein einzelnes Pixel (2, 1) mit Alpha 9 in einem 4 × 3 Bild', () => {
|
||||||
|
const data = pixels(4, 3, (x, y) => (x === 2 && y === 1 ? 9 : 0));
|
||||||
|
expect(trimBox(data, 4, 3)).toEqual({ x: 2, y: 1, w: 1, h: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Alpha ≤ 8 überall: nichts Sichtbares, null', () => {
|
||||||
|
expect(trimBox(pixels(4, 3, () => 0), 4, 3)).toBeNull();
|
||||||
|
expect(trimBox(pixels(4, 3, () => 8), 4, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein volles Bild: die ganze Fläche', () => {
|
||||||
|
expect(trimBox(pixels(4, 3, () => 255), 4, 3)).toEqual({ x: 0, y: 0, w: 4, h: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Rahmen umfasst alle sichtbaren Pixel, auch weit auseinanderliegende', () => {
|
||||||
|
const visible = new Set(['1,0', '2,2']);
|
||||||
|
const data = pixels(5, 4, (x, y) => (visible.has(`${x},${y}`) ? 255 : 0));
|
||||||
|
expect(trimBox(data, 5, 4)).toEqual({ x: 1, y: 0, w: 2, h: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein sichtbares Rechteck in der Mitte', () => {
|
||||||
|
expect(trimBox(pixels(10, 8, only(3, 2, 4, 5)), 10, 8)).toEqual({ x: 3, y: 2, w: 4, h: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nur die Deckkraft zählt: weiße, aber durchsichtige Pixel sind leer', () => {
|
||||||
|
// Alle Farbkanäle sind 255, nur Alpha ist 0.
|
||||||
|
expect(trimBox(pixels(3, 3, () => 0), 3, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Schwelle lässt sich ändern (Alpha ≤ Schwelle gilt als leer)', () => {
|
||||||
|
const data = pixels(3, 3, (x, y) => (x === 1 && y === 1 ? 100 : 0));
|
||||||
|
expect(trimBox(data, 3, 3, 100)).toBeNull();
|
||||||
|
expect(trimBox(data, 3, 3, 99)).toEqual({ x: 1, y: 1, w: 1, h: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Bild ohne Fläche hat nichts Sichtbares', () => {
|
||||||
|
expect(trimBox(new Uint8ClampedArray(0), 0, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fitWithin', () => {
|
||||||
|
it('(1440, 500) in 720 × 250: genau halbiert', () => {
|
||||||
|
expect(fitWithin(1440, 500, 720, 250)).toEqual({ w: 720, h: 250 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('was schon hineinpasst, bleibt unverändert (nie vergrößern)', () => {
|
||||||
|
expect(fitWithin(300, 100, 720, 250)).toEqual({ w: 300, h: 100 });
|
||||||
|
expect(fitWithin(720, 250, 720, 250)).toEqual({ w: 720, h: 250 });
|
||||||
|
expect(fitWithin(10, 10, 720, 250)).toEqual({ w: 10, h: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die breite Seite begrenzt: (2000, 20) wird 720 × 7', () => {
|
||||||
|
expect(fitWithin(2000, 20, 720, 250)).toEqual({ w: 720, h: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die hohe Seite begrenzt: (500, 1000) wird 125 × 250', () => {
|
||||||
|
expect(fitWithin(500, 1000, 720, 250)).toEqual({ w: 125, h: 250 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rundet auf ganze Pixel', () => {
|
||||||
|
// 333 × 0,72 = 239,76
|
||||||
|
expect(fitWithin(1000, 333, 720, 250)).toEqual({ w: 720, h: 240 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jede Seite hat mindestens 1 Pixel', () => {
|
||||||
|
expect(fitWithin(100_000, 10, 720, 250)).toEqual({ w: 720, h: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('svgRasterSize', () => {
|
||||||
|
it.each([
|
||||||
|
[1, 2048, 2048],
|
||||||
|
[2, 2048, 1024],
|
||||||
|
[3, 2048, 683],
|
||||||
|
[0.5, 1024, 2048],
|
||||||
|
])('Seitenverhältnis %f: %i × %i', (aspect, w, h) => {
|
||||||
|
expect(svgRasterSize(aspect)).toEqual({ w, h });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([NaN, 0, -1, Infinity, -Infinity])('ungültiges Seitenverhältnis (%f): quadratisch 2048 × 2048', (aspect) => {
|
||||||
|
expect(svgRasterSize(aspect)).toEqual({ w: 2048, h: 2048 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die längste Seite hat immer genau 2048 px, nie mehr', () => {
|
||||||
|
for (const aspect of [0.01, 0.3, 0.9, 1, 1.1, 2.5, 7, 100]) {
|
||||||
|
const { w, h } = svgRasterSize(aspect);
|
||||||
|
expect(Math.max(w, h), String(aspect)).toBe(2048);
|
||||||
|
expect(Math.min(w, h), String(aspect)).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein extremes Seitenverhältnis lässt die kurze Seite bei 1 Pixel (nie 0)', () => {
|
||||||
|
expect(svgRasterSize(100_000)).toEqual({ w: 2048, h: 1 });
|
||||||
|
expect(svgRasterSize(1e-9)).toEqual({ w: 1, h: 2048 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasTransparency', () => {
|
||||||
|
it('alles deckend (Alpha 255): nein', () => {
|
||||||
|
expect(hasTransparency(pixels(4, 3, () => 255))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein einziges Pixel mit Alpha 254: ja', () => {
|
||||||
|
expect(hasTransparency(pixels(4, 3, (x, y) => (x === 3 && y === 2 ? 254 : 255)))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('völlig durchsichtige Pixel: ja', () => {
|
||||||
|
expect(hasTransparency(pixels(2, 2, (x) => (x === 0 ? 0 : 255)))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nur Alpha zählt, nicht die Farbe', () => {
|
||||||
|
const data = new Uint8ClampedArray([0, 0, 0, 255, 0, 0, 0, 255]);
|
||||||
|
expect(hasTransparency(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Pixel gibt es nichts Durchsichtiges', () => {
|
||||||
|
expect(hasTransparency(new Uint8ClampedArray(0))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planLogo', () => {
|
||||||
|
it('schneidet auf das Sichtbare zu und verkleinert auf höchstens 720 × 250', () => {
|
||||||
|
// 2000 × 1000 Pixel, sichtbar sind 1440 × 500 ab (100, 200).
|
||||||
|
const plan = planLogo(pixels(2000, 1000, only(100, 200, 1440, 500)), 2000, 1000);
|
||||||
|
expect(plan).toEqual({ box: { x: 100, y: 200, w: 1440, h: 500 }, size: { w: 720, h: 250 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein kleines Logo bleibt in seiner Größe (nie vergrößern)', () => {
|
||||||
|
const plan = planLogo(pixels(100, 60, only(10, 10, 40, 20)), 100, 60);
|
||||||
|
expect(plan).toEqual({ box: { x: 10, y: 10, w: 40, h: 20 }, size: { w: 40, h: 20 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nichts Sichtbares: „Das Logo ist leer.“', () => {
|
||||||
|
expect(() => planLogo(pixels(10, 10, () => 8), 10, 10)).toThrow(EMPTY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weniger als 8 Pixel in einer Richtung: „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“', () => {
|
||||||
|
expect(() => planLogo(pixels(50, 50, only(5, 5, 7, 20)), 50, 50)).toThrow(SMALL);
|
||||||
|
expect(() => planLogo(pixels(50, 50, only(5, 5, 20, 7)), 50, 50)).toThrow(SMALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('genau 8 × 8 Pixel sind erlaubt', () => {
|
||||||
|
expect(planLogo(pixels(50, 50, only(5, 5, 8, 8)), 50, 50).size).toEqual({ w: 8, h: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Zielgröße zählt: ein sehr breites Logo (2000 × 20) wird nur 7 Pixel hoch und ist zu klein', () => {
|
||||||
|
expect(() => planLogo(pixels(2000, 20, () => 255), 2000, 20)).toThrow(SMALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Meldungen stehen in der Sprache der Seite', () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
expect(() => planLogo(pixels(10, 10, () => 0), 10, 10)).toThrow('The logo is empty.');
|
||||||
|
expect(() => planLogo(pixels(50, 50, only(5, 5, 7, 7)), 50, 50)).toThrow('The logo is too small (at least 8 × 8 pixels).');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sizeSvgRoot', () => {
|
||||||
|
const NS = 'xmlns="http://www.w3.org/2000/svg"';
|
||||||
|
/** Wie oft das Attribut `name` im öffnenden Tag des Wurzelelements steht. */
|
||||||
|
const countIn = (svg: string, name: string): number => {
|
||||||
|
const root = /^[^]*?(<svg\b[^>]*>)/.exec(svg)?.[1] ?? '';
|
||||||
|
return (root.match(new RegExp(`\\s${name}\\s*=`, 'g')) ?? []).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('schreibt Breite und Höhe ins Wurzelelement und lässt den Rest unverändert', () => {
|
||||||
|
const svg = `<svg ${NS} viewBox="0 0 300 100"><rect width="300" height="100"/></svg>`;
|
||||||
|
expect(sizeSvgRoot(svg, 2048, 683)).toBe(
|
||||||
|
`<svg width="2048" height="683" ${NS} viewBox="0 0 300 100"><rect width="300" height="100"/></svg>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine vorhandene eigene Größe wird ersetzt, nicht doppelt geschrieben (doppelte Attribute machen das SVG ungültig)', () => {
|
||||||
|
const svg = `<svg ${NS} width="100px" height='50' viewBox="0 0 100 50"><g/></svg>`;
|
||||||
|
const sized = sizeSvgRoot(svg, 2048, 1024);
|
||||||
|
expect(sized).toBe(`<svg width="2048" height="1024" ${NS} viewBox="0 0 100 50"><g/></svg>`);
|
||||||
|
expect(countIn(sized, 'width')).toBe(1);
|
||||||
|
expect(countIn(sized, 'height')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Attribute in Zeilen- und Tabulatorform und mit Leerzeichen um das Gleichheitszeichen werden ebenfalls ersetzt', () => {
|
||||||
|
const svg = `<svg\n ${NS}\n\twidth = "10"\n height\n="20"\n viewBox="0 0 1 2">`;
|
||||||
|
const sized = sizeSvgRoot(svg, 1024, 2048);
|
||||||
|
expect(countIn(sized, 'width')).toBe(1);
|
||||||
|
expect(countIn(sized, 'height')).toBe(1);
|
||||||
|
expect(sized.startsWith('<svg width="1024" height="2048"')).toBe(true);
|
||||||
|
expect(sized).toContain('viewBox="0 0 1 2"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Attribute mit ähnlichem Namen (stroke-width, data-height) und Größen innerer Elemente bleiben', () => {
|
||||||
|
const inner = '<svg width="5" height="5"/><rect width="1" height="1"/></svg>';
|
||||||
|
const sized = sizeSvgRoot(`<svg ${NS} stroke-width="3" data-height="9" viewBox="0 0 1 1">${inner}`, 2048, 2048);
|
||||||
|
expect(sized).toBe(`<svg width="2048" height="2048" ${NS} stroke-width="3" data-height="9" viewBox="0 0 1 1">${inner}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein „>“ in einem Attributwert beendet das Wurzelelement nicht', () => {
|
||||||
|
const svg = `<svg ${NS} id="a>b" width="7" viewBox="0 0 4 2"><g/></svg>`;
|
||||||
|
expect(sizeSvgRoot(svg, 2048, 1024)).toBe(`<svg width="2048" height="1024" ${NS} id="a>b" viewBox="0 0 4 2"><g/></svg>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XML-Kopf, Kommentar und Doctype vor dem Wurzelelement stören nicht', () => {
|
||||||
|
const head =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><!-- Generator: Beispiel --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "x.dtd">';
|
||||||
|
const sized = sizeSvgRoot(`${head}<svg ${NS} width="1" height="1"/>`, 2048, 2048);
|
||||||
|
expect(sized).toBe(`${head}<svg width="2048" height="2048" ${NS}/>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein selbstschließendes Wurzelelement bleibt gültig', () => {
|
||||||
|
expect(sizeSvgRoot(`<svg ${NS}/>`, 8, 8)).toBe(`<svg width="8" height="8" ${NS}/>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne <svg> bleibt der Text unverändert (das Laden als Bild meldet den Fehler)', () => {
|
||||||
|
expect(sizeSvgRoot('kein Bild', 8, 8)).toBe('kein Bild');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Wurzelelement ohne Ende bleibt unverändert', () => {
|
||||||
|
expect(sizeSvgRoot(`<svg ${NS} width="1`, 8, 8)).toBe(`<svg ${NS} width="1`);
|
||||||
|
expect(sizeSvgRoot(`<svg ${NS} width="1"`, 8, 8)).toBe(`<svg ${NS} width="1"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Text voller offener „<svg x="“ braucht nur linear viel Zeit (kein Hängen im Tab)', () => {
|
||||||
|
// 160 kB; ein Suchmuster, das an jedem „<svg“ neu ansetzt, bräuchte hier Sekunden.
|
||||||
|
const hostile = '<svg x="'.repeat(20_000);
|
||||||
|
const start = performance.now();
|
||||||
|
expect(sizeSvgRoot(hostile, 2048, 2048)).toBe(hostile);
|
||||||
|
expect(performance.now() - start).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareLogo (Dekoder und Canvas gestellt)', () => {
|
||||||
|
interface FakeCanvas {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Argumente aller `drawImage`-Aufrufe. */
|
||||||
|
draws: unknown[][];
|
||||||
|
smoothing: string;
|
||||||
|
}
|
||||||
|
let canvases: FakeCanvas[];
|
||||||
|
let blobs: Blob[];
|
||||||
|
let revoked: string[];
|
||||||
|
let images: FakeImage[];
|
||||||
|
let imageFails: boolean;
|
||||||
|
const close = vi.fn();
|
||||||
|
|
||||||
|
/** Ersatz für `Image`: meldet „geladen“ (oder einen Fehler), sobald `src` gesetzt ist. */
|
||||||
|
class FakeImage {
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
constructor() {
|
||||||
|
images.push(this);
|
||||||
|
}
|
||||||
|
set src(_value: string) {
|
||||||
|
queueMicrotask(() => (imageFails ? this.onerror?.() : this.onload?.()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* happy-dom hat keinen 2D-Kontext: `document.createElement('canvas')` liefert Fakes. Der erste Canvas trägt das Quellbild
|
||||||
|
* (Deckkraft laut `visible`; mit `blank` ein unberührtes, leeres Feld, das auch bei 40 Megapixeln kaum Speicher braucht), der zweite
|
||||||
|
* das Ergebnis (überall deckend, mit `resultHole` fehlt dem ersten Pixel die Deckkraft).
|
||||||
|
*/
|
||||||
|
function stubCanvas(
|
||||||
|
opts: { visible?: (x: number, y: number) => number; blank?: boolean; resultHole?: boolean; noBlob?: boolean; noContext?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const create = document.createElement.bind(document);
|
||||||
|
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
|
||||||
|
if (tag !== 'canvas') return create(tag);
|
||||||
|
const isSource = canvases.length === 0;
|
||||||
|
const canvas: FakeCanvas = { width: 0, height: 0, draws: [], smoothing: '' };
|
||||||
|
const ctx = {
|
||||||
|
set imageSmoothingQuality(value: string) {
|
||||||
|
canvas.smoothing = value;
|
||||||
|
},
|
||||||
|
drawImage: (...args: unknown[]) => void canvas.draws.push(args),
|
||||||
|
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
||||||
|
data: isSource
|
||||||
|
? opts.blank
|
||||||
|
? new Uint8ClampedArray(w * h * 4)
|
||||||
|
: pixels(w, h, opts.visible ?? (() => 255))
|
||||||
|
: pixels(w, h, (x, y) => (opts.resultHole && x === 0 && y === 0 ? 0 : 255)),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
canvases.push(canvas);
|
||||||
|
return Object.assign(canvas, {
|
||||||
|
getContext: () => (opts.noContext ? null : ctx),
|
||||||
|
toBlob: (done: BlobCallback, type?: string) => done(opts.noBlob ? null : new Blob(['png'], { type })),
|
||||||
|
}) as unknown as HTMLElement;
|
||||||
|
}) as typeof document.createElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitmap = (width: number, height: number) => vi.fn(async () => ({ width, height, close }));
|
||||||
|
const rasterFile = () => new File(['x'], 'logo.png', { type: 'image/png' });
|
||||||
|
const svgFile = (text: string, name = 'logo.svg') => new File([text], name, { type: 'image/svg+xml' });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
canvases = [];
|
||||||
|
blobs = [];
|
||||||
|
revoked = [];
|
||||||
|
images = [];
|
||||||
|
imageFails = false;
|
||||||
|
close.mockReset();
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
|
||||||
|
blobs.push(blob as Blob);
|
||||||
|
return `blob:svg-${blobs.length}`;
|
||||||
|
});
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => void revoked.push(url));
|
||||||
|
vi.stubGlobal('Image', FakeImage);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rasterbild (PNG, JPG, WebP)', () => {
|
||||||
|
it('zeichnet in Originalgröße, schneidet auf das Sichtbare zu und gibt ein PNG aus', async () => {
|
||||||
|
const decode = bitmap(100, 40);
|
||||||
|
vi.stubGlobal('createImageBitmap', decode);
|
||||||
|
stubCanvas({ visible: only(10, 5, 20, 10) });
|
||||||
|
const file = rasterFile();
|
||||||
|
|
||||||
|
const prepared = await prepareLogo(file);
|
||||||
|
|
||||||
|
expect(decode).toHaveBeenCalledWith(file);
|
||||||
|
expect(canvases).toHaveLength(2);
|
||||||
|
expect(canvases[0]).toMatchObject({ width: 100, height: 40 });
|
||||||
|
expect(canvases[0].draws).toEqual([[expect.objectContaining({ width: 100, height: 40 }), 0, 0, 100, 40]]);
|
||||||
|
// Ergebnis: genau der sichtbare Ausschnitt, 1:1 (nie vergrößert).
|
||||||
|
expect(canvases[1]).toMatchObject({ width: 20, height: 10, smoothing: 'high' });
|
||||||
|
expect(canvases[1].draws).toEqual([[canvases[0], 10, 5, 20, 10, 0, 0, 20, 10]]);
|
||||||
|
expect(prepared).toMatchObject({ width: 20, height: 10, hasTransparency: false });
|
||||||
|
expect(prepared.png.type).toBe('image/png');
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verkleinert ein großes Logo auf höchstens 720 × 250', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(2000, 1000));
|
||||||
|
stubCanvas({ visible: only(100, 200, 1440, 500) });
|
||||||
|
|
||||||
|
const prepared = await prepareLogo(rasterFile());
|
||||||
|
|
||||||
|
expect(canvases[1]).toMatchObject({ width: 720, height: 250 });
|
||||||
|
expect(canvases[1].draws).toEqual([[canvases[0], 100, 200, 1440, 500, 0, 0, 720, 250]]);
|
||||||
|
expect([prepared.width, prepared.height]).toEqual([720, 250]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasTransparency kommt aus den Pixeln des fertigen Logos', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
|
||||||
|
stubCanvas({ visible: only(10, 5, 20, 10), resultHole: true });
|
||||||
|
await expect(prepareLogo(rasterFile())).resolves.toMatchObject({ hasTransparency: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Bild über 40 Megapixel wird abgelehnt, bevor ein Canvas entsteht', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(8001, 5000));
|
||||||
|
stubCanvas();
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS);
|
||||||
|
expect(canvases).toHaveLength(0);
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('genau 40 Megapixel sind noch erlaubt (das leere Bild scheitert erst als „leer“)', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(8000, 5000));
|
||||||
|
stubCanvas({ blank: true });
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
|
||||||
|
expect(canvases[0]).toMatchObject({ width: 8000, height: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nichts Sichtbares: „Das Logo ist leer.“', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
|
||||||
|
stubCanvas({ visible: () => 0 });
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unter 8 × 8 Pixel: „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
|
||||||
|
stubCanvas({ visible: only(10, 5, 7, 7) });
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(SMALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Dekoder lehnt die Datei ab: „Das Logo konnte nicht gelesen werden.“', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new DOMException('kaputt', 'InvalidStateError')));
|
||||||
|
stubCanvas();
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||||
|
expect(canvases).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne 2D-Canvas: „Das Logo konnte nicht gelesen werden.“, das Bild wird trotzdem freigegeben', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
|
||||||
|
stubCanvas({ noContext: true });
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erzeugt der Canvas kein PNG: „Das Logo konnte nicht gelesen werden.“', async () => {
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(100, 40));
|
||||||
|
stubCanvas({ visible: only(10, 5, 20, 10), noBlob: true });
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Fehlertext steht in der Sprache der Seite', async () => {
|
||||||
|
setLang('en', { persist: false });
|
||||||
|
vi.stubGlobal('createImageBitmap', bitmap(8001, 5000));
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow('The image is too large (max. 40 megapixels).');
|
||||||
|
vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new Error('kaputt')));
|
||||||
|
await expect(prepareLogo(rasterFile())).rejects.toThrow('The logo couldn’t be read.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SVG', () => {
|
||||||
|
const SVG =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="5" viewBox="0 0 300 100">' +
|
||||||
|
'<script>alert(1)</script><rect width="300" height="100"/></svg>';
|
||||||
|
|
||||||
|
it('schreibt die Rastergröße ins Wurzelelement, lädt es nur als Bild und rastert mit 2048 px an der längsten Seite', async () => {
|
||||||
|
stubCanvas({ visible: only(0, 100, 2048, 483) });
|
||||||
|
|
||||||
|
const prepared = await prepareLogo(svgFile(SVG));
|
||||||
|
|
||||||
|
// Geladen wird ein Blob als Bild; das Seitenverhältnis 3 : 1 aus der viewBox ergibt 2048 × 683.
|
||||||
|
expect(blobs).toHaveLength(1);
|
||||||
|
expect(blobs[0].type).toBe('image/svg+xml');
|
||||||
|
expect(await blobs[0].text()).toBe(SVG.replace(' width="10" height="5"', '').replace('<svg', '<svg width="2048" height="683"'));
|
||||||
|
expect(images).toHaveLength(1);
|
||||||
|
expect(revoked).toEqual(['blob:svg-1']);
|
||||||
|
// Das SVG kommt nie in die Seite, Skripte laufen darum nie.
|
||||||
|
expect(document.querySelector('svg, script, img')).toBeNull();
|
||||||
|
|
||||||
|
expect(canvases[0]).toMatchObject({ width: 2048, height: 683 });
|
||||||
|
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 683]]);
|
||||||
|
// Zuschnitt auf das Sichtbare (2048 × 483), dann auf die Zielgröße verkleinert.
|
||||||
|
expect(canvases[1]).toMatchObject({ width: 720, height: 170 });
|
||||||
|
expect(canvases[1].draws).toEqual([[canvases[0], 0, 100, 2048, 483, 0, 0, 720, 170]]);
|
||||||
|
expect(prepared).toMatchObject({ width: 720, height: 170 });
|
||||||
|
expect(prepared.png.type).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne brauchbare viewBox wird quadratisch mit 2048 × 2048 gerastert', async () => {
|
||||||
|
stubCanvas({ visible: only(0, 0, 200, 100) });
|
||||||
|
await prepareLogo(svgFile('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100"/></svg>'));
|
||||||
|
expect(canvases[0]).toMatchObject({ width: 2048, height: 2048 });
|
||||||
|
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 2048]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein SVG ohne Typ wird am Namen erkannt', async () => {
|
||||||
|
stubCanvas({ visible: only(0, 0, 100, 100) });
|
||||||
|
await prepareLogo(new File([SVG], 'Logo.SVG', { type: '' }));
|
||||||
|
expect(blobs[0].type).toBe('image/svg+xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich das SVG nicht laden: „Das Logo konnte nicht gelesen werden.“, die Object-URL wird freigegeben', async () => {
|
||||||
|
imageFails = true;
|
||||||
|
stubCanvas();
|
||||||
|
await expect(prepareLogo(svgFile('kein Bild'))).rejects.toThrow(READ);
|
||||||
|
expect(revoked).toEqual(['blob:svg-1']);
|
||||||
|
expect(canvases).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein leeres SVG: „Das Logo ist leer.“', async () => {
|
||||||
|
stubCanvas({ visible: () => 0 });
|
||||||
|
await expect(prepareLogo(svgFile(SVG))).rejects.toThrow(EMPTY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vorab geprüft', () => {
|
||||||
|
it('ein falscher Typ wird abgelehnt, ohne zu dekodieren', async () => {
|
||||||
|
const decode = bitmap(10, 10);
|
||||||
|
vi.stubGlobal('createImageBitmap', decode);
|
||||||
|
await expect(prepareLogo(new File(['x'], 'a.gif', { type: 'image/gif' }))).rejects.toThrow(TYPE);
|
||||||
|
expect(decode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine zu große Datei wird abgelehnt, ohne zu dekodieren', async () => {
|
||||||
|
const decode = bitmap(10, 10);
|
||||||
|
vi.stubGlobal('createImageBitmap', decode);
|
||||||
|
const big = new File([new Uint8Array(5_242_881)], 'a.png', { type: 'image/png' });
|
||||||
|
await expect(prepareLogo(big)).rejects.toThrow(SIZE);
|
||||||
|
expect(decode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Ein ausgewähltes Logo für den Upload vorbereiten: Datei prüfen, dekodieren (SVG nur als Bild, also ohne Skripte), transparente
|
||||||
|
// Ränder abschneiden, auf höchstens 720 × 250 px verkleinern (nie vergrößern) und als PNG ausgeben. Die Maße sind die des Dienstes
|
||||||
|
// (deploy/api/logos.js), der trotzdem selbst prüft. Zuschnitt, Skalierung und Transparenz sind reine Helfer (einzeln testbar);
|
||||||
|
// `prepareLogo` fügt sie mit dem Canvas des Browsers zusammen. Kein Netzwerk, kein Appwrite.
|
||||||
|
import { svgAspect } from '../card/marks';
|
||||||
|
import { msg, t } from '../i18n/i18n';
|
||||||
|
|
||||||
|
/** Für das `accept`-Attribut des Datei-Felds: die erlaubten Typen, dazu `.svg` für Systeme, die den Typ nicht kennen. */
|
||||||
|
export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg';
|
||||||
|
|
||||||
|
/** Höchstmaße und Mindestmaß des fertigen Logos in Pixeln (wie der Dienst sie prüft). */
|
||||||
|
export const LOGO_MAX_W = 720;
|
||||||
|
export const LOGO_MAX_H = 250;
|
||||||
|
export const LOGO_MIN = 8;
|
||||||
|
/** Pixel mit Alpha bis hierher gelten als leer (nicht sichtbar). */
|
||||||
|
export const ALPHA_EMPTY = 8;
|
||||||
|
/**
|
||||||
|
* Spec § 4: ein SVG ohne eigene Größe gilt als 1000 × 1000, also quadratisch. Gerastert wird immer mit SVG_RASTER_MAX; aus dem
|
||||||
|
* Fallback bleibt nur das quadratische Seitenverhältnis (siehe svgRasterSize).
|
||||||
|
*/
|
||||||
|
export const SVG_FALLBACK_SIZE = 1000;
|
||||||
|
/** Ein SVG wird mit genau so vielen Pixeln an der längsten Seite gerastert (scharf genug fürs Verkleinern auf 720 × 250). */
|
||||||
|
export const SVG_RASTER_MAX = 2048;
|
||||||
|
/** Grenzen der gewählten Datei: Dateigröße und Breite × Höhe (ein kleines, stark komprimiertes Bild kann sonst Gigabytes belegen). */
|
||||||
|
export const LOGO_SOURCE_MAX_BYTES = 5_242_880;
|
||||||
|
export const LOGO_SOURCE_MAX_PIXELS = 40_000_000;
|
||||||
|
|
||||||
|
// Die Meldungen (Spec § 4 und § 11); übersetzt wird beim Auslösen, siehe `problem`.
|
||||||
|
export const LOGO_TYPE_MESSAGE = msg('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.');
|
||||||
|
export const LOGO_SIZE_MESSAGE = msg('Die Datei ist größer als 5 MB.');
|
||||||
|
export const LOGO_PIXELS_MESSAGE = msg('Das Bild ist zu groß (max. 40 Megapixel).');
|
||||||
|
export const LOGO_READ_MESSAGE = msg('Das Logo konnte nicht gelesen werden.');
|
||||||
|
export const LOGO_EMPTY_MESSAGE = msg('Das Logo ist leer.');
|
||||||
|
export const LOGO_SMALL_MESSAGE = msg('Das Logo ist zu klein (mindestens 8 × 8 Pixel).');
|
||||||
|
|
||||||
|
/** Das fertige Logo: PNG samt Maßen; `hasTransparency` entscheidet, ob „Einfarbig“ wählbar ist. */
|
||||||
|
export interface PreparedLogo {
|
||||||
|
png: Blob;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
hasTransparency: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Box {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Fehler mit fertigem, übersetztem Text für die Oberfläche; jeder andere Fehler beim Verarbeiten gilt als „nicht lesbar“. */
|
||||||
|
class LogoError extends Error {}
|
||||||
|
|
||||||
|
/** Ein `LogoError` mit der Meldung in der Sprache der Seite. */
|
||||||
|
function problem(text: string): LogoError {
|
||||||
|
return new LogoError(t(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
const RASTER_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
const SVG_TYPE = 'image/svg+xml';
|
||||||
|
|
||||||
|
/** SVG: am Typ erkannt, oder am Namen, wenn der Browser keinen Typ liefert (manche Systeme kennen .svg nicht). */
|
||||||
|
function isSvgFile(f: { type: string; name: string }): boolean {
|
||||||
|
return f.type === SVG_TYPE || (f.type === '' && /\.svg$/i.test(f.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft Typ und Größe, bevor die Datei dekodiert wird. Der Typ hat Vorrang vor der Größe; die Meldung ist schon übersetzt. */
|
||||||
|
export function validateLogoFile(f: { type: string; size: number; name: string }): { ok: true } | { ok: false; message: string } {
|
||||||
|
if (!RASTER_TYPES.includes(f.type) && !isSvgFile(f)) return { ok: false, message: t(LOGO_TYPE_MESSAGE) };
|
||||||
|
if (f.size > LOGO_SOURCE_MAX_BYTES) return { ok: false, message: t(LOGO_SIZE_MESSAGE) };
|
||||||
|
if (f.size <= 0) return { ok: false, message: t(LOGO_READ_MESSAGE) };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der kleinste Rahmen um alle sichtbaren Pixel (Alpha größer als `threshold`) eines RGBA-Bildes; null, wenn nichts sichtbar ist.
|
||||||
|
* Nur die Deckkraft zählt, die Farbe nicht.
|
||||||
|
*/
|
||||||
|
export function trimBox(data: Uint8ClampedArray, w: number, h: number, threshold = ALPHA_EMPTY): Box | null {
|
||||||
|
let minX = w;
|
||||||
|
let minY = h;
|
||||||
|
let maxX = -1;
|
||||||
|
let maxY = -1;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const row = y * w * 4 + 3;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
if (data[row + x * 4] > threshold) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxX < 0 ? null : { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verkleinert w × h so, dass es in maxW × maxH passt (Seitenverhältnis bleibt): nie größer als das Original, gerundet, mindestens 1. */
|
||||||
|
export function fitWithin(w: number, h: number, maxW: number, maxH: number): Size {
|
||||||
|
const scale = Math.min(1, maxW / w, maxH / h);
|
||||||
|
return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rastergröße eines SVGs bei diesem Seitenverhältnis (Breite durch Höhe): die längste Seite hat genau 2048 px, nie mehr, die
|
||||||
|
* kürzere mindestens 1. Ohne brauchbares Seitenverhältnis (nicht endlich oder nicht größer als 0) ist es quadratisch.
|
||||||
|
*/
|
||||||
|
export function svgRasterSize(aspect: number): Size {
|
||||||
|
if (!Number.isFinite(aspect) || aspect <= 0) return { w: SVG_RASTER_MAX, h: SVG_RASTER_MAX };
|
||||||
|
if (aspect >= 1) return { w: SVG_RASTER_MAX, h: Math.max(1, Math.round(SVG_RASTER_MAX / aspect)) };
|
||||||
|
return { w: Math.max(1, Math.round(SVG_RASTER_MAX * aspect)), h: SVG_RASTER_MAX };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hat das Bild (RGBA) durchsichtige Stellen, also irgendein Pixel mit Alpha unter 255? */
|
||||||
|
export function hasTransparency(data: Uint8ClampedArray): boolean {
|
||||||
|
for (let i = 3; i < data.length; i += 4) if (data[i] < 255) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was aus den Pixeln eines Bildes wird: der sichtbare Ausschnitt und seine Zielgröße (höchstens 720 × 250, nie größer als der
|
||||||
|
* Ausschnitt). Wirft „Das Logo ist leer.“, wenn nichts Sichtbares bleibt, und „Das Logo ist zu klein …“, wenn die fertige Größe
|
||||||
|
* unter 8 × 8 Pixel läge (die Größe, die der Dienst prüft; ein sehr breites Logo kann dabei zu niedrig werden).
|
||||||
|
*/
|
||||||
|
export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box: Box; size: Size } {
|
||||||
|
const box = trimBox(data, w, h);
|
||||||
|
if (!box) throw problem(LOGO_EMPTY_MESSAGE);
|
||||||
|
const size = fitWithin(box.w, box.h, LOGO_MAX_W, LOGO_MAX_H);
|
||||||
|
if (size.w < LOGO_MIN || size.h < LOGO_MIN) throw problem(LOGO_SMALL_MESSAGE);
|
||||||
|
return { box, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt dem Wurzelelement eines SVGs die Größe `w` × `h` (damit alle Browser es scharf auf den Canvas zeichnen, wie `withIntrinsicSize`
|
||||||
|
* in card/marks.ts). Eine eigene `width`/`height` des Wurzelelements fällt dabei weg: ein doppeltes Attribut machte das SVG ungültig.
|
||||||
|
* Nur das erste `<svg …>` wird angefasst; ohne eines (oder ohne dessen Ende) bleibt der Text unverändert.
|
||||||
|
*/
|
||||||
|
export function sizeSvgRoot(svg: string, w: number, h: number): string {
|
||||||
|
const start = svg.search(/<svg\b/);
|
||||||
|
if (start < 0) return svg;
|
||||||
|
// Das öffnende Tag endet am ersten „>“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem ersten „<svg“ (`y`): so braucht
|
||||||
|
// auch eine Datei voller offener „<svg x="“ nur linear viel Zeit, ein Suchmuster, das an jedem „<svg“ neu ansetzt, hinge im Tab.
|
||||||
|
const tag = /<svg\b((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
|
||||||
|
tag.lastIndex = start;
|
||||||
|
const hit = tag.exec(svg);
|
||||||
|
if (!hit) return svg;
|
||||||
|
const kept = hit[1].replace(/\s(?:width|height)\s*=\s*(?:"[^"]*"|'[^']*')/g, '');
|
||||||
|
return `${svg.slice(0, start)}<svg width="${w}" height="${h}"${kept}>${svg.slice(start + hit[0].length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Dekodieren und Zeichnen (braucht einen echten Canvas) --------------------------------------------
|
||||||
|
|
||||||
|
/** Das Quellbild in Originalgröße auf einem Canvas samt seinen Pixeln (RGBA). */
|
||||||
|
interface Drawn {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
data: Uint8ClampedArray;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Canvas der Größe w × h mit 2D-Kontext. Es werden Pixel gelesen, daher `willReadFrequently`. */
|
||||||
|
function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw problem(LOGO_READ_MESSAGE);
|
||||||
|
return { canvas, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet die Quelle in Größe w × h auf einen neuen Canvas und liest dessen Pixel. */
|
||||||
|
function draw(source: CanvasImageSource, w: number, h: number): Drawn {
|
||||||
|
const { canvas, ctx } = newCanvas(w, h);
|
||||||
|
ctx.drawImage(source, 0, 0, w, h);
|
||||||
|
return { canvas, data: ctx.getImageData(0, 0, w, h).data, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG, JPG oder WebP: dekodieren, Breite × Höhe prüfen (bevor etwas gezeichnet wird) und in Originalgröße zeichnen. */
|
||||||
|
async function drawRaster(file: File): Promise<Drawn> {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
try {
|
||||||
|
if (bitmap.width * bitmap.height > LOGO_SOURCE_MAX_PIXELS) throw problem(LOGO_PIXELS_MESSAGE);
|
||||||
|
return draw(bitmap, bitmap.width, bitmap.height);
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt ein SVG als Bild, nie als Teil der Seite: so laufen Skripte darin nicht und es lädt nichts nach. */
|
||||||
|
function loadSvgImage(svg: string): Promise<HTMLImageElement> {
|
||||||
|
const url = URL.createObjectURL(new Blob([svg], { type: SVG_TYPE }));
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(problem(LOGO_READ_MESSAGE));
|
||||||
|
img.src = url;
|
||||||
|
}).finally(() => URL.revokeObjectURL(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG: Seitenverhältnis aus der viewBox (ohne: quadratisch), gerastert mit 2048 px an der längsten Seite. */
|
||||||
|
async function drawSvg(file: File): Promise<Drawn> {
|
||||||
|
const text = await file.text();
|
||||||
|
const { w, h } = svgRasterSize(svgAspect(text));
|
||||||
|
return draw(await loadSvgImage(sizeSvgRoot(text, w, h)), w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
|
return new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(problem(LOGO_READ_MESSAGE))), 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht aus der gewählten Datei das fertige Logo (siehe Kopf der Datei). Wirft einen Fehler mit der Meldung in der Sprache der
|
||||||
|
* Seite: Typ, Größe, zu viele Pixel, leer, zu klein oder nicht lesbar (alles, was beim Dekodieren oder Zeichnen schiefgeht).
|
||||||
|
*/
|
||||||
|
export async function prepareLogo(file: File): Promise<PreparedLogo> {
|
||||||
|
const check = validateLogoFile(file);
|
||||||
|
if (!check.ok) throw new LogoError(check.message);
|
||||||
|
try {
|
||||||
|
const { canvas, data, w, h } = isSvgFile(file) ? await drawSvg(file) : await drawRaster(file);
|
||||||
|
const { box, size } = planLogo(data, w, h);
|
||||||
|
const out = newCanvas(size.w, size.h);
|
||||||
|
// Beim Verkleinern ist die Standardqualität (low) grob.
|
||||||
|
out.ctx.imageSmoothingQuality = 'high';
|
||||||
|
out.ctx.drawImage(canvas, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h);
|
||||||
|
return {
|
||||||
|
png: await toPng(out.canvas),
|
||||||
|
width: size.w,
|
||||||
|
height: size.h,
|
||||||
|
hasTransparency: hasTransparency(out.ctx.getImageData(0, 0, size.w, size.h).data),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw err instanceof LogoError ? err : problem(LOGO_READ_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
createFile: vi.fn(),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
requireUserId: vi.fn(),
|
||||||
|
uploadLogo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Appwrite, Nutzer und der API-Aufruf sind nur für den Test der Standard-Abhängigkeiten gebraucht; sonst kommen Fakes (deps) zum Einsatz.
|
||||||
|
vi.mock('../account/appwrite', () => ({
|
||||||
|
IDS: { logoBucket: 'logo-images' },
|
||||||
|
account: {},
|
||||||
|
client: {},
|
||||||
|
tables: {},
|
||||||
|
storage: { createFile: mocks.createFile, deleteFile: mocks.deleteFile },
|
||||||
|
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||||||
|
}));
|
||||||
|
vi.mock('../account/cards', () => ({ requireUserId: mocks.requireUserId }));
|
||||||
|
vi.mock('./logo-api', () => ({ uploadLogo: mocks.uploadLogo }));
|
||||||
|
|
||||||
|
import { ID_PATTERN } from '../card/config';
|
||||||
|
import { ApiError } from '../community/api';
|
||||||
|
import type { PreparedLogo } from './prepare-logo';
|
||||||
|
import { uploadPreparedLogo, type LogoForm, type UploadLogoDeps } from './upload-logo';
|
||||||
|
|
||||||
|
const prepared = (patch: Partial<PreparedLogo> = {}): PreparedLogo => ({
|
||||||
|
png: new Blob(['png-bytes'], { type: 'image/png' }),
|
||||||
|
width: 200,
|
||||||
|
height: 80,
|
||||||
|
hasTransparency: true,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', mono: false };
|
||||||
|
|
||||||
|
type Call = { name: string; args: unknown[] };
|
||||||
|
|
||||||
|
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; die hochgeladene Datei bekommt die ID f1. */
|
||||||
|
function fakeDeps(overrides: Partial<UploadLogoDeps> = {}) {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const log = <T>(name: string, args: unknown[], result: T): T => {
|
||||||
|
calls.push({ name, args });
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const deps: UploadLogoDeps = {
|
||||||
|
userId: () => log('userId', [], 'u1'),
|
||||||
|
createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: 'f1' }),
|
||||||
|
deleteFile: async (id) => log('deleteFile', [id], undefined),
|
||||||
|
uploadLogo: async (input) => log('uploadLogo', [input], { id: 'f1' }),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
deps,
|
||||||
|
calls,
|
||||||
|
names: () => calls.map((c) => c.name),
|
||||||
|
of: (name: string) => calls.filter((c) => c.name === name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('uploadPreparedLogo', () => {
|
||||||
|
it('Reihenfolge: Nutzer, Upload, API', async () => {
|
||||||
|
const fake = fakeDeps();
|
||||||
|
await uploadPreparedLogo(prepared(), form, fake.deps);
|
||||||
|
expect(fake.names()).toEqual(['userId', 'createFile', 'uploadLogo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lädt das PNG als logo.png (image/png) mit read(any) und delete(user:<ich>) hoch', async () => {
|
||||||
|
const fake = fakeDeps();
|
||||||
|
const png = prepared().png;
|
||||||
|
await uploadPreparedLogo({ ...prepared(), png }, form, fake.deps);
|
||||||
|
const [file, permissions] = fake.of('createFile')[0].args as [File, string[]];
|
||||||
|
expect(file.name).toBe('logo.png');
|
||||||
|
expect(file.type).toBe('image/png');
|
||||||
|
expect(file.size).toBe(png.size);
|
||||||
|
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schickt der API die neue Datei-ID und genau vier Felder, Name und Anzeigename getrimmt', async () => {
|
||||||
|
const fake = fakeDeps();
|
||||||
|
await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: ' Kenso\t', mono: false }, fake.deps);
|
||||||
|
expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId: 'f1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein leerer Anzeigename bleibt leer (der Server macht daraus „Anonym“)', async () => {
|
||||||
|
const fake = fakeDeps();
|
||||||
|
await uploadPreparedLogo(prepared(), { ...form, displayName: ' ' }, fake.deps);
|
||||||
|
expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ displayName: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ mono: true, hasTransparency: true, sent: true },
|
||||||
|
{ mono: true, hasTransparency: false, sent: false },
|
||||||
|
{ mono: false, hasTransparency: true, sent: false },
|
||||||
|
{ mono: false, hasTransparency: false, sent: false },
|
||||||
|
])('mono $mono bei hasTransparency $hasTransparency: gesendet wird $sent', async ({ mono, hasTransparency, sent }) => {
|
||||||
|
const fake = fakeDeps();
|
||||||
|
const logo = await uploadPreparedLogo(prepared({ hasTransparency }), { ...form, mono }, fake.deps);
|
||||||
|
expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ mono: sent });
|
||||||
|
expect(logo.mono).toBe(sent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Erfolg: das neue Logo (ID = Datei-ID, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => {
|
||||||
|
const fake = fakeDeps();
|
||||||
|
const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: 'Kenso', mono: true }, fake.deps);
|
||||||
|
expect(logo).toEqual({
|
||||||
|
id: 'f1',
|
||||||
|
name: 'Beispielbank',
|
||||||
|
displayName: 'Kenso',
|
||||||
|
fileId: 'f1',
|
||||||
|
ownerId: 'u1',
|
||||||
|
mono: true,
|
||||||
|
likeCount: 0,
|
||||||
|
hidden: false,
|
||||||
|
reportCount: 0,
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(new Date(logo.createdAt).toISOString()).toBe(logo.createdAt);
|
||||||
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
|
||||||
|
it.each([400, 401, 403, 404])(
|
||||||
|
'eindeutig abgelehnt (%i): die Datei dieses Versuchs wird gelöscht, der Fehler kommt an',
|
||||||
|
async (code) => {
|
||||||
|
const failure = new ApiError(code, 'Abgelehnt.');
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('das Limit von 30 Logos (429 tooManyLogos, eindeutig): die Datei wird gelöscht', async () => {
|
||||||
|
const failure = new ApiError(429, 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', true, 'tooManyLogos');
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([408, 429, 500, 502, 503, 504])(
|
||||||
|
'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Datei bleibt liegen, der Fehler kommt an',
|
||||||
|
async (code) => {
|
||||||
|
const failure = new ApiError(code, 'Unklar.');
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('der Dienst ist nicht erreichbar (unavailable): die Datei bleibt liegen', async () => {
|
||||||
|
const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable');
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409 logoTaken: die Datei gehört schon einem Logo und wird NICHT gelöscht, der Fehler kommt an', async () => {
|
||||||
|
const failure = new ApiError(409, 'Diese Datei wird schon für ein Logo verwendet.', undefined, 'logoTaken');
|
||||||
|
expect(failure.definitive).toBe(true);
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entscheidend ist der Schlüssel logoTaken, nicht der Status: ein anderes 409 wird aufgeräumt', async () => {
|
||||||
|
const failure = new ApiError(409, 'Anderer Konflikt.', undefined, 'alreadyPublished');
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
|
||||||
|
const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true, 'jwtUnsupported');
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => {
|
||||||
|
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
|
||||||
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('deleteFile')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('löscht nie etwas anderes als die Datei dieses Versuchs', async () => {
|
||||||
|
const fake = fakeDeps({
|
||||||
|
createFile: async () => ({ $id: 'neu42' }),
|
||||||
|
uploadLogo: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')),
|
||||||
|
});
|
||||||
|
await uploadPreparedLogo(prepared(), form, fake.deps).catch(() => undefined);
|
||||||
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['neu42']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Fehler beim Aufräumen (auch ein sofort geworfener) überdeckt den ursprünglichen Fehler nicht', async () => {
|
||||||
|
const failure = new ApiError(400, 'Bitte gib einen Namen an.');
|
||||||
|
const rejecting = fakeDeps({
|
||||||
|
uploadLogo: async () => Promise.reject(failure),
|
||||||
|
deleteFile: async () => Promise.reject(new Error('delete kaputt')),
|
||||||
|
});
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, rejecting.deps)).rejects.toBe(failure);
|
||||||
|
|
||||||
|
const throwing = fakeDeps({
|
||||||
|
uploadLogo: async () => Promise.reject(failure),
|
||||||
|
deleteFile: () => {
|
||||||
|
throw new Error('delete kaputt');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, throwing.deps)).rejects.toBe(failure);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vor dem API-Aufruf', () => {
|
||||||
|
it('scheitert der Upload, wird die API nicht gerufen und nichts gelöscht, der Fehler kommt an', async () => {
|
||||||
|
const failure = new Error('upload kaputt');
|
||||||
|
const fake = fakeDeps({ createFile: async () => Promise.reject(failure) });
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.of('uploadLogo')).toHaveLength(0);
|
||||||
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne angemeldeten Nutzer wird nichts hochgeladen', async () => {
|
||||||
|
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
|
||||||
|
const fake = fakeDeps({
|
||||||
|
userId: () => {
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
||||||
|
expect(fake.names()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||||
|
mocks.requireUserId.mockReturnValue('u1');
|
||||||
|
// Wie Appwrite: die Datei hat die ID, mit der sie angelegt wurde.
|
||||||
|
mocks.createFile.mockImplementation(async ({ fileId }: { fileId: string }) => ({ $id: fileId }));
|
||||||
|
mocks.deleteFile.mockResolvedValue({});
|
||||||
|
mocks.uploadLogo.mockImplementation(async ({ fileId }: { fileId: string }) => ({ id: fileId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lädt mit frischer ID in den Logo-Bucket und ruft die API mit genau dieser ID', async () => {
|
||||||
|
const logo = await uploadPreparedLogo(prepared(), form);
|
||||||
|
expect(mocks.createFile).toHaveBeenCalledTimes(1);
|
||||||
|
const { bucketId, fileId, file, permissions } = mocks.createFile.mock.calls[0][0] as {
|
||||||
|
bucketId: string;
|
||||||
|
fileId: string;
|
||||||
|
file: File;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
expect(bucketId).toBe('logo-images');
|
||||||
|
expect(fileId).toMatch(ID_PATTERN);
|
||||||
|
expect(file.name).toBe('logo.png');
|
||||||
|
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
||||||
|
expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId });
|
||||||
|
expect(logo.id).toBe(fileId);
|
||||||
|
expect(mocks.deleteFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder Versuch bekommt seine eigene Datei-ID', async () => {
|
||||||
|
await uploadPreparedLogo(prepared(), form);
|
||||||
|
await uploadPreparedLogo(prepared(), form);
|
||||||
|
const [first, second] = mocks.createFile.mock.calls.map((call) => (call[0] as { fileId: string }).fileId);
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt bei eindeutiger Ablehnung genau diese Datei im Logo-Bucket auf', async () => {
|
||||||
|
mocks.uploadLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.'));
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form)).rejects.toBeInstanceOf(ApiError);
|
||||||
|
const { fileId } = mocks.createFile.mock.calls[0][0] as { fileId: string };
|
||||||
|
expect(mocks.deleteFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.deleteFile).toHaveBeenCalledWith({ bucketId: 'logo-images', fileId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne angemeldeten Nutzer (401 aus requireUserId): nichts wird hochgeladen', async () => {
|
||||||
|
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
|
||||||
|
mocks.requireUserId.mockImplementation(() => {
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
await expect(uploadPreparedLogo(prepared(), form)).rejects.toBe(failure);
|
||||||
|
expect(mocks.createFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Ein fertig verarbeitetes Logo hochladen: das PNG in den Logo-Bucket legen, dann `/api/logos/hochladen` rufen (logo-api.ts).
|
||||||
|
// Aufgeräumt wird nur bei eindeutigem Scheitern (wie beim Veröffentlichen, community/publish.ts): dann löscht der Client die Datei
|
||||||
|
// dieses Versuchs wieder. Bleibt der Ausgang offen, bleibt sie liegen (siehe uploadPreparedLogo).
|
||||||
|
import { ID, Permission, Role } from 'appwrite';
|
||||||
|
import { IDS, storage } from '../account/appwrite';
|
||||||
|
import { requireUserId } from '../account/cards';
|
||||||
|
import { ApiError, isDefinitiveFailure } from '../community/api';
|
||||||
|
import { uploadLogo, type LogoUploadInput } from './logo-api';
|
||||||
|
import type { CommunityLogo } from './logos';
|
||||||
|
import type { PreparedLogo } from './prepare-logo';
|
||||||
|
|
||||||
|
export interface LogoForm {
|
||||||
|
name: string;
|
||||||
|
/** Leer bedeutet „Anonym“ (das setzt der Dienst). */
|
||||||
|
displayName: string;
|
||||||
|
/** Einfarbig: nur wirksam, wenn das Logo durchsichtige Stellen hat. */
|
||||||
|
mono: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alles, was `uploadPreparedLogo` von außen braucht (im Test ersetzbar, ohne Netz und Appwrite). */
|
||||||
|
export interface UploadLogoDeps {
|
||||||
|
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
|
||||||
|
userId(): string;
|
||||||
|
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
|
||||||
|
deleteFile(fileId: string): Promise<void>;
|
||||||
|
uploadLogo(input: LogoUploadInput): Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDeps(): UploadLogoDeps {
|
||||||
|
return {
|
||||||
|
userId: requireUserId,
|
||||||
|
// Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat.
|
||||||
|
createFile: (file, permissions) =>
|
||||||
|
storage.createFile({ bucketId: IDS.logoBucket, fileId: ID.unique(), file, permissions }),
|
||||||
|
deleteFile: async (fileId) => {
|
||||||
|
await storage.deleteFile({ bucketId: IDS.logoBucket, fileId });
|
||||||
|
},
|
||||||
|
uploadLogo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Räumt auf, soweit es geht; ein Fehler (auch 404, auch ein sofort geworfener) darf den eigentlichen Fehler nie überdecken. */
|
||||||
|
async function deleteQuietly(deps: UploadLogoDeps, fileId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await deps.deleteFile(fileId);
|
||||||
|
} catch {
|
||||||
|
// Die Datei bleibt dann liegen: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zu dieser Datei gibt es schon ein Logo (409 `logoTaken`): die Datei gehört diesem Logo, auch wenn die Ablehnung eindeutig ist.
|
||||||
|
* Entschieden wird nach dem Schlüssel des Dienstes, nicht nach dem Status.
|
||||||
|
*/
|
||||||
|
function isLogoTaken(err: unknown): boolean {
|
||||||
|
return err instanceof ApiError && err.serverCode === 'logoTaken';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt das Logo hoch: das PNG in den Logo-Bucket (`read("any")`, `delete("user:<ich>")`), dann legt die API das Logo daraus an.
|
||||||
|
* Liefert das neue Logo, so wie die Liste es danach zeigt (die Zeilen-ID ist die Datei-ID). „Einfarbig“ gilt nur für Logos mit
|
||||||
|
* durchsichtigen Stellen. Name und Anzeigename werden getrimmt, ihre Grenzen prüft der Dienst.
|
||||||
|
*
|
||||||
|
* Aufräumen (nur die eine Datei dieses Versuchs, nie etwas anderes):
|
||||||
|
* - Scheitert der Upload, wurde die API noch nicht gerufen: es gibt kein Logo, das auf die Datei zeigen könnte.
|
||||||
|
* - Scheitert die API eindeutig (4xx außer 408/429, auch das Limit von 30 Logos, oder schon der JWT-Abruf), wurde nichts
|
||||||
|
* angelegt: die Datei wird gelöscht. Ausnahme `logoTaken` (409): ein anderes Logo trägt diese Datei-ID, die Datei bleibt.
|
||||||
|
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API das Logo schon angelegt
|
||||||
|
* haben. Dann bleibt die Datei liegen: sonst zeigte ein öffentliches Logo auf ein gelöschtes Bild.
|
||||||
|
* Der Fehler wird in jedem Fall weitergeworfen.
|
||||||
|
*/
|
||||||
|
export async function uploadPreparedLogo(
|
||||||
|
prepared: PreparedLogo,
|
||||||
|
form: LogoForm,
|
||||||
|
deps: UploadLogoDeps = defaultDeps(),
|
||||||
|
): Promise<CommunityLogo> {
|
||||||
|
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
|
||||||
|
const userId = deps.userId();
|
||||||
|
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
|
||||||
|
const name = form.name.trim();
|
||||||
|
const displayName = form.displayName.trim();
|
||||||
|
const mono = form.mono && prepared.hasTransparency;
|
||||||
|
|
||||||
|
const file = new File([prepared.png], 'logo.png', { type: 'image/png' });
|
||||||
|
const fileId = (await deps.createFile(file, permissions)).$id;
|
||||||
|
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
({ id } = await deps.uploadLogo({ name, displayName, mono, fileId }));
|
||||||
|
} catch (err) {
|
||||||
|
if (isDefinitiveFailure(err) && !isLogoTaken(err)) await deleteQuietly(deps, fileId);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
displayName,
|
||||||
|
fileId: id,
|
||||||
|
ownerId: userId,
|
||||||
|
mono,
|
||||||
|
likeCount: 0,
|
||||||
|
hidden: false,
|
||||||
|
reportCount: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user