436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|