many changes
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { RepositoryClient } from "./repository-client";
|
||||
|
||||
vi.mock("@tauri-apps/api/core");
|
||||
|
||||
/**
|
||||
* Integration tests documenting Phase 1 & 2 refactoring:
|
||||
* - Sorting moved to backend (no frontend compareFn)
|
||||
* - Filtering moved to backend (no frontend iteration/matching)
|
||||
* - URL construction moved to backend (async Tauri invoke)
|
||||
* - Search moved to backend (backend search command)
|
||||
*/
|
||||
|
||||
describe("Backend Integration - Refactored Business Logic", () => {
|
||||
let client: RepositoryClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new RepositoryClient();
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
describe("Sorting Delegated to Backend", () => {
|
||||
it("should pass sortBy to backend instead of frontend sorting", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: "item1", name: "Album A" },
|
||||
{ id: "item2", name: "Album B" },
|
||||
{ id: "item3", name: "Album C" },
|
||||
],
|
||||
totalRecordCount: 3,
|
||||
});
|
||||
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
});
|
||||
|
||||
// Backend should have done the sorting
|
||||
expect(result.items[0].name).toBe("Album A");
|
||||
|
||||
// Frontend doesn't have a compareFn
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_items", {
|
||||
handle: "test-handle-123",
|
||||
parentId: "library123",
|
||||
options: {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should support different sort fields via backend", async () => {
|
||||
const sortFields = ["SortName", "Artist", "Album", "DatePlayed", "ProductionYear"];
|
||||
|
||||
for (const sortField of sortFields) {
|
||||
vi.clearAllMocks();
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
sortBy: sortField,
|
||||
sortOrder: "Ascending",
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
sortBy: sortField,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should pass sort order to backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Descending",
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
sortOrder: "Descending",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT include frontend compareFn (removed entirely)", async () => {
|
||||
// Old code pattern:
|
||||
// sortOptions: [{
|
||||
// key: "title",
|
||||
// label: "Title",
|
||||
// compareFn: (a, b) => a.name.localeCompare(b.name) // ← REMOVED
|
||||
// }]
|
||||
|
||||
// New code pattern:
|
||||
// sortOptions: [{
|
||||
// key: "SortName", // Jellyfin field name
|
||||
// label: "Title"
|
||||
// }]
|
||||
|
||||
const config = {
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
{ key: "Artist", label: "Artist" },
|
||||
],
|
||||
};
|
||||
|
||||
// Verify no compareFn property exists
|
||||
for (const option of config.sortOptions) {
|
||||
expect((option as any).compareFn).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filtering Delegated to Backend", () => {
|
||||
it("should pass includeItemTypes to backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
includeItemTypes: ["Audio", "MusicAlbum"],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
includeItemTypes: ["Audio", "MusicAlbum"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass genres filter to backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
genres: ["Rock", "Jazz"],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
genres: ["Rock", "Jazz"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT include frontend filtering logic", async () => {
|
||||
// Old code pattern:
|
||||
// let filtered = items.filter(item => {
|
||||
// return searchFields.some(field => {
|
||||
// const fieldValue = item[field]?.toLowerCase() ?? "";
|
||||
// return fieldValue.includes(query.toLowerCase());
|
||||
// });
|
||||
// }); // ← REMOVED
|
||||
|
||||
// New code pattern:
|
||||
// Use backend search instead
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [{ id: "item1", name: "Search Result" }],
|
||||
totalRecordCount: 1,
|
||||
});
|
||||
|
||||
const result = await client.search("query");
|
||||
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
query: "query",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should support pagination via backend", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 1000,
|
||||
});
|
||||
|
||||
await client.getItems("library123", {
|
||||
startIndex: 100,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_items",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
startIndex: 100,
|
||||
limit: 50,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search Delegated to Backend", () => {
|
||||
it("should use backend search command instead of frontend filtering", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: "item1", name: "Found Item" },
|
||||
{ id: "item2", name: "Another Found Item" },
|
||||
],
|
||||
totalRecordCount: 2,
|
||||
});
|
||||
|
||||
const result = await client.search("query");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
query: "query",
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should support search with item type filters", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
await client.search("query", {
|
||||
includeItemTypes: ["Audio"],
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_search",
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT do client-side search filtering", async () => {
|
||||
// Old code pattern:
|
||||
// const query = searchInput.toLowerCase();
|
||||
// const results = items.filter(item =>
|
||||
// config.searchFields.some(field =>
|
||||
// item[field]?.toLowerCase()?.includes(query)
|
||||
// )
|
||||
// ); // ← REMOVED
|
||||
|
||||
// New code pattern:
|
||||
// Call backend search directly
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [{ id: "item1" }],
|
||||
totalRecordCount: 1,
|
||||
});
|
||||
|
||||
const result = await client.search("search term");
|
||||
|
||||
// Backend did the filtering
|
||||
expect(result.items).toBeDefined();
|
||||
expect(invoke).toHaveBeenCalledWith("repository_search", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Construction Delegated to Backend", () => {
|
||||
it("should get image URLs from backend (not construct in frontend)", async () => {
|
||||
const backendUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getImageUrl("item123", "Primary");
|
||||
|
||||
// Backend constructed and returned the URL
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_image_url",
|
||||
expect.objectContaining({
|
||||
itemId: "item123",
|
||||
imageType: "Primary",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT construct image URLs in frontend", async () => {
|
||||
// Old code pattern:
|
||||
// return `${serverUrl}/Items/${itemId}/Images/${imageType}?api_key=${token}&maxWidth=${options.maxWidth}`;
|
||||
// ← REMOVED - NEVER construct URLs in frontend
|
||||
|
||||
(invoke as any).mockResolvedValueOnce("https://server.com/image");
|
||||
|
||||
const url = await client.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
||||
|
||||
// URL came from backend, not constructed in frontend
|
||||
expect(typeof url).toBe("string");
|
||||
expect(url).toContain("http");
|
||||
});
|
||||
|
||||
it("should get video stream URLs from backend", async () => {
|
||||
const backendUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getVideoStreamUrl("item123");
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_video_stream_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should get subtitle URLs from backend", async () => {
|
||||
const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getSubtitleUrl("item123", "source456", 0);
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_subtitle_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should get video download URLs from backend", async () => {
|
||||
const backendUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1280&api_key=token";
|
||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||
|
||||
const url = await client.getVideoDownloadUrl("item123", "720p");
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"repository_get_video_download_url",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should never expose access token in frontend code", async () => {
|
||||
// The access token is NEVER used in frontend URL construction
|
||||
// It's only stored in backend for secure URL generation
|
||||
|
||||
// Frontend code NEVER has direct access to use the token
|
||||
const client2 = new RepositoryClient();
|
||||
// client2._accessToken is private and should never be accessed or used
|
||||
|
||||
// All token usage is in backend via Tauri commands
|
||||
expect(invoke).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Config Simplification", () => {
|
||||
it("should have simplified MediaListConfig (no searchFields)", () => {
|
||||
// Old type:
|
||||
// interface MediaListConfig {
|
||||
// searchFields: string[]; // ← REMOVED
|
||||
// compareFn?: (a, b) => number; // ← REMOVED
|
||||
// }
|
||||
|
||||
// New type:
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "Title" },
|
||||
// No compareFn
|
||||
],
|
||||
// No searchFields
|
||||
};
|
||||
|
||||
// Verify no searchFields
|
||||
expect((config as any).searchFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use Jellyfin field names in sort options", () => {
|
||||
// Old:
|
||||
// { key: "title", label: "Title", compareFn: ... }
|
||||
|
||||
// New:
|
||||
// { key: "SortName", label: "Title" }
|
||||
|
||||
const sortOptions = [
|
||||
{ key: "SortName", label: "A-Z" },
|
||||
{ key: "Artist", label: "Artist" },
|
||||
{ key: "Album", label: "Album" },
|
||||
{ key: "DatePlayed", label: "Recent" },
|
||||
];
|
||||
|
||||
for (const option of sortOptions) {
|
||||
// Should be Jellyfin field names
|
||||
expect(typeof option.key).toBe("string");
|
||||
expect(option.key).toMatch(/^[A-Z]/); // Jellyfin fields start with capital
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Debounced Search Implementation", () => {
|
||||
it("should debounce search without frontend filtering", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockSearch = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
(invoke as any).mockImplementation((cmd: string, args: any) => {
|
||||
if (cmd === "repository_search") {
|
||||
return mockSearch(args.query);
|
||||
}
|
||||
return Promise.resolve({ items: [], totalRecordCount: 0 });
|
||||
});
|
||||
|
||||
// Simulate rapid search queries
|
||||
await client.search("t");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
await client.search("te");
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
await client.search("test");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// All calls go to backend (debouncing happens in component via $effect)
|
||||
expect(invoke).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("End-to-End Data Flow", () => {
|
||||
it("should support complete flow: load → sort → display", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: "id1", name: "Album A", sortName: "A" },
|
||||
{ id: "id2", name: "Album B", sortName: "B" },
|
||||
],
|
||||
totalRecordCount: 2,
|
||||
});
|
||||
|
||||
// Frontend requests items with sort
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
});
|
||||
|
||||
// Backend returned pre-sorted items
|
||||
expect(result.items[0].sortName).toBe("A");
|
||||
expect(result.items[1].sortName).toBe("B");
|
||||
|
||||
// Frontend just displays them
|
||||
// No compareFn, no local sorting
|
||||
});
|
||||
|
||||
it("should support complete flow: search → load images → display", async () => {
|
||||
// 1. Frontend calls backend search
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [{ id: "item1", name: "Result", primaryImageTag: "tag1" }],
|
||||
totalRecordCount: 1,
|
||||
});
|
||||
|
||||
const searchResult = await client.search("query");
|
||||
expect(searchResult.items.length).toBe(1);
|
||||
|
||||
// 2. Frontend loads image URL from backend
|
||||
(invoke as any).mockResolvedValueOnce("https://server.com/image.jpg");
|
||||
|
||||
const imageUrl = await client.getImageUrl("item1", "Primary");
|
||||
expect(imageUrl).toContain("http");
|
||||
|
||||
// 3. Frontend displays search results with images
|
||||
// No client-side filtering, sorting, or URL construction
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance Characteristics", () => {
|
||||
it("should reduce memory usage by not storing frontend sorting state", async () => {
|
||||
// Old: Frontend stores items + sorting state + filtered results
|
||||
// Old: Multiple copies of data (original, filtered, sorted)
|
||||
|
||||
// New: Backend returns already-sorted data
|
||||
// New: Frontend just stores the result
|
||||
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: Array.from({ length: 10000 }, (_, i) => ({
|
||||
id: `id${i}`,
|
||||
name: `Item ${i}`,
|
||||
})),
|
||||
totalRecordCount: 10000,
|
||||
});
|
||||
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
// Backend handled sorting
|
||||
expect(result.items.length).toBe(10000);
|
||||
// Frontend just stores the result array
|
||||
});
|
||||
|
||||
it("should reduce CPU usage by avoiding client-side operations", async () => {
|
||||
// Old pattern required:
|
||||
// - Parsing all items into memory
|
||||
// - Iterating to apply filters
|
||||
// - Sorting algorithm (O(n log n) comparisons)
|
||||
// - Updating multiple state variables
|
||||
|
||||
// New pattern:
|
||||
(invoke as any).mockResolvedValueOnce({
|
||||
items: [], // Backend already filtered/sorted
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
// Frontend just awaits backend result
|
||||
const result = await client.getItems("library123", {
|
||||
sortBy: "SortName",
|
||||
includeItemTypes: ["Audio"],
|
||||
});
|
||||
|
||||
// No client-side work
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user