refactor(errors): classify auth and browser failures
This commit is contained in:
@@ -78,6 +78,22 @@ describe("ExceptionHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps browser capability and permission errors", () => {
|
||||
expect(ExceptionHandler.handle(new Error("MediaRecorder not supported"))).toEqual({
|
||||
code: ExceptionCode.unsupported,
|
||||
message: "This feature is not supported in this browser.",
|
||||
});
|
||||
|
||||
expect(
|
||||
ExceptionHandler.handle(
|
||||
new DOMException("Microphone permission denied", "NotAllowedError"),
|
||||
),
|
||||
).toEqual({
|
||||
code: ExceptionCode.permissionDenied,
|
||||
message: "Permission denied.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an existing AppException result", () => {
|
||||
const error = new AppException({
|
||||
code: ExceptionCode.notFound,
|
||||
|
||||
@@ -9,6 +9,8 @@ export const ExceptionCode = {
|
||||
validation: "VALIDATION_ERROR",
|
||||
storage: "STORAGE_ERROR",
|
||||
payment: "PAYMENT_ERROR",
|
||||
permissionDenied: "PERMISSION_DENIED",
|
||||
unsupported: "UNSUPPORTED_FEATURE",
|
||||
cancelled: "CANCELLED",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppException } from "./app-exception";
|
||||
import { ExceptionCode } from "./exception-code";
|
||||
import type { ExceptionResult } from "./exception-result";
|
||||
import { ApiExceptionHandler } from "./handlers/api-exception-handler";
|
||||
import { BrowserExceptionHandler } from "./handlers/browser-exception-handler";
|
||||
import {
|
||||
DEFAULT_EXCEPTION_MESSAGE,
|
||||
FallbackExceptionHandler,
|
||||
@@ -25,6 +26,10 @@ export class ExceptionHandler {
|
||||
return NetworkExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
if (BrowserExceptionHandler.canHandle(error)) {
|
||||
return BrowserExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
if (ValidationExceptionHandler.canHandle(error)) {
|
||||
return ValidationExceptionHandler.handle(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ExceptionCode } from "../exception-code";
|
||||
import type { ExceptionResult } from "../exception-result";
|
||||
|
||||
export class BrowserExceptionHandler {
|
||||
static canHandle(error: unknown): error is Error | DOMException {
|
||||
if (!isErrorLike(error)) return false;
|
||||
return isPermissionError(error) || isUnsupportedFeatureError(error);
|
||||
}
|
||||
|
||||
static handle(error: Error | DOMException): ExceptionResult {
|
||||
if (isPermissionError(error)) {
|
||||
return {
|
||||
code: ExceptionCode.permissionDenied,
|
||||
message: "Permission denied.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: ExceptionCode.unsupported,
|
||||
message: "This feature is not supported in this browser.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isPermissionError(error: Error | DOMException): boolean {
|
||||
const name = error.name.toLowerCase();
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
name === "notallowederror" ||
|
||||
name === "permissiondeniederror" ||
|
||||
message.includes("permission denied") ||
|
||||
(message.includes("permission") && message.includes("denied"))
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsupportedFeatureError(error: Error | DOMException): boolean {
|
||||
const name = error.name.toLowerCase();
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
name === "notsupportederror" ||
|
||||
message.includes("not supported") ||
|
||||
message.includes("not available on server") ||
|
||||
message.includes("unsupported")
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorLike(error: unknown): error is Error | DOMException {
|
||||
if (error instanceof Error) return true;
|
||||
return typeof DOMException !== "undefined" && error instanceof DOMException;
|
||||
}
|
||||
@@ -69,7 +69,11 @@ async function refreshLoginToken(baseUrl: string): Promise<void> {
|
||||
const storage = AuthStorage.getInstance();
|
||||
const refreshResult = await storage.getRefreshToken();
|
||||
if (!Result.isOk(refreshResult) || !refreshResult.data) {
|
||||
throw new Error("No refresh token available");
|
||||
throw new ApiError(
|
||||
"HTTP_UNAUTHORIZED",
|
||||
"No refresh token available",
|
||||
ErrorCode.httpUnauthorized,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${ApiPath.refresh}`, {
|
||||
@@ -79,7 +83,12 @@ async function refreshLoginToken(baseUrl: string): Promise<void> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Refresh failed: ${response.status}`);
|
||||
throw new ApiError(
|
||||
apiCodeForStatus(response.status),
|
||||
"Refresh failed",
|
||||
response.status,
|
||||
{ statusText: response.statusText },
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
@@ -91,7 +100,7 @@ async function refreshLoginToken(baseUrl: string): Promise<void> {
|
||||
if (json.data.token) await storage.setLoginToken(json.data.token);
|
||||
if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken);
|
||||
} else {
|
||||
throw new Error("Refresh response invalid");
|
||||
throw new ApiError(ErrorCode.parseError, "Refresh response invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +118,12 @@ async function refreshGuestToken(baseUrl: string): Promise<void> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Guest refresh failed: ${response.status}`);
|
||||
throw new ApiError(
|
||||
apiCodeForStatus(response.status),
|
||||
"Guest refresh failed",
|
||||
response.status,
|
||||
{ statusText: response.statusText },
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
@@ -121,7 +135,22 @@ async function refreshGuestToken(baseUrl: string): Promise<void> {
|
||||
if (json.data.token) await storage.setGuestToken(json.data.token);
|
||||
if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId);
|
||||
} else {
|
||||
throw new Error("Guest refresh response invalid");
|
||||
throw new ApiError(ErrorCode.parseError, "Guest refresh response invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function apiCodeForStatus(status: number): string {
|
||||
switch (status) {
|
||||
case ErrorCode.httpUnauthorized:
|
||||
return "HTTP_UNAUTHORIZED";
|
||||
case ErrorCode.httpForbidden:
|
||||
return "HTTP_FORBIDDEN";
|
||||
case ErrorCode.httpNotFound:
|
||||
return "HTTP_NOT_FOUND";
|
||||
case ErrorCode.httpServerError:
|
||||
return "HTTP_SERVER_ERROR";
|
||||
default:
|
||||
return "HTTP_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AuthApi, authApi } from "@/data/services/api";
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
|
||||
import {
|
||||
AppleLoginRequest,
|
||||
FacebookLoginRequest,
|
||||
@@ -141,9 +142,10 @@ export class AuthRepository implements IAuthRepository {
|
||||
}
|
||||
return response;
|
||||
}).catch((e) => {
|
||||
const errorResult = ExceptionHandler.handle(e);
|
||||
log.error("[AuthRepository.guestLogin] API call FAILED", {
|
||||
errorName: e?.name,
|
||||
errorMessage: e?.message,
|
||||
errorCode: errorResult.code,
|
||||
errorMessage: errorResult.message,
|
||||
});
|
||||
return Result.err(e);
|
||||
});
|
||||
@@ -227,7 +229,13 @@ export class AuthRepository implements IAuthRepository {
|
||||
async refreshToken(): Promise<Result<RefreshTokenResponse>> {
|
||||
const existing = await this.storage.getRefreshToken();
|
||||
if (!existing.success || !existing.data) {
|
||||
return Result.err(new Error("No refresh token available"));
|
||||
return Result.err(
|
||||
new ApiError(
|
||||
"HTTP_UNAUTHORIZED",
|
||||
"No refresh token available",
|
||||
ErrorCode.httpUnauthorized,
|
||||
),
|
||||
);
|
||||
}
|
||||
const refreshToken = existing.data;
|
||||
return Result.wrap(async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import memoryDriver from "unstorage/drivers/memory";
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { SpAsyncUtil } from "@/utils";
|
||||
|
||||
import { ErrorCode } from "../api_result";
|
||||
import { createHttpClient } from "../http_client";
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
@@ -86,4 +87,61 @@ describe("createHttpClient", () => {
|
||||
"Bearer new-login-token",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects with an API unauthorized error when no refresh token exists", async () => {
|
||||
const authStorage = AuthStorage.getInstance();
|
||||
await authStorage.setLoginToken("old-login-token");
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse(
|
||||
{ message: "expired" },
|
||||
{ status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const http = createHttpClient();
|
||||
await expect(http("/api/chat/history")).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
code: "HTTP_UNAUTHORIZED",
|
||||
status: ErrorCode.httpUnauthorized,
|
||||
message: "No refresh token available",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects with API status context when token refresh fails", async () => {
|
||||
const authStorage = AuthStorage.getInstance();
|
||||
await authStorage.setLoginToken("old-login-token");
|
||||
await authStorage.setRefreshToken("refresh-token");
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
if (url.pathname === "/api/auth/refresh") {
|
||||
return jsonResponse(
|
||||
{ message: "refresh broken" },
|
||||
{ status: ErrorCode.httpServerError, statusText: "Server Error" },
|
||||
);
|
||||
}
|
||||
return jsonResponse(
|
||||
{ message: "expired" },
|
||||
{ status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const http = createHttpClient();
|
||||
await expect(http("/api/chat/history")).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
code: "HTTP_SERVER_ERROR",
|
||||
status: ErrorCode.httpServerError,
|
||||
message: "Refresh failed",
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([
|
||||
"/api/chat/history",
|
||||
"/api/auth/refresh",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user