diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index cceed9d..78eed24 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { of, Subject, throwError } from 'rxjs'; import { vi } from 'vitest'; @@ -237,4 +238,96 @@ describe('WorldStore', () => { expect(store.loading()).toBe(false); expect(store.currentTravel()).toEqual({ status: 'IDLE' }); }); + + it('maps a known HttpErrorResponse travel error code to a specific German message', async () => { + await store.load(); + store.selectConnection(currentLocation.connections[0]); + api.startTravel.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { + statusCode: 409, + code: 'TRAVEL_ALREADY_ACTIVE', + message: 'The character is already travelling.', + }, + }), + ), + ); + // Hold the post-failure resync pending so the mapped message is + // observable before it gets cleared by a successful resync. + const pendingResync = new Subject(); + api.getCurrentTravel.mockReturnValue(pendingResync); + + await store.startTravel(); + + expect(store.error()).toBe('Du befindest dich bereits auf Reisen.'); + + pendingResync.next({ status: 'IDLE' }); + pendingResync.complete(); + }); + + it('falls back to the generic message for an HttpErrorResponse with no known code', async () => { + await store.load(); + store.selectConnection(currentLocation.connections[0]); + api.startTravel.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 500, + error: { message: 'Internal server error' }, + }), + ), + ); + const pendingResync = new Subject(); + api.getCurrentTravel.mockReturnValue(pendingResync); + + await store.startTravel(); + + expect(store.error()).toBe('Weltzustand konnte nicht geladen werden.'); + + pendingResync.next({ status: 'IDLE' }); + pendingResync.complete(); + }); + + it('uses the message of a genuine non-HTTP Error', async () => { + await store.load(); + store.selectConnection(currentLocation.connections[0]); + api.startTravel.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); + const pendingResync = new Subject(); + api.getCurrentTravel.mockReturnValue(pendingResync); + + await store.startTravel(); + + expect(store.error()).toBe('Netzwerkfehler'); + + pendingResync.next({ status: 'IDLE' }); + pendingResync.complete(); + }); + + it('resyncs current travel state from the server after a failed travel start', async () => { + await store.load(); + store.selectConnection(currentLocation.connections[0]); + api.startTravel.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { + statusCode: 409, + code: 'TRAVEL_ALREADY_ACTIVE', + message: 'The character is already travelling.', + }, + }), + ), + ); + api.getCurrentTravel.mockReturnValue(of(travelling)); + + await store.startTravel(); + await vi.advanceTimersByTimeAsync(0); + + expect(api.getCurrentTravel).toHaveBeenCalledTimes(2); + expect(store.currentTravel()).toEqual(travelling); + }); }); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 04343bc..3f466e3 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, OnDestroy, signal } from '@angular/core'; import { firstValueFrom, forkJoin } from 'rxjs'; import { @@ -8,6 +9,17 @@ import { } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; +const GENERIC_ERROR_MESSAGE = 'Weltzustand konnte nicht geladen werden.'; + +// Mirrors the `TravelErrorCode` union in `apps/api/src/travel/travel.errors.ts`. +// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`. +const TRAVEL_ERROR_MESSAGES: Readonly> = { + TRAVEL_ALREADY_ACTIVE: 'Du befindest dich bereits auf Reisen.', + INVALID_TRAVEL_TARGET: 'Dieses Ziel ist von hier aus nicht erreichbar.', + CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.', + TRAVEL_STATE_INVALID: 'Der Reisezustand ist ungültig. Bitte lade die Seite neu.', +}; + @Injectable({ providedIn: 'root' }) export class WorldStore implements OnDestroy { private readonly characterState = signal(null); @@ -88,6 +100,7 @@ export class WorldStore implements OnDestroy { } catch (error) { if (!this.destroyed) { this.errorState.set(this.toErrorMessage(error)); + this.pollCurrentTravel(); } } finally { if (!this.destroyed) { @@ -241,6 +254,11 @@ export class WorldStore implements OnDestroy { } private toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Weltzustand konnte nicht geladen werden.'; + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + return (code && TRAVEL_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE; + } + + return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE; } }