diff --git a/web/src/community/publish.test.ts b/web/src/community/publish.test.ts index 171ef97..0e615a7 100644 --- a/web/src/community/publish.test.ts +++ b/web/src/community/publish.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_CONFIG } from '../card/config'; +import { DEFAULT_CONFIG, type TemplateId } from '../card/config'; import type { SavedCard } from '../account/cards'; const mocks = vi.hoisted(() => ({ @@ -30,6 +30,7 @@ import { ApiError, publishCard } from './api'; import { LOGO_MISSING_PUBLISH, PNG_MAX_BYTES, + assertLogoAvailable, normalizeTags, publishSavedCard, renderForGallery, @@ -263,7 +264,12 @@ describe('publishSavedCard', () => { describe('Logo der Karte beim Veröffentlichen', () => { const photo = { source: {}, w: 3000, h: 2000 }; const source = new Blob(['src'], { type: 'image/jpeg' }); - const withLogo = (logo: string | null): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, logo } }); + const withLogo = (logo: string | null, template: TemplateId = DEFAULT_CONFIG.template): SavedCard => ({ + ...card, + config: { ...DEFAULT_CONFIG, template, logo }, + }); + /** Die Vorlagen, die das Community-Logo zeichnen (alle außer „Nur Bild“). */ + const DRAWING = ['minimal', 'classic', 'signature'] as const; const assetsFor = (patch: Record = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch }); beforeEach(() => { @@ -304,10 +310,21 @@ describe('Logo der Karte beim Veröffentlichen', () => { expect(out.png.type).toBe('image/png'); }); - it('fehlendes Logo (gelöscht oder ausgeblendet): Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', async () => { + it.each(DRAWING)( + 'fehlendes Logo (gelöscht oder ausgeblendet), Vorlage %s zeichnet es: Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', + async (template) => { + mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); + await expect(renderForGallery(withLogo('gone', template), source)).rejects.toThrow(LOGO_MISSING_PUBLISH); + expect(mocks.exportCard).not.toHaveBeenCalled(); + }, + ); + + it('fehlendes Logo bei „Nur Bild“ (image): die Vorlage zeichnet es nie, es wird ganz normal gerendert', async () => { mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); - await expect(renderForGallery(withLogo('gone'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH); - expect(mocks.exportCard).not.toHaveBeenCalled(); + const out = await renderForGallery(withLogo('gone', 'image'), source); + expect(mocks.exportCard).toHaveBeenCalledTimes(2); + expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' }); + expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']); }); it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => { @@ -316,16 +333,24 @@ describe('Logo der Karte beim Veröffentlichen', () => { await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure); }); - it('publishSavedCard: mit fehlendem Logo wird nichts hochgeladen, nichts gelöscht und die API nicht gerufen', async () => { + it('publishSavedCard: classic mit fehlendem Logo – nichts wird hochgeladen, nichts gelöscht, die API nicht gerufen', async () => { mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); const fake = fakeDeps({ render: renderForGallery }); - await expect(publishSavedCard(withLogo('gone'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH); + await expect(publishSavedCard(withLogo('gone', 'classic'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH); expect(fake.names()).toEqual(['userId', 'loadImage']); expect(fake.of('createFile')).toHaveLength(0); expect(fake.of('deleteFile')).toHaveLength(0); expect(fake.of('publishCard')).toHaveLength(0); }); + it('publishSavedCard: „Nur Bild“ (image) mit fehlendem Logo – wird veröffentlicht, als wäre nichts', async () => { + mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); + const fake = fakeDeps({ render: renderForGallery }); + await expect(publishSavedCard(withLogo('gone', 'image'), form, fake.deps)).resolves.toEqual({ id: 'g1' }); + expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']); + expect(fake.of('deleteFile')).toHaveLength(0); + }); + it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => { const fake = fakeDeps({ render: renderForGallery }); await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' }); @@ -333,6 +358,23 @@ describe('Logo der Karte beim Veröffentlichen', () => { }); }); +describe('assertLogoAvailable', () => { + const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' }); + const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing }); + + it.each(['minimal', 'classic', 'signature'] as const)('Logo fehlt und Vorlage %s zeichnet es: wirft LOGO_MISSING_PUBLISH', (template) => { + expect(() => assertLogoAvailable(config(template), assets(true))).toThrow(LOGO_MISSING_PUBLISH); + }); + + it('Logo fehlt, aber „Nur Bild“ zeichnet nie ein Logo: wirft nicht', () => { + expect(() => assertLogoAvailable(config('image'), assets(true))).not.toThrow(); + }); + + it.each(['image', 'minimal', 'classic', 'signature'] as const)('Logo ist da (oder keins gewählt): Vorlage %s wirft nicht', (template) => { + expect(() => assertLogoAvailable(config(template), assets(false))).not.toThrow(); + }); +}); + describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => { const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); diff --git a/web/src/community/publish.ts b/web/src/community/publish.ts index 6a90cc5..57bac5a 100644 --- a/web/src/community/publish.ts +++ b/web/src/community/publish.ts @@ -4,9 +4,10 @@ import { ID, Permission, Role } from 'appwrite'; import { IDS, storage } from '../account/appwrite'; import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards'; -import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; +import type { CardConfig } from '../card/config'; +import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH, layoutFor } from '../card/geometry'; import { clampCrop } from '../card/crop'; -import { exportCard, loadCardAssets } from '../card/render'; +import { exportCard, loadCardAssets, type LoadedAssets } from '../card/render'; import { decodeImage } from '../editor/upload'; import { msg } from '../i18n/i18n'; import { isDefinitiveFailure, publishCard, type PublishInput } from './api'; @@ -15,7 +16,7 @@ import { isDefinitiveFailure, publishCard, type PublishInput } from './api'; export const PNG_MAX_BYTES = 5_242_880; export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).'; export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; -/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: veröffentlicht wird sie nicht ohne dieses Logo. */ +/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: ohne dieses Logo wird sie weder veröffentlicht noch heruntergeladen. */ export const LOGO_MISSING_PUBLISH = msg( 'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.', ); @@ -52,12 +53,22 @@ export function normalizeTags(input: string): string[] { return tags; } +/** + * Prüft nach dem Laden der Bilder einer gespeicherten Karte, ob ihr beim Rendern etwas fehlt (Veröffentlichen und Herunterladen + * aus „Meine Karten“): Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet) und zeichnet die Vorlage es, wirft + * das LOGO_MISSING_PUBLISH. So wird nie eine andere Karte veröffentlicht oder heruntergeladen, als der Nutzer sie gestaltet hat. + * Bei „Nur Bild“ wird ein Logo nie gezeichnet: dort fehlt nichts, und es wird ganz normal gerendert. + */ +export function assertLogoAvailable(config: CardConfig, assets: LoadedAssets): void { + if (assets.logoMissing && layoutFor(config.template).logo) throw new Error(LOGO_MISSING_PUBLISH); +} + export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> { const image = await decodeImage(imageBlob); const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; const assets = await loadCardAssets(config, image); - // Ohne das gewählte Logo würde eine andere Karte veröffentlicht, als der Nutzer sie gestaltet hat: lieber abbrechen. - if (assets.logoMissing) throw new Error(LOGO_MISSING_PUBLISH); + // Lieber abbrechen, als eine Karte ohne ihr Logo zu veröffentlichen. + assertLogoAvailable(config, assets); const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY }); return { png, preview }; diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index b708809..8d09415 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import type { SavedCard } from '../account/cards'; -import { DEFAULT_CONFIG } from '../card/config'; +import { DEFAULT_CONFIG, type TemplateId } from '../card/config'; import { createStore, initialEditorState } from '../editor/state'; import type { GalleryCard } from '../community/gallery'; @@ -67,6 +67,7 @@ vi.mock('../community/api', async (importOriginal) => ({ vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications })); import { ApiError } from '../community/api'; +import { LOGO_MISSING_PUBLISH } from '../community/publish'; import { mountCardsPage } from './cards-page'; const user: User = { $id: 'u1', email: 'a@b.de' }; @@ -301,6 +302,80 @@ describe('Meine Karten: Öffnen und Herunterladen', () => { }); }); +describe('Meine Karten: Herunterladen mit fehlendem Logo', () => { + const image = { source: {}, w: 3000, h: 2000 }; + const png = new Blob(['png'], { type: 'image/png' }); + /** Eine Karte, die ein Logo wählt, das es nicht mehr gibt (loadCardAssets meldet logoMissing). */ + const onTemplate = (template: TemplateId) => + card('a', 'Alpha', { config: { ...structuredClone(DEFAULT_CONFIG), template, logo: 'gone' } }); + const msg = () => tileFor('a').querySelector('.card-msg')!; + + beforeEach(() => { + mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' })); + mocks.decodeImage.mockResolvedValue(image); + mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: true }); + mocks.exportCard.mockResolvedValue(png); + }); + + async function download(template: TemplateId): Promise { + mocks.list.mockResolvedValue([onTemplate(template)]); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + button(tileFor('a'), 'Herunterladen').click(); + } + + it.each(['minimal', 'classic', 'signature'] as const)( + 'Vorlage %s zeichnet das Logo: die Meldung steht an der Kachel, es wird nichts heruntergeladen', + async (template) => { + await download(template); + await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH)); + expect(msg().textContent).toBe( + 'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.', + ); + expect(msg().classList.contains('is-warning')).toBe(false); + expect(mocks.exportCard).not.toHaveBeenCalled(); + expect(mocks.saveBlobAs).not.toHaveBeenCalled(); + // Der Knopf ist danach wieder bedienbar und trägt seine Beschriftung. + const again = button(tileFor('a'), 'Herunterladen'); + expect(again.disabled).toBe(false); + expect(tileFor('a').getAttribute('aria-busy')).toBeNull(); + }, + ); + + it('„Nur Bild“ (image) zeichnet nie ein Logo: der Download läuft ganz normal, ohne Meldung', async () => { + await download('image'); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); + expect(mocks.exportCard).toHaveBeenCalledTimes(1); + expect(msg().textContent).toBe(''); + }); + + it('nach der Meldung geht ein zweiter Versuch (Logo wieder da) durch, die Meldung ist dann weg', async () => { + await download('classic'); + await vi.waitFor(() => expect(msg().textContent).toBe(LOGO_MISSING_PUBLISH)); + mocks.loadCardAssets.mockResolvedValue({ image, logo: { source: {}, w: 200, h: 100 }, brand: null, logoMissing: false }); + button(tileFor('a'), 'Herunterladen').click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledTimes(1)); + expect(msg().textContent).toBe(''); + }); + + it('Englisch: dieselbe Meldung, übersetzt', async () => { + const { setLang } = await import('../i18n/i18n'); + setLang('en', { persist: false }); + await download('signature'); + await vi.waitFor(() => + expect(msg().textContent).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.'), + ); + expect(mocks.saveBlobAs).not.toHaveBeenCalled(); + }); + + it('eine Karte ohne gewähltes Logo wird normal heruntergeladen', async () => { + mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false }); + await download('classic'); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); + expect(msg().textContent).toBe(''); + }); +}); + describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => { const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }); const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }); diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index 848f8d9..5fb450a 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -12,13 +12,14 @@ import { CARD_W } from '../card/geometry'; import { exportCard, loadCardAssets } from '../card/render'; import { ApiError, isAlreadyPublished, isDefinitiveFailure, withdrawCard } from '../community/api'; import { myPublications } from '../community/gallery'; -import { publishSavedCard, type PublishForm } from '../community/publish'; +import { LOGO_MISSING_PUBLISH, assertLogoAvailable, publishSavedCard, type PublishForm } from '../community/publish'; import { publishDialog } from '../community/publish-dialog'; import { confirmDialog } from '../editor/confirm-dialog'; import { el } from '../editor/dom'; import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download'; import type { EditorStore } from '../editor/state'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload'; +import { t } from '../i18n/i18n'; import { formatDate } from './format'; import { navigate } from './router'; @@ -429,6 +430,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi const image = await decodeImage(blob); const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; const assets = await loadCardAssets(config, image); + // Fehlt das gewählte Logo und würde die Vorlage es zeichnen, nicht stillschweigend ohne Logo herunterladen: die Meldung + // steht dann an der Kachel (siehe failureText). Bei „Nur Bild“ fehlt nichts. + assertLogoAvailable(config, assets); const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); saveBlobAs(png, downloadFileName(card.name, new Date())); const warning = pngSizeWarning(png.size); @@ -533,6 +537,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi if (err instanceof LoginCancelled) return LOGIN_NEEDED; if (err instanceof ApiError) return err.message; // der Dienst antwortet auf Deutsch if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message; + if (err instanceof Error && err.message === LOGO_MISSING_PUBLISH) return t(LOGO_MISSING_PUBLISH); return errorMessage(err, 'load'); }