feat(ai-character): 관리자 인증 셸 구현
This commit is contained in:
215
src/shared/api/__tests__/client-auth.test.ts
Normal file
215
src/shared/api/__tests__/client-auth.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { AccessDeniedError } from "../api-error";
|
||||
import { createApiClient } from "../client";
|
||||
import { server } from "../../test/server";
|
||||
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("authenticated API requests", () => {
|
||||
test("sends Korean language without bearer authentication to login", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
let acceptLanguage: string | null = null;
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, ({ request }) => {
|
||||
acceptLanguage = request.headers.get("Accept-Language");
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/admin/member/login",
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer caller-supplied-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "secret" }),
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(acceptLanguage).toBe("ko");
|
||||
expect(authorization).toBeNull();
|
||||
});
|
||||
|
||||
test("sends bearer authentication to a protected request", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/protected`, ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/api/v2/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(authorization).toBe("Bearer header.payload.signature");
|
||||
});
|
||||
|
||||
test("sends bearer authentication to logout", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
let authorization: string | null = null;
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/member/logout",
|
||||
method: "POST",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(authorization).toBe("Bearer header.payload.signature");
|
||||
});
|
||||
|
||||
test("clears the session and redirects once for each concurrent 401 burst", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const clearSession = vi.fn();
|
||||
const onAuthExpired = vi.fn();
|
||||
const client = createApiClient({
|
||||
getToken: () => "header.payload.signature",
|
||||
clearSession,
|
||||
onAuthExpired,
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/protected`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
},
|
||||
{ status: 401 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const firstBurst = await Promise.allSettled([
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
]);
|
||||
const secondBurst = await Promise.allSettled([
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
}),
|
||||
]);
|
||||
|
||||
// Then
|
||||
expect(firstBurst).toHaveLength(2);
|
||||
expect(secondBurst).toHaveLength(2);
|
||||
expect(clearSession).toHaveBeenCalledTimes(2);
|
||||
expect(onAuthExpired).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("surfaces access denied without clearing the session", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client, clearSession, onAuthExpired } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/protected`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
},
|
||||
{ status: 403 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/protected",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(AccessDeniedError);
|
||||
expect(clearSession).not.toHaveBeenCalled();
|
||||
expect(onAuthExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not write sensitive request data to the console", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient("jwt.secret.value");
|
||||
const multipartBody = new FormData();
|
||||
multipartBody.append("request", JSON.stringify({ password: "secret-password" }));
|
||||
multipartBody.append("file", new File(["private-content"], "private.txt"));
|
||||
const consoleDebug = vi.spyOn(console, "debug");
|
||||
const consoleError = vi.spyOn(console, "error");
|
||||
const consoleInfo = vi.spyOn(console, "info");
|
||||
const consoleLog = vi.spyOn(console, "log");
|
||||
const consoleWarn = vi.spyOn(console, "warn");
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/api/v2/protected`, () =>
|
||||
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
await client.request({
|
||||
path: "/api/v2/protected?signedUrl=https%3A%2F%2Fcdn.example.com%2Fprivate%3Fsignature%3Dabc",
|
||||
method: "POST",
|
||||
body: multipartBody,
|
||||
responseSchema: valueSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(consoleDebug).not.toHaveBeenCalled();
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
expect(consoleInfo).not.toHaveBeenCalled();
|
||||
expect(consoleLog).not.toHaveBeenCalled();
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
31
src/shared/api/__tests__/client-test-helpers.ts
Normal file
31
src/shared/api/__tests__/client-test-helpers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createApiClient } from "../client";
|
||||
|
||||
export const apiBaseUrl = "https://api.example.com";
|
||||
export const valueSchema = z.object({ value: z.string() });
|
||||
|
||||
export type TestClient = {
|
||||
readonly client: ReturnType<typeof createApiClient>;
|
||||
readonly clearSession: ReturnType<typeof vi.fn>;
|
||||
readonly onAuthExpired: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
export function createTestClient(token = "header.payload.signature"): TestClient {
|
||||
let currentToken: string | null = token;
|
||||
const clearSession = vi.fn(() => {
|
||||
currentToken = null;
|
||||
});
|
||||
const onAuthExpired = vi.fn();
|
||||
|
||||
return {
|
||||
client: createApiClient({
|
||||
getToken: () => currentToken,
|
||||
clearSession,
|
||||
onAuthExpired,
|
||||
}),
|
||||
clearSession,
|
||||
onAuthExpired,
|
||||
};
|
||||
}
|
||||
117
src/shared/api/__tests__/client.test.ts
Normal file
117
src/shared/api/__tests__/client.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "../api-error";
|
||||
import { server } from "../../test/server";
|
||||
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("API client", () => {
|
||||
test("accepts a successful envelope without errorProperty", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/public`, () =>
|
||||
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const response = await client.request({
|
||||
path: "/public",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response).toEqual({ value: "ok" });
|
||||
});
|
||||
|
||||
test("accepts a successful envelope with null errorProperty", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/public`, () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { value: "ok" },
|
||||
errorProperty: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const response = await client.request({
|
||||
path: "/public",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response).toEqual({ value: "ok" });
|
||||
});
|
||||
|
||||
test.each([400, 404, 405, 415, 500])(
|
||||
"preserves a Korean server error envelope for status %i",
|
||||
async (status) => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
const message = `서버 오류 ${status}`;
|
||||
const errorProperty = "request";
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/error`, () =>
|
||||
HttpResponse.json(
|
||||
{ success: false, message, data: null, errorProperty },
|
||||
{ status },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/error",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toMatchObject({ status, message, errorProperty });
|
||||
},
|
||||
);
|
||||
|
||||
test("exposes server errors as ApiError", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/error`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "잘못된 요청입니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/error",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
66
src/shared/api/__tests__/pagination.test.ts
Normal file
66
src/shared/api/__tests__/pagination.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test("defines the documented page response shape", () => {
|
||||
// Given
|
||||
const page: PageData<string> = {
|
||||
totalCount: 1,
|
||||
page: 0,
|
||||
size: 20,
|
||||
hasNext: false,
|
||||
items: ["루나"],
|
||||
};
|
||||
|
||||
// When
|
||||
const firstItem = page.items[0];
|
||||
|
||||
// Then
|
||||
expect(firstItem).toBe("루나");
|
||||
});
|
||||
});
|
||||
58
src/shared/api/__tests__/query-client.test.ts
Normal file
58
src/shared/api/__tests__/query-client.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ApiError } from "../api-error";
|
||||
import { createQueryClient, shouldRetryQuery } from "../query-client";
|
||||
|
||||
describe("TanStack Query defaults", () => {
|
||||
test("does not retry unauthenticated query errors", () => {
|
||||
// Given
|
||||
const error = new ApiError({
|
||||
status: 401,
|
||||
message: "인증 정보가 없습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
|
||||
// When
|
||||
const shouldRetry = shouldRetryQuery(0, error);
|
||||
|
||||
// Then
|
||||
expect(shouldRetry).toBe(false);
|
||||
});
|
||||
|
||||
test("does not retry access-denied query errors", () => {
|
||||
// Given
|
||||
const error = new ApiError({
|
||||
status: 403,
|
||||
message: "접근 권한이 없습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
|
||||
// When
|
||||
const shouldRetry = shouldRetryQuery(0, error);
|
||||
|
||||
// Then
|
||||
expect(shouldRetry).toBe(false);
|
||||
});
|
||||
|
||||
test("limits retries for other query errors", () => {
|
||||
// Given
|
||||
const error = new Error("network failure");
|
||||
|
||||
// When
|
||||
const shouldRetry = shouldRetryQuery(2, error);
|
||||
|
||||
// Then
|
||||
expect(shouldRetry).toBe(false);
|
||||
});
|
||||
|
||||
test("disables mutation retries", () => {
|
||||
// Given
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
// When
|
||||
const retry = queryClient.getDefaultOptions().mutations?.retry;
|
||||
|
||||
// Then
|
||||
expect(retry).toBe(false);
|
||||
});
|
||||
});
|
||||
21
src/shared/api/api-error.ts
Normal file
21
src/shared/api/api-error.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type ApiErrorOptions = {
|
||||
readonly status: number;
|
||||
readonly message: string;
|
||||
readonly errorProperty: string | null;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
override readonly name: string = "ApiError";
|
||||
readonly status: number;
|
||||
readonly errorProperty: string | null;
|
||||
|
||||
constructor(options: ApiErrorOptions) {
|
||||
super(options.message);
|
||||
this.status = options.status;
|
||||
this.errorProperty = options.errorProperty;
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessDeniedError extends ApiError {
|
||||
override readonly name: string = "AccessDeniedError";
|
||||
}
|
||||
131
src/shared/api/client.ts
Normal file
131
src/shared/api/client.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { AccessDeniedError, ApiError } from "./api-error";
|
||||
import { getRuntimeEnv } from "../config/env";
|
||||
import { createApiResponseSchema } from "./types";
|
||||
|
||||
type AuthenticationMode = "none" | "required";
|
||||
|
||||
export type ApiClientDependencies = {
|
||||
readonly getToken: () => string | null;
|
||||
readonly clearSession: () => void;
|
||||
readonly onAuthExpired: () => void;
|
||||
};
|
||||
|
||||
export type ApiRequestOptions<Data> = {
|
||||
readonly path: string;
|
||||
readonly responseSchema: z.ZodType<Data>;
|
||||
readonly authentication: AuthenticationMode;
|
||||
readonly method?: string;
|
||||
readonly headers?: HeadersInit;
|
||||
readonly body?: BodyInit | null;
|
||||
};
|
||||
|
||||
export type ApiClient = {
|
||||
readonly request: <Data>(options: ApiRequestOptions<Data>) => Promise<Data>;
|
||||
};
|
||||
|
||||
function toApiError(
|
||||
status: number,
|
||||
response: {
|
||||
readonly message: string;
|
||||
readonly errorProperty: string | null;
|
||||
},
|
||||
): ApiError {
|
||||
if (status === 403) {
|
||||
return new AccessDeniedError({
|
||||
status,
|
||||
message: response.message,
|
||||
errorProperty: response.errorProperty,
|
||||
});
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
status,
|
||||
message: response.message,
|
||||
errorProperty: response.errorProperty,
|
||||
});
|
||||
}
|
||||
|
||||
export function createApiClient(dependencies: ApiClientDependencies): ApiClient {
|
||||
let hasHandledAuthenticationExpiry = false;
|
||||
let activeProtectedRequestCount = 0;
|
||||
|
||||
return {
|
||||
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
||||
const isProtectedRequest = options.authentication === "required";
|
||||
|
||||
if (isProtectedRequest) {
|
||||
activeProtectedRequestCount += 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Accept-Language", "ko");
|
||||
headers.delete("Authorization");
|
||||
|
||||
if (isProtectedRequest) {
|
||||
const token = dependencies.getToken();
|
||||
|
||||
if (token !== null) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = { headers };
|
||||
|
||||
if (options.method !== undefined) {
|
||||
init.method = options.method;
|
||||
}
|
||||
if (options.body !== undefined) {
|
||||
init.body = options.body;
|
||||
}
|
||||
|
||||
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
|
||||
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
|
||||
await response.json(),
|
||||
);
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new ApiError({
|
||||
status: response.status,
|
||||
message: "API 응답 형식이 올바르지 않습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
}
|
||||
|
||||
const apiResponse = parsedResponse.data;
|
||||
|
||||
if (response.ok && apiResponse.success) {
|
||||
return apiResponse.data;
|
||||
}
|
||||
|
||||
if (!apiResponse.success) {
|
||||
if (response.status === 401 && isProtectedRequest) {
|
||||
if (!hasHandledAuthenticationExpiry) {
|
||||
hasHandledAuthenticationExpiry = true;
|
||||
dependencies.clearSession();
|
||||
dependencies.onAuthExpired();
|
||||
}
|
||||
}
|
||||
|
||||
throw toApiError(response.status, apiResponse);
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
status: response.status,
|
||||
message: "API 오류 응답 형식이 올바르지 않습니다.",
|
||||
errorProperty: null,
|
||||
});
|
||||
} finally {
|
||||
if (isProtectedRequest) {
|
||||
activeProtectedRequestCount -= 1;
|
||||
|
||||
if (activeProtectedRequestCount === 0) {
|
||||
hasHandledAuthenticationExpiry = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
24
src/shared/api/pagination.ts
Normal file
24
src/shared/api/pagination.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type PageData<Item> = {
|
||||
readonly totalCount: number;
|
||||
readonly page: number;
|
||||
readonly size: number;
|
||||
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),
|
||||
};
|
||||
}
|
||||
22
src/shared/api/query-client.ts
Normal file
22
src/shared/api/query-client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ApiError } from "./api-error";
|
||||
|
||||
export function shouldRetryQuery(failureCount: number, error: unknown): boolean {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 2;
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: shouldRetryQuery },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const queryClient = createQueryClient();
|
||||
34
src/shared/api/types.ts
Normal file
34
src/shared/api/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type ApiSuccessResponse<Data> = {
|
||||
readonly success: true;
|
||||
readonly message: null;
|
||||
readonly data: Data;
|
||||
readonly errorProperty?: null;
|
||||
};
|
||||
|
||||
export type ApiErrorResponse = {
|
||||
readonly success: false;
|
||||
readonly message: string;
|
||||
readonly data: null;
|
||||
readonly errorProperty: string | null;
|
||||
};
|
||||
|
||||
export type ApiResponse<Data> = ApiSuccessResponse<Data> | ApiErrorResponse;
|
||||
|
||||
export function createApiResponseSchema<Data>(dataSchema: z.ZodType<Data>) {
|
||||
return z.discriminatedUnion("success", [
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
message: z.null(),
|
||||
data: dataSchema,
|
||||
errorProperty: z.null().optional(),
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
message: z.string(),
|
||||
data: z.null(),
|
||||
errorProperty: z.string().nullable(),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
12
src/shared/lib/__tests__/formatters.test.ts
Normal file
12
src/shared/lib/__tests__/formatters.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { formatCanAmount, formatSeoulDateTime } from "@/shared/lib/formatters";
|
||||
|
||||
test("formatSeoulDateTime displays UTC input in Asia/Seoul", () => {
|
||||
expect(formatSeoulDateTime("2026-07-25T15:30:00.000Z")).toBe("2026. 07. 26. 00:30");
|
||||
});
|
||||
|
||||
test("formatCanAmount displays non-negative integer can units without domain status labels", () => {
|
||||
expect(formatCanAmount(0)).toBe("0캔");
|
||||
expect(formatCanAmount(12345)).toBe("12,345캔");
|
||||
});
|
||||
103
src/shared/lib/crop-image.test.ts
Normal file
103
src/shared/lib/crop-image.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { calculateCropOutputSize, calculateCropSourceRect, createCroppedImageFile } from "@/shared/lib/crop-image";
|
||||
|
||||
type RenderedCrop = {
|
||||
readonly bottomAlpha: number;
|
||||
readonly height: number;
|
||||
readonly sourceHeight: number;
|
||||
readonly sourceWidth: number;
|
||||
readonly sourceX: number;
|
||||
readonly sourceY: number;
|
||||
readonly topAlpha: number;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
function restoreDescriptor(property: "getContext" | "toBlob", descriptor: PropertyDescriptor | undefined): void {
|
||||
if (descriptor === undefined) {
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, property);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, property, descriptor);
|
||||
}
|
||||
|
||||
test("calculateCropOutputSize caps width at maxWidth and keeps aspect height", () => {
|
||||
expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 })).toEqual({ height: 600, width: 600 });
|
||||
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1600, sourceWidth: 1200 })).toEqual({ height: 1414, width: 1000 });
|
||||
});
|
||||
|
||||
test("calculateCropOutputSize never upscales when noUpscale is true", () => {
|
||||
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: true, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 300, width: 600 });
|
||||
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: false, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 400, width: 800 });
|
||||
});
|
||||
|
||||
test("calculateCropSourceRect crops the largest centered source rectangle for the requested aspect", () => {
|
||||
expect(calculateCropSourceRect({ aspect: 1, offsetX: 0, offsetY: 0, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
|
||||
expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: -20, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 290, sourceY: 0, width: 600 });
|
||||
});
|
||||
|
||||
test("createCroppedImageFile keeps a wide no-upscale crop opaque at the top and bottom", async () => {
|
||||
let renderedCrop: RenderedCrop | null = null;
|
||||
const imageFile = new File(["wide"], "wide.png", { type: "image/png" });
|
||||
const outputSize = calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 });
|
||||
const originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext");
|
||||
const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "toBlob");
|
||||
|
||||
try {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
drawImage: (...args: readonly unknown[]) => {
|
||||
const [sourceImage, sourceX, sourceY, sourceWidth, sourceHeight, targetX, targetY, targetWidth, targetHeight] = args;
|
||||
if (!(sourceImage instanceof EventTarget) || typeof sourceX !== "number" || typeof sourceY !== "number" || typeof sourceWidth !== "number" || typeof sourceHeight !== "number" || typeof targetX !== "number" || typeof targetY !== "number" || typeof targetWidth !== "number" || typeof targetHeight !== "number") {
|
||||
throw new Error("Unexpected crop render call");
|
||||
}
|
||||
|
||||
renderedCrop = {
|
||||
bottomAlpha: sourceY + sourceHeight <= 600 && targetY + targetHeight <= outputSize.height ? 255 : 0,
|
||||
height: targetHeight,
|
||||
sourceHeight,
|
||||
sourceWidth,
|
||||
sourceX,
|
||||
sourceY,
|
||||
topAlpha: sourceY >= 0 && targetY === 0 ? 255 : 0,
|
||||
width: targetWidth,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
|
||||
configurable: true,
|
||||
value: (callback: BlobCallback) => {
|
||||
callback(new Blob([JSON.stringify(renderedCrop)], { type: imageFile.type }));
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class FakeImage extends EventTarget {
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("load")));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const croppedFile = await createCroppedImageFile({
|
||||
aspect: 1,
|
||||
file: imageFile,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
outputHeight: outputSize.height,
|
||||
outputWidth: outputSize.width,
|
||||
previewUrl: "blob:wide",
|
||||
sourceHeight: 600,
|
||||
sourceWidth: 1200,
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
await expect(croppedFile.text()).resolves.toBe(JSON.stringify({ bottomAlpha: 255, height: 600, sourceHeight: 600, sourceWidth: 600, sourceX: 300, sourceY: 0, topAlpha: 255, width: 600 }));
|
||||
} finally {
|
||||
restoreDescriptor("getContext", originalGetContext);
|
||||
restoreDescriptor("toBlob", originalToBlob);
|
||||
}
|
||||
});
|
||||
101
src/shared/lib/crop-image.ts
Normal file
101
src/shared/lib/crop-image.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export type CropOutputSizeRequest = {
|
||||
readonly aspect: number | "free";
|
||||
readonly maxWidth: number;
|
||||
readonly noUpscale: boolean;
|
||||
readonly sourceHeight: number;
|
||||
readonly sourceWidth: number;
|
||||
};
|
||||
|
||||
export type CropOutputSize = {
|
||||
readonly height: number;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
export type CropRenderRequest = {
|
||||
readonly aspect: number | "free";
|
||||
readonly file: File;
|
||||
readonly offsetX: number;
|
||||
readonly offsetY: number;
|
||||
readonly outputHeight: number;
|
||||
readonly outputWidth: number;
|
||||
readonly previewUrl: string;
|
||||
readonly sourceHeight: number;
|
||||
readonly sourceWidth: number;
|
||||
readonly zoom: number;
|
||||
};
|
||||
|
||||
export type CropSourceRectRequest = {
|
||||
readonly aspect: number | "free";
|
||||
readonly offsetX: number;
|
||||
readonly offsetY: number;
|
||||
readonly sourceHeight: number;
|
||||
readonly sourceWidth: number;
|
||||
readonly zoom: number;
|
||||
};
|
||||
|
||||
export type CropSourceRect = {
|
||||
readonly height: number;
|
||||
readonly sourceX: number;
|
||||
readonly sourceY: number;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
function getAspect(aspect: number | "free", sourceWidth: number, sourceHeight: number): number {
|
||||
return aspect === "free" ? sourceWidth / sourceHeight : aspect;
|
||||
}
|
||||
|
||||
export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
|
||||
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
|
||||
const sourceAspect = sourceWidth / sourceHeight;
|
||||
const baseWidth = sourceAspect > cropAspect ? Math.round(sourceHeight * cropAspect) : sourceWidth;
|
||||
const baseHeight = sourceAspect > cropAspect ? sourceHeight : Math.round(sourceWidth / cropAspect);
|
||||
const width = Math.round(baseWidth / zoom);
|
||||
const height = Math.round(baseHeight / zoom);
|
||||
const maxSourceX = sourceWidth - width;
|
||||
const maxSourceY = sourceHeight - height;
|
||||
const centeredX = Math.round((sourceWidth - width) / 2 - offsetX / zoom);
|
||||
const centeredY = Math.round((sourceHeight - height) / 2 - offsetY / zoom);
|
||||
|
||||
return {
|
||||
height,
|
||||
sourceX: Math.min(Math.max(centeredX, 0), maxSourceX),
|
||||
sourceY: Math.min(Math.max(centeredY, 0), maxSourceY),
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth }: CropOutputSizeRequest): CropOutputSize {
|
||||
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
|
||||
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom: 1 });
|
||||
const width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
|
||||
|
||||
return { height: Math.round(width / cropAspect), width };
|
||||
}
|
||||
|
||||
export function createCroppedImageFile(request: CropRenderRequest): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = request.outputWidth;
|
||||
canvas.height = request.outputHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) {
|
||||
reject(new Error("Canvas context unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
const cropRect = calculateCropSourceRect(request);
|
||||
context.drawImage(image, cropRect.sourceX, cropRect.sourceY, cropRect.width, cropRect.height, 0, 0, request.outputWidth, request.outputHeight);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob === null) {
|
||||
reject(new Error("Canvas result unavailable"));
|
||||
return;
|
||||
}
|
||||
resolve(new File([blob], request.file.name, { type: request.file.type }));
|
||||
}, request.file.type);
|
||||
});
|
||||
image.addEventListener("error", () => reject(new Error("Image preview unavailable")));
|
||||
image.src = request.previewUrl;
|
||||
});
|
||||
}
|
||||
23
src/shared/lib/formatters.ts
Normal file
23
src/shared/lib/formatters.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const seoulDateTimeFormatter = new Intl.DateTimeFormat("ko-KR", {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
minute: "2-digit",
|
||||
month: "2-digit",
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const canAmountFormatter = new Intl.NumberFormat("ko-KR", {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export function formatSeoulDateTime(utcDateTime: string | Date): string {
|
||||
const parts = Object.fromEntries(seoulDateTimeFormatter.formatToParts(new Date(utcDateTime)).map((part) => [part.type, part.value]));
|
||||
|
||||
return `${parts.year}. ${parts.month}. ${parts.day}. ${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
export function formatCanAmount(amount: number): string {
|
||||
return `${canAmountFormatter.format(Math.max(0, Math.trunc(amount)))}캔`;
|
||||
}
|
||||
3
src/shared/test/server.ts
Normal file
3
src/shared/test/server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { setupServer } from "msw/node";
|
||||
|
||||
export const server = setupServer();
|
||||
11
src/shared/test/setup-isolation.test.ts
Normal file
11
src/shared/test/setup-isolation.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
test("test setup can stub a global in one test", () => {
|
||||
vi.stubGlobal("indexedDB", { open: vi.fn() });
|
||||
|
||||
expect(indexedDB.open).toBeDefined();
|
||||
});
|
||||
|
||||
test("test setup restores stubbed globals before the next test", () => {
|
||||
expect("indexedDB" in globalThis).toBe(false);
|
||||
});
|
||||
@@ -1,10 +1,23 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, vi } from "vitest";
|
||||
|
||||
import { server } from "./server";
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
117
src/shared/ui/__tests__/admin-audio-player.test.tsx
Normal file
117
src/shared/ui/__tests__/admin-audio-player.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
|
||||
import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider";
|
||||
|
||||
let playSpy: ReturnType<typeof vi.spyOn>;
|
||||
let pauseSpy: ReturnType<typeof vi.spyOn>;
|
||||
let loadSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
|
||||
pauseSpy = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
||||
loadSpy = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
playSpy.mockRestore();
|
||||
pauseSpy.mockRestore();
|
||||
loadSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("AdminAudioPlayer wraps native audio with controls and no download or autoplay", () => {
|
||||
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
|
||||
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio).toHaveAttribute("src", "https://cdn.example.com/signed/audio.m4a?token=secret");
|
||||
expect(audio).not.toHaveAttribute("autoplay");
|
||||
expect(audio).toHaveAttribute("controlsList", "nodownload");
|
||||
expect(screen.getByRole("button", { name: "재생" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("slider", { name: "재생 위치" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("slider", { name: "볼륨" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("AudioPlaybackProvider keeps only one player active by player id", () => {
|
||||
render(
|
||||
<AudioPlaybackProvider>
|
||||
<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/one.m4a?token=secret-one" title="첫 번째" />
|
||||
<AdminAudioPlayer playerId="two" src="https://cdn.example.com/signed/two.m4a?token=secret-two" title="두 번째" />
|
||||
</AudioPlaybackProvider>,
|
||||
);
|
||||
|
||||
const playButtons = screen.getAllByRole("button", { name: "재생" });
|
||||
const firstPlayButton = playButtons[0];
|
||||
const secondPlayButton = playButtons[1];
|
||||
if (firstPlayButton === undefined || secondPlayButton === undefined) {
|
||||
throw new Error("expected two play buttons");
|
||||
}
|
||||
|
||||
fireEvent.click(firstPlayButton);
|
||||
fireEvent.click(secondPlayButton);
|
||||
|
||||
expect(playSpy).toHaveBeenCalledTimes(2);
|
||||
expect(pauseSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("AdminAudioPlayer supports keyboard play, seek, volume, speed, generic error, and manual retry only", () => {
|
||||
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
|
||||
|
||||
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
|
||||
fireEvent.keyDown(player, { key: " " });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "재생 위치" }), { target: { value: "12" } });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "볼륨" }), { target: { value: "0.5" } });
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "재생 속도" }), { target: { value: "1.5" } });
|
||||
const audio = document.querySelector("audio");
|
||||
if (!(audio instanceof HTMLAudioElement)) {
|
||||
throw new Error("expected native audio element");
|
||||
}
|
||||
fireEvent.error(audio);
|
||||
|
||||
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("오디오를 재생할 수 없습니다");
|
||||
fireEvent.click(screen.getByRole("button", { name: "오디오 다시 시도" }));
|
||||
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||
expect(playSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("AdminAudioPlayer ignores Enter and Space from descendant controls", () => {
|
||||
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("combobox", { name: "재생 속도" }), { key: "Enter" });
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "재생" }), { key: " " });
|
||||
|
||||
expect(playSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("AudioPlaybackProvider and AdminAudioPlayer do not log or persist signed URLs", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const localStorageSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const signedUrl = "https://cdn.example.com/signed/audio.m4a?token=secret";
|
||||
|
||||
render(
|
||||
<AudioPlaybackProvider>
|
||||
<AdminAudioPlayer playerId="one" src={signedUrl} title="샘플 오디오" />
|
||||
</AudioPlaybackProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "재생" }));
|
||||
const audio = document.querySelector("audio");
|
||||
if (!(audio instanceof HTMLAudioElement)) {
|
||||
throw new Error("expected native audio element");
|
||||
}
|
||||
fireEvent.error(audio);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(localStorageSpy).not.toHaveBeenCalledWith(expect.any(String), expect.stringContaining(signedUrl));
|
||||
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
localStorageSpy.mockRestore();
|
||||
});
|
||||
60
src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx
Normal file
60
src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
|
||||
|
||||
test("ConfirmDeactivateDialog confirms deactivation with target and impact copy, not a switch", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfirmDeactivateDialog
|
||||
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
open
|
||||
targetName="루나"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveTextContent("사용자는 이 캐릭터를 더 이상 선택할 수 없습니다.");
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("ConfirmDeactivateDialog traps focus and returns it to the trigger after cancel", async () => {
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)} type="button">
|
||||
비활성화 열기
|
||||
</button>
|
||||
<ConfirmDeactivateDialog impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다." onCancel={() => setOpen(false)} onConfirm={() => setOpen(false)} open={open} targetName="루나" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByRole("button", { name: "비활성화 열기" });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
const cancel = screen.getByRole("button", { name: "취소" });
|
||||
const confirm = screen.getByRole("button", { name: "비활성화" });
|
||||
await waitFor(() => expect(cancel).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab", shiftKey: true });
|
||||
expect(confirm).toHaveFocus();
|
||||
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab" });
|
||||
expect(cancel).toHaveFocus();
|
||||
fireEvent.click(cancel);
|
||||
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
41
src/shared/ui/__tests__/file-field.test.tsx
Normal file
41
src/shared/ui/__tests__/file-field.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
|
||||
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
|
||||
const onChange = vi.fn();
|
||||
const value = new File(["image"], "profile.png", { type: "image/png" });
|
||||
|
||||
render(
|
||||
<FileField
|
||||
accept="image/png"
|
||||
acceptDescription="PNG만 업로드할 수 있습니다."
|
||||
description="프로필 이미지를 선택하세요."
|
||||
error="파일이 너무 큽니다."
|
||||
label="대표 이미지"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("대표 이미지");
|
||||
expect(input).toHaveAttribute("accept", "image/png");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
|
||||
expect(screen.getByText("profile.png")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("FileField emits File or null and clear selection without owning upload policy", () => {
|
||||
const onChange = vi.fn();
|
||||
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
||||
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={null} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("오디오"), { target: { files: [selected] } });
|
||||
expect(onChange).toHaveBeenCalledWith(selected);
|
||||
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const sharedFileMediaFiles = [
|
||||
"src/shared/validation/file-validation.ts",
|
||||
"src/shared/validation/audio-file-policy.ts",
|
||||
"src/shared/validation/image-policy.ts",
|
||||
"src/shared/lib/crop-image.ts",
|
||||
"src/shared/ui/file-field.tsx",
|
||||
"src/shared/ui/image-crop-dialog.tsx",
|
||||
"src/shared/ui/upload-progress.tsx",
|
||||
"src/shared/ui/admin-audio-player.tsx",
|
||||
"src/shared/ui/audio-playback-provider.tsx",
|
||||
"src/shared/ui/audio-playback-context.ts",
|
||||
"src/shared/ui/use-audio-playback.ts",
|
||||
] as const;
|
||||
|
||||
test("shared file media primitives do not import endpoints, query cache, or domain DTOs", async () => {
|
||||
const contents = await Promise.all(sharedFileMediaFiles.map((filePath) => readFile(filePath, "utf8")));
|
||||
|
||||
for (const content of contents) {
|
||||
expect(content).not.toMatch(/@\/features\//);
|
||||
expect(content).not.toMatch(/@tanstack\/react-query/);
|
||||
expect(content).not.toMatch(/@\/shared\/api/);
|
||||
expect(content).not.toMatch(/endpoint|DTO/iu);
|
||||
}
|
||||
});
|
||||
21
src/shared/ui/__tests__/icon-only-action.test.tsx
Normal file
21
src/shared/ui/__tests__/icon-only-action.test.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { IconOnlyAction } from "@/shared/ui/icon-only-action";
|
||||
|
||||
describe("IconOnlyAction", () => {
|
||||
test("has an accessible name and a tooltip without duplicating the name as description", () => {
|
||||
render(
|
||||
<IconOnlyAction label="새로고침">
|
||||
<span aria-hidden="true">R</span>
|
||||
</IconOnlyAction>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "새로고침" });
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
|
||||
expect(tooltip).toHaveTextContent("새로고침");
|
||||
expect(button).not.toHaveAccessibleDescription("새로고침");
|
||||
expect(button).not.toHaveAttribute("aria-describedby");
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
});
|
||||
});
|
||||
68
src/shared/ui/__tests__/image-crop-dialog.test.tsx
Normal file
68
src/shared/ui/__tests__/image-crop-dialog.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
|
||||
const image = {
|
||||
file: new File(["image"], "profile.png", { type: "image/png" }),
|
||||
height: 600,
|
||||
previewUrl: "blob:profile",
|
||||
width: 600,
|
||||
};
|
||||
|
||||
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
|
||||
const onApply = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={onApply} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
await screen.findByText("예상 결과 600 × 600px");
|
||||
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 }));
|
||||
expect(onApply).toHaveBeenCalledWith(expect.any(File));
|
||||
fireEvent.click(screen.getByRole("button", { name: "취소" }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 2, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.keyDown(preview, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(preview, { key: "+" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 1 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog renders nothing when closed", () => {
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open={false} policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
29
src/shared/ui/__tests__/page-state.test.tsx
Normal file
29
src/shared/ui/__tests__/page-state.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
|
||||
test("PageState exposes accessible loading, empty, error, retry, and content states", () => {
|
||||
const onRetry = vi.fn();
|
||||
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
|
||||
|
||||
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("자료 없음");
|
||||
|
||||
rerender(<PageState description="잠시 후 다시 시도하세요." onRetry={onRetry} state="error" title="불러오지 못했습니다" />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("불러오지 못했습니다");
|
||||
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<PageState state="content">
|
||||
<p>공유 콘텐츠</p>
|
||||
</PageState>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("공유 콘텐츠")).toBeInTheDocument();
|
||||
});
|
||||
42
src/shared/ui/__tests__/resource-pagination.test.tsx
Normal file
42
src/shared/ui/__tests__/resource-pagination.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import type { PageData } from "@/shared/api/pagination";
|
||||
import { ResourcePagination } from "@/shared/ui/resource-pagination";
|
||||
|
||||
const pageData: PageData<string> = {
|
||||
totalCount: 42,
|
||||
page: 1,
|
||||
size: 20,
|
||||
hasNext: true,
|
||||
items: [],
|
||||
};
|
||||
|
||||
test("ResourcePagination uses PageData and real buttons for accessible page movement", () => {
|
||||
const onPageChange = vi.fn();
|
||||
const onSizeChange = vi.fn();
|
||||
|
||||
render(<ResourcePagination data={pageData} onPageChange={onPageChange} onSizeChange={onSizeChange} />);
|
||||
|
||||
const previous = screen.getByRole("button", { name: "이전 페이지" });
|
||||
const next = screen.getByRole("button", { name: "다음 페이지" });
|
||||
|
||||
expect(previous.tagName).toBe("BUTTON");
|
||||
expect(next).toBeEnabled();
|
||||
expect(screen.getByText("총 42개 · 2페이지")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(previous);
|
||||
fireEvent.click(next);
|
||||
fireEvent.change(screen.getByLabelText("페이지 크기"), { target: { value: "50" } });
|
||||
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 0);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 2);
|
||||
expect(onSizeChange).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
test("ResourcePagination disables unavailable previous and next actions", () => {
|
||||
render(<ResourcePagination data={{ ...pageData, page: 0, hasNext: false }} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "이전 페이지" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled();
|
||||
});
|
||||
18
src/shared/ui/__tests__/responsive-resource-list.test.tsx
Normal file
18
src/shared/ui/__tests__/responsive-resource-list.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
|
||||
|
||||
test("ResponsiveResourceList renders only desktop and mobile slots without domain props", () => {
|
||||
render(
|
||||
<ResponsiveResourceList
|
||||
ariaLabel="공유 자료 목록"
|
||||
desktop={<table><tbody><tr><td>데스크톱 슬롯</td></tr></tbody></table>}
|
||||
mobile={<ul><li>모바일 슬롯</li></ul>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("region", { name: "공유 자료 목록" })).toBeInTheDocument();
|
||||
expect(screen.getByText("데스크톱 슬롯")).toBeInTheDocument();
|
||||
expect(screen.getByText("모바일 슬롯")).toBeInTheDocument();
|
||||
});
|
||||
42
src/shared/ui/__tests__/search-toolbar.test.tsx
Normal file
42
src/shared/ui/__tests__/search-toolbar.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { SearchToolbar } from "@/shared/ui/search-toolbar";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("SearchToolbar keeps search controlled, renders filters, and emits only a debounced generic query", () => {
|
||||
vi.useFakeTimers();
|
||||
const onQueryChange = vi.fn();
|
||||
const onSearchChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<SearchToolbar
|
||||
filters={<select aria-label="상태 필터"><option>전체</option></select>}
|
||||
onQueryChange={onQueryChange}
|
||||
onSearchChange={onSearchChange}
|
||||
search=""
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } });
|
||||
|
||||
expect(onSearchChange).toHaveBeenCalledWith("루나");
|
||||
expect(screen.getByLabelText("상태 필터")).toBeInTheDocument();
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<SearchToolbar
|
||||
filters={<select aria-label="상태 필터"><option>전체</option></select>}
|
||||
onQueryChange={onQueryChange}
|
||||
onSearchChange={onSearchChange}
|
||||
search="루나"
|
||||
/>,
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(299));
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(onQueryChange).toHaveBeenCalledWith("루나");
|
||||
});
|
||||
35
src/shared/ui/__tests__/status-badge.test.tsx
Normal file
35
src/shared/ui/__tests__/status-badge.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { StatusBadge } from "@/shared/ui/status-badge";
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
test("renders visible Korean text labels for every state", () => {
|
||||
render(
|
||||
<>
|
||||
<StatusBadge status="OPEN" />
|
||||
<StatusBadge status="SCHEDULED" />
|
||||
<StatusBadge status="INACTIVE" />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("상태: 공개")).toHaveTextContent("공개");
|
||||
expect(screen.getByLabelText("상태: 예약")).toHaveTextContent("예약");
|
||||
expect(screen.getByLabelText("상태: 비활성")).toHaveTextContent("비활성");
|
||||
});
|
||||
|
||||
test("renders a domain label with optional icon and description without relying on color meaning", () => {
|
||||
render(
|
||||
<StatusBadge
|
||||
description={<span>오늘 18:00 자동 전환</span>}
|
||||
icon={<span aria-hidden="true">S</span>}
|
||||
label="검수 대기"
|
||||
tone="warning"
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByLabelText("상태: 검수 대기, 오늘 18:00 자동 전환");
|
||||
|
||||
expect(badge).toHaveTextContent("검수 대기");
|
||||
expect(badge).toHaveTextContent("오늘 18:00 자동 전환");
|
||||
});
|
||||
});
|
||||
48
src/shared/ui/__tests__/unsaved-changes-guard.test.tsx
Normal file
48
src/shared/ui/__tests__/unsaved-changes-guard.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
|
||||
|
||||
test("UnsavedChangesGuard blocks only dirty route leave and returns focus to the trigger on cancel", async () => {
|
||||
const onLeave = vi.fn();
|
||||
const { rerender } = render(
|
||||
<UnsavedChangesGuard dirty impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
|
||||
{(requestRouteLeave) => (
|
||||
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
|
||||
목록으로 이동
|
||||
</button>
|
||||
)}
|
||||
</UnsavedChangesGuard>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "목록으로 이동" });
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(onLeave).not.toHaveBeenCalled();
|
||||
const dialog = screen.getByRole("alertdialog", { name: "이 화면을 떠나시겠습니까?" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
const cancel = screen.getByRole("button", { name: "계속 편집" });
|
||||
const leave = screen.getByRole("button", { name: "떠나기" });
|
||||
await waitFor(() => expect(cancel).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true });
|
||||
expect(leave).toHaveFocus();
|
||||
fireEvent.keyDown(dialog, { key: "Tab" });
|
||||
expect(cancel).toHaveFocus();
|
||||
|
||||
fireEvent.click(cancel);
|
||||
expect(trigger).toHaveFocus();
|
||||
|
||||
rerender(
|
||||
<UnsavedChangesGuard dirty={false} impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
|
||||
{(requestRouteLeave) => (
|
||||
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
|
||||
목록으로 이동
|
||||
</button>
|
||||
)}
|
||||
</UnsavedChangesGuard>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "목록으로 이동" }));
|
||||
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
25
src/shared/ui/__tests__/upload-progress.test.tsx
Normal file
25
src/shared/ui/__tests__/upload-progress.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { UploadProgress } from "@/shared/ui/upload-progress";
|
||||
|
||||
test("UploadProgress displays status and progress without owning an upload client", () => {
|
||||
render(<UploadProgress fileName="voice.mp3" progress={45} status="uploading" />);
|
||||
|
||||
expect(screen.getByRole("progressbar", { name: "업로드 진행률" })).toHaveAttribute("aria-valuenow", "45");
|
||||
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
|
||||
expect(screen.getByText("업로드 중")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("UploadProgress exposes cancel and retry callbacks only", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
render(<UploadProgress onCancel={onCancel} onRetry={onRetry} progress={0} status="error" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "업로드 취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
141
src/shared/ui/admin-audio-player.tsx
Normal file
141
src/shared/ui/admin-audio-player.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useAudioPlayback } from "./use-audio-playback";
|
||||
|
||||
export type AdminAudioPlayerProps = {
|
||||
readonly playerId: string;
|
||||
readonly src: string;
|
||||
readonly title: string;
|
||||
};
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return "0:00";
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||
|
||||
return `${minutes}:${remainingSeconds}`;
|
||||
}
|
||||
|
||||
export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const { clearPlayer, requestPlay } = useAudioPlayback(playerId, audioRef);
|
||||
|
||||
function play() {
|
||||
const audio = audioRef.current;
|
||||
if (audio === null) {
|
||||
return;
|
||||
}
|
||||
setHasError(false);
|
||||
requestPlay();
|
||||
void audio.play().catch(() => setHasError(true));
|
||||
}
|
||||
|
||||
function pause() {
|
||||
audioRef.current?.pause();
|
||||
clearPlayer();
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
play();
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setHasError(false);
|
||||
audioRef.current?.load();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
||||
if (event.currentTarget !== event.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
}
|
||||
|
||||
function changeCurrentTime(nextTime: number) {
|
||||
const audio = audioRef.current;
|
||||
setCurrentTime(nextTime);
|
||||
if (audio !== null) {
|
||||
audio.currentTime = nextTime;
|
||||
}
|
||||
}
|
||||
|
||||
function changeVolume(nextVolume: number) {
|
||||
if (audioRef.current !== null) {
|
||||
audioRef.current.volume = nextVolume;
|
||||
}
|
||||
}
|
||||
|
||||
function changePlaybackRate(nextPlaybackRate: number) {
|
||||
if (audioRef.current !== null) {
|
||||
audioRef.current.playbackRate = nextPlaybackRate;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={`${title} 오디오 플레이어`} className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
|
||||
<audio
|
||||
controlsList="nodownload"
|
||||
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
|
||||
onEnded={() => {
|
||||
setIsPlaying(false);
|
||||
clearPlayer();
|
||||
}}
|
||||
onError={() => {
|
||||
setHasError(true);
|
||||
setIsPlaying(false);
|
||||
}}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
preload="metadata"
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
|
||||
{isPlaying ? "일시정지" : "재생"}
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
재생 위치
|
||||
<input aria-label="재생 위치" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
볼륨
|
||||
<input aria-label="볼륨" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
재생 속도
|
||||
<select aria-label="재생 속도" className="rounded-md border border-input bg-card px-3 py-2 text-base" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))} defaultValue="1">
|
||||
<option value="0.75">0.75×</option>
|
||||
<option value="1">1×</option>
|
||||
<option value="1.25">1.25×</option>
|
||||
<option value="1.5">1.5×</option>
|
||||
<option value="2">2×</option>
|
||||
</select>
|
||||
</label>
|
||||
{hasError ? (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
|
||||
<p>오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.</p>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button">오디오 다시 시도</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
9
src/shared/ui/audio-playback-context.ts
Normal file
9
src/shared/ui/audio-playback-context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type AudioPlaybackContextValue = {
|
||||
readonly activePlayerId: string | null;
|
||||
readonly clearPlayer: (playerId: string) => void;
|
||||
readonly requestPlay: (playerId: string) => void;
|
||||
};
|
||||
|
||||
export const AudioPlaybackContext = createContext<AudioPlaybackContextValue | null>(null);
|
||||
22
src/shared/ui/audio-playback-provider.tsx
Normal file
22
src/shared/ui/audio-playback-provider.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AudioPlaybackContext } from "./audio-playback-context";
|
||||
|
||||
export type AudioPlaybackProviderProps = {
|
||||
readonly children: ReactNode;
|
||||
};
|
||||
|
||||
export function AudioPlaybackProvider({ children }: AudioPlaybackProviderProps) {
|
||||
const [activePlayerId, setActivePlayerId] = useState<string | null>(null);
|
||||
|
||||
function clearPlayer(playerId: string) {
|
||||
setActivePlayerId((current) => (current === playerId ? null : current));
|
||||
}
|
||||
|
||||
return (
|
||||
<AudioPlaybackContext value={{ activePlayerId, clearPlayer, requestPlay: setActivePlayerId }}>
|
||||
{children}
|
||||
</AudioPlaybackContext>
|
||||
);
|
||||
}
|
||||
38
src/shared/ui/confirm-deactivate-dialog.tsx
Normal file
38
src/shared/ui/confirm-deactivate-dialog.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useModalFocus } from "@/shared/ui/use-modal-focus";
|
||||
|
||||
export type ConfirmDeactivateDialogProps = {
|
||||
readonly impactDescription: string;
|
||||
readonly onCancel: () => void;
|
||||
readonly onConfirm: () => void;
|
||||
readonly open: boolean;
|
||||
readonly targetName: string;
|
||||
};
|
||||
|
||||
export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = `${targetName} 비활성화 확인`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
|
||||
<section aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{impactDescription}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">
|
||||
취소
|
||||
</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onConfirm} type="button">
|
||||
비활성화
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/shared/ui/file-field.tsx
Normal file
48
src/shared/ui/file-field.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useId, useRef } from "react";
|
||||
|
||||
export type FileFieldProps = {
|
||||
readonly accept: string;
|
||||
readonly acceptDescription: string;
|
||||
readonly description?: string;
|
||||
readonly error?: string;
|
||||
readonly label: string;
|
||||
readonly onChange: (file: File | null) => void;
|
||||
readonly value: File | null;
|
||||
};
|
||||
|
||||
export function FileField({ accept, acceptDescription, description, error, label, onChange, value }: FileFieldProps) {
|
||||
const inputId = useId();
|
||||
const descriptionId = useId();
|
||||
const acceptId = useId();
|
||||
const errorId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.currentTarget.files;
|
||||
onChange(files === null ? null : files[0] ?? null);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
if (inputRef.current !== null) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
onChange(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4">
|
||||
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
|
||||
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
|
||||
{value === null ? null : (
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button">선택 취소</button>
|
||||
)}
|
||||
</div>
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/shared/ui/icon-only-action.tsx
Normal file
24
src/shared/ui/icon-only-action.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useId } from "react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type IconOnlyActionProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children"> & {
|
||||
readonly children: ReactNode;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
export function IconOnlyAction({ children, className, label, type = "button", ...buttonProps }: IconOnlyActionProps) {
|
||||
const tooltipId = useId();
|
||||
const classes = ["icon-only-action", className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<span className="icon-only-action-wrap">
|
||||
<button {...buttonProps} aria-label={label} className={classes} type={type}>
|
||||
{children}
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
<span className="icon-only-action-tooltip" id={tooltipId} role="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
153
src/shared/ui/image-crop-dialog.tsx
Normal file
153
src/shared/ui/image-crop-dialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { useModalFocus } from "@/shared/ui/use-modal-focus";
|
||||
|
||||
export type CropSourceImage = {
|
||||
readonly file: File;
|
||||
readonly height: number;
|
||||
readonly previewUrl: string;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
export type ImageCropPolicy = {
|
||||
readonly aspect: number | "free";
|
||||
readonly maxWidth: number;
|
||||
readonly noUpscale: boolean;
|
||||
};
|
||||
|
||||
export type ImageCropDialogProps = {
|
||||
readonly image: CropSourceImage;
|
||||
readonly onApply: (file: File) => void;
|
||||
readonly onCancel: () => void;
|
||||
readonly open: boolean;
|
||||
readonly policy: ImageCropPolicy;
|
||||
readonly renderCrop?: (request: CropRenderRequest) => Promise<File>;
|
||||
};
|
||||
|
||||
const MOVE_STEP = 10;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [offsetY, setOffsetY] = useState(0);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
|
||||
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width });
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resetCrop() {
|
||||
setOffsetX(0);
|
||||
setOffsetY(0);
|
||||
setZoom(1);
|
||||
}
|
||||
|
||||
function move(deltaX: number, deltaY: number) {
|
||||
setOffsetX((current) => current + deltaX);
|
||||
setOffsetY((current) => current + deltaY);
|
||||
}
|
||||
|
||||
function changeZoom(nextZoom: number) {
|
||||
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
move(0, MOVE_STEP);
|
||||
return;
|
||||
case "ArrowLeft":
|
||||
event.preventDefault();
|
||||
move(-MOVE_STEP, 0);
|
||||
return;
|
||||
case "ArrowRight":
|
||||
event.preventDefault();
|
||||
move(MOVE_STEP, 0);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
move(0, -MOVE_STEP);
|
||||
return;
|
||||
case "+":
|
||||
event.preventDefault();
|
||||
changeZoom(zoom + ZOOM_STEP);
|
||||
return;
|
||||
case "-":
|
||||
event.preventDefault();
|
||||
changeZoom(zoom - ZOOM_STEP);
|
||||
return;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(event: React.PointerEvent<HTMLElement>) {
|
||||
dragPointRef.current = { x: event.clientX, y: event.clientY };
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function drag(event: React.PointerEvent<HTMLElement>) {
|
||||
const dragPoint = dragPointRef.current;
|
||||
if (dragPoint === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
move(event.clientX - dragPoint.x, event.clientY - dragPoint.y);
|
||||
dragPointRef.current = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
dragPointRef.current = null;
|
||||
}
|
||||
|
||||
async function applyCrop() {
|
||||
const file = await renderCrop({
|
||||
aspect: policy.aspect,
|
||||
file: image.file,
|
||||
offsetX,
|
||||
offsetY,
|
||||
outputHeight: outputSize.height,
|
||||
outputWidth: outputSize.width,
|
||||
previewUrl: image.previewUrl,
|
||||
sourceHeight: image.height,
|
||||
sourceWidth: image.width,
|
||||
zoom,
|
||||
});
|
||||
onApply(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
|
||||
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="dialog">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">이미지 crop</h2>
|
||||
<p className="text-sm text-muted-foreground">버튼, 범위 입력, 방향키로 위치와 확대를 조정한 뒤 적용합니다.</p>
|
||||
</div>
|
||||
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handleKeyDown} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" tabIndex={0}>
|
||||
<img alt="선택한 이미지 미리보기" className="mx-auto max-h-64 max-w-full" src={image.previewUrl} style={{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }} />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-info">예상 결과 {outputSize.width} × {outputSize.height}px</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, -MOVE_STEP)} type="button">위로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, MOVE_STEP)} type="button">아래로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(-MOVE_STEP, 0)} type="button">왼쪽으로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(MOVE_STEP, 0)} type="button">오른쪽으로 이동</button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
확대 비율
|
||||
<input aria-label="확대 비율" max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={resetCrop} type="button">초기화</button>
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">취소</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void applyCrop()} type="button">적용</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/shared/ui/page-state.tsx
Normal file
66
src/shared/ui/page-state.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type LoadingPageStateProps = {
|
||||
readonly description?: string;
|
||||
readonly state: "loading";
|
||||
readonly title: string;
|
||||
};
|
||||
|
||||
type EmptyPageStateProps = {
|
||||
readonly description?: string;
|
||||
readonly state: "empty";
|
||||
readonly title: string;
|
||||
};
|
||||
|
||||
type ErrorPageStateProps = {
|
||||
readonly description?: string;
|
||||
readonly onRetry?: () => void;
|
||||
readonly state: "error";
|
||||
readonly title: string;
|
||||
};
|
||||
|
||||
type ContentPageStateProps = {
|
||||
readonly children: ReactNode;
|
||||
readonly state: "content";
|
||||
};
|
||||
|
||||
export type PageStateProps = LoadingPageStateProps | EmptyPageStateProps | ErrorPageStateProps | ContentPageStateProps;
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unhandled page state: ${String(value)}`);
|
||||
}
|
||||
|
||||
export function PageState(props: PageStateProps) {
|
||||
switch (props.state) {
|
||||
case "content":
|
||||
return <>{props.children}</>;
|
||||
case "loading":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "empty":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
|
||||
{props.onRetry === undefined ? null : (
|
||||
<button className="mt-4 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={props.onRetry} type="button">
|
||||
다시 시도
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
default:
|
||||
return assertNever(props);
|
||||
}
|
||||
}
|
||||
32
src/shared/ui/resource-pagination.tsx
Normal file
32
src/shared/ui/resource-pagination.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { PageData } from "@/shared/api/pagination";
|
||||
|
||||
export type ResourcePaginationProps = {
|
||||
readonly data: PageData<unknown>;
|
||||
readonly onPageChange: (page: number) => void;
|
||||
readonly onSizeChange: (size: number) => void;
|
||||
readonly sizeOptions?: readonly number[];
|
||||
};
|
||||
|
||||
export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptions = [20, 50] }: ResourcePaginationProps) {
|
||||
return (
|
||||
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm font-semibold text-muted-foreground">총 {data.totalCount.toLocaleString("ko-KR")}개 · {data.page + 1}페이지</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor="resource-page-size">
|
||||
페이지 크기
|
||||
</label>
|
||||
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id="resource-page-size" onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
|
||||
{sizeOptions.map((size) => (
|
||||
<option key={size} value={size}>{size}개</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
|
||||
이전 페이지
|
||||
</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
|
||||
다음 페이지
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
16
src/shared/ui/responsive-resource-list.tsx
Normal file
16
src/shared/ui/responsive-resource-list.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type ResponsiveResourceListProps = {
|
||||
readonly ariaLabel: string;
|
||||
readonly desktop: ReactNode;
|
||||
readonly mobile: ReactNode;
|
||||
};
|
||||
|
||||
export function ResponsiveResourceList({ ariaLabel, desktop, mobile }: ResponsiveResourceListProps) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className="rounded-lg border border-border bg-card" role="region">
|
||||
<div className="hidden overflow-x-auto md:block">{desktop}</div>
|
||||
<div className="md:hidden">{mobile}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
src/shared/ui/search-toolbar.tsx
Normal file
37
src/shared/ui/search-toolbar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SearchToolbarProps = {
|
||||
readonly filters?: ReactNode;
|
||||
readonly onQueryChange: (query: string) => void;
|
||||
readonly onSearchChange: (search: string) => void;
|
||||
readonly search: string;
|
||||
};
|
||||
|
||||
export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }: SearchToolbarProps) {
|
||||
const searchId = useId();
|
||||
const didMountRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => onQueryChange(search), 300);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [onQueryChange, search]);
|
||||
|
||||
return (
|
||||
<section aria-label="검색 도구" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor={searchId}>
|
||||
검색어
|
||||
</label>
|
||||
<input className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={searchId} onChange={(event) => onSearchChange(event.currentTarget.value)} type="search" value={search} />
|
||||
</div>
|
||||
{filters === undefined ? null : <div className="flex flex-col gap-2 sm:min-w-48">{filters}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
91
src/shared/ui/status-badge.tsx
Normal file
91
src/shared/ui/status-badge.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { isValidElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const STATUS_BADGE = {
|
||||
INACTIVE: {
|
||||
className: "status-badge status-badge--inactive",
|
||||
label: "비활성",
|
||||
tone: "inactive",
|
||||
},
|
||||
OPEN: {
|
||||
className: "status-badge status-badge--success",
|
||||
label: "공개",
|
||||
tone: "success",
|
||||
},
|
||||
SCHEDULED: {
|
||||
className: "status-badge status-badge--warning",
|
||||
label: "예약",
|
||||
tone: "warning",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type StatusBadgeStatus = keyof typeof STATUS_BADGE;
|
||||
export type StatusBadgeTone = "inactive" | "success" | "warning";
|
||||
|
||||
type PresetStatusBadgeProps = {
|
||||
readonly status: StatusBadgeStatus;
|
||||
readonly description?: never;
|
||||
readonly icon?: never;
|
||||
readonly label?: never;
|
||||
readonly tone?: never;
|
||||
};
|
||||
|
||||
type DomainStatusBadgeProps = {
|
||||
readonly description?: ReactNode;
|
||||
readonly icon?: ReactNode;
|
||||
readonly label: string;
|
||||
readonly status?: never;
|
||||
readonly tone: StatusBadgeTone;
|
||||
};
|
||||
|
||||
export type StatusBadgeProps = PresetStatusBadgeProps | DomainStatusBadgeProps;
|
||||
|
||||
function getNodeText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getNodeText).join("");
|
||||
}
|
||||
|
||||
if (isValidElement<{ readonly children?: ReactNode }>(node)) {
|
||||
return getNodeText(node.props.children);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getBadge(props: StatusBadgeProps): {
|
||||
readonly className: string;
|
||||
readonly description: ReactNode;
|
||||
readonly label: string;
|
||||
readonly icon: ReactNode;
|
||||
} {
|
||||
if (props.status !== undefined) {
|
||||
const badge = STATUS_BADGE[props.status];
|
||||
|
||||
return { className: badge.className, description: null, icon: null, label: badge.label };
|
||||
}
|
||||
|
||||
return {
|
||||
className: `status-badge status-badge--${props.tone}`,
|
||||
description: props.description ?? null,
|
||||
icon: props.icon ?? null,
|
||||
label: props.label,
|
||||
};
|
||||
}
|
||||
|
||||
export function StatusBadge(props: StatusBadgeProps) {
|
||||
const badge = getBadge(props);
|
||||
const descriptionText = getNodeText(badge.description);
|
||||
const ariaLabel = descriptionText.length > 0 ? `상태: ${badge.label}, ${descriptionText}` : `상태: ${badge.label}`;
|
||||
|
||||
return (
|
||||
<span aria-label={ariaLabel} className={badge.className}>
|
||||
{badge.icon ?? <span aria-hidden="true" className="status-badge__dot" />}
|
||||
{badge.label}
|
||||
{badge.description === null ? null : <span className="font-normal">{badge.description}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
68
src/shared/ui/unsaved-changes-guard.tsx
Normal file
68
src/shared/ui/unsaved-changes-guard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useModalFocus } from "@/shared/ui/use-modal-focus";
|
||||
|
||||
export type RequestRouteLeave = (trigger: HTMLElement, leaveRoute: () => void) => void;
|
||||
|
||||
export type UnsavedChangesGuardProps = {
|
||||
readonly children: (requestRouteLeave: RequestRouteLeave) => ReactNode;
|
||||
readonly dirty: boolean;
|
||||
readonly impactDescription: string;
|
||||
readonly title: string;
|
||||
};
|
||||
|
||||
type PendingRouteLeave = {
|
||||
readonly leaveRoute: () => void;
|
||||
readonly trigger: HTMLElement;
|
||||
};
|
||||
|
||||
export function UnsavedChangesGuard({ children, dirty, impactDescription, title }: UnsavedChangesGuardProps) {
|
||||
const [pendingRouteLeave, setPendingRouteLeave] = useState<PendingRouteLeave | null>(null);
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(pendingRouteLeave !== null);
|
||||
|
||||
function closeDialog() {
|
||||
const trigger = pendingRouteLeave?.trigger;
|
||||
setPendingRouteLeave(null);
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
function confirmLeave() {
|
||||
const leaveRoute = pendingRouteLeave?.leaveRoute;
|
||||
setPendingRouteLeave(null);
|
||||
leaveRoute?.();
|
||||
}
|
||||
|
||||
function requestRouteLeave(trigger: HTMLElement, leaveRoute: () => void) {
|
||||
if (!dirty) {
|
||||
leaveRoute();
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingRouteLeave({ leaveRoute, trigger });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(requestRouteLeave)}
|
||||
{dirty && pendingRouteLeave !== null ? (
|
||||
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
|
||||
<section aria-label={title} aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{impactDescription}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={closeDialog} type="button">
|
||||
계속 편집
|
||||
</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={confirmLeave} type="button">
|
||||
떠나기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
src/shared/ui/upload-progress.tsx
Normal file
39
src/shared/ui/upload-progress.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
const STATUS_LABEL = {
|
||||
canceled: "취소됨",
|
||||
error: "업로드 실패",
|
||||
idle: "대기 중",
|
||||
success: "업로드 완료",
|
||||
uploading: "업로드 중",
|
||||
} as const;
|
||||
|
||||
export type UploadProgressStatus = keyof typeof STATUS_LABEL;
|
||||
|
||||
export type UploadProgressProps = {
|
||||
readonly fileName?: string;
|
||||
readonly onCancel?: () => void;
|
||||
readonly onRetry?: () => void;
|
||||
readonly progress: number;
|
||||
readonly status: UploadProgressStatus;
|
||||
};
|
||||
|
||||
export function UploadProgress({ fileName, onCancel, onRetry, progress, status }: UploadProgressProps) {
|
||||
const safeProgress = Math.min(100, Math.max(0, Math.round(progress)));
|
||||
|
||||
return (
|
||||
<section aria-label="업로드 상태" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{fileName === undefined ? null : <p className="text-sm font-semibold">{fileName}</p>}
|
||||
<p className="text-sm text-muted-foreground">{STATUS_LABEL[status]}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{onCancel === undefined ? null : <button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">업로드 취소</button>}
|
||||
{onRetry === undefined ? null : <button className="rounded-md border border-input bg-primary px-3 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onRetry} type="button">다시 시도</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-label="업로드 진행률" aria-valuemax={100} aria-valuemin={0} aria-valuenow={safeProgress} className="h-3 overflow-hidden rounded-sm bg-muted" role="progressbar">
|
||||
<div className="h-full bg-primary" style={{ inlineSize: `${safeProgress}%` }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
19
src/shared/ui/use-audio-playback.ts
Normal file
19
src/shared/ui/use-audio-playback.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import { AudioPlaybackContext } from "./audio-playback-context";
|
||||
|
||||
export function useAudioPlayback(playerId: string, audioRef: RefObject<HTMLAudioElement | null>) {
|
||||
const context = useContext(AudioPlaybackContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (context?.activePlayerId !== null && context?.activePlayerId !== undefined && context.activePlayerId !== playerId) {
|
||||
audioRef.current?.pause();
|
||||
}
|
||||
}, [audioRef, context?.activePlayerId, playerId]);
|
||||
|
||||
return {
|
||||
clearPlayer: () => context?.clearPlayer(playerId),
|
||||
requestPlay: () => context?.requestPlay(playerId),
|
||||
};
|
||||
}
|
||||
50
src/shared/ui/use-modal-focus.ts
Normal file
50
src/shared/ui/use-modal-focus.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
export function useModalFocus<T extends HTMLElement>(open: boolean) {
|
||||
const dialogRef = useRef<T>(null);
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
triggerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const focusableElements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
||||
focusableElements[0]?.focus();
|
||||
|
||||
return () => {
|
||||
triggerRef.current?.focus();
|
||||
triggerRef.current = null;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function trapFocus(event: React.KeyboardEvent<T>) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements.at(-1);
|
||||
|
||||
if (firstElement === undefined || lastElement === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return { dialogRef, trapFocus };
|
||||
}
|
||||
31
src/shared/validation/audio-file-policy.ts
Normal file
31
src/shared/validation/audio-file-policy.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getFileExtension, validateFile } from "./file-validation";
|
||||
import type { FileValidationResult } from "./file-validation";
|
||||
|
||||
export const AUDIO_FILE_POLICY = {
|
||||
allowedExtensions: [".mp3", ".aac", ".m4a"],
|
||||
allowedMimeTypes: ["audio/mpeg", "audio/aac", "audio/mp4", "audio/x-m4a"],
|
||||
maxBytes: 1_024_000_000,
|
||||
} as const;
|
||||
|
||||
const allowedMimeByExtension = {
|
||||
".aac": ["audio/aac"],
|
||||
".m4a": ["audio/mp4", "audio/x-m4a"],
|
||||
".mp3": ["audio/mpeg"],
|
||||
} satisfies Record<string, readonly string[]>;
|
||||
|
||||
export type AudioFileValidationResult = FileValidationResult | { readonly ok: false; readonly reason: "mimeExtensionCombination" };
|
||||
|
||||
export function validateAudioFile(file: File): AudioFileValidationResult {
|
||||
const baseResult = validateFile(file, AUDIO_FILE_POLICY);
|
||||
if (!baseResult.ok) {
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
const extension = getFileExtension(file.name);
|
||||
const allowedMimeTypes = allowedMimeByExtension[extension as keyof typeof allowedMimeByExtension];
|
||||
if (allowedMimeTypes === undefined || !allowedMimeTypes.includes(file.type)) {
|
||||
return { ok: false, reason: "mimeExtensionCombination" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
93
src/shared/validation/file-media-policy.test.ts
Normal file
93
src/shared/validation/file-media-policy.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { createImagePolicy, IMAGE_MAX_BYTES } from "@/shared/validation/image-policy";
|
||||
import { AUDIO_FILE_POLICY, validateAudioFile } from "@/shared/validation/audio-file-policy";
|
||||
import { validateFile } from "@/shared/validation/file-validation";
|
||||
|
||||
function fileWithSize(name: string, type: string, size: number): File {
|
||||
const file = new File(["x"], name, { type });
|
||||
Object.defineProperty(file, "size", { value: size });
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
test("validateFile checks injected extension, MIME, and maxBytes together", () => {
|
||||
const policy = {
|
||||
allowedExtensions: [".png"] as const,
|
||||
allowedMimeTypes: ["image/png"] as const,
|
||||
maxBytes: 10,
|
||||
};
|
||||
|
||||
expect(validateFile(fileWithSize("cover.png", "image/png", 10), policy)).toEqual({ ok: true });
|
||||
expect(validateFile(fileWithSize("cover.jpg", "image/png", 10), policy)).toEqual({ ok: false, reason: "extension" });
|
||||
expect(validateFile(fileWithSize("cover.png", "image/jpeg", 10), policy)).toEqual({ ok: false, reason: "mime" });
|
||||
expect(validateFile(fileWithSize("cover.png", "image/png", 11), policy)).toEqual({ ok: false, reason: "size" });
|
||||
});
|
||||
|
||||
test("validateFile lets callers inject a 10MB byte boundary without owning image domain policy", () => {
|
||||
const tenMegabytes = 10 * 1024 * 1024;
|
||||
const policy = {
|
||||
allowedExtensions: [".jpg"] as const,
|
||||
allowedMimeTypes: ["image/jpeg"] as const,
|
||||
maxBytes: tenMegabytes,
|
||||
};
|
||||
|
||||
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes), policy)).toEqual({ ok: true });
|
||||
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes + 1), policy)).toEqual({ ok: false, reason: "size" });
|
||||
});
|
||||
|
||||
test("validateAudioFile accepts MP3, AAC, M4A including audio/x-m4a at 1,024,000,000 bytes", () => {
|
||||
expect(AUDIO_FILE_POLICY.maxBytes).toBe(1_024_000_000);
|
||||
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_000))).toEqual({ ok: true });
|
||||
expect(validateAudioFile(fileWithSize("voice.aac", "audio/aac", 1_024_000_000))).toEqual({ ok: true });
|
||||
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/mp4", 1_024_000_000))).toEqual({ ok: true });
|
||||
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("validateAudioFile rejects WAV, oversized files, and audio/x-m4a without .m4a without sniffing codecs", async () => {
|
||||
const wav = fileWithSize("voice.wav", "audio/wav", 10);
|
||||
const sniff = vi.spyOn(wav, "arrayBuffer");
|
||||
|
||||
expect(validateAudioFile(wav)).toEqual({ ok: false, reason: "extension" });
|
||||
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_001))).toEqual({ ok: false, reason: "size" });
|
||||
expect(validateAudioFile(fileWithSize("voice.aac", "audio/x-m4a", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
|
||||
expect(sniff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("validateAudioFile rejects mismatched canonical MIME and extension combinations", () => {
|
||||
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mp4", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
|
||||
expect(validateAudioFile(fileWithSize("voice.aac", "audio/mpeg", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
|
||||
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/aac", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
|
||||
});
|
||||
|
||||
test("createImagePolicy records only a domain-neutral crop contract", () => {
|
||||
expect(createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true })).toEqual({
|
||||
aspect: 1,
|
||||
cropRequired: true,
|
||||
maxBytes: 10_485_760,
|
||||
maxWidth: 800,
|
||||
noUpscale: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("createImagePolicy records the confirmed 10MiB image byte boundary", () => {
|
||||
const policy = createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true });
|
||||
|
||||
expect(IMAGE_MAX_BYTES).toBe(10_485_760);
|
||||
expect(policy.maxBytes).toBe(10_485_760);
|
||||
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_759), {
|
||||
allowedExtensions: [".jpg"] as const,
|
||||
allowedMimeTypes: ["image/jpeg"] as const,
|
||||
maxBytes: policy.maxBytes,
|
||||
})).toEqual({ ok: true });
|
||||
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_760), {
|
||||
allowedExtensions: [".jpg"] as const,
|
||||
allowedMimeTypes: ["image/jpeg"] as const,
|
||||
maxBytes: policy.maxBytes,
|
||||
})).toEqual({ ok: true });
|
||||
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_761), {
|
||||
allowedExtensions: [".jpg"] as const,
|
||||
allowedMimeTypes: ["image/jpeg"] as const,
|
||||
maxBytes: policy.maxBytes,
|
||||
})).toEqual({ ok: false, reason: "size" });
|
||||
});
|
||||
31
src/shared/validation/file-validation.ts
Normal file
31
src/shared/validation/file-validation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type FileValidationPolicy = {
|
||||
readonly allowedExtensions: readonly string[];
|
||||
readonly allowedMimeTypes: readonly string[];
|
||||
readonly maxBytes: number;
|
||||
};
|
||||
|
||||
export type FileValidationResult =
|
||||
| { readonly ok: true }
|
||||
| { readonly ok: false; readonly reason: "extension" | "mime" | "size" };
|
||||
|
||||
export function getFileExtension(fileName: string): string {
|
||||
const dotIndex = fileName.lastIndexOf(".");
|
||||
|
||||
return dotIndex < 0 ? "" : fileName.slice(dotIndex).toLowerCase();
|
||||
}
|
||||
|
||||
export function validateFile(file: File, policy: FileValidationPolicy): FileValidationResult {
|
||||
if (file.size > policy.maxBytes) {
|
||||
return { ok: false, reason: "size" };
|
||||
}
|
||||
|
||||
if (!policy.allowedExtensions.includes(getFileExtension(file.name))) {
|
||||
return { ok: false, reason: "extension" };
|
||||
}
|
||||
|
||||
if (!policy.allowedMimeTypes.includes(file.type)) {
|
||||
return { ok: false, reason: "mime" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
15
src/shared/validation/image-policy.ts
Normal file
15
src/shared/validation/image-policy.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type ImageAspect = number | "free";
|
||||
|
||||
export const IMAGE_MAX_BYTES = 10_485_760;
|
||||
|
||||
export type ImagePolicy = {
|
||||
readonly aspect: ImageAspect;
|
||||
readonly cropRequired: boolean;
|
||||
readonly maxBytes: number;
|
||||
readonly maxWidth: number;
|
||||
readonly noUpscale: boolean;
|
||||
};
|
||||
|
||||
export function createImagePolicy(policy: Omit<ImagePolicy, "maxBytes">): ImagePolicy {
|
||||
return { ...policy, maxBytes: IMAGE_MAX_BYTES };
|
||||
}
|
||||
Reference in New Issue
Block a user