Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 102 additions & 20 deletions src/hooks/__tests__/useCurations.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down
96 changes: 70 additions & 26 deletions src/hooks/useCurations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,53 +27,88 @@ import type {
CurationV2Detail,
CurationV2SearchParams,
CurationV2Spec,
CurationV2SpecCode,
CurationV2SpecListItem,
CurationV2UpdateRequest,
CurationV2UpdateResponse,
} from '@/types/api';

/**
* 큐레이션 스펙 목록 조회 훅
*
* 목록 API는 version manifest로 사용한다. 브라우저 cache가 1시간 이내면 즉시 복원하고,
* stale 상태 또는 탭 재포커스 때만 목록을 다시 조회해 상세 schema cache를 정리한다.
*/
export function useCurationSpecs() {
return useApiQuery<CurationV2SpecListItem[]>(curationKeys.specs(), curationService.listSpecs, {
staleTime: 1000 * 60 * 5,
});
const adminId = useAuthStore((state) => state.user?.adminId);
const browserCache = adminId ? readCurationSpecBrowserCache(adminId) : null;

return useApiQuery<CurationV2SpecListItem[]>(
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<CurationV2Spec>
) {
return useApiQuery<CurationV2Spec>(
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<CurationV2SpecListItem | null>(
curationKeys.specByCode(specCode ?? ''),
return useApiQuery<CurationV2Spec>(
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,
}
);
}
Expand Down
Loading