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
925 lines
31 KiB
TypeScript
925 lines
31 KiB
TypeScript
import { sql } from "kysely";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js";
|
|
import { RevisionRepository } from "../../../src/database/repositories/revision.js";
|
|
import type { EmDashRuntime } from "../../../src/emdash-runtime.js";
|
|
import { setI18nConfig } from "../../../src/i18n/config.js";
|
|
import {
|
|
CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
} from "../../../src/media/usage/content-refresh.js";
|
|
import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import { createTestRuntime } from "../../utils/mcp-runtime.js";
|
|
import {
|
|
describeEachDialect,
|
|
setupForDialect,
|
|
teardownForDialect,
|
|
type DialectTestContext,
|
|
} from "../../utils/test-db.js";
|
|
|
|
describeEachDialect("runtime content media usage refresh", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
let runtime: EmDashRuntime;
|
|
let usageRepo: MediaUsageRepository;
|
|
let revisionRepo: RevisionRepository;
|
|
|
|
beforeEach(async () => {
|
|
setI18nConfig(null);
|
|
ctx = await setupForDialect(dialect);
|
|
const registry = new SchemaRegistry(ctx.db);
|
|
await registry.createCollection({ slug: "posts", label: "Posts" });
|
|
await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
|
|
await registry.createField("posts", { slug: "hero", label: "Hero", type: "image" });
|
|
await registry.createField("posts", {
|
|
slug: "shared_hero",
|
|
label: "Shared Hero",
|
|
type: "image",
|
|
translatable: false,
|
|
});
|
|
await registry.createCollection({ slug: "plain_posts", label: "Plain Posts", supports: [] });
|
|
await registry.createField("plain_posts", {
|
|
slug: "title",
|
|
label: "Title",
|
|
type: "string",
|
|
});
|
|
await registry.createField("plain_posts", { slug: "hero", label: "Hero", type: "image" });
|
|
await registry.createCollection({
|
|
slug: "localized_posts",
|
|
label: "Localized Posts",
|
|
supports: [],
|
|
});
|
|
await registry.createField("localized_posts", {
|
|
slug: "title",
|
|
label: "Title",
|
|
type: "string",
|
|
translatable: false,
|
|
});
|
|
await registry.createField("localized_posts", {
|
|
slug: "hero",
|
|
label: "Hero",
|
|
type: "image",
|
|
});
|
|
await registry.createField("localized_posts", {
|
|
slug: "shared_hero",
|
|
label: "Shared Hero",
|
|
type: "image",
|
|
translatable: false,
|
|
});
|
|
await registry.createField("localized_posts", {
|
|
slug: "summary",
|
|
label: "Summary",
|
|
type: "string",
|
|
translatable: false,
|
|
});
|
|
|
|
runtime = createTestRuntime(ctx.db);
|
|
usageRepo = new MediaUsageRepository(ctx.db);
|
|
revisionRepo = new RevisionRepository(ctx.db);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
setI18nConfig(null);
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("refreshes columns usage after runtime content create", async () => {
|
|
const created = await runtime.handleContentCreate("plain_posts", {
|
|
slug: "created-post",
|
|
data: {
|
|
title: "Created Post",
|
|
hero: mediaRef("media-created"),
|
|
},
|
|
});
|
|
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
const contentId = created.data.item.id;
|
|
expect(await usageRepo.findSource(sourceKey("plain_posts", contentId, "columns"))).toEqual(
|
|
expect.objectContaining({
|
|
contentTitle: "Created Post",
|
|
sourceCompleteness: "complete",
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-created")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ contentId, sourceVariant: "columns" }),
|
|
occurrence: expect.objectContaining({ fieldPath: "hero", mediaId: "media-created" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("refreshes columns usage after runtime non-revision content update", async () => {
|
|
const created = await runtime.handleContentCreate("plain_posts", {
|
|
slug: "updated-post",
|
|
data: {
|
|
title: "Updated Post",
|
|
hero: mediaRef("media-old"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
|
|
const updated = await runtime.handleContentUpdate("plain_posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-new") },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-old")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-new")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({
|
|
contentId: created.data.item.id,
|
|
sourceVariant: "columns",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("refreshes draft overlay usage after runtime revision-enabled content update", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "drafted-post",
|
|
data: {
|
|
title: "Drafted Post",
|
|
hero: mediaRef("media-live"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
|
|
const updated = await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-draft") },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
|
expect.objectContaining({ sourceVariant: "columns", contentTitle: "Drafted Post" }),
|
|
);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toEqual(expect.objectContaining({ sourceVariant: "draft_overlay" }));
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("marks coverage stale when a failed draft update has already advanced stored draft data", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "failed-metadata-draft-post",
|
|
data: {
|
|
title: "Failed Metadata Draft Post",
|
|
hero: mediaRef("media-live"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
const contentId = created.data.item.id;
|
|
await usageRepo.upsertIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
status: "complete",
|
|
lastErrorCode: null,
|
|
});
|
|
|
|
const updated = await runtime.handleContentUpdate("posts", contentId, {
|
|
data: { hero: mediaRef("media-unrefreshed-draft") },
|
|
bylines: [{ bylineId: "missing-byline" }],
|
|
});
|
|
|
|
expect(updated.success).toBe(false);
|
|
expect((await revisionRepo.findByEntry("posts", contentId, { limit: 1 }))[0]?.data).toEqual(
|
|
expect.objectContaining({ hero: mediaRef("media-unrefreshed-draft") }),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-unrefreshed-draft")).toEqual([]);
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
status: "stale",
|
|
lastErrorCode: "CONTENT_USAGE_STALE",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps runtime updates successful when draft overlay usage refresh fails", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "failed-refresh-post",
|
|
data: {
|
|
title: "Failed Refresh Post",
|
|
hero: mediaRef("media-live-before-failure"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
const contentId = created.data.item.id;
|
|
|
|
const firstDraft = await runtime.handleContentUpdate("posts", contentId, {
|
|
data: { hero: mediaRef("media-draft-before-failure") },
|
|
});
|
|
expect(firstDraft.success).toBe(true);
|
|
const draftSourceBefore = await usageRepo.findSource(
|
|
sourceKey("posts", contentId, "draft_overlay"),
|
|
);
|
|
expect(draftSourceBefore).toEqual(
|
|
expect.objectContaining({
|
|
sourceCompleteness: "complete",
|
|
lastErrorCode: null,
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-before-failure")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ contentId, sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]);
|
|
await corruptFuturePostDraftRevisionSnapshots(ctx);
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const updated = await runtime
|
|
.handleContentUpdate("posts", contentId, {
|
|
data: { hero: mediaRef("media-draft-after-failure") },
|
|
})
|
|
.finally(() => {
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
const draftSourceAfter = await usageRepo.findSource(
|
|
sourceKey("posts", contentId, "draft_overlay"),
|
|
);
|
|
expect(draftSourceAfter).toEqual(
|
|
expect.objectContaining({
|
|
sourceCompleteness: "failed",
|
|
lastErrorCode: "DRAFT_REVISION_INVALID",
|
|
}),
|
|
);
|
|
expect(draftSourceAfter?.currentGeneration).toBe(draftSourceBefore?.currentGeneration);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-before-failure")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({
|
|
contentId,
|
|
sourceVariant: "draft_overlay",
|
|
lastErrorCode: "DRAFT_REVISION_INVALID",
|
|
}),
|
|
}),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-after-failure")).toEqual([]);
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
status: "stale",
|
|
lastErrorCode: "DRAFT_REVISION_INVALID",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("refreshes columns usage for duplicated content", async () => {
|
|
const created = await runtime.handleContentCreate("plain_posts", {
|
|
slug: "original-post",
|
|
data: {
|
|
title: "Original Post",
|
|
hero: mediaRef("media-copy"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
|
|
const duplicated = await runtime.handleContentDuplicate("plain_posts", created.data.item.id);
|
|
|
|
expect(duplicated.success).toBe(true);
|
|
if (!duplicated.success) throw new Error(duplicated.error.message);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-copy")).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({
|
|
contentId: duplicated.data.item.id,
|
|
sourceVariant: "columns",
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("refreshes draft overlay usage after runtime revision restore", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "restored-post",
|
|
data: {
|
|
title: "Restored Post",
|
|
hero: mediaRef("media-live"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
|
|
const firstDraft = await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-restored") },
|
|
});
|
|
expect(firstDraft.success).toBe(true);
|
|
const revisionToRestore = (
|
|
await revisionRepo.findByEntry("posts", created.data.item.id, { limit: 1 })
|
|
)[0];
|
|
expect(revisionToRestore).toBeDefined();
|
|
const secondDraft = await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-current-draft") },
|
|
});
|
|
expect(secondDraft.success).toBe(true);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-current-draft")).toHaveLength(1);
|
|
|
|
const restored = await runtime.handleRevisionRestore(revisionToRestore!.id, "user-1");
|
|
|
|
expect(restored.success).toBe(true);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-current-draft")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-restored")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("refreshes usage when publishing a draft overlay", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "publish-post",
|
|
data: {
|
|
title: "Publish Post",
|
|
hero: mediaRef("media-live"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-draft") },
|
|
});
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]);
|
|
|
|
const published = await runtime.handleContentPublish("posts", created.data.item.id);
|
|
|
|
expect(published.success).toBe(true);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toBeNull();
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
});
|
|
|
|
it("refreshes usage when unpublishing creates a draft overlay", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "unpublish-post",
|
|
data: {
|
|
title: "Unpublish Post",
|
|
hero: mediaRef("media-unpublish"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
const published = await runtime.handleContentPublish("posts", created.data.item.id);
|
|
expect(published.success).toBe(true);
|
|
|
|
const unpublished = await runtime.handleContentUnpublish("posts", created.data.item.id);
|
|
|
|
expect(unpublished.success).toBe(true);
|
|
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
|
expect.objectContaining({ contentStatus: "draft", sourceVariant: "columns" }),
|
|
);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toEqual(expect.objectContaining({ contentStatus: "draft", sourceVariant: "draft_overlay" }));
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-unpublish")).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("refreshes schedule metadata on schedule and unschedule", async () => {
|
|
const created = await runtime.handleContentCreate("plain_posts", {
|
|
slug: "scheduled-post",
|
|
data: {
|
|
title: "Scheduled Post",
|
|
hero: mediaRef("media-schedule"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
const scheduledAt = new Date(Date.now() + 86_400_000).toISOString();
|
|
|
|
const scheduled = await runtime.handleContentSchedule(
|
|
"plain_posts",
|
|
created.data.item.id,
|
|
scheduledAt,
|
|
);
|
|
|
|
expect(scheduled.success).toBe(true);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("plain_posts", created.data.item.id, "columns")),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
contentStatus: "scheduled",
|
|
contentScheduledAt: scheduledAt,
|
|
}),
|
|
);
|
|
|
|
const unscheduled = await runtime.handleContentUnschedule("plain_posts", created.data.item.id);
|
|
|
|
expect(unscheduled.success).toBe(true);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("plain_posts", created.data.item.id, "columns")),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
contentStatus: "draft",
|
|
contentScheduledAt: null,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("refreshes usage when discarding a draft overlay", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "discard-post",
|
|
data: {
|
|
title: "Discard Post",
|
|
hero: mediaRef("media-live"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-discard") },
|
|
});
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).not.toBeNull();
|
|
|
|
const discarded = await runtime.handleContentDiscardDraft("posts", created.data.item.id);
|
|
|
|
expect(discarded.success).toBe(true);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toBeNull();
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-discard")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
});
|
|
|
|
it("refreshes usage when scheduled publish runs through the runtime", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "scheduled-publish-post",
|
|
data: {
|
|
title: "Scheduled Publish Post",
|
|
hero: mediaRef("media-live"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-scheduled-draft") },
|
|
});
|
|
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
|
await sql`
|
|
UPDATE ${sql.ref("ec_posts")}
|
|
SET status = ${"scheduled"},
|
|
scheduled_at = ${dueAt}
|
|
WHERE id = ${created.data.item.id}
|
|
`.execute(ctx.db);
|
|
|
|
const result = await runtime.publishScheduled();
|
|
|
|
expect(result).toEqual([{ collection: "posts", id: created.data.item.id }]);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toBeNull();
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-scheduled-draft")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
});
|
|
|
|
it("refreshes trash metadata while preserving usage on soft delete", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "trashed-post",
|
|
data: {
|
|
title: "Trashed Post",
|
|
hero: mediaRef("media-live-trash"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-draft-trash") },
|
|
});
|
|
|
|
const deleted = await runtime.handleContentDelete("posts", "trashed-post");
|
|
|
|
expect(deleted.success).toBe(true);
|
|
if (!deleted.success) throw new Error(deleted.error.message);
|
|
expect(deleted.data.id).toBe(created.data.item.id);
|
|
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
|
expect.objectContaining({
|
|
contentDeletedAt: expect.any(String),
|
|
sourceVariant: "columns",
|
|
}),
|
|
);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
contentDeletedAt: expect.any(String),
|
|
sourceVariant: "draft_overlay",
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live-trash")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-trash")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("refreshes trash metadata when restoring content", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "restore-trash-post",
|
|
data: {
|
|
title: "Restore Trash Post",
|
|
hero: mediaRef("media-live-restore"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-draft-restore") },
|
|
});
|
|
const deleted = await runtime.handleContentDelete("posts", created.data.item.id);
|
|
expect(deleted.success).toBe(true);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toEqual(expect.objectContaining({ contentDeletedAt: expect.any(String) }));
|
|
|
|
const restored = await runtime.handleContentRestore("posts", "restore-trash-post");
|
|
|
|
expect(restored.success).toBe(true);
|
|
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
|
expect.objectContaining({
|
|
contentDeletedAt: null,
|
|
sourceVariant: "columns",
|
|
}),
|
|
);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
contentDeletedAt: null,
|
|
sourceVariant: "draft_overlay",
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live-restore")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-restore")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("deletes usage sources and current occurrences on permanent delete", async () => {
|
|
const created = await runtime.handleContentCreate("posts", {
|
|
slug: "permanent-delete-post",
|
|
data: {
|
|
title: "Permanent Delete Post",
|
|
hero: mediaRef("media-live-permanent"),
|
|
},
|
|
});
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) throw new Error(created.error.message);
|
|
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
|
data: { hero: mediaRef("media-draft-permanent") },
|
|
});
|
|
const deleted = await runtime.handleContentDelete("posts", created.data.item.id);
|
|
expect(deleted.success).toBe(true);
|
|
|
|
const permanentlyDeleted = await runtime.handleContentPermanentDelete(
|
|
"posts",
|
|
"permanent-delete-post",
|
|
);
|
|
|
|
expect(permanentlyDeleted.success).toBe(true);
|
|
if (!permanentlyDeleted.success) throw new Error(permanentlyDeleted.error.message);
|
|
expect(permanentlyDeleted.data.id).toBe(created.data.item.id);
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns")),
|
|
).toBeNull();
|
|
expect(
|
|
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
|
).toBeNull();
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live-permanent")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-permanent")).toEqual([]);
|
|
});
|
|
|
|
it("refreshes i18n siblings when a non-translatable image syncs across locales", async () => {
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
|
const { enId, frId } = await createLocalizedPostsPair(runtime, "shared-image", {
|
|
enSharedHero: "media-shared-old-en",
|
|
frSharedHero: "media-shared-old-fr",
|
|
});
|
|
|
|
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
|
data: { shared_hero: mediaRef("media-shared-new") },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-shared-old-en")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-shared-old-fr")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-shared-new")).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ contentId: enId, sourceVariant: "columns" }),
|
|
occurrence: expect.objectContaining({ fieldPath: "shared_hero" }),
|
|
}),
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ contentId: frId, sourceVariant: "columns" }),
|
|
occurrence: expect.objectContaining({ fieldPath: "shared_hero" }),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("does not refresh i18n siblings for translatable image updates", async () => {
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
|
const { enId, frId } = await createLocalizedPostsPair(runtime, "translatable-image", {
|
|
enHero: "media-hero-old-en",
|
|
frHero: "media-hero-old-fr",
|
|
});
|
|
const frSourceBefore = await usageRepo.findSource(
|
|
sourceKey("localized_posts", frId, "columns"),
|
|
);
|
|
expect(frSourceBefore).not.toBeNull();
|
|
|
|
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
|
data: { hero: mediaRef("media-hero-new-en") },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-hero-new-en")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ contentId: enId, sourceVariant: "columns" }),
|
|
}),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-hero-old-fr")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ contentId: frId, sourceVariant: "columns" }),
|
|
}),
|
|
]);
|
|
expect(
|
|
(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns")))
|
|
?.currentGeneration,
|
|
).toBe(frSourceBefore?.currentGeneration);
|
|
});
|
|
|
|
it("does not refresh i18n siblings for non-usage non-translatable updates", async () => {
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
|
const { enId, frId } = await createLocalizedPostsPair(runtime, "non-usage-sync");
|
|
const frSourceBefore = await usageRepo.findSource(
|
|
sourceKey("localized_posts", frId, "columns"),
|
|
);
|
|
expect(frSourceBefore).not.toBeNull();
|
|
|
|
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
|
data: { summary: "Shared summary" },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(
|
|
(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns")))
|
|
?.currentGeneration,
|
|
).toBe(frSourceBefore?.currentGeneration);
|
|
});
|
|
|
|
it("does not refresh i18n siblings for revision-enabled draft saves", async () => {
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
|
const en = await runtime.handleContentCreate("posts", {
|
|
slug: "draft-sibling-en",
|
|
locale: "en",
|
|
data: {
|
|
title: "English Draft Sibling",
|
|
shared_hero: mediaRef("media-draft-sibling-old-en"),
|
|
},
|
|
});
|
|
expect(en.success).toBe(true);
|
|
if (!en.success) throw new Error(en.error.message);
|
|
const fr = await runtime.handleContentCreate("posts", {
|
|
slug: "draft-sibling-fr",
|
|
locale: "fr",
|
|
translationOf: en.data.item.id,
|
|
data: {
|
|
title: "French Draft Sibling",
|
|
shared_hero: mediaRef("media-draft-sibling-old-fr"),
|
|
},
|
|
});
|
|
expect(fr.success).toBe(true);
|
|
if (!fr.success) throw new Error(fr.error.message);
|
|
const frSourceBefore = await usageRepo.findSource(
|
|
sourceKey("posts", fr.data.item.id, "columns"),
|
|
);
|
|
expect(frSourceBefore).not.toBeNull();
|
|
|
|
const updated = await runtime.handleContentUpdate("posts", en.data.item.id, {
|
|
data: { shared_hero: mediaRef("media-draft-sibling-new") },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-sibling-new")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({
|
|
contentId: en.data.item.id,
|
|
sourceVariant: "draft_overlay",
|
|
}),
|
|
}),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-sibling-old-fr")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({
|
|
contentId: fr.data.item.id,
|
|
sourceVariant: "columns",
|
|
}),
|
|
}),
|
|
]);
|
|
expect(
|
|
(await usageRepo.findSource(sourceKey("posts", fr.data.item.id, "columns")))
|
|
?.currentGeneration,
|
|
).toBe(frSourceBefore?.currentGeneration);
|
|
});
|
|
|
|
it("refreshes trashed i18n siblings when non-translatable fields sync to them", async () => {
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
|
const { enId, frId } = await createLocalizedPostsPair(runtime, "trashed-sibling", {
|
|
enSharedHero: "media-trash-old-en",
|
|
frSharedHero: "media-trash-old-fr",
|
|
});
|
|
const deleted = await runtime.handleContentDelete("localized_posts", frId);
|
|
expect(deleted.success).toBe(true);
|
|
|
|
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
|
data: { shared_hero: mediaRef("media-trash-new") },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-trash-old-fr")).toEqual([]);
|
|
expect(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns"))).toEqual(
|
|
expect.objectContaining({
|
|
contentDeletedAt: expect.any(String),
|
|
contentId: frId,
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-trash-new")).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ contentId: enId, contentDeletedAt: null }),
|
|
}),
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({
|
|
contentId: frId,
|
|
contentDeletedAt: expect.any(String),
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("refreshes i18n sibling source metadata for non-translatable display fields", async () => {
|
|
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
|
const { enId, frId } = await createLocalizedPostsPair(runtime, "shared-title", {
|
|
enTitle: "English Title",
|
|
frTitle: "French Title",
|
|
});
|
|
|
|
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
|
data: { title: "Shared Title" },
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(await usageRepo.findSource(sourceKey("localized_posts", enId, "columns"))).toEqual(
|
|
expect.objectContaining({ contentTitle: "Shared Title" }),
|
|
);
|
|
expect(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns"))).toEqual(
|
|
expect.objectContaining({ contentTitle: "Shared Title" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
async function createLocalizedPostsPair(
|
|
runtime: EmDashRuntime,
|
|
slugPrefix: string,
|
|
input: {
|
|
enTitle?: string;
|
|
frTitle?: string;
|
|
enHero?: string;
|
|
frHero?: string;
|
|
enSharedHero?: string;
|
|
frSharedHero?: string;
|
|
} = {},
|
|
): Promise<{ enId: string; frId: string }> {
|
|
const en = await runtime.handleContentCreate("localized_posts", {
|
|
slug: `${slugPrefix}-en`,
|
|
locale: "en",
|
|
data: {
|
|
title: input.enTitle ?? "English Post",
|
|
hero: mediaRef(input.enHero ?? `${slugPrefix}-hero-en`),
|
|
shared_hero: mediaRef(input.enSharedHero ?? `${slugPrefix}-shared-en`),
|
|
},
|
|
});
|
|
expect(en.success).toBe(true);
|
|
if (!en.success) throw new Error(en.error.message);
|
|
|
|
const fr = await runtime.handleContentCreate("localized_posts", {
|
|
slug: `${slugPrefix}-fr`,
|
|
locale: "fr",
|
|
translationOf: en.data.item.id,
|
|
data: {
|
|
title: input.frTitle ?? "French Post",
|
|
hero: mediaRef(input.frHero ?? `${slugPrefix}-hero-fr`),
|
|
shared_hero: mediaRef(input.frSharedHero ?? `${slugPrefix}-shared-fr`),
|
|
},
|
|
});
|
|
expect(fr.success).toBe(true);
|
|
if (!fr.success) throw new Error(fr.error.message);
|
|
|
|
return { enId: en.data.item.id, frId: fr.data.item.id };
|
|
}
|
|
|
|
function mediaRef(id: string): Record<string, unknown> {
|
|
return {
|
|
id,
|
|
provider: "local",
|
|
mimeType: "image/webp",
|
|
width: 100,
|
|
height: 100,
|
|
};
|
|
}
|
|
|
|
function sourceKey(
|
|
collectionSlug: string,
|
|
contentId: string,
|
|
sourceVariant: "columns" | "draft_overlay",
|
|
): string {
|
|
return buildContentMediaUsageSourceKey({ collectionSlug, contentId, sourceVariant });
|
|
}
|
|
|
|
async function corruptFuturePostDraftRevisionSnapshots(ctx: DialectTestContext): Promise<void> {
|
|
if (ctx.dialect === "postgres") {
|
|
await sql`
|
|
CREATE FUNCTION corrupt_posts_draft_revision()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
IF NEW.draft_revision_id IS NOT NULL THEN
|
|
UPDATE revisions SET data = '{' WHERE id = NEW.draft_revision_id;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$
|
|
`.execute(ctx.db);
|
|
await sql`
|
|
CREATE TRIGGER corrupt_posts_draft_revision
|
|
AFTER UPDATE OF draft_revision_id ON ec_posts
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION corrupt_posts_draft_revision()
|
|
`.execute(ctx.db);
|
|
return;
|
|
}
|
|
|
|
await sql`
|
|
CREATE TRIGGER corrupt_posts_draft_revision
|
|
AFTER UPDATE OF draft_revision_id ON ec_posts
|
|
WHEN NEW.draft_revision_id IS NOT NULL
|
|
BEGIN
|
|
UPDATE revisions SET data = '{' WHERE id = NEW.draft_revision_id;
|
|
END
|
|
`.execute(ctx.db);
|
|
}
|