Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

326 lines
11 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);
expect(postStats!.scheduled).toBe(0);
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);
expect(pageStats!.scheduled).toBe(0);
});
it("counts entries with pending schedules in collection stats", async () => {
db = await setupTestDatabaseWithCollections();
const contentRepo = new ContentRepository(db);
const draftScheduled = await contentRepo.create(
createPostFixture({ slug: "draft-scheduled" }),
);
await contentRepo.schedule(
"post",
draftScheduled.id,
new Date(Date.now() + 86_400_000).toISOString(),
);
const publishedScheduled = await contentRepo.create(
createPostFixture({ slug: "published-scheduled" }),
);
await contentRepo.publish("post", publishedScheduled.id);
await contentRepo.schedule(
"post",
publishedScheduled.id,
new Date(Date.now() + 172_800_000).toISOString(),
);
await contentRepo.create(createPostFixture({ slug: "archived", status: "archived" }));
const result = await handleDashboardStats(db);
expect(result.success).toBe(true);
const postStats = result.data!.collections.find((c) => c.slug === "post");
expect(postStats).toBeDefined();
expect(postStats!.total).toBe(3);
expect(postStats!.scheduled).toBe(2);
});
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("merges recent items across many title-bearing collections (#895)", async () => {
// Regression for #895: with enough title-bearing collections, the
// previous chained UNION ALL query exceeded D1's compound-SELECT cap
// and returned a 500. The per-collection fan-out path must merge,
// sort, and slice to 10 across all collections regardless of count.
db = await setupTestDatabase();
const registry = new SchemaRegistry(db);
const contentRepo = new ContentRepository(db);
const collectionCount = 12;
const slugs: string[] = [];
for (let i = 0; i < collectionCount; i++) {
const slug = `coll_${String(i).padStart(2, "0")}`;
slugs.push(slug);
await registry.createCollection({
slug,
label: `Collection ${i}`,
labelSingular: `Item ${i}`,
});
await registry.createField(slug, { slug: "title", label: "Title", type: "string" });
}
// One entry per collection, with monotonically increasing updated_at
// so we can deterministically assert ordering.
for (let i = 0; i < collectionCount; i++) {
await contentRepo.create({
type: slugs[i]!,
slug: `entry-${String(i).padStart(2, "0")}`,
data: { title: `Title ${i}` },
status: "draft",
});
await new Promise((r) => setTimeout(r, 5));
}
const result = await handleDashboardStats(db);
expect(result.success).toBe(true);
const { recentItems } = result.data!;
// Capped at 10 even when 12 collections each have an entry
expect(recentItems).toHaveLength(10);
// Newest 10 are the last 10 created, in reverse-creation order.
const expected = slugs.slice(-10).toReversed();
expect(recentItems.map((i) => i.collection)).toEqual(expected);
expect(recentItems[0]!.title).toBe(`Title ${collectionCount - 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");
});
});
});