diff --git a/__tests__/hooks/useBookStatus.test.ts b/__tests__/hooks/useBookStatus.test.ts index 19a533f2..52a3cb51 100644 --- a/__tests__/hooks/useBookStatus.test.ts +++ b/__tests__/hooks/useBookStatus.test.ts @@ -1,7 +1,9 @@ import { test, expect, describe, beforeEach, afterEach, vi } from 'vitest'; -import { renderHook, waitFor, act } from "../test-utils"; -import { useBookStatus } from "@/hooks/useBookStatus"; +import { renderHook, waitFor, act, createTestQueryClient } from "../test-utils"; +import { useBookStatus, invalidateBookQueries } from "@/hooks/useBookStatus"; import type { Book } from "@/hooks/useBookDetail"; +import { QueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/query-keys'; const originalFetch = global.fetch; @@ -387,4 +389,121 @@ describe("useBookStatus", () => { expect(result.current.selectedStatus).toBe("reading"); }); }); + + describe("invalidateBookQueries", () => { + let queryClient: QueryClient; + let invalidateSpy: any; + + beforeEach(() => { + queryClient = createTestQueryClient(); + invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + }); + + afterEach(() => { + invalidateSpy.mockRestore(); + }); + + test("should invalidate all book-related queries", () => { + const bookId = '123'; + + invalidateBookQueries(queryClient, bookId); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.book.detail(123) }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.sessions.byBook(123) }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.progress.byBook(123) }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.dashboard.all() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.library.books() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.readNext.base() }); + }); + + test("should invalidate shelf caches surgically when cached shelves available", () => { + const bookId = '123'; + + // Mock cached shelves + queryClient.setQueryData(queryKeys.book.shelves(123), { + success: true, + data: [{ id: 1 }, { id: 2 }, { id: 3 }] + }); + + invalidateBookQueries(queryClient, bookId); + + // Verify surgical invalidation for each shelf + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.shelf.byId(1) }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.shelf.byId(2) }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.shelf.byId(3) }); + + // Verify nuclear invalidation was NOT called + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: queryKeys.shelf.base() }); + }); + + test("should invalidate all shelves when cache unavailable", () => { + const bookId = '123'; + + // No cached shelves - cache unavailable + invalidateBookQueries(queryClient, bookId); + + // Verify nuclear invalidation + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.shelf.base() }); + + // Verify no surgical (specific shelf ID) invalidations occurred + const surgicalCalls = invalidateSpy.mock.calls.filter( + (call: any) => + Array.isArray(call[0]?.queryKey) && + call[0].queryKey[0] === 'shelf' && + call[0].queryKey.length === 2 && + typeof call[0].queryKey[1] === 'number' + ); + expect(surgicalCalls).toHaveLength(0); + }); + + test("should NOT invalidate shelves when book is on no shelves", () => { + const bookId = '123'; + + // Mock cached shelves with empty array (book on zero shelves) + queryClient.setQueryData(queryKeys.book.shelves(123), { + success: true, + data: [] + }); + + invalidateBookQueries(queryClient, bookId); + + // Should NOT trigger nuclear invalidation (performance optimization) + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: queryKeys.shelf.base() }); + + // Should NOT invalidate any specific shelves (none exist) + const surgicalCalls = invalidateSpy.mock.calls.filter( + (call: any) => + Array.isArray(call[0]?.queryKey) && + call[0].queryKey[0] === 'shelf' && + call[0].queryKey.length === 2 + ); + expect(surgicalCalls).toHaveLength(0); + }); + + test("should handle invalid cache data gracefully", () => { + const bookId = '123'; + + // Mock invalid cache structure + queryClient.setQueryData(queryKeys.book.shelves(123), { + invalid: 'structure' + }); + + invalidateBookQueries(queryClient, bookId); + + // Verify nuclear invalidation (fallback for invalid data) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.shelf.base() }); + }); + + test("should handle null cache data gracefully", () => { + const bookId = '123'; + + // Mock null cache + queryClient.setQueryData(queryKeys.book.shelves(123), null); + + invalidateBookQueries(queryClient, bookId); + + // Verify nuclear invalidation + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.shelf.base() }); + }); + }); }); diff --git a/app/api/books/[id]/status/route.ts b/app/api/books/[id]/status/route.ts index 23b19ddf..095fcc5f 100644 --- a/app/api/books/[id]/status/route.ts +++ b/app/api/books/[id]/status/route.ts @@ -66,8 +66,6 @@ export async function POST(request: NextRequest, props: { params: Promise<{ id: const result = await sessionService.updateStatus(bookId, statusData); - // Note: Cache invalidation handled by SessionService.invalidateCache() - // Return full result if session was archived, otherwise just the session if (result.sessionArchived) { return NextResponse.json({ diff --git a/hooks/useBookStatus.ts b/hooks/useBookStatus.ts index 0bcea6e2..66cfc13f 100644 --- a/hooks/useBookStatus.ts +++ b/hooks/useBookStatus.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient, QueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; import type { Book } from "./useBookDetail"; import { toast } from "@/utils/toast"; @@ -7,6 +7,8 @@ import { getLogger } from "@/lib/logger"; import { bookApi, ApiError } from "@/lib/api"; import { libraryService } from "@/lib/library-service"; +const logger = getLogger().child({ hook: 'useBookStatus' }); + interface ProgressEntry { id: number; currentPage: number; @@ -38,15 +40,59 @@ function requiresArchiveConfirmation( /** * Invalidates all queries related to a book * Exported to allow reuse in components that make direct API calls + * + * Also invalidates shelf caches for shelves containing this book to ensure + * shelf pages reflect updated book status immediately. */ -export function invalidateBookQueries(queryClient: any, bookId: string): void { - queryClient.invalidateQueries({ queryKey: queryKeys.book.detail(parseInt(bookId)) }); - queryClient.invalidateQueries({ queryKey: queryKeys.sessions.byBook(parseInt(bookId)) }); - queryClient.invalidateQueries({ queryKey: queryKeys.progress.byBook(parseInt(bookId)) }); +export function invalidateBookQueries(queryClient: QueryClient, bookId: string): void { + const numericBookId = parseInt(bookId); + + // Invalidate book-related queries + queryClient.invalidateQueries({ queryKey: queryKeys.book.detail(numericBookId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.sessions.byBook(numericBookId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.progress.byBook(numericBookId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all() }); queryClient.invalidateQueries({ queryKey: queryKeys.library.books() }); queryClient.invalidateQueries({ queryKey: queryKeys.readNext.base() }); + // Invalidate shelves containing this book + // Try to get shelves from cache; if available, invalidate only those shelves (surgical) + // If not cached, invalidate all shelves (nuclear) to ensure correctness + const bookShelvesKey = queryKeys.book.shelves(numericBookId); + const cachedShelves = queryClient.getQueryData(bookShelvesKey); + + // Type guard to validate cached shelves structure + const isValidShelvesData = (data: unknown): data is { success: boolean; data: Array<{ id: number }> } => { + return ( + data !== null && + typeof data === 'object' && + 'data' in data && + Array.isArray((data as any).data) + ); + }; + + if (isValidShelvesData(cachedShelves)) { + // Cache is valid - use surgical approach + if (cachedShelves.data.length > 0) { + // Book is on some shelves - invalidate only those + logger.debug( + { bookId: numericBookId, shelfIds: cachedShelves.data.map(s => s.id) }, + 'Surgical shelf invalidation' + ); + cachedShelves.data.forEach((shelf: { id: number }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.shelf.byId(shelf.id) }); + }); + } + // else: Book is on zero shelves (data.length === 0) - no-op, nothing to invalidate + } else { + // Cache unavailable or invalid - invalidate all shelves to be safe + logger.debug( + { bookId: numericBookId, cacheState: cachedShelves === null ? 'null' : 'invalid' }, + 'Nuclear shelf invalidation (cache unavailable)' + ); + queryClient.invalidateQueries({ queryKey: queryKeys.shelf.base() }); + } + // Clear entire LibraryService cache to ensure status changes reflect across all filters libraryService.clearCache(); }