fix: map backend travel error codes to German messages and resync on failed start

HttpErrorResponse implements the Error interface structurally but does not
extend the Error class, so `error instanceof Error` was always false for
HTTP failures and every travel error (400/409/500/network) fell through to
the same generic fallback string, hiding the backend's specific
TravelDomainError code/message (e.g. TRAVEL_ALREADY_ACTIVE) from the user.

toErrorMessage now checks `error instanceof HttpErrorResponse` first and
maps known travel error codes to specific German messages, falling back to
the generic message for unknown codes and to `error.message` for genuine
non-HTTP errors.

startTravel()'s catch block also now resyncs current travel state from the
server (reusing the existing pollCurrentTravel/refreshCurrentTravel path)
so a failed start caused by a race with another tab/request doesn't leave
the store's local state stale.
This commit is contained in:
Bastian Wagner
2026-08-19 10:50:20 +02:00
parent 1bb4feb6fb
commit 2e7b280270
2 changed files with 112 additions and 1 deletions

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { of, Subject, throwError } from 'rxjs'; import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest'; import { vi } from 'vitest';
@@ -237,4 +238,96 @@ describe('WorldStore', () => {
expect(store.loading()).toBe(false); expect(store.loading()).toBe(false);
expect(store.currentTravel()).toEqual({ status: 'IDLE' }); 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<CurrentTravel>();
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<CurrentTravel>();
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<CurrentTravel>();
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);
});
}); });

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, OnDestroy, signal } from '@angular/core'; import { Injectable, OnDestroy, signal } from '@angular/core';
import { firstValueFrom, forkJoin } from 'rxjs'; import { firstValueFrom, forkJoin } from 'rxjs';
import { import {
@@ -8,6 +9,17 @@ import {
} from '../../core/api/game-api.models'; } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service'; 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<Record<string, string>> = {
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' }) @Injectable({ providedIn: 'root' })
export class WorldStore implements OnDestroy { export class WorldStore implements OnDestroy {
private readonly characterState = signal<CharacterResponse | null>(null); private readonly characterState = signal<CharacterResponse | null>(null);
@@ -88,6 +100,7 @@ export class WorldStore implements OnDestroy {
} catch (error) { } catch (error) {
if (!this.destroyed) { if (!this.destroyed) {
this.errorState.set(this.toErrorMessage(error)); this.errorState.set(this.toErrorMessage(error));
this.pollCurrentTravel();
} }
} finally { } finally {
if (!this.destroyed) { if (!this.destroyed) {
@@ -241,6 +254,11 @@ export class WorldStore implements OnDestroy {
} }
private toErrorMessage(error: unknown): string { 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;
} }
} }