Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
import type { Kysely } from "kysely";
|
|
import { describe, it, expect, afterEach } from "vitest";
|
|
|
|
import { handleDashboardStats } from "../../../src/api/handlers/dashboard.js";
|
|
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import { createPostFixture, createPageFixture } from "../../utils/fixtures.js";
|
|
import {
|
|
setupTestDatabase,
|
|
setupTestDatabaseWithCollections,
|
|
teardownTestDatabase,
|
|
} from "../../utils/test-db.js";
|
|
|
|
describe("Dashboard Handlers", () => {
|
|
describe("handleDashboardStats", () => {
|
|
let db: Kysely<Database>;
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("returns empty stats when no collections exist", async () => {
|
|
db = await setupTestDatabase();
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toBeDefined();
|
|
expect(result.data!.collections).toEqual([]);
|
|
expect(result.data!.mediaCount).toBe(0);
|
|
expect(result.data!.userCount).toBe(0);
|
|
expect(result.data!.recentItems).toEqual([]);
|
|
});
|
|
|
|
it("returns collection stats with correct counts", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
|
|
// Create some posts with different statuses
|
|
await contentRepo.create(createPostFixture({ slug: "post-1" }));
|
|
await contentRepo.create(createPostFixture({ slug: "post-2", status: "published" }));
|
|
await contentRepo.create(createPostFixture({ slug: "post-3", status: "published" }));
|
|
|
|
// Create a draft page
|
|
await contentRepo.create(createPageFixture({ slug: "page-1" }));
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const { collections } = result.data!;
|
|
|
|
// Both collections should be present
|
|
expect(collections).toHaveLength(2);
|
|
|
|
const postStats = collections.find((c) => c.slug === "post");
|
|
expect(postStats).toBeDefined();
|
|
expect(postStats!.label).toBe("Posts");
|
|
expect(postStats!.total).toBe(3);
|
|
expect(postStats!.published).toBe(2);
|
|
expect(postStats!.draft).toBe(1);
|
|
|
|
const pageStats = collections.find((c) => c.slug === "page");
|
|
expect(pageStats).toBeDefined();
|
|
expect(pageStats!.label).toBe("Pages");
|
|
expect(pageStats!.total).toBe(1);
|
|
expect(pageStats!.published).toBe(0);
|
|
expect(pageStats!.draft).toBe(1);
|
|
});
|
|
|
|
it("returns recent items across collections", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
|
|
await contentRepo.create(createPostFixture({ slug: "post-1" }));
|
|
// Small delay for distinct updated_at
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await contentRepo.create(createPageFixture({ slug: "page-1" }));
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const { recentItems } = result.data!;
|
|
|
|
expect(recentItems.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Most recently updated should be first
|
|
expect(recentItems[0]!.collection).toBe("page");
|
|
expect(recentItems[0]!.collectionLabel).toBe("Pages");
|
|
expect(recentItems[0]!.slug).toBe("page-1");
|
|
expect(recentItems[0]!.status).toBe("draft");
|
|
|
|
expect(recentItems[1]!.collection).toBe("post");
|
|
expect(recentItems[1]!.collectionLabel).toBe("Posts");
|
|
expect(recentItems[1]!.slug).toBe("post-1");
|
|
});
|
|
|
|
it("recent items use title field when available", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
|
|
// setupTestDatabaseWithCollections creates post/page with title fields
|
|
await contentRepo.create(
|
|
createPostFixture({
|
|
slug: "my-post",
|
|
data: { title: "My Great Post", content: [] },
|
|
}),
|
|
);
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const postItem = result.data!.recentItems.find((i) => i.slug === "my-post");
|
|
expect(postItem).toBeDefined();
|
|
expect(postItem!.title).toBe("My Great Post");
|
|
});
|
|
|
|
it("recent items fall back to slug when collection has no title field", async () => {
|
|
db = await setupTestDatabase();
|
|
const registry = new SchemaRegistry(db);
|
|
|
|
// Create a collection without a title field
|
|
await registry.createCollection({
|
|
slug: "events",
|
|
label: "Events",
|
|
labelSingular: "Event",
|
|
});
|
|
await registry.createField("events", {
|
|
slug: "date",
|
|
label: "Date",
|
|
type: "datetime",
|
|
});
|
|
|
|
const contentRepo = new ContentRepository(db);
|
|
await contentRepo.create({
|
|
type: "events",
|
|
slug: "launch-party",
|
|
data: { date: "2026-03-01" },
|
|
status: "draft",
|
|
});
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const eventItem = result.data!.recentItems.find((i) => i.collection === "events");
|
|
expect(eventItem).toBeDefined();
|
|
// No title field, should fall back to slug
|
|
expect(eventItem!.title).toBe("launch-party");
|
|
});
|
|
|
|
it("excludes soft-deleted items from recent items", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
|
|
const post = await contentRepo.create(createPostFixture({ slug: "will-delete" }));
|
|
await contentRepo.create(createPostFixture({ slug: "will-keep" }));
|
|
|
|
// Soft-delete the first post
|
|
await contentRepo.delete("post", post.id);
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const slugs = result.data!.recentItems.map((i) => i.slug);
|
|
expect(slugs).toContain("will-keep");
|
|
expect(slugs).not.toContain("will-delete");
|
|
});
|
|
|
|
it("limits recent items to 10", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
|
|
// Create 15 posts
|
|
for (let i = 0; i < 15; i++) {
|
|
await contentRepo.create(createPostFixture({ slug: `post-${String(i).padStart(2, "0")}` }));
|
|
}
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data!.recentItems).toHaveLength(10);
|
|
});
|
|
|
|
it("recent items are ordered by updated_at descending", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
|
|
await contentRepo.create(createPostFixture({ slug: "oldest" }));
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await contentRepo.create(createPostFixture({ slug: "middle" }));
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await contentRepo.create(createPostFixture({ slug: "newest" }));
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const slugs = result.data!.recentItems.map((i) => i.slug);
|
|
expect(slugs).toEqual(["newest", "middle", "oldest"]);
|
|
});
|
|
|
|
it("counts exclude soft-deleted items", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
|
|
const post = await contentRepo.create(createPostFixture({ slug: "to-delete" }));
|
|
await contentRepo.create(createPostFixture({ slug: "to-keep" }));
|
|
await contentRepo.delete("post", post.id);
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const postStats = result.data!.collections.find((c) => c.slug === "post");
|
|
// count() in ContentRepository filters deleted_at IS NULL
|
|
expect(postStats!.total).toBe(1);
|
|
});
|
|
|
|
it("returns camelCase keys in recent items", async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
const contentRepo = new ContentRepository(db);
|
|
await contentRepo.create(createPostFixture());
|
|
|
|
const result = await handleDashboardStats(db);
|
|
|
|
expect(result.success).toBe(true);
|
|
const item = result.data!.recentItems[0]!;
|
|
// Verify camelCase API shape
|
|
expect(item).toHaveProperty("id");
|
|
expect(item).toHaveProperty("collection");
|
|
expect(item).toHaveProperty("collectionLabel");
|
|
expect(item).toHaveProperty("title");
|
|
expect(item).toHaveProperty("slug");
|
|
expect(item).toHaveProperty("status");
|
|
expect(item).toHaveProperty("updatedAt");
|
|
expect(item).toHaveProperty("authorId");
|
|
// Should NOT have snake_case keys
|
|
expect(item).not.toHaveProperty("collection_label");
|
|
expect(item).not.toHaveProperty("updated_at");
|
|
expect(item).not.toHaveProperty("author_id");
|
|
});
|
|
});
|
|
});
|