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", () => {
|
it("preserves an existing AppException result", () => {
|
||||||
const error = new AppException({
|
const error = new AppException({
|
||||||
code: ExceptionCode.notFound,
|
code: ExceptionCode.notFound,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const ExceptionCode = {
|
|||||||
validation: "VALIDATION_ERROR",
|
validation: "VALIDATION_ERROR",
|
||||||
storage: "STORAGE_ERROR",
|
storage: "STORAGE_ERROR",
|
||||||
payment: "PAYMENT_ERROR",
|
payment: "PAYMENT_ERROR",
|
||||||
|
permissionDenied: "PERMISSION_DENIED",
|
||||||
|
unsupported: "UNSUPPORTED_FEATURE",
|
||||||
cancelled: "CANCELLED",
|
cancelled: "CANCELLED",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AppException } from "./app-exception";
|
|||||||
import { ExceptionCode } from "./exception-code";
|
import { ExceptionCode } from "./exception-code";
|
||||||
import type { ExceptionResult } from "./exception-result";
|
import type { ExceptionResult } from "./exception-result";
|
||||||
import { ApiExceptionHandler } from "./handlers/api-exception-handler";
|
import { ApiExceptionHandler } from "./handlers/api-exception-handler";
|
||||||
|
import { BrowserExceptionHandler } from "./handlers/browser-exception-handler";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EXCEPTION_MESSAGE,
|
DEFAULT_EXCEPTION_MESSAGE,
|
||||||
FallbackExceptionHandler,
|
FallbackExceptionHandler,
|
||||||
@@ -25,6 +26,10 @@ export class ExceptionHandler {
|
|||||||
return NetworkExceptionHandler.handle(error);
|
return NetworkExceptionHandler.handle(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (BrowserExceptionHandler.canHandle(error)) {
|
||||||
|
return BrowserExceptionHandler.handle(error);
|
||||||
|
}
|
||||||
|
|
||||||
if (ValidationExceptionHandler.canHandle(error)) {
|
if (ValidationExceptionHandler.canHandle(error)) {
|
||||||
return ValidationExceptionHandler.handle(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 storage = AuthStorage.getInstance();
|
||||||
const refreshResult = await storage.getRefreshToken();
|
const refreshResult = await storage.getRefreshToken();
|
||||||
if (!Result.isOk(refreshResult) || !refreshResult.data) {
|
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}`, {
|
const response = await fetch(`${baseUrl}${ApiPath.refresh}`, {
|
||||||
@@ -79,7 +83,12 @@ async function refreshLoginToken(baseUrl: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 {
|
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.token) await storage.setLoginToken(json.data.token);
|
||||||
if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken);
|
if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken);
|
||||||
} else {
|
} 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) {
|
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 {
|
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.token) await storage.setGuestToken(json.data.token);
|
||||||
if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId);
|
if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId);
|
||||||
} else {
|
} 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";
|
"use client";
|
||||||
|
|
||||||
import { AuthApi, authApi } from "@/data/services/api";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
|
import { ApiError, AuthApi, authApi, ErrorCode } from "@/data/services/api";
|
||||||
import {
|
import {
|
||||||
AppleLoginRequest,
|
AppleLoginRequest,
|
||||||
FacebookLoginRequest,
|
FacebookLoginRequest,
|
||||||
@@ -141,9 +142,10 @@ export class AuthRepository implements IAuthRepository {
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
|
const errorResult = ExceptionHandler.handle(e);
|
||||||
log.error("[AuthRepository.guestLogin] API call FAILED", {
|
log.error("[AuthRepository.guestLogin] API call FAILED", {
|
||||||
errorName: e?.name,
|
errorCode: errorResult.code,
|
||||||
errorMessage: e?.message,
|
errorMessage: errorResult.message,
|
||||||
});
|
});
|
||||||
return Result.err(e);
|
return Result.err(e);
|
||||||
});
|
});
|
||||||
@@ -227,7 +229,13 @@ export class AuthRepository implements IAuthRepository {
|
|||||||
async refreshToken(): Promise<Result<RefreshTokenResponse>> {
|
async refreshToken(): Promise<Result<RefreshTokenResponse>> {
|
||||||
const existing = await this.storage.getRefreshToken();
|
const existing = await this.storage.getRefreshToken();
|
||||||
if (!existing.success || !existing.data) {
|
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;
|
const refreshToken = existing.data;
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import memoryDriver from "unstorage/drivers/memory";
|
|||||||
import { AuthStorage } from "@/data/storage/auth";
|
import { AuthStorage } from "@/data/storage/auth";
|
||||||
import { SpAsyncUtil } from "@/utils";
|
import { SpAsyncUtil } from "@/utils";
|
||||||
|
|
||||||
|
import { ErrorCode } from "../api_result";
|
||||||
import { createHttpClient } from "../http_client";
|
import { createHttpClient } from "../http_client";
|
||||||
|
|
||||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||||
@@ -86,4 +87,61 @@ describe("createHttpClient", () => {
|
|||||||
"Bearer new-login-token",
|
"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