forked from syntaxbullet/aurorabot
feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
This commit is contained in:
435
web/src/server.items.test.ts
Normal file
435
web/src/server.items.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
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: "Common",
|
||||
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: "Uncommon",
|
||||
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 ?? "Common",
|
||||
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: "Common",
|
||||
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: "Uncommon",
|
||||
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=Common`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items.every((item) => item.rarity === "Common")).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: "Rare",
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user