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 { 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user