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
123 changes: 121 additions & 2 deletions __tests__/hooks/useBookStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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() });
});
});
});
2 changes: 0 additions & 2 deletions app/api/books/[id]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
56 changes: 51 additions & 5 deletions hooks/useBookStatus.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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";
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;
Expand Down Expand Up @@ -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() });
Comment on lines +88 to +93
}

// Clear entire LibraryService cache to ensure status changes reflect across all filters
libraryService.clearCache();
}
Expand Down
Loading