add @tanstack/query

This commit is contained in:
pa
2026-03-06 18:14:24 +09:00
parent 7d2bb022a4
commit e665b3815d
40 changed files with 2171 additions and 232 deletions

View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mockRequest = vi.fn();
const mockPatchAndRefetchActiveQuery = vi.fn(() => Promise.resolve());
const mockFetchWithEntityPolicy = vi.fn();
const mockApplyCurrentUser = vi.fn((json) => ({ id: json.id || 'usr_me', ...json }));
const mockApplyUser = vi.fn((json) => ({ ...json }));
const mockApplyWorld = vi.fn((json) => ({ ...json }));
vi.mock('../../service/request', () => ({
request: (...args) => mockRequest(...args)
}));
vi.mock('../../stores', () => ({
useUserStore: () => ({
currentUser: { id: 'usr_me' },
applyCurrentUser: mockApplyCurrentUser,
applyUser: mockApplyUser
}),
useWorldStore: () => ({
applyWorld: mockApplyWorld
})
}));
vi.mock('../../query', () => ({
entityQueryPolicies: {
user: { staleTime: 20000, gcTime: 90000, retry: 1, refetchOnWindowFocus: false },
avatar: { staleTime: 60000, gcTime: 300000, retry: 1, refetchOnWindowFocus: false },
world: { staleTime: 60000, gcTime: 300000, retry: 1, refetchOnWindowFocus: false },
worldCollection: { staleTime: 60000, gcTime: 300000, retry: 1, refetchOnWindowFocus: false },
instance: { staleTime: 0, gcTime: 10000, retry: 0, refetchOnWindowFocus: false }
},
fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args),
patchAndRefetchActiveQuery: (...args) =>
mockPatchAndRefetchActiveQuery(...args),
queryKeys: {
user: (userId) => ['user', userId],
avatar: (avatarId) => ['avatar', avatarId],
world: (worldId) => ['world', worldId],
worldsByUser: (params) => ['worlds', 'user', params.userId, params],
instance: (worldId, instanceId) => ['instance', worldId, instanceId]
}
}));
import avatarRequest from '../avatar';
import userRequest from '../user';
import worldRequest from '../world';
describe('entity mutation query sync', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('saveCurrentUser patches and refetches active user query', async () => {
mockRequest.mockResolvedValue({ id: 'usr_me', status: 'active' });
await userRequest.saveCurrentUser({ status: 'active' });
expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['user', 'usr_me']
})
);
});
test('saveAvatar patches and refetches active avatar query', async () => {
mockRequest.mockResolvedValue({ id: 'avtr_1', name: 'Avatar' });
await avatarRequest.saveAvatar({ id: 'avtr_1', name: 'Avatar' });
expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['avatar', 'avtr_1']
})
);
});
test('saveWorld patches and refetches active world query', async () => {
mockRequest.mockResolvedValue({ id: 'wrld_1', name: 'World' });
await worldRequest.saveWorld({ id: 'wrld_1', name: 'World' });
expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['world', 'wrld_1']
})
);
});
test('getCachedWorlds uses policy wrapper for world list data', async () => {
mockFetchWithEntityPolicy.mockResolvedValue({
data: {
json: [{ id: 'wrld_1' }],
params: { userId: 'usr_me', n: 50, offset: 0 }
},
cache: true
});
const args = await worldRequest.getCachedWorlds({
userId: 'usr_me',
n: 50,
offset: 0,
sort: 'updated',
order: 'descending',
user: 'me',
releaseStatus: 'all'
});
expect(mockFetchWithEntityPolicy).toHaveBeenCalled();
expect(args.cache).toBe(true);
});
});

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mockRequest = vi.fn();
const mockFetchWithEntityPolicy = vi.fn();
const mockInvalidateQueries = vi.fn().mockResolvedValue();
const mockHandleFavoriteAdd = vi.fn();
const mockHandleFavoriteDelete = vi.fn();
const mockHandleFavoriteGroupClear = vi.fn();
vi.mock('../../service/request', () => ({
request: (...args) => mockRequest(...args)
}));
vi.mock('../../stores', () => ({
useFavoriteStore: () => ({
handleFavoriteAdd: (...args) => mockHandleFavoriteAdd(...args),
handleFavoriteDelete: (...args) => mockHandleFavoriteDelete(...args),
handleFavoriteGroupClear: (...args) =>
mockHandleFavoriteGroupClear(...args)
}),
useUserStore: () => ({
currentUser: { id: 'usr_me' }
})
}));
vi.mock('../../query', () => ({
entityQueryPolicies: {
favoriteCollection: {
staleTime: 60000,
gcTime: 300000,
retry: 1,
refetchOnWindowFocus: false
}
},
fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args),
queryClient: {
invalidateQueries: (...args) => mockInvalidateQueries(...args)
},
queryKeys: {
favoriteLimits: () => ['favorite', 'limits'],
favorites: (params) => ['favorite', 'items', params],
favoriteGroups: (params) => ['favorite', 'groups', params],
favoriteWorlds: (params) => ['favorite', 'worlds', params],
favoriteAvatars: (params) => ['favorite', 'avatars', params]
}
}));
import favoriteRequest from '../favorite';
describe('favorite query sync', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('cached favorite reads go through fetchWithEntityPolicy', async () => {
mockFetchWithEntityPolicy.mockResolvedValue({
data: { json: [], params: { n: 300, offset: 0 } },
cache: true
});
const args = await favoriteRequest.getCachedFavorites({
n: 300,
offset: 0
});
expect(mockFetchWithEntityPolicy).toHaveBeenCalled();
expect(args.cache).toBe(true);
});
test('favorite mutations invalidate active favorite queries', async () => {
mockRequest.mockResolvedValue({ ok: true });
await favoriteRequest.addFavorite({ type: 'world', favoriteId: 'wrld_1' });
await favoriteRequest.deleteFavorite({ objectId: 'fav_1' });
await favoriteRequest.saveFavoriteGroup({
type: 'world',
group: 'worlds1',
displayName: 'Worlds'
});
await favoriteRequest.clearFavoriteGroup({
type: 'world',
group: 'worlds1'
});
expect(mockInvalidateQueries).toHaveBeenCalledTimes(4);
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['favorite'],
refetchType: 'active'
});
});
});

View File

@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mockRequest = vi.fn();
const mockFetchWithEntityPolicy = vi.fn();
const mockInvalidateQueries = vi.fn().mockResolvedValue();
const mockApplyUser = vi.fn((json) => json);
vi.mock('../../service/request', () => ({
request: (...args) => mockRequest(...args)
}));
vi.mock('../../stores/user', () => ({
useUserStore: () => ({
applyUser: (...args) => mockApplyUser(...args)
})
}));
vi.mock('../../query', () => ({
entityQueryPolicies: {
friendList: {
staleTime: 20000,
gcTime: 90000,
retry: 1,
refetchOnWindowFocus: false
}
},
fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args),
queryClient: {
invalidateQueries: (...args) => mockInvalidateQueries(...args)
},
queryKeys: {
friends: (params) => ['friends', params]
}
}));
import friendRequest from '../friend';
describe('friend query sync', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('getCachedFriends uses query policy wrapper', async () => {
mockFetchWithEntityPolicy.mockResolvedValue({
data: {
json: [{ id: 'usr_1', displayName: 'A' }],
params: { n: 50, offset: 0 }
},
cache: true
});
const args = await friendRequest.getCachedFriends({ n: 50, offset: 0 });
expect(mockFetchWithEntityPolicy).toHaveBeenCalled();
expect(args.cache).toBe(true);
expect(args.json[0].id).toBe('usr_1');
});
test('friend mutations invalidate active friends queries', async () => {
mockRequest.mockResolvedValue({ ok: true });
await friendRequest.sendFriendRequest({ userId: 'usr_1' });
await friendRequest.cancelFriendRequest({ userId: 'usr_1' });
await friendRequest.deleteFriend({ userId: 'usr_1' });
expect(mockInvalidateQueries).toHaveBeenCalledTimes(3);
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['friends'],
refetchType: 'active'
});
});
});

View File

@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mockRequest = vi.fn();
const mockFetchWithEntityPolicy = vi.fn();
const mockInvalidateQueries = vi.fn().mockResolvedValue();
const mockApplyGroup = vi.fn((json) => json);
vi.mock('../../service/request', () => ({
request: (...args) => mockRequest(...args)
}));
vi.mock('../../stores', () => ({
useGroupStore: () => ({
applyGroup: (...args) => mockApplyGroup(...args)
}),
useUserStore: () => ({
currentUser: { id: 'usr_me' }
})
}));
vi.mock('../../query', () => ({
entityQueryPolicies: {
group: {
staleTime: 60000,
gcTime: 300000,
retry: 1,
refetchOnWindowFocus: false
},
groupCollection: {
staleTime: 60000,
gcTime: 300000,
retry: 1,
refetchOnWindowFocus: false
}
},
fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args),
queryClient: {
invalidateQueries: (...args) => mockInvalidateQueries(...args)
},
queryKeys: {
group: (groupId, includeRoles) => ['group', groupId, Boolean(includeRoles)],
groupPosts: (params) => ['group', params.groupId, 'posts', params],
groupMember: (params) => ['group', params.groupId, 'member', params.userId],
groupMembers: (params) => ['group', params.groupId, 'members', params],
groupGallery: (params) => ['group', params.groupId, 'gallery', params.galleryId, params],
groupCalendar: (groupId) => ['group', groupId, 'calendar'],
groupCalendarEvent: (params) => ['group', params.groupId, 'calendarEvent', params.eventId]
}
}));
import groupRequest from '../group';
describe('group query sync', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('cached group resources use fetchWithEntityPolicy', async () => {
mockFetchWithEntityPolicy.mockResolvedValue({
data: { json: [], params: { groupId: 'grp_1', n: 100, offset: 0 } },
cache: true
});
const a = await groupRequest.getCachedGroupMembers({
groupId: 'grp_1',
n: 100,
offset: 0,
sort: 'joinedAt:desc'
});
const b = await groupRequest.getCachedGroupGallery({
groupId: 'grp_1',
galleryId: 'gal_1',
n: 100,
offset: 0
});
expect(mockFetchWithEntityPolicy).toHaveBeenCalledTimes(2);
expect(a.cache && b.cache).toBe(true);
});
test('group mutations invalidate scoped active group queries', async () => {
mockRequest.mockResolvedValue({ ok: true });
await groupRequest.setGroupRepresentation('grp_1', {
isRepresenting: true
});
await groupRequest.deleteGroupPost({
groupId: 'grp_1',
postId: 'post_1'
});
await groupRequest.setGroupMemberProps('usr_me', 'grp_1', {
visibility: 'visible'
});
expect(mockInvalidateQueries).toHaveBeenCalledTimes(3);
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['group', 'grp_1'],
refetchType: 'active'
});
});
});

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mockRequest = vi.fn();
const mockFetchWithEntityPolicy = vi.fn();
const mockInvalidateQueries = vi.fn().mockResolvedValue();
const mockRemoveQueries = vi.fn();
vi.mock('../../service/request', () => ({
request: (...args) => mockRequest(...args)
}));
vi.mock('../../stores', () => ({
useUserStore: () => ({
currentUser: { id: 'usr_me' }
})
}));
vi.mock('../../query', () => ({
entityQueryPolicies: {
galleryCollection: {
staleTime: 60000,
gcTime: 300000,
retry: 1,
refetchOnWindowFocus: false
},
inventoryCollection: {
staleTime: 20000,
gcTime: 120000,
retry: 1,
refetchOnWindowFocus: false
},
fileObject: {
staleTime: 60000,
gcTime: 300000,
retry: 1,
refetchOnWindowFocus: false
}
},
fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args),
queryClient: {
invalidateQueries: (...args) => mockInvalidateQueries(...args),
removeQueries: (...args) => mockRemoveQueries(...args)
},
queryKeys: {
galleryFiles: (params) => ['gallery', 'files', params],
prints: (params) => ['gallery', 'prints', params],
print: (printId) => ['gallery', 'print', printId],
inventoryItems: (params) => ['inventory', 'items', params],
userInventoryItem: (params) => ['inventory', 'item', params.userId, params.inventoryId],
file: (fileId) => ['file', fileId]
}
}));
import inventoryRequest from '../inventory';
import miscRequest from '../misc';
import vrcPlusIconRequest from '../vrcPlusIcon';
import vrcPlusImageRequest from '../vrcPlusImage';
describe('media and inventory query sync', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('cached media/inventory reads go through fetchWithEntityPolicy', async () => {
mockFetchWithEntityPolicy.mockResolvedValue({
data: { json: [], params: {} },
cache: true
});
const a = await vrcPlusIconRequest.getCachedFileList({ tag: 'icon', n: 100 });
const b = await vrcPlusImageRequest.getCachedPrints({ n: 100 });
const c = await inventoryRequest.getCachedInventoryItems({
n: 100,
offset: 0,
order: 'newest'
});
const d = await miscRequest.getCachedFile({ fileId: 'file_1' });
expect(mockFetchWithEntityPolicy).toHaveBeenCalledTimes(4);
expect(a.cache && b.cache && c.cache && d.cache).toBe(true);
});
test('media mutations invalidate gallery queries and file delete removes file query', async () => {
mockRequest.mockResolvedValue({ ok: true });
await vrcPlusIconRequest.deleteFile('file_icon_1');
await vrcPlusImageRequest.deletePrint('print_1');
await vrcPlusImageRequest.uploadEmoji('img', { tag: 'emoji' });
await miscRequest.deleteFile('file_misc_1');
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['gallery'],
refetchType: 'active'
});
expect(mockRemoveQueries).toHaveBeenCalledWith({
queryKey: ['file', 'file_misc_1'],
exact: true
});
});
});