import { describe, test, expect, afterAll, beforeAll, mock } from "bun:test"; import type { WebServerInstance } from "./server"; import { createWebServer } from "./server"; /** * Items API Integration Tests * * Tests the full CRUD functionality for the Items management API. * Uses mocked database and service layers. */ // --- Mock Types --- interface MockItem { id: number; name: string; description: string | null; rarity: string; type: string; price: bigint | null; iconUrl: string; imageUrl: string; usageData: { consume: boolean; effects: any[] } | null; } // --- Mock Data --- let mockItems: MockItem[] = [ { id: 1, name: "Health Potion", description: "Restores health", rarity: "C", type: "CONSUMABLE", price: 100n, iconUrl: "/assets/items/1.png", imageUrl: "/assets/items/1.png", usageData: { consume: true, effects: [] }, }, { id: 2, name: "Iron Sword", description: "A basic sword", rarity: "R", type: "EQUIPMENT", price: 500n, iconUrl: "/assets/items/2.png", imageUrl: "/assets/items/2.png", usageData: null, }, ]; let mockIdCounter = 3; // --- Mock Items Service --- mock.module("@shared/modules/items/items.service", () => ({ itemsService: { getAllItems: mock(async (filters: any = {}) => { let filtered = [...mockItems]; if (filters.search) { const search = filters.search.toLowerCase(); filtered = filtered.filter( (item) => item.name.toLowerCase().includes(search) || (item.description?.toLowerCase().includes(search) ?? false) ); } if (filters.type) { filtered = filtered.filter((item) => item.type === filters.type); } if (filters.rarity) { filtered = filtered.filter((item) => item.rarity === filters.rarity); } return { items: filtered, total: filtered.length, }; }), getItemById: mock(async (id: number) => { return mockItems.find((item) => item.id === id) ?? null; }), isNameTaken: mock(async (name: string, excludeId?: number) => { return mockItems.some( (item) => item.name.toLowerCase() === name.toLowerCase() && item.id !== excludeId ); }), createItem: mock(async (data: any) => { const newItem: MockItem = { id: mockIdCounter++, name: data.name, description: data.description ?? null, rarity: data.rarity ?? "C", type: data.type, price: data.price ?? null, iconUrl: data.iconUrl, imageUrl: data.imageUrl, usageData: data.usageData ?? null, }; mockItems.push(newItem); return newItem; }), updateItem: mock(async (id: number, data: any) => { const index = mockItems.findIndex((item) => item.id === id); if (index === -1) return null; mockItems[index] = { ...mockItems[index], ...data }; return mockItems[index]; }), deleteItem: mock(async (id: number) => { const index = mockItems.findIndex((item) => item.id === id); if (index === -1) return null; const [deleted] = mockItems.splice(index, 1); return deleted; }), }, })); // --- Mock Utilities --- mock.module("@shared/lib/utils", () => ({ jsonReplacer: (key: string, value: any) => typeof value === "bigint" ? value.toString() : value, })); // --- Mock Logger --- mock.module("@shared/lib/logger", () => ({ logger: { info: () => { }, warn: () => { }, error: () => { }, debug: () => { }, }, })); describe("Items API", () => { const port = 3002; const hostname = "127.0.0.1"; const baseUrl = `http://${hostname}:${port}`; let serverInstance: WebServerInstance | null = null; beforeAll(async () => { // Reset mock data before all tests mockItems = [ { id: 1, name: "Health Potion", description: "Restores health", rarity: "C", type: "CONSUMABLE", price: 100n, iconUrl: "/assets/items/1.png", imageUrl: "/assets/items/1.png", usageData: { consume: true, effects: [] }, }, { id: 2, name: "Iron Sword", description: "A basic sword", rarity: "R", type: "EQUIPMENT", price: 500n, iconUrl: "/assets/items/2.png", imageUrl: "/assets/items/2.png", usageData: null, }, ]; mockIdCounter = 3; serverInstance = await createWebServer({ port, hostname }); }); afterAll(async () => { if (serverInstance) { await serverInstance.stop(); } }); // =========================================== // GET /api/items Tests // =========================================== describe("GET /api/items", () => { test("should return all items", async () => { const response = await fetch(`${baseUrl}/api/items`); expect(response.status).toBe(200); const data = (await response.json()) as { items: MockItem[]; total: number }; expect(data.items).toBeInstanceOf(Array); expect(data.total).toBeGreaterThanOrEqual(0); }); test("should filter items by search query", async () => { const response = await fetch(`${baseUrl}/api/items?search=potion`); expect(response.status).toBe(200); const data = (await response.json()) as { items: MockItem[]; total: number }; expect(data.items.every((item) => item.name.toLowerCase().includes("potion") || (item.description?.toLowerCase().includes("potion") ?? false) )).toBe(true); }); test("should filter items by type", async () => { const response = await fetch(`${baseUrl}/api/items?type=CONSUMABLE`); expect(response.status).toBe(200); const data = (await response.json()) as { items: MockItem[]; total: number }; expect(data.items.every((item) => item.type === "CONSUMABLE")).toBe(true); }); test("should filter items by rarity", async () => { const response = await fetch(`${baseUrl}/api/items?rarity=C`); expect(response.status).toBe(200); const data = (await response.json()) as { items: MockItem[]; total: number }; expect(data.items.every((item) => item.rarity === "C")).toBe(true); }); }); // =========================================== // GET /api/items/:id Tests // =========================================== describe("GET /api/items/:id", () => { test("should return a single item by ID", async () => { const response = await fetch(`${baseUrl}/api/items/1`); expect(response.status).toBe(200); const data = (await response.json()) as MockItem; expect(data.id).toBe(1); expect(data.name).toBe("Health Potion"); }); test("should return 404 for non-existent item", async () => { const response = await fetch(`${baseUrl}/api/items/9999`); expect(response.status).toBe(404); const data = (await response.json()) as { error: string }; expect(data.error).toBe("Item not found"); }); }); // =========================================== // POST /api/items Tests // =========================================== describe("POST /api/items", () => { test("should create a new item", async () => { const newItem = { name: "Magic Staff", description: "A powerful staff", rarity: "SR", type: "EQUIPMENT", price: "1000", iconUrl: "/assets/items/placeholder.png", imageUrl: "/assets/items/placeholder.png", }; const response = await fetch(`${baseUrl}/api/items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(newItem), }); expect(response.status).toBe(201); const data = (await response.json()) as { success: boolean; item: MockItem }; expect(data.success).toBe(true); expect(data.item.name).toBe("Magic Staff"); expect(data.item.id).toBeGreaterThan(0); }); test("should reject item without required fields", async () => { const response = await fetch(`${baseUrl}/api/items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description: "No name or type" }), }); expect(response.status).toBe(400); const data = (await response.json()) as { error: string }; expect(data.error).toContain("required"); }); test("should reject duplicate item name", async () => { const response = await fetch(`${baseUrl}/api/items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Health Potion", // Already exists type: "CONSUMABLE", iconUrl: "/assets/items/placeholder.png", imageUrl: "/assets/items/placeholder.png", }), }); expect(response.status).toBe(409); const data = (await response.json()) as { error: string }; expect(data.error).toContain("already exists"); }); }); // =========================================== // PUT /api/items/:id Tests // =========================================== describe("PUT /api/items/:id", () => { test("should update an existing item", async () => { const response = await fetch(`${baseUrl}/api/items/1`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description: "Updated description", price: "200", }), }); expect(response.status).toBe(200); const data = (await response.json()) as { success: boolean; item: MockItem }; expect(data.success).toBe(true); expect(data.item.description).toBe("Updated description"); }); test("should return 404 for updating non-existent item", async () => { const response = await fetch(`${baseUrl}/api/items/9999`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "New Name" }), }); expect(response.status).toBe(404); }); test("should reject duplicate name when updating", async () => { const response = await fetch(`${baseUrl}/api/items/2`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Health Potion", // ID 1 has this name }), }); expect(response.status).toBe(409); }); }); // =========================================== // DELETE /api/items/:id Tests // =========================================== describe("DELETE /api/items/:id", () => { test("should delete an existing item", async () => { // First, create an item to delete const createResponse = await fetch(`${baseUrl}/api/items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Item to Delete", type: "MATERIAL", iconUrl: "/assets/items/placeholder.png", imageUrl: "/assets/items/placeholder.png", }), }); const { item } = (await createResponse.json()) as { item: MockItem }; // Now delete it const deleteResponse = await fetch(`${baseUrl}/api/items/${item.id}`, { method: "DELETE", }); expect(deleteResponse.status).toBe(204); // Verify it's gone const getResponse = await fetch(`${baseUrl}/api/items/${item.id}`); expect(getResponse.status).toBe(404); }); test("should return 404 for deleting non-existent item", async () => { const response = await fetch(`${baseUrl}/api/items/9999`, { method: "DELETE", }); expect(response.status).toBe(404); }); }); // =========================================== // Static Asset Serving Tests // =========================================== describe("Static Asset Serving (/assets/*)", () => { test("should return 404 for non-existent asset", async () => { const response = await fetch(`${baseUrl}/assets/items/nonexistent.png`); expect(response.status).toBe(404); }); test("should prevent path traversal attacks", async () => { const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`); // Should either return 403 (Forbidden) or 404 (Not found after sanitization) expect([403, 404]).toContain(response.status); }); }); // =========================================== // Validation Edge Cases // =========================================== describe("Validation Edge Cases", () => { test("should handle empty search query gracefully", async () => { const response = await fetch(`${baseUrl}/api/items?search=`); expect(response.status).toBe(200); }); test("should handle invalid pagination values", async () => { const response = await fetch(`${baseUrl}/api/items?limit=abc&offset=xyz`); // Should not crash, may use defaults expect(response.status).toBe(200); }); test("should handle missing content-type header", async () => { const response = await fetch(`${baseUrl}/api/items`, { method: "POST", body: JSON.stringify({ name: "Test", type: "MATERIAL" }), }); // May fail due to no content-type, but shouldn't crash expect([200, 201, 400, 415]).toContain(response.status); }); }); });