diff --git a/src/hooks/__tests__/useCurations.test.ts b/src/hooks/__tests__/useCurations.test.ts index b3c4be1..fea39f9 100644 --- a/src/hooks/__tests__/useCurations.test.ts +++ b/src/hooks/__tests__/useCurations.test.ts @@ -1,19 +1,22 @@ -import { describe, expect, it, vi } from 'vitest'; -import { waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { server } from '@/test/mocks/server'; import { wrapApiResponse } from '@/test/mocks/data'; import { renderHook } from '@/test/test-utils'; -import type { - CurationV2CreateRequest, - CurationV2Spec, - CurationV2SpecListItem, -} from '@/types/api'; +import { + cacheCurationSpecDetail, + getCachedCurationSpecDetail, + readCurationSpecBrowserCache, + writeCurationSpecBrowserCache, +} from '@/lib/curation-spec-browser-cache'; +import { useAuthStore } from '@/stores/auth'; +import type { CurationV2CreateRequest, CurationV2Spec, CurationV2SpecListItem } from '@/types/api'; import { useCurationCreate, useCurationList, - useCurationSpecByCode, + useCurationSpec, useCurationSpecs, useCurationUpdate, } from '../useCurations'; @@ -68,47 +71,126 @@ const createRequest: CurationV2CreateRequest = { }, }; +const ADMIN_ID = 7; + +beforeEach(() => { + window.localStorage.clear(); + act(() => { + useAuthStore.setState({ + user: { adminId: ADMIN_ID, email: 'admin@example.com', roles: ['ROOT_ADMIN'] }, + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + isAuthenticated: true, + }); + }); +}); + describe('useCurations hooks', () => { - it('큐레이션 스펙 목록을 반환한다', async () => { + it('fresh browser manifest를 목록 API 호출 없이 사용한다', async () => { + let listRequestCount = 0; server.use( http.get(SPEC_BASE, () => { + listRequestCount++; return HttpResponse.json(wrapApiResponse([mockTastingEventSpecListItem])); }) ); + writeCurationSpecBrowserCache(ADMIN_ID, { + schemaVersion: 1, + checkedAt: Date.now(), + specs: [mockTastingEventSpecListItem], + details: {}, + }); + const { result } = renderHook(() => useCurationSpecs()); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.data?.[0]?.version).toBe(1)); + expect(listRequestCount).toBe(0); + }); - expect(result.current.data![0]!.code).toBe('WHISKY_TASTING_EVENT'); + it('stale manifest 확인 후 version이 바뀐 detail cache를 제거한다', async () => { + const staleCache = cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: Date.now() - 1000 * 60 * 60 - 1, + specs: [mockTastingEventSpecListItem], + details: {}, + }, + mockTastingEventSpec, + Date.now() - 1000 * 60 * 60 - 1 + ); + writeCurationSpecBrowserCache(ADMIN_ID, staleCache); + + server.use( + http.get(SPEC_BASE, () => + HttpResponse.json(wrapApiResponse([{ ...mockTastingEventSpecListItem, version: 2 }])) + ) + ); + + const { result } = renderHook(() => useCurationSpecs()); + + await waitFor(() => expect(result.current.data?.[0]?.version).toBe(2)); + + const cache = readCurationSpecBrowserCache(ADMIN_ID); + expect(getCachedCurationSpecDetail(cache, mockTastingEventSpec.id, 1)).toBeNull(); }); - it('specCode로 큐레이션 스펙을 resolve한다', async () => { + it('matching browser detail을 재사용하고 detail API를 호출하지 않는다', async () => { + let detailRequestCount = 0; server.use( - http.get(SPEC_BASE, () => { - return HttpResponse.json(wrapApiResponse([mockTastingEventSpecListItem])); + http.get(`${SPEC_BASE}/:specId`, () => { + detailRequestCount++; + return HttpResponse.json(wrapApiResponse(mockTastingEventSpec)); }) ); - const { result } = renderHook(() => useCurationSpecByCode('WHISKY_TASTING_EVENT')); + const cache = cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: Date.now(), + specs: [mockTastingEventSpecListItem], + details: {}, + }, + mockTastingEventSpec, + Date.now() + ); + writeCurationSpecBrowserCache(ADMIN_ID, cache); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const { result } = renderHook(() => useCurationSpec(mockTastingEventSpec.id, 1)); + + await waitFor(() => expect(result.current.data?.id).toBe(mockTastingEventSpec.id)); + expect(detailRequestCount).toBe(0); + }); - expect(result.current.data?.id).toBe(3); + it('detail cache miss 시 응답 schema를 browser cache에 저장한다', async () => { + server.use( + http.get(`${SPEC_BASE}/:specId`, () => + HttpResponse.json(wrapApiResponse(mockTastingEventSpec)) + ) + ); + + const { result } = renderHook(() => useCurationSpec(mockTastingEventSpec.id, 1)); + + await waitFor(() => expect(result.current.data?.id).toBe(mockTastingEventSpec.id)); + + const cache = readCurationSpecBrowserCache(ADMIN_ID); + expect(getCachedCurationSpecDetail(cache, mockTastingEventSpec.id, 1)?.data).toEqual( + mockTastingEventSpec + ); }); - it('존재하지 않는 specCode는 null로 반환한다', async () => { + it('큐레이션 스펙 목록을 반환한다', async () => { server.use( http.get(SPEC_BASE, () => { return HttpResponse.json(wrapApiResponse([mockTastingEventSpecListItem])); }) ); - const { result } = renderHook(() => useCurationSpecByCode('UNKNOWN_SPEC')); + const { result } = renderHook(() => useCurationSpecs()); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toBeNull(); + expect(result.current.data![0]!.code).toBe('WHISKY_TASTING_EVENT'); }); it('spec 기반 큐레이션 목록을 반환한다', async () => { diff --git a/src/hooks/useCurations.ts b/src/hooks/useCurations.ts index edf034e..2245a94 100644 --- a/src/hooks/useCurations.ts +++ b/src/hooks/useCurations.ts @@ -4,12 +4,21 @@ import { useQueryClient } from '@tanstack/react-query'; +import { + cacheCurationSpecDetail, + CURATION_SPEC_QUERY_GC_MS, + CURATION_SPEC_REVALIDATE_AFTER_MS, + getCachedCurationSpecDetail, + readCurationSpecBrowserCache, + reconcileCurationSpecManifest, + writeCurationSpecBrowserCache, +} from '@/lib/curation-spec-browser-cache'; +import { useAuthStore } from '@/stores/auth'; import { useApiQuery, type UseApiQueryOptions } from './useApiQuery'; import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; import { curationKeys, curationService, - resolveCurationSpecByCode, type CurationListResponse, } from '@/services/curation.service'; import type { @@ -18,7 +27,6 @@ import type { CurationV2Detail, CurationV2SearchParams, CurationV2Spec, - CurationV2SpecCode, CurationV2SpecListItem, CurationV2UpdateRequest, CurationV2UpdateResponse, @@ -26,45 +34,81 @@ import type { /** * 큐레이션 스펙 목록 조회 훅 + * + * 목록 API는 version manifest로 사용한다. 브라우저 cache가 1시간 이내면 즉시 복원하고, + * stale 상태 또는 탭 재포커스 때만 목록을 다시 조회해 상세 schema cache를 정리한다. */ export function useCurationSpecs() { - return useApiQuery(curationKeys.specs(), curationService.listSpecs, { - staleTime: 1000 * 60 * 5, - }); + const adminId = useAuthStore((state) => state.user?.adminId); + const browserCache = adminId ? readCurationSpecBrowserCache(adminId) : null; + + return useApiQuery( + curationKeys.specs(adminId ?? 0), + async () => { + const specs = await curationService.listSpecs(); + + if (adminId) { + const checkedAt = Date.now(); + const reconciledCache = reconcileCurationSpecManifest( + readCurationSpecBrowserCache(adminId), + specs, + checkedAt + ); + writeCurationSpecBrowserCache(adminId, reconciledCache); + } + + return specs; + }, + { + staleTime: CURATION_SPEC_REVALIDATE_AFTER_MS, + gcTime: CURATION_SPEC_QUERY_GC_MS, + refetchOnWindowFocus: true, + initialData: browserCache?.specs, + initialDataUpdatedAt: browserCache?.checkedAt, + } + ); } /** * 큐레이션 스펙 상세 조회 훅 + * + * 상세 query key에 version을 포함해 manifest version이 바뀌면 기존 TanStack Query data도 + * 재사용하지 않는다. 현재 version의 browser cache가 있으면 상세 API 요청을 생략한다. */ export function useCurationSpec( specId: number | undefined, + version: number | undefined, options?: UseApiQueryOptions ) { - return useApiQuery( - curationKeys.spec(specId ?? 0), - () => curationService.getSpec(specId!), - { - enabled: !!specId && specId > 0, - staleTime: 1000 * 60 * 5, - ...options, - } - ); -} + const adminId = useAuthStore((state) => state.user?.adminId); + const browserCache = adminId ? readCurationSpecBrowserCache(adminId) : null; + const cachedDetail = + specId && version ? getCachedCurationSpecDetail(browserCache, specId, version) : null; + const { enabled: isEnabled = true, ...restOptions } = options ?? {}; -/** - * 큐레이션 스펙 code 기준 조회 훅 - * specId는 환경별로 달라질 수 있으므로 code로 조회 후 런타임에 resolve한다. - */ -export function useCurationSpecByCode(specCode: CurationV2SpecCode | undefined) { - return useApiQuery( - curationKeys.specByCode(specCode ?? ''), + return useApiQuery( + curationKeys.spec(adminId ?? 0, specId ?? 0, version ?? 0), async () => { - const specs = await curationService.listSpecs(); - return specCode ? resolveCurationSpecByCode(specs, specCode) : null; + const spec = await curationService.getSpec(specId!); + + if (adminId && spec.version === version) { + const nextCache = cacheCurationSpecDetail( + readCurationSpecBrowserCache(adminId), + spec, + Date.now() + ); + writeCurationSpecBrowserCache(adminId, nextCache); + } + + return spec; }, { - enabled: !!specCode, - staleTime: 1000 * 60 * 5, + ...restOptions, + enabled: !!specId && specId > 0 && !!version && isEnabled, + staleTime: Infinity, + gcTime: CURATION_SPEC_QUERY_GC_MS, + initialData: cachedDetail?.data, + initialDataUpdatedAt: cachedDetail?.cachedAt, } ); } diff --git a/src/lib/__tests__/curation-spec-browser-cache.test.ts b/src/lib/__tests__/curation-spec-browser-cache.test.ts new file mode 100644 index 0000000..750526a --- /dev/null +++ b/src/lib/__tests__/curation-spec-browser-cache.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + cacheCurationSpecDetail, + CURATION_SPEC_CACHE_RETENTION_MS, + getCachedCurationSpecDetail, + getCurationSpecBrowserCacheKey, + readCurationSpecBrowserCache, + reconcileCurationSpecManifest, + writeCurationSpecBrowserCache, +} from '../curation-spec-browser-cache'; +import type { CurationV2Spec, CurationV2SpecListItem } from '@/types/api'; + +const ADMIN_ID = 7; +const NOW = new Date('2026-07-24T08:00:00.000Z').getTime(); + +const tastingEventListItem: CurationV2SpecListItem = { + id: 3, + code: 'WHISKY_TASTING_EVENT', + name: '위스키 시음회', + description: '시음회', + version: 2, + isActive: true, +}; + +const pairingListItem: CurationV2SpecListItem = { + id: 2, + code: 'WHISKY_PAIRING', + name: '위스키 페어링', + description: '페어링', + version: 2, + isActive: true, +}; + +const tastingEventSpec: CurationV2Spec = { + ...tastingEventListItem, + hydratorKey: 'alcohol', + requestSpec: { type: 'object', properties: { eventDate: { type: 'string' } } }, + responseSpec: { type: 'object' }, +}; + +const pairingSpec: CurationV2Spec = { + ...pairingListItem, + hydratorKey: 'alcohol', + requestSpec: { type: 'object', properties: { foodName: { type: 'string' } } }, + responseSpec: { type: 'object' }, +}; + +describe('curation spec browser cache', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('손상되었거나 보존 기간이 지난 cache는 무시한다', () => { + localStorage.setItem(getCurationSpecBrowserCacheKey(ADMIN_ID), '{broken json'); + expect(readCurationSpecBrowserCache(ADMIN_ID)).toBeNull(); + + writeCurationSpecBrowserCache(ADMIN_ID, { + schemaVersion: 1, + checkedAt: NOW - CURATION_SPEC_CACHE_RETENTION_MS - 1, + specs: [tastingEventListItem], + details: {}, + }); + + expect(readCurationSpecBrowserCache(ADMIN_ID)).toBeNull(); + expect(localStorage.getItem(getCurationSpecBrowserCacheKey(ADMIN_ID))).toBeNull(); + }); + + it('manifest version 변경 또는 inactive 상태의 detail만 제거한다', () => { + const cached = cacheCurationSpecDetail( + cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: NOW, + specs: [tastingEventListItem, pairingListItem], + details: {}, + }, + tastingEventSpec, + NOW + ), + pairingSpec, + NOW + ); + + const reconciled = reconcileCurationSpecManifest( + cached, + [ + tastingEventListItem, + { + ...pairingListItem, + isActive: false, + }, + ], + NOW + 1000 + ); + + expect(getCachedCurationSpecDetail(reconciled, 3, 2)?.data).toEqual(tastingEventSpec); + expect(getCachedCurationSpecDetail(reconciled, 2, 2)).toBeNull(); + expect(reconciled.checkedAt).toBe(NOW + 1000); + }); + + it('현재 manifest version과 일치하는 detail만 반환한다', () => { + const cache = cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: NOW, + specs: [tastingEventListItem], + details: {}, + }, + tastingEventSpec, + NOW + ); + + expect(getCachedCurationSpecDetail(cache, 3, 2)?.data).toEqual(tastingEventSpec); + expect(getCachedCurationSpecDetail(cache, 3, 3)).toBeNull(); + }); +}); diff --git a/src/lib/curation-spec-browser-cache.ts b/src/lib/curation-spec-browser-cache.ts new file mode 100644 index 0000000..cab1e93 --- /dev/null +++ b/src/lib/curation-spec-browser-cache.ts @@ -0,0 +1,169 @@ +import type { CurationV2Spec, CurationV2SpecListItem } from '@/types/api'; + +export const CURATION_SPEC_REVALIDATE_AFTER_MS = 1000 * 60 * 60; +export const CURATION_SPEC_QUERY_GC_MS = 1000 * 60 * 60; +export const CURATION_SPEC_CACHE_RETENTION_MS = 1000 * 60 * 60 * 24 * 30; + +const CURATION_SPEC_CACHE_SCHEMA_VERSION = 1; + +export interface CachedCurationSpecDetail { + version: number; + cachedAt: number; + data: CurationV2Spec; +} + +export interface CurationSpecBrowserCache { + schemaVersion: typeof CURATION_SPEC_CACHE_SCHEMA_VERSION; + checkedAt: number; + specs: CurationV2SpecListItem[]; + details: Record; +} + +export function getCurationSpecBrowserCacheKey(adminId: number) { + return `bottlenote:curation-spec-cache:v${CURATION_SPEC_CACHE_SCHEMA_VERSION}:${adminId}`; +} + +function getLocalStorage() { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.localStorage; + } catch { + return null; + } +} + +function removeLocalStorageItem(storage: Storage, key: string) { + try { + storage.removeItem(key); + } catch { + // Browser storage privacy mode failures must not block curation forms. + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isCurationSpecBrowserCache(value: unknown): value is CurationSpecBrowserCache { + if (!isRecord(value)) { + return false; + } + + return ( + value.schemaVersion === CURATION_SPEC_CACHE_SCHEMA_VERSION && + typeof value.checkedAt === 'number' && + Array.isArray(value.specs) && + isRecord(value.details) + ); +} + +function createEmptyCurationSpecBrowserCache(checkedAt = Date.now()): CurationSpecBrowserCache { + return { + schemaVersion: CURATION_SPEC_CACHE_SCHEMA_VERSION, + checkedAt, + specs: [], + details: {}, + }; +} + +export function readCurationSpecBrowserCache(adminId: number): CurationSpecBrowserCache | null { + const storage = getLocalStorage(); + if (!storage) { + return null; + } + + const key = getCurationSpecBrowserCacheKey(adminId); + + try { + const raw = storage.getItem(key); + if (!raw) { + return null; + } + + const cache: unknown = JSON.parse(raw); + if ( + !isCurationSpecBrowserCache(cache) || + Date.now() - cache.checkedAt > CURATION_SPEC_CACHE_RETENTION_MS + ) { + removeLocalStorageItem(storage, key); + return null; + } + + return cache; + } catch { + removeLocalStorageItem(storage, key); + return null; + } +} + +export function writeCurationSpecBrowserCache(adminId: number, cache: CurationSpecBrowserCache) { + const storage = getLocalStorage(); + if (!storage) { + return; + } + + try { + storage.setItem(getCurationSpecBrowserCacheKey(adminId), JSON.stringify(cache)); + } catch { + // Browser storage quota/privacy mode failures must not block curation forms. + } +} + +export function reconcileCurationSpecManifest( + cache: CurationSpecBrowserCache | null, + remoteSpecs: CurationV2SpecListItem[], + checkedAt: number +): CurationSpecBrowserCache { + const currentCache = cache ?? createEmptyCurationSpecBrowserCache(checkedAt); + const remoteSpecById = new Map(remoteSpecs.map((spec) => [spec.id, spec])); + + const details = Object.fromEntries( + Object.entries(currentCache.details).flatMap(([specId, detail]) => { + const remoteSpec = remoteSpecById.get(Number(specId)); + if (!remoteSpec || !remoteSpec.isActive || remoteSpec.version !== detail.version) { + return []; + } + + return [[specId, { ...detail, cachedAt: checkedAt }]]; + }) + ); + + return { + schemaVersion: CURATION_SPEC_CACHE_SCHEMA_VERSION, + checkedAt, + specs: remoteSpecs, + details, + }; +} + +export function getCachedCurationSpecDetail( + cache: CurationSpecBrowserCache | null, + specId: number, + version: number +): CachedCurationSpecDetail | null { + const detail = cache?.details[String(specId)]; + return detail?.version === version ? detail : null; +} + +export function cacheCurationSpecDetail( + cache: CurationSpecBrowserCache | null, + spec: CurationV2Spec, + cachedAt: number +): CurationSpecBrowserCache { + const currentCache = cache ?? createEmptyCurationSpecBrowserCache(cachedAt); + + return { + ...currentCache, + details: { + ...currentCache.details, + [spec.id]: { + version: spec.version, + cachedAt, + data: spec, + }, + }, + }; +} diff --git a/src/pages/curation/CurationCreate.tsx b/src/pages/curation/CurationCreate.tsx index 99af2d4..f66076d 100644 --- a/src/pages/curation/CurationCreate.tsx +++ b/src/pages/curation/CurationCreate.tsx @@ -4,12 +4,8 @@ import { useNavigate, useParams } from 'react-router'; import { DetailPageHeader } from '@/components/common/DetailPageHeader'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { useCurationSpec, useCurationSpecByCode } from '@/hooks/useCurations'; -import { - CurationSpecCode, - type CurationV2Spec, - type CurationV2SpecCode, -} from '@/types/api'; +import { useCurationSpec, useCurationSpecs } from '@/hooks/useCurations'; +import { CurationSpecCode, type CurationV2Spec, type CurationV2SpecCode } from '@/types/api'; import { SchemaDrivenCurationForm } from './schema-driven/SchemaDrivenCurationForm'; import { @@ -35,9 +31,12 @@ type CurationCreateStrategy = export function CurationCreatePage() { const navigate = useNavigate(); const { specCode } = useParams<{ specCode: CurationV2SpecCode }>(); - const specsQuery = useCurationSpecByCode(specCode); - const targetSpec = specsQuery.data?.isActive ? specsQuery.data : null; - const specDetailQuery = useCurationSpec(targetSpec?.id, { showErrorToast: false }); + const specsQuery = useCurationSpecs(); + const targetSpec = + specsQuery.data?.find((spec) => spec.code === specCode && spec.isActive) ?? null; + const specDetailQuery = useCurationSpec(targetSpec?.id, targetSpec?.version, { + showErrorToast: false, + }); const handleBack = () => navigate('/dashboard/curations'); let strategy: CurationCreateStrategy | null = null; let schemaError: Error | null = null; diff --git a/src/pages/curation/__tests__/CurationCreate.test.tsx b/src/pages/curation/__tests__/CurationCreate.test.tsx index eac1d8d..ae9e8b0 100644 --- a/src/pages/curation/__tests__/CurationCreate.test.tsx +++ b/src/pages/curation/__tests__/CurationCreate.test.tsx @@ -1,15 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; +import { + cacheCurationSpecDetail, + writeCurationSpecBrowserCache, +} from '@/lib/curation-spec-browser-cache'; +import { useAuthStore } from '@/stores/auth'; import { server } from '@/test/mocks/server'; import { wrapApiResponse } from '@/test/mocks/data'; import { render } from '@/test/test-utils'; -import type { CurationV2Spec } from '@/types/api'; +import type { CurationV2Spec, CurationV2SpecListItem } from '@/types/api'; import { CurationCreatePage } from '../CurationCreate'; const SPEC_BASE = '/admin/api/v2/curation-specs'; +const ADMIN_ID = 7; const routeState = vi.hoisted(() => ({ specCode: 'PROGRAM' })); vi.mock('react-router', async (importOriginal) => { @@ -49,6 +55,15 @@ vi.mock('../schema-driven/schema-driven-curation.form-model', () => ({ describe('CurationCreatePage', () => { beforeEach(() => { routeState.specCode = 'PROGRAM'; + window.localStorage.clear(); + act(() => { + useAuthStore.setState({ + user: { adminId: ADMIN_ID, email: 'admin@example.com', roles: ['ROOT_ADMIN'] }, + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + isAuthenticated: true, + }); + }); }); it.each([ @@ -73,10 +88,53 @@ describe('CurationCreatePage', () => { expect(await screen.findByText('큐레이션 스펙을 찾을 수 없습니다.')).toBeInTheDocument(); }); + + it('브라우저에 저장된 현재 version의 목록과 상세 스펙을 재사용한다', async () => { + let listRequestCount = 0; + let detailRequestCount = 0; + const spec = createSpec('PROGRAM'); + const specListItem = toSpecListItem(spec); + const cache = cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: Date.now(), + specs: [specListItem], + details: {}, + }, + spec, + Date.now() + ); + writeCurationSpecBrowserCache(ADMIN_ID, cache); + server.use( + http.get(SPEC_BASE, () => { + listRequestCount++; + return HttpResponse.json(wrapApiResponse([specListItem])); + }), + http.get(`${SPEC_BASE}/:specId`, () => { + detailRequestCount++; + return HttpResponse.json(wrapApiResponse(spec)); + }) + ); + + render(); + + expect(await screen.findByText('schema-driven 전략 렌더러')).toBeInTheDocument(); + expect(listRequestCount).toBe(0); + expect(detailRequestCount).toBe(0); + }); }); function mockSpec(code: string, isActive = true) { - const spec: CurationV2Spec = { + const spec = createSpec(code, isActive); + + server.use( + http.get(SPEC_BASE, () => HttpResponse.json(wrapApiResponse([toSpecListItem(spec)]))), + http.get(`${SPEC_BASE}/:specId`, () => HttpResponse.json(wrapApiResponse(spec))) + ); +} + +function createSpec(code: string, isActive = true): CurationV2Spec { + return { id: 10, code, name: code, @@ -87,22 +145,15 @@ function mockSpec(code: string, isActive = true) { requestSpec: { type: 'object', properties: {} }, responseSpec: { type: 'object' }, }; +} - server.use( - http.get(SPEC_BASE, () => - HttpResponse.json( - wrapApiResponse([ - { - id: spec.id, - code: spec.code, - name: spec.name, - description: spec.description, - version: spec.version, - isActive: spec.isActive, - }, - ]) - ) - ), - http.get(`${SPEC_BASE}/:specId`, () => HttpResponse.json(wrapApiResponse(spec))) - ); +function toSpecListItem(spec: CurationV2Spec): CurationV2SpecListItem { + return { + id: spec.id, + code: spec.code, + name: spec.name, + description: spec.description, + version: spec.version, + isActive: spec.isActive, + }; } diff --git a/src/services/curation.service.ts b/src/services/curation.service.ts index 5542f4c..f0599f6 100644 --- a/src/services/curation.service.ts +++ b/src/services/curation.service.ts @@ -24,10 +24,9 @@ import { export const curationKeys = { all: ['curation'] as const, - specs: () => [...curationKeys.all, 'specs'] as const, - spec: (specId: number) => [...curationKeys.specs(), specId] as const, - specByCode: (specCode: CurationV2SpecCode) => - [...curationKeys.specs(), 'code', specCode] as const, + specs: (adminId: number) => [...curationKeys.all, 'specs', adminId] as const, + spec: (adminId: number, specId: number, version: number) => + [...curationKeys.specs(adminId), 'detail', specId, version] as const, lists: () => [...curationKeys.all, 'list'] as const, list: (params?: CurationV2SearchParams) => params ? ([...curationKeys.lists(), params] as const) : curationKeys.lists(),