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:
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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' })
|
||||
export class WorldStore implements OnDestroy {
|
||||
private readonly characterState = signal<CharacterResponse | null>(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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user