feat(ai-character): 리소스 관리 기반 정비
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { AccessDeniedError } from "../api-error";
|
||||
import { AccessDeniedError, ApiError, UNKNOWN_API_ERROR_MESSAGE } from "../api-error";
|
||||
import { createApiClient } from "../client";
|
||||
import { server } from "../../test/server";
|
||||
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
|
||||
@@ -147,6 +147,42 @@ describe("authenticated API requests", () => {
|
||||
expect(onAuthExpired).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["malformed JSON", () => new HttpResponse("not-json", { status: 401 })],
|
||||
["empty body", () => new HttpResponse(null, { status: 401 })],
|
||||
["schema mismatch", () => HttpResponse.json({ success: false, data: null, errorProperty: null }, { status: 401 })],
|
||||
])("clears the session once for concurrent protected 401 responses with %s", async (_label, responseFactory) => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client, clearSession, onAuthExpired } = createTestClient();
|
||||
server.use(http.get(`${apiBaseUrl}/protected`, responseFactory));
|
||||
|
||||
// When
|
||||
const results = await Promise.allSettled([
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
]);
|
||||
|
||||
// Then
|
||||
for (const result of results) {
|
||||
expect(result.status).toBe("rejected");
|
||||
if (result.status === "rejected") {
|
||||
expect(result.reason).toBeInstanceOf(ApiError);
|
||||
expect(result.reason).toMatchObject({ status: 401, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
|
||||
}
|
||||
}
|
||||
expect(clearSession).toHaveBeenCalledTimes(1);
|
||||
expect(onAuthExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("surfaces access denied without clearing the session", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "../api-error";
|
||||
import { ApiError, UNKNOWN_API_ERROR_MESSAGE } from "../api-error";
|
||||
import { server } from "../../test/server";
|
||||
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("API client", () => {
|
||||
await expect(request).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
test("surfaces a network failure without a mock response", async () => {
|
||||
test("normalizes a network failure to the shared unknown API error", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
@@ -129,6 +129,27 @@ describe("API client", () => {
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(TypeError);
|
||||
await expect(request).rejects.toMatchObject({ status: 0, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["malformed JSON", "not-json"],
|
||||
["malformed envelope", JSON.stringify({ success: false, data: null, errorProperty: null })],
|
||||
["empty message", JSON.stringify({ success: false, message: "", data: null, errorProperty: null })],
|
||||
])("normalizes %s error responses to the shared unknown API error", async (_label, body) => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(http.get(`${apiBaseUrl}/unknown-error`, () => new HttpResponse(body, { status: 500 })));
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/unknown-error",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toMatchObject({ status: 500, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createPageParams, type PageData } from "../pagination";
|
||||
|
||||
describe("createPageParams", () => {
|
||||
test("uses documented default page and size", () => {
|
||||
// Given
|
||||
const request = {};
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams).toEqual({ page: 0, size: 20 });
|
||||
});
|
||||
|
||||
test("clamps size to the documented lower bound", () => {
|
||||
// Given
|
||||
const request = { size: 1 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.size).toBe(20);
|
||||
});
|
||||
|
||||
test("clamps size to the documented upper bound", () => {
|
||||
// Given
|
||||
const request = { size: 51 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.size).toBe(50);
|
||||
});
|
||||
|
||||
test("keeps a provided page unchanged", () => {
|
||||
// Given
|
||||
const request = { page: 3, size: 20 };
|
||||
|
||||
// When
|
||||
const pageParams = createPageParams(request);
|
||||
|
||||
// Then
|
||||
expect(pageParams.page).toBe(3);
|
||||
});
|
||||
import type { PageData } from "../pagination";
|
||||
|
||||
describe("PageData", () => {
|
||||
test("defines the documented page response shape", () => {
|
||||
// Given
|
||||
const page: PageData<string> = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const UNKNOWN_API_ERROR_MESSAGE = "알 수 없는 오류가 발생했습니다.";
|
||||
|
||||
export type ApiErrorOptions = {
|
||||
readonly status: number;
|
||||
readonly message: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { AccessDeniedError, ApiError } from "./api-error";
|
||||
import { AccessDeniedError, ApiError, UNKNOWN_API_ERROR_MESSAGE } from "./api-error";
|
||||
import { getRuntimeEnv } from "../config/env";
|
||||
import { createApiResponseSchema } from "./types";
|
||||
|
||||
@@ -32,21 +32,26 @@ function toApiError(
|
||||
readonly errorProperty: string | null;
|
||||
},
|
||||
): ApiError {
|
||||
const message = response.message.trim().length === 0 ? UNKNOWN_API_ERROR_MESSAGE : response.message;
|
||||
if (status === 403) {
|
||||
return new AccessDeniedError({
|
||||
status,
|
||||
message: response.message,
|
||||
message,
|
||||
errorProperty: response.errorProperty,
|
||||
});
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
status,
|
||||
message: response.message,
|
||||
message,
|
||||
errorProperty: response.errorProperty,
|
||||
});
|
||||
}
|
||||
|
||||
function unknownApiError(status: number): ApiError {
|
||||
return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
|
||||
}
|
||||
|
||||
export function createApiClient(dependencies: ApiClientDependencies): ApiClient {
|
||||
let hasHandledAuthenticationExpiry = false;
|
||||
let activeProtectedRequestCount = 0;
|
||||
@@ -81,17 +86,36 @@ export function createApiClient(dependencies: ApiClientDependencies): ApiClient
|
||||
init.body = options.body;
|
||||
}
|
||||
|
||||
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
|
||||
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
|
||||
await response.json(),
|
||||
);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
throw unknownApiError(0);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status === 401 && isProtectedRequest && !hasHandledAuthenticationExpiry && dependencies.getToken() !== null) {
|
||||
hasHandledAuthenticationExpiry = true;
|
||||
dependencies.clearSession();
|
||||
dependencies.onAuthExpired();
|
||||
}
|
||||
|
||||
let responseJson: unknown;
|
||||
try {
|
||||
responseJson = await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw unknownApiError(response.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(responseJson);
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new ApiError({
|
||||
status: response.status,
|
||||
message: "API 응답 형식이 올바르지 않습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
throw unknownApiError(response.status);
|
||||
}
|
||||
|
||||
const apiResponse = parsedResponse.data;
|
||||
@@ -100,23 +124,11 @@ export function createApiClient(dependencies: ApiClientDependencies): ApiClient
|
||||
return apiResponse.data;
|
||||
}
|
||||
|
||||
if (!apiResponse.success) {
|
||||
if (response.status === 401 && isProtectedRequest) {
|
||||
if (!hasHandledAuthenticationExpiry) {
|
||||
hasHandledAuthenticationExpiry = true;
|
||||
dependencies.clearSession();
|
||||
dependencies.onAuthExpired();
|
||||
}
|
||||
}
|
||||
if (!apiResponse.success) {
|
||||
throw toApiError(response.status, apiResponse);
|
||||
}
|
||||
|
||||
throw toApiError(response.status, apiResponse);
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
status: response.status,
|
||||
message: "API 오류 응답 형식이 올바르지 않습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
throw unknownApiError(response.status);
|
||||
} finally {
|
||||
if (isProtectedRequest) {
|
||||
activeProtectedRequestCount -= 1;
|
||||
|
||||
@@ -5,20 +5,3 @@ export type PageData<Item> = {
|
||||
readonly hasNext: boolean;
|
||||
readonly items: readonly Item[];
|
||||
};
|
||||
|
||||
export type PageParams = {
|
||||
readonly page?: number;
|
||||
readonly size?: number;
|
||||
};
|
||||
|
||||
export function createPageParams(params: PageParams = {}): {
|
||||
readonly page: number;
|
||||
readonly size: number;
|
||||
} {
|
||||
const size = params.size ?? 20;
|
||||
|
||||
return {
|
||||
page: params.page ?? 0,
|
||||
size: Math.min(Math.max(size, 20), 50),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user