diff --git a/.changeset/media-usage-reads.md b/.changeset/media-usage-reads.md new file mode 100644 index 0000000000..a5c9c8d545 --- /dev/null +++ b/.changeset/media-usage-reads.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds coverage-aware media usage counts and read-only Used in details to the REST API and core client. diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 8a20a5cf69..875af32603 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -178,22 +178,23 @@ DELETE /_emdash/api/content/:collection/:id ### List Media ```http -GET /_emdash/api/media +GET /_emdash/api/media?includeUsage=1 ``` #### Parameters -| Parameter | Type | Description | -| ---------- | -------- | ---------------------------- | -| `cursor` | `string` | Pagination cursor | -| `limit` | `number` | Items per page (default: 20) | -| `mimeType` | `string` | Filter by MIME type prefix | +| Parameter | Type | Description | +| -------------- | -------- | -------------------------------------------------------------- | +| `cursor` | `string` | Opaque pagination cursor | +| `limit` | `number` | Items per page, from 1 to 100 (default: 50) | +| `mimeType` | `string` | Filter by one or more comma-separated MIME types | +| `q` | `string` | Case-insensitive filename search | +| `includeUsage` | `1` | Include a coverage-aware `usage` summary on every returned item | #### Response ```json { - "success": true, "data": { "items": [ { @@ -203,8 +204,15 @@ GET /_emdash/api/media "size": 102400, "width": 1920, "height": 1080, - "url": "https://cdn.example.com/photo.jpg", - "createdAt": "2025-01-24T12:00:00Z" + "url": "/_emdash/api/media/file/uploads/photo.jpg", + "createdAt": "2025-01-24T12:00:00Z", + "usage": { + "count": 3, + "coverage": { + "scope": "all_content_collections", + "status": "complete" + } + } } ], "nextCursor": "eyJpZCI6..." @@ -215,9 +223,99 @@ GET /_emdash/api/media ### Get Media ```http -GET /_emdash/api/media/:id +GET /_emdash/api/media/:id?includeUsage=1 +``` + +`includeUsage` is optional on both list and get. Its only accepted value is `1`. When omitted, +the `usage` property is omitted and the server does not run usage queries. + +### Usage Summaries + +`usage.count` is the number of distinct active EmDash content rows or locales whose selected +current indexed source references the media item. Repeated references and multiple source +variants for the same content entry count once. Trashed content does not count. + +A numeric count can reveal draft-like content. It is returned only when a session user has +`content:read_drafts`, or when an API token has `admin` scope and its associated user also has +that permission. Other media readers receive `usage.count: null`; this is a successful redacted +response, not an error. + +Every requested summary includes aggregate coverage for all currently registered content +collections: + +| Status | Meaning | +| ---------- | --------------------------------------------------------------------- | +| `complete` | Every registered collection has current, completed usage coverage | +| `never` | No registered collection has completed an initial usage repair | +| `running` | A usage repair is currently running | +| `partial` | Coverage is mixed or only part of the registered scope was indexed | +| `failed` | Coverage failed across the registered scope | +| `stale` | Indexed coverage is outdated | +| `unknown` | Stored coverage contains a state this version does not recognize | + +Only `complete` supports a scoped complete-zero statement within the EmDash-managed fields +described below. Counts with any other status are indexed projections and may over-report or +under-report. Even complete results are advisory during concurrent writes; usage reads are not a +transactional lock and must not be used as a deletion guarantee. + +### Get Media Usage Details + +```http +GET /_emdash/api/media/:id/usage?limit=50&cursor=... +``` + +This endpoint requires `media:read` and `content:read_drafts`. Token-authenticated callers also +require `admin` scope; token scope does not bypass the associated user's permissions. + +`limit` controls content entry groups per page, from 1 to 100 (default: 50). Pagination never +splits the sources or occurrences for one returned entry group. + +```json +{ + "data": { + "items": [ + { + "collection": "posts", + "contentId": "01CONTENT...", + "title": "Launch notes", + "slug": "launch-notes", + "locale": "en", + "status": "published", + "scheduledAt": null, + "deletedAt": null, + "sources": [ + { + "variant": "columns", + "occurrences": [ + { + "fieldSlug": "hero", + "fieldPath": "hero", + "occurrenceIndex": 0, + "referenceType": "image_field" + } + ] + } + ] + } + ], + "nextCursor": "eyJvcmRlclZhbHVlIjoicG9zdHMiLCJpZCI6IjAxLi4uIn0", + "coverage": { + "scope": "all_content_collections", + "status": "complete" + } + } +} ``` +Authorized details include active and trashed entries. A non-null `deletedAt` identifies a +trashed entry. Sources are `columns` or `draft_overlay`; occurrences identify the supported field +and path without exposing internal index metadata. + +Media usage covers local media references in top-level image and file fields, repeater image +fields, and Portable Text image blocks managed by EmDash content collections. It does not scan +custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or +provider-only assets. + ### Create Media ```http diff --git a/packages/core/src/api/authorize.ts b/packages/core/src/api/authorize.ts index baea83de9a..e23f7bf4e0 100644 --- a/packages/core/src/api/authorize.ts +++ b/packages/core/src/api/authorize.ts @@ -6,7 +6,7 @@ */ import type { Permission, RoleLevel } from "@emdash-cms/auth"; -import { hasPermission, canActOnOwn } from "@emdash-cms/auth"; +import { hasPermission, canActOnOwn, hasScope } from "@emdash-cms/auth"; import { apiError } from "./error.js"; @@ -15,6 +15,15 @@ interface UserLike { role: RoleLevel; } +export function canReadMediaUsageCount( + user: UserLike | null | undefined, + tokenScopes: string[] | undefined, +): boolean { + return ( + hasPermission(user, "content:read_drafts") && (!tokenScopes || hasScope(tokenScopes, "admin")) + ); +} + /** * Check if user has a permission. Returns a 401/403 Response if not, or null if authorized. * diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index 4a79e751a8..49fdbd1d5d 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -85,6 +85,7 @@ export const ErrorCode = { MEDIA_CREATE_ERROR: "MEDIA_CREATE_ERROR", MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR", MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR", + MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR", MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR", NO_STORAGE: "NO_STORAGE", NO_FILE: "NO_FILE", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 181ec2cb68..4e08f4e6be 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -62,9 +62,19 @@ export { } from "./media.js"; export { + aggregateMediaUsageCoverageStatus, + handleMediaUsageDetails, + handleMediaUsageSummaries, handleMediaUsageRepair, toMediaUsageRepairResponse, + type MediaUsageCoverage, + type MediaUsageCoverageStatus, + type MediaUsageDetailsResponse, + type MediaUsageEntryDetail, + type MediaUsageOccurrenceDetail, type MediaUsageRepairResponse, + type MediaUsageSourceDetail, + type MediaUsageSummary, } from "./media-usage.js"; // Schema handlers diff --git a/packages/core/src/api/handlers/media-usage.ts b/packages/core/src/api/handlers/media-usage.ts index e98f88ec8e..6b7d33c6aa 100644 --- a/packages/core/src/api/handlers/media-usage.ts +++ b/packages/core/src/api/handlers/media-usage.ts @@ -1,22 +1,150 @@ import type { Kysely } from "kysely"; +import { + MediaUsageRepository, + type MediaUsageCollectionIndexStatusScope, + type MediaUsageEntryGroup, +} from "../../database/repositories/media-usage.js"; +import { MediaRepository } from "../../database/repositories/media.js"; +import { InvalidCursorError } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; +import { + CONTENT_MEDIA_USAGE_ADAPTER_ID, + CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, +} from "../../media/usage/content-refresh.js"; import { repairContentMediaUsageAll, repairContentMediaUsageCollection, type ContentMediaUsageRepairAllResult, type ContentMediaUsageRepairCollectionResult, } from "../../media/usage/content-repair.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../media/usage/content-snapshots.js"; import { ErrorCode } from "../errors.js"; -import type { MediaUsageRepairRequest, MediaUsageRepairResponse } from "../schemas/media-usage.js"; +import type { + MediaUsageCoverage, + MediaUsageCoverageStatus, + MediaUsageDetailsResponse, + MediaUsageEntryDetail, + MediaUsageOccurrenceDetail, + MediaUsageRepairRequest, + MediaUsageRepairResponse, + MediaUsageSummary, +} from "../schemas/media-usage.js"; import type { ApiResult } from "../types.js"; -export type { MediaUsageRepairRequest, MediaUsageRepairResponse } from "../schemas/media-usage.js"; +export type { + MediaUsageCoverage, + MediaUsageCoverageStatus, + MediaUsageDetailsResponse, + MediaUsageEntryDetail, + MediaUsageOccurrenceDetail, + MediaUsageSourceDetail, + MediaUsageRepairRequest, + MediaUsageRepairResponse, + MediaUsageSummary, +} from "../schemas/media-usage.js"; type ContentMediaUsageRepairResult = | ContentMediaUsageRepairCollectionResult | ContentMediaUsageRepairAllResult; +export function aggregateMediaUsageCoverageStatus( + scopes: readonly MediaUsageCollectionIndexStatusScope[], +): MediaUsageCoverageStatus { + const statuses = scopes.map(normalizeMediaUsageCoverageStatus); + if (statuses.every((status) => status === "complete")) { + return "complete"; + } + if (statuses.includes("unknown")) return "unknown"; + if (statuses.includes("running")) return "running"; + if (statuses.includes("stale")) return "stale"; + if (statuses.includes("partial")) return "partial"; + if (statuses.every((status) => status === "never")) return "never"; + if (statuses.every((status) => status === "failed")) return "failed"; + return "partial"; +} + +export async function handleMediaUsageSummaries( + db: Kysely, + mediaIds: readonly string[], + options: { includeCount: boolean }, +): Promise>> { + if (mediaIds.length === 0) return { success: true, data: {} }; + + try { + const repository = new MediaUsageRepository(db); + const coverage = await loadMediaUsageCoverage(repository); + const counts = options.includeCount + ? await repository.findActiveEntryCountsByMediaIds(mediaIds) + : null; + const summaries: Record = {}; + + for (const mediaId of new Set(mediaIds)) { + summaries[mediaId] = { + count: counts ? (counts.get(mediaId) ?? 0) : null, + coverage, + }; + } + + return { success: true, data: summaries }; + } catch (error) { + console.error("[media-usage] summary read failed:", error); + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_READ_ERROR, + message: "Failed to read media usage", + }, + }; + } +} + +export async function handleMediaUsageDetails( + db: Kysely, + mediaId: string, + options: { cursor?: string; limit?: number }, +): Promise> { + try { + const media = await new MediaRepository(db).findById(mediaId); + if (!media) { + return { + success: false, + error: { + code: ErrorCode.NOT_FOUND, + message: `Media item not found: ${mediaId}`, + }, + }; + } + + const repository = new MediaUsageRepository(db); + const coverage = await loadMediaUsageCoverage(repository); + const page = await repository.findCurrentEntryUsagePageByMediaId(mediaId, options); + return { + success: true, + data: { + items: page.items.map(toMediaUsageEntryDetail), + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), + coverage, + }, + }; + } catch (error) { + if (error instanceof InvalidCursorError) { + return { + success: false, + error: { code: ErrorCode.INVALID_CURSOR, message: error.message }, + }; + } + console.error("[media-usage] detail read failed:", error); + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_READ_ERROR, + message: "Failed to read media usage", + }, + }; + } +} + export async function handleMediaUsageRepair( db: Kysely, input: MediaUsageRepairRequest, @@ -78,3 +206,84 @@ function toMediaUsageRepairCollectionSummary(result: ContentMediaUsageRepairColl completedAt: result.completedAt, }; } + +function normalizeMediaUsageCoverageStatus( + scope: MediaUsageCollectionIndexStatusScope, +): MediaUsageCoverageStatus { + if (scope.status === null) return "never"; + if (scope.status === "complete") { + return scope.schemaVersion === CONTENT_SOURCE_SCHEMA_VERSION ? "complete" : "stale"; + } + if ( + scope.status === "never" || + scope.status === "running" || + scope.status === "partial" || + scope.status === "failed" || + scope.status === "stale" + ) { + return scope.status; + } + return "unknown"; +} + +async function loadMediaUsageCoverage( + repository: MediaUsageRepository, +): Promise { + const scopes = await repository.findCollectionIndexStatusScopes({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + }); + return { + scope: "all_content_collections", + status: aggregateMediaUsageCoverageStatus(scopes), + }; +} + +function toMediaUsageEntryDetail(group: MediaUsageEntryGroup): MediaUsageEntryDetail { + const preferred = + group.sources.find(({ source }) => source.sourceVariant === "draft_overlay") ?? + group.sources.find(({ source }) => source.sourceVariant === "columns"); + if (!preferred) { + throw new Error("Media usage entry has no supported source"); + } + + return { + collection: group.collectionSlug, + contentId: group.contentId, + title: preferred.source.contentTitle, + slug: preferred.source.contentSlug, + locale: preferred.source.locale, + status: preferred.source.contentStatus, + scheduledAt: preferred.source.contentScheduledAt, + deletedAt: group.contentDeletedAt, + sources: group.sources.flatMap(({ source, occurrences }) => { + if (source.sourceVariant !== "columns" && source.sourceVariant !== "draft_overlay") { + return []; + } + return [ + { + variant: source.sourceVariant, + occurrences: occurrences.map((occurrence) => ({ + fieldSlug: occurrence.fieldSlug, + fieldPath: occurrence.fieldPath, + occurrenceIndex: occurrence.occurrenceIndex, + referenceType: normalizeMediaUsageReferenceType(occurrence.referenceType), + })), + }, + ]; + }), + }; +} + +function normalizeMediaUsageReferenceType( + referenceType: string, +): MediaUsageOccurrenceDetail["referenceType"] { + if ( + referenceType === "image_field" || + referenceType === "file_field" || + referenceType === "portable_text_image" + ) { + return referenceType; + } + return "unknown"; +} diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index 206f43b9e8..fc59fbd712 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -37,14 +37,22 @@ import { contentUpdateBody, trashedContentListResponseSchema, } from "../schemas/content.js"; -import { mediaUsageRepairBody, mediaUsageRepairResponseSchema } from "../schemas/media-usage.js"; +import { + mediaUsageDetailsQuery, + mediaUsageDetailsResponseSchema, + mediaUsageRepairBody, + mediaUsageRepairResponseSchema, +} from "../schemas/media-usage.js"; import { DEFAULT_MAX_UPLOAD_SIZE, mediaConfirmBody, mediaConfirmResponseSchema, mediaExistingResponseSchema, + mediaGetQuery, mediaListQuery, + mediaListReadResponseSchema, mediaListResponseSchema, + mediaReadResponseSchema, mediaResponseSchema, mediaUpdateBody, mediaUploadUrlBody, @@ -661,15 +669,19 @@ function buildMediaPaths(maxUploadSize: number) { get: { operationId: "listMedia", summary: "List media items", + description: + "Lists media items. Set `includeUsage=1` to attach coverage-aware advisory usage counts; a count may be null when the caller cannot read draft-derived usage.", tags: ["Media"], requestParams: { query: mediaListQuery }, responses: { "200": { description: "Media list", - content: { [JSON_CONTENT]: { schema: successEnvelope(mediaListResponseSchema) } }, + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaListReadResponseSchema) }, + }, }, ...authErrors, - ...standardErrors(500), + ...standardErrors(400, 500), }, }, }, @@ -677,17 +689,20 @@ function buildMediaPaths(maxUploadSize: number) { get: { operationId: "getMedia", summary: "Get a media item", + description: + "Gets a media item. Set `includeUsage=1` to attach a coverage-aware advisory usage count; the count may be null when the caller cannot read draft-derived usage.", tags: ["Media"], requestParams: { path: z.object({ id: z.string().meta({ description: "Media ID" }) }), + query: mediaGetQuery, }, responses: { "200": { description: "Media item", - content: { [JSON_CONTENT]: { schema: successEnvelope(mediaResponseSchema) } }, + content: { [JSON_CONTENT]: { schema: successEnvelope(mediaReadResponseSchema) } }, }, ...authErrors, - ...standardErrors(404, 500), + ...standardErrors(400, 404, 500), }, }, put: { @@ -724,6 +739,29 @@ function buildMediaPaths(maxUploadSize: number) { }, }, }, + "/_emdash/api/media/{id}/usage": { + get: { + operationId: "getMediaUsage", + summary: "Get media usage details", + description: + "Returns paginated content entry groups whose current indexed sources reference a local media item. Results include aggregate coverage and are advisory during concurrent writes. Requires media read and draft-content read permission; token-authenticated callers also require admin scope.", + tags: ["Media"], + requestParams: { + path: z.object({ id: z.string().meta({ description: "Media ID" }) }), + query: mediaUsageDetailsQuery, + }, + responses: { + "200": { + description: "Entry-grouped media usage details", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaUsageDetailsResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + }, + }, + }, "/_emdash/api/admin/media-usage/repair": { post: { operationId: "repairMediaUsage", @@ -2417,6 +2455,10 @@ export function generateOpenApiDocument( }, ], components: { + schemas: { + // Preserve the previously published component while media reads use richer schemas. + MediaListResponse: mediaListResponseSchema, + }, securitySchemes: { session: { type: "apiKey", diff --git a/packages/core/src/api/schemas/media-usage.ts b/packages/core/src/api/schemas/media-usage.ts index 7cd89f25e7..cfed0cd28b 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -2,6 +2,71 @@ import { z } from "zod"; import { slugPattern } from "./common.js"; +export const mediaUsageCoverageStatusSchema = z + .enum(["complete", "never", "running", "partial", "failed", "stale", "unknown"]) + .meta({ id: "MediaUsageCoverageStatus" }); + +export const mediaUsageCoverageSchema = z + .object({ + scope: z.literal("all_content_collections"), + status: mediaUsageCoverageStatusSchema, + }) + .meta({ id: "MediaUsageCoverage" }); + +export const mediaUsageSummarySchema = z + .object({ + count: z.number().int().min(0).nullable(), + coverage: mediaUsageCoverageSchema, + }) + .meta({ id: "MediaUsageSummary" }); + +export const mediaUsageDetailsQuery = z.object({ + cursor: z.string().min(1).max(2048).optional().meta({ + description: "Opaque content-entry-group cursor", + }), + limit: z.coerce.number().int().min(1).max(100).optional().default(50).meta({ + description: "Maximum number of content entry groups to return (1-100, default 50)", + }), +}); + +export const mediaUsageOccurrenceDetailSchema = z + .object({ + fieldSlug: z.string(), + fieldPath: z.string(), + occurrenceIndex: z.number().int().min(0), + referenceType: z.enum(["image_field", "file_field", "portable_text_image", "unknown"]), + }) + .meta({ id: "MediaUsageOccurrenceDetail" }); + +export const mediaUsageSourceDetailSchema = z + .object({ + variant: z.enum(["columns", "draft_overlay"]), + occurrences: z.array(mediaUsageOccurrenceDetailSchema), + }) + .meta({ id: "MediaUsageSourceDetail" }); + +export const mediaUsageEntryDetailSchema = z + .object({ + collection: z.string(), + contentId: z.string(), + title: z.string().nullable(), + slug: z.string().nullable(), + locale: z.string().nullable(), + status: z.string().nullable(), + scheduledAt: z.string().nullable(), + deletedAt: z.string().nullable(), + sources: z.array(mediaUsageSourceDetailSchema), + }) + .meta({ id: "MediaUsageEntryDetail" }); + +export const mediaUsageDetailsResponseSchema = z + .object({ + items: z.array(mediaUsageEntryDetailSchema), + nextCursor: z.string().optional(), + coverage: mediaUsageCoverageSchema, + }) + .meta({ id: "MediaUsageDetailsResponse" }); + export const mediaUsageRepairStatusSchema = z .enum(["complete", "partial", "failed", "stale"]) .meta({ id: "MediaUsageRepairStatus" }); @@ -46,3 +111,10 @@ export const mediaUsageRepairResponseSchema = z export type MediaUsageRepairRequest = z.infer; export type MediaUsageRepairResponse = z.infer; +export type MediaUsageCoverageStatus = z.infer; +export type MediaUsageCoverage = z.infer; +export type MediaUsageSummary = z.infer; +export type MediaUsageOccurrenceDetail = z.infer; +export type MediaUsageSourceDetail = z.infer; +export type MediaUsageEntryDetail = z.infer; +export type MediaUsageDetailsResponse = z.infer; diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 3b2519e10c..cdcfee3598 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { cursorPaginationQuery } from "./common.js"; +import { mediaUsageSummarySchema } from "./media-usage.js"; // --------------------------------------------------------------------------- // Media: Input schemas @@ -23,9 +24,20 @@ export const mediaListQuery = cursorPaginationQuery mimeType: mimeTypeFilter, /** Case-insensitive filename substring search (also matches extensions). */ q: z.string().trim().min(1).max(200).optional(), + includeUsage: z.literal("1").optional().meta({ + description: "Include a coverage-aware usage summary on each media item", + }), }) .meta({ id: "MediaListQuery" }); +export const mediaGetQuery = z + .object({ + includeUsage: z.literal("1").optional().meta({ + description: "Include a coverage-aware usage summary on the media item", + }), + }) + .meta({ id: "MediaGetQuery" }); + export const mediaUpdateBody = z .object({ alt: z.string().optional(), @@ -115,6 +127,25 @@ export const mediaResponseSchema = z .object({ item: mediaItemSchema }) .meta({ id: "MediaResponse" }); +export const mediaReadItemSchema = mediaItemSchema + .extend({ usage: mediaUsageSummarySchema.optional() }) + .meta({ id: "MediaReadItem" }); + +export const mediaReadResponseSchema = z + .object({ item: mediaReadItemSchema }) + .meta({ id: "MediaReadResponse" }); + +export const mediaListReadItemSchema = mediaReadItemSchema + .extend({ url: z.string() }) + .meta({ id: "MediaListReadItem" }); + +export const mediaListReadResponseSchema = z + .object({ + items: z.array(mediaListReadItemSchema), + nextCursor: z.string().optional(), + }) + .meta({ id: "MediaListReadResponse" }); + export const mediaListResponseSchema = z .object({ items: z.array(mediaItemSchema), diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 2174640914..82932bb271 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -213,6 +213,11 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/media/[id].ts"), }); + injectRoute({ + pattern: "/_emdash/api/media/[id]/usage", + entrypoint: resolveRoute("api/media/[id]/usage.ts"), + }); + injectRoute({ pattern: "/_emdash/api/media/[id]/confirm", entrypoint: resolveRoute("api/media/[id]/confirm.ts"), diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index b1bb4e7b92..9dadf3f588 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -10,9 +10,10 @@ import * as path from "node:path"; import type { APIRoute } from "astro"; import { ulid } from "ulidx"; -import { requirePerm } from "#api/authorize.js"; +import { canReadMediaUsageCount, requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js"; import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js"; +import { handleMediaUsageSummaries } from "#api/handlers/media-usage.js"; import { isParseError, parseQuery } from "#api/parse.js"; import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js"; import { MediaRepository } from "#db/repositories/media.js"; @@ -65,8 +66,26 @@ export const GET: APIRoute = async ({ request, locals }) => { // Add URL to each media item (relative URLs for portability) const itemsWithUrl = result.data.items.map((item) => addUrlToMedia(item)); + if (query.includeUsage !== "1") { + return apiSuccess({ items: itemsWithUrl, nextCursor: result.data.nextCursor }); + } + + const includeCount = canReadMediaUsageCount(user, locals.tokenScopes); + const usageResult = await handleMediaUsageSummaries( + emdash.db, + itemsWithUrl.map((item) => item.id), + { includeCount }, + ); + if (!usageResult.success) return unwrapResult(usageResult); + + const itemsWithUsage = []; + for (const item of itemsWithUrl) { + const usage = usageResult.data[item.id]; + if (!usage) return apiError("MEDIA_USAGE_READ_ERROR", "Failed to read media usage", 500); + itemsWithUsage.push({ ...item, usage }); + } - return apiSuccess({ items: itemsWithUrl, nextCursor: result.data.nextCursor }); + return apiSuccess({ items: itemsWithUsage, nextCursor: result.data.nextCursor }); }; /** diff --git a/packages/core/src/astro/routes/api/media/[id].ts b/packages/core/src/astro/routes/api/media/[id].ts index ae5adee373..b70123c8c9 100644 --- a/packages/core/src/astro/routes/api/media/[id].ts +++ b/packages/core/src/astro/routes/api/media/[id].ts @@ -8,17 +8,18 @@ import type { APIRoute } from "astro"; -import { requireOwnerPerm, requirePerm } from "#api/authorize.js"; -import { apiError, handleError, unwrapResult } from "#api/error.js"; -import { isParseError, parseBody } from "#api/parse.js"; -import { mediaUpdateBody } from "#api/schemas.js"; +import { canReadMediaUsageCount, requireOwnerPerm, requirePerm } from "#api/authorize.js"; +import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js"; +import { handleMediaUsageSummaries } from "#api/handlers/media-usage.js"; +import { isParseError, parseBody, parseQuery } from "#api/parse.js"; +import { mediaGetQuery, mediaUpdateBody } from "#api/schemas.js"; export const prerender = false; /** * Get media item */ -export const GET: APIRoute = async ({ params, locals }) => { +export const GET: APIRoute = async ({ params, request, locals }) => { const { emdash, user } = locals; const { id } = params; @@ -32,9 +33,19 @@ export const GET: APIRoute = async ({ params, locals }) => { if (!emdash?.handleMediaGet) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const query = parseQuery(new URL(request.url), mediaGetQuery); + if (isParseError(query)) return query; const result = await emdash.handleMediaGet(id); - return unwrapResult(result); + if (!result.success || query.includeUsage !== "1") return unwrapResult(result); + + const includeCount = canReadMediaUsageCount(user, locals.tokenScopes); + const usageResult = await handleMediaUsageSummaries(emdash.db, [id], { includeCount }); + if (!usageResult.success) return unwrapResult(usageResult); + const usage = usageResult.data[id]; + if (!usage) return apiError("MEDIA_USAGE_READ_ERROR", "Failed to read media usage", 500); + + return apiSuccess({ item: { ...result.data.item, usage } }); }; /** diff --git a/packages/core/src/astro/routes/api/media/[id]/usage.ts b/packages/core/src/astro/routes/api/media/[id]/usage.ts new file mode 100644 index 0000000000..6a702e7335 --- /dev/null +++ b/packages/core/src/astro/routes/api/media/[id]/usage.ts @@ -0,0 +1,30 @@ +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { handleMediaUsageDetails } from "#api/handlers/media-usage.js"; +import { isParseError, parseQuery } from "#api/parse.js"; +import { mediaUsageDetailsQuery } from "#api/schemas.js"; +import { requireScope } from "#auth/scopes.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + + const mediaDenied = requirePerm(user, "media:read"); + if (mediaDenied) return mediaDenied; + const contentDenied = requirePerm(user, "content:read_drafts"); + if (contentDenied) return contentDenied; + const scopeDenied = requireScope(locals, "admin"); + if (scopeDenied) return scopeDenied; + + const { id } = params; + if (!id) return apiError("INVALID_REQUEST", "Media ID required", 400); + if (!emdash?.db) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const query = parseQuery(new URL(request.url), mediaUsageDetailsQuery); + if (isParseError(query)) return query; + + return unwrapResult(await handleMediaUsageDetails(emdash.db, id, query)); +}; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 4930b158f7..47c6134eaf 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -127,6 +127,62 @@ export interface Field { sortOrder?: number; } +/** Aggregate trust state for media usage reads */ +export type MediaUsageCoverageStatus = + | "complete" + | "never" + | "running" + | "partial" + | "failed" + | "stale" + | "unknown"; + +/** Aggregate media usage coverage across all content collections */ +export interface MediaUsageCoverage { + scope: "all_content_collections"; + status: MediaUsageCoverageStatus; +} + +/** Coverage-aware usage count for a media item */ +export interface MediaUsageSummary { + count: number | null; + coverage: MediaUsageCoverage; +} + +/** One indexed media reference within a content source */ +export interface MediaUsageOccurrenceDetail { + fieldSlug: string; + fieldPath: string; + occurrenceIndex: number; + referenceType: "image_field" | "file_field" | "portable_text_image" | "unknown"; +} + +/** Indexed references from one visible content source */ +export interface MediaUsageSourceDetail { + variant: "columns" | "draft_overlay"; + occurrences: MediaUsageOccurrenceDetail[]; +} + +/** One content entry that references a media item */ +export interface MediaUsageEntryDetail { + collection: string; + contentId: string; + title: string | null; + slug: string | null; + locale: string | null; + status: string | null; + scheduledAt: string | null; + deletedAt: string | null; + sources: MediaUsageSourceDetail[]; +} + +/** Entry-grouped media usage details */ +export interface MediaUsageDetailsResponse { + items: MediaUsageEntryDetail[]; + nextCursor?: string; + coverage: MediaUsageCoverage; +} + /** Media item */ export interface MediaItem { id: string; @@ -140,6 +196,7 @@ export interface MediaItem { caption?: string; createdAt: string; updatedAt: string; + usage?: MediaUsageSummary; } /** Media usage repair request */ @@ -678,22 +735,47 @@ export class EmDashClient { mimeType?: string; limit?: number; cursor?: string; + includeUsage?: boolean; }): Promise> { const params = new URLSearchParams(); if (options?.mimeType) params.set("mimeType", options.mimeType); if (options?.limit) params.set("limit", String(options.limit)); if (options?.cursor) params.set("cursor", options.cursor); + if (options?.includeUsage === true) params.set("includeUsage", "1"); const qs = params.toString(); return this.request>("GET", `/media${qs ? `?${qs}` : ""}`); } /** Get a single media item */ - async mediaGet(id: string): Promise { - const data = await this.request<{ item: MediaItem }>("GET", `/media/${encodeURIComponent(id)}`); + async mediaGet(id: string, options?: { includeUsage?: boolean }): Promise { + const params = new URLSearchParams(); + if (options?.includeUsage === true) params.set("includeUsage", "1"); + + const qs = params.toString(); + const data = await this.request<{ item: MediaItem }>( + "GET", + `/media/${encodeURIComponent(id)}${qs ? `?${qs}` : ""}`, + ); return data.item; } + /** Get entry-grouped usage details for a media item */ + async mediaGetUsage( + id: string, + options?: { limit?: number; cursor?: string }, + ): Promise { + const params = new URLSearchParams(); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + if (options?.cursor !== undefined) params.set("cursor", options.cursor); + + const qs = params.toString(); + return this.request( + "GET", + `/media/${encodeURIComponent(id)}/usage${qs ? `?${qs}` : ""}`, + ); + } + /** Upload a media file */ async mediaUpload( file: Uint8Array | Blob, diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 11c1efac55..3ebd0fff68 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -20,7 +20,7 @@ import type { MediaUsageTable, } from "../types.js"; import { validateIdentifier } from "../validate.js"; -import { decodeCursor, encodeCursor, type FindManyResult } from "./types.js"; +import { decodeCursor, encodeCursor, InvalidCursorError, type FindManyResult } from "./types.js"; type DatabaseExecutor = Kysely | Transaction; type MediaUsageSourceNullableStringColumn = @@ -537,6 +537,9 @@ export class MediaUsageRepository { const requestedLimit = Math.floor(options.limit ?? 50); const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(1, requestedLimit), 100) : 50; const cursor = options.cursor ? decodeCursor(options.cursor) : null; + if (cursor && (cursor.orderValue.length === 0 || cursor.id.length === 0)) { + throw new InvalidCursorError(options.cursor ?? ""); + } let matchedGroups = this.currentContentMediaUsageBaseQuery() .select(["s.collection_slug as collection_slug", "s.content_id as content_id"]) .where("u.media_id", "=", mediaId) diff --git a/packages/core/tests/integration/database/media-usage-read-repository.test.ts b/packages/core/tests/integration/database/media-usage-read-repository.test.ts index 59c975de9e..b93ab6759e 100644 --- a/packages/core/tests/integration/database/media-usage-read-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-read-repository.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it } from "vitest"; import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; -import { InvalidCursorError } from "../../../src/database/repositories/types.js"; +import { encodeCursor, InvalidCursorError } from "../../../src/database/repositories/types.js"; import { buildContentMediaUsageSourceKey, type MediaUsageContentSourceVariant, @@ -185,6 +185,21 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { }); }); + it("returns trashed entries in details while excluding them from active counts", async () => { + await registerCollection(ctx, "posts"); + const deletedAt = "2026-01-01T00:00:00.000Z"; + await repo.replaceSource(contentSource("trash", "columns", { contentDeletedAt: deletedAt }), [ + occurrence("hero", "media-trash"), + ]); + + const counts = await repo.findActiveEntryCountsByMediaIds(["media-trash"]); + const page = await repo.findCurrentEntryUsagePageByMediaId("media-trash"); + + expect(counts.get("media-trash")).toBe(0); + expect(page.items.map(entryIdentity)).toEqual([["posts", "trash"]]); + expect(page.items[0]?.contentDeletedAt).toBe(deletedAt); + }); + it("returns zero-filled counts across multiple D1-sized batches", async () => { const mediaIds = Array.from({ length: SQL_BATCH_SIZE + 1 }, (_, index) => `media-${index}`); @@ -287,6 +302,15 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { ).rejects.toBeInstanceOf(InvalidCursorError); }); + it.each([encodeCursor("", "entry-a"), encodeCursor("posts", "")])( + "rejects structurally empty entry-group cursor components", + async (cursor) => { + await expect( + repo.findCurrentEntryUsagePageByMediaId("media-shared", { cursor }), + ).rejects.toBeInstanceOf(InvalidCursorError); + }, + ); + it("defaults non-finite entry-group limits", async () => { await registerCollection(ctx, "posts"); await repo.replaceSource(contentSource("entry-a", "columns"), [ diff --git a/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts b/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts index 28058491b9..6936572361 100644 --- a/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts +++ b/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts @@ -1,6 +1,7 @@ import { sql } from "kysely"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { handleMediaUsageSummaries } from "../../../src/api/handlers/media-usage.js"; 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"; @@ -9,6 +10,7 @@ import { CONTENT_MEDIA_USAGE_ADAPTER_ID, CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, } from "../../../src/media/usage/content-refresh.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../../src/media/usage/content-snapshots.js"; import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { createTestRuntime } from "../../utils/mcp-runtime.js"; @@ -288,6 +290,87 @@ describeEachDialect("runtime content media usage refresh", (dialect) => { ); }); + it("suppresses draft columns usage after a real runtime overlay refresh failure", async () => { + const mediaId = "media-columns-before-failed-overlay"; + const created = await runtime.handleContentCreate("posts", { + slug: "failed-first-overlay", + status: "draft", + data: { + title: "Failed First Overlay", + hero: mediaRef(mediaId), + }, + }); + 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("posts", contentId, "columns"))).toEqual( + expect.objectContaining({ + contentStatus: "draft", + sourceCompleteness: "complete", + }), + ); + for (const scopeKey of ["posts", "plain_posts", "localized_posts"]) { + await usageRepo.upsertIndexStatus({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey, + status: "complete", + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + lastErrorCode: null, + }); + } + + expect(await handleMediaUsageSummaries(ctx.db, [mediaId], { includeCount: true })).toEqual({ + success: true, + data: { + [mediaId]: { + count: 1, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }, + }); + + await corruptFuturePostDraftRevisionSnapshots(ctx); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const updated = await runtime + .handleContentUpdate("posts", contentId, { + data: { hero: mediaRef("media-overlay-after-failure") }, + }) + .finally(() => consoleError.mockRestore()); + + expect(updated.success).toBe(true); + expect(await usageRepo.findSource(sourceKey("posts", contentId, "draft_overlay"))).toEqual( + expect.objectContaining({ + contentStatus: "draft", + sourceCompleteness: "failed", + lastErrorCode: "DRAFT_REVISION_INVALID", + }), + ); + expect(await usageRepo.findCurrentUsageByMediaId(mediaId)).toHaveLength(1); + expect(await handleMediaUsageSummaries(ctx.db, [mediaId], { includeCount: true })).toEqual({ + success: true, + data: { + [mediaId]: { + count: 0, + coverage: { scope: "all_content_collections", status: "stale" }, + }, + }, + }); + 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", diff --git a/packages/core/tests/unit/api/media-usage-read-route.test.ts b/packages/core/tests/unit/api/media-usage-read-route.test.ts new file mode 100644 index 0000000000..98eaf7bd45 --- /dev/null +++ b/packages/core/tests/unit/api/media-usage-read-route.test.ts @@ -0,0 +1,478 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { handleMediaUsageDetails } from "../../../src/api/handlers/media-usage.js"; +import { + mediaUsageDetailsQuery, + mediaUsageDetailsResponseSchema, +} from "../../../src/api/schemas/index.js"; +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import * as usageRoute from "../../../src/astro/routes/api/media/[id]/usage.js"; +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { MediaRepository, type MediaItem } from "../../../src/database/repositories/media.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { + CONTENT_MEDIA_USAGE_ADAPTER_ID, + CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, +} from "../../../src/media/usage/content-refresh.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../../src/media/usage/content-snapshots.js"; +import { + buildContentMediaUsageSourceKey, + type MediaUsageContentSourceVariant, +} from "../../../src/media/usage/source-key.js"; + +type RouteContext = Parameters[0]; + +interface SuccessBody { + data: T; +} + +interface ErrorBody { + error: { code: string; message: string }; +} + +describe("media usage detail schemas", () => { + it("defaults and validates entry-group pagination", () => { + expect(mediaUsageDetailsQuery.parse({})).toEqual({ limit: 50 }); + expect(mediaUsageDetailsQuery.parse({ limit: "100", cursor: "cursor" })).toEqual({ + limit: 100, + cursor: "cursor", + }); + for (const input of [ + { limit: "0" }, + { limit: "101" }, + { limit: "1.5" }, + { cursor: "" }, + { cursor: "x".repeat(2049) }, + ]) { + expect(mediaUsageDetailsQuery.safeParse(input).success).toBe(false); + } + }); +}); + +describe("media usage details handler and route", () => { + let sqlite: Database.Database; + let db: Kysely; + let queries: string[]; + let usedMedia: MediaItem; + let unreferencedMedia: MediaItem; + + beforeEach(async () => { + queries = []; + sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") queries.push(event.query.sql); + }, + }); + await runMigrations(db); + + const mediaRepository = new MediaRepository(db); + usedMedia = await mediaRepository.create({ + filename: "used.png", + mimeType: "image/png", + storageKey: "used.png", + }); + unreferencedMedia = await mediaRepository.create({ + filename: "unreferenced.png", + mimeType: "image/png", + storageKey: "unreferenced.png", + }); + + await db + .insertInto("_emdash_collections") + .values([ + { id: "collection-pages", slug: "pages", label: "Pages", has_seo: 0 }, + { id: "collection-posts", slug: "posts", label: "Posts", has_seo: 0 }, + ]) + .execute(); + const usageRepository = new MediaUsageRepository(db); + for (const scopeKey of ["pages", "posts"]) { + await usageRepository.upsertIndexStatus({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey, + status: "complete", + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + }); + } + + await usageRepository.replaceSource( + contentSource("entry-page", "columns", { + collectionSlug: "pages", + contentTitle: "Page title", + contentSlug: "page-slug", + contentStatus: "draft", + }), + [occurrence("hero", "hero", "image_field", usedMedia.id)], + ); + await usageRepository.replaceSource( + contentSource("entry-post", "columns", { + contentTitle: "Columns title", + contentSlug: "columns-slug", + contentStatus: "published", + }), + [ + occurrence("hero", "hero", "image_field", usedMedia.id), + occurrence("body", "body.file", "file_field", usedMedia.id, 1), + ], + ); + const overlay = contentSource("entry-post", "draft_overlay", { + contentTitle: null, + contentSlug: "overlay-slug", + locale: "fr", + contentStatus: "scheduled", + contentScheduledAt: "2026-08-01T00:00:00.000Z", + contentDeletedAt: "2026-07-01T00:00:00.000Z", + }); + await usageRepository.replaceSource(overlay, [ + occurrence("content", "content[0]", "portable_text_image", usedMedia.id), + occurrence("future", "future.path", "image_field", usedMedia.id, 2), + ]); + await db + .updateTable("_emdash_media_usage") + .set({ reference_type: "future_reference_type" }) + .where("source_key", "=", overlay.sourceKey) + .where("field_slug", "=", "future") + .execute(); + queries = []; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await db.destroy(); + }); + + it("registers a GET-only local-media usage route", () => { + const routes: Array<{ pattern: string; entrypoint: string }> = []; + injectCoreRoutes((route) => routes.push(route)); + const matches = routes.filter((route) => route.pattern === "/_emdash/api/media/[id]/usage"); + + expect(matches).toHaveLength(1); + expect(matches[0]?.entrypoint).toContain("api/media/_id_/usage"); + expect(usageRoute.GET).toBeTypeOf("function"); + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + expect(usageRoute).not.toHaveProperty(method); + } + }); + + it("maps preferred metadata, conservative deletion, and narrow nested DTOs", async () => { + const result = await handleMediaUsageDetails(db, usedMedia.id, { limit: 10 }); + + expect(result).toEqual({ + success: true, + data: { + items: [ + { + collection: "pages", + contentId: "entry-page", + title: "Page title", + slug: "page-slug", + locale: "en", + status: "draft", + scheduledAt: null, + deletedAt: null, + sources: [ + { + variant: "columns", + occurrences: [ + { + fieldSlug: "hero", + fieldPath: "hero", + occurrenceIndex: 0, + referenceType: "image_field", + }, + ], + }, + ], + }, + { + collection: "posts", + contentId: "entry-post", + title: null, + slug: "overlay-slug", + locale: "fr", + status: "scheduled", + scheduledAt: "2026-08-01T00:00:00.000Z", + deletedAt: "2026-07-01T00:00:00.000Z", + sources: [ + { + variant: "columns", + occurrences: [ + { + fieldSlug: "body", + fieldPath: "body.file", + occurrenceIndex: 1, + referenceType: "file_field", + }, + { + fieldSlug: "hero", + fieldPath: "hero", + occurrenceIndex: 0, + referenceType: "image_field", + }, + ], + }, + { + variant: "draft_overlay", + occurrences: [ + { + fieldSlug: "content", + fieldPath: "content[0]", + occurrenceIndex: 0, + referenceType: "portable_text_image", + }, + { + fieldSlug: "future", + fieldPath: "future.path", + occurrenceIndex: 2, + referenceType: "unknown", + }, + ], + }, + ], + }, + ], + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }); + if (!result.success) throw new Error("Expected media usage details"); + expect(mediaUsageDetailsResponseSchema.parse(result.data)).toEqual(result.data); + expect(queries).toHaveLength(3); + const serialized = JSON.stringify(result); + for (const forbidden of [ + "sourceKey", + "generation", + "revisionId", + "translationGroup", + "providerAssetId", + "sourceCompleteness", + "lastErrorCode", + ]) { + expect(serialized).not.toContain(`"${forbidden}"`); + } + expect(serialized).not.toContain("count"); + }); + + it("returns empty details plus coverage for an existing unreferenced media item", async () => { + const result = await handleMediaUsageDetails(db, unreferencedMedia.id, {}); + + expect(result).toEqual({ + success: true, + data: { + items: [], + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }); + expect(queries).toHaveLength(3); + }); + + it("returns NOT_FOUND before coverage or grouped usage reads", async () => { + const result = await handleMediaUsageDetails(db, "missing-media", {}); + + expect(result).toEqual({ + success: false, + error: { code: "NOT_FOUND", message: "Media item not found: missing-media" }, + }); + expect(queries).toHaveLength(1); + expect(queries[0]).not.toContain("_emdash_media_usage"); + }); + + it("maps malformed cursors to INVALID_CURSOR", async () => { + const result = await handleMediaUsageDetails(db, usedMedia.id, { cursor: "not-a-cursor" }); + + expect(result).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: "INVALID_CURSOR" }), + }), + ); + expect(queries).toHaveLength(2); + }); + + it("returns generic read errors without leaking database failures", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + await db.schema.dropTable("_emdash_media_usage").execute(); + queries = []; + + const result = await handleMediaUsageDetails(db, usedMedia.id, {}); + + expect(result).toEqual({ + success: false, + error: { code: "MEDIA_USAGE_READ_ERROR", message: "Failed to read media usage" }, + }); + expect(JSON.stringify(result)).not.toContain("no such table"); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it("pages entry groups through the route within three queries per page", async () => { + const firstResponse = await invokeRoute({ + id: usedMedia.id, + query: "?limit=1", + role: Role.CONTRIBUTOR, + }); + const first = await readSuccess<{ + items: Array<{ collection: string; contentId: string }>; + nextCursor?: string; + }>(firstResponse); + + expect(first.items).toEqual([ + expect.objectContaining({ collection: "pages", contentId: "entry-page" }), + ]); + expect(first.nextCursor).toBeTypeOf("string"); + expect(queries).toHaveLength(3); + queries = []; + + const secondResponse = await invokeRoute({ + id: usedMedia.id, + query: `?limit=1&cursor=${encodeURIComponent(first.nextCursor!)}`, + role: Role.CONTRIBUTOR, + }); + const second = await readSuccess<{ + items: Array<{ collection: string; contentId: string }>; + nextCursor?: string; + }>(secondResponse); + + expect(second.items).toEqual([ + expect.objectContaining({ collection: "posts", contentId: "entry-post" }), + ]); + expect(second.nextCursor).toBeUndefined(); + expect(queries).toHaveLength(3); + }); + + it.each([ + ["missing ID", undefined, "", Role.CONTRIBUTOR, undefined, 400, "INVALID_REQUEST"], + ["empty cursor", "media", "?cursor=", Role.CONTRIBUTOR, undefined, 400, "VALIDATION_ERROR"], + ] as const)( + "returns stable validation errors for %s", + async (_name, id, query, role, tokenScopes, status, code) => { + const response = await invokeRoute({ id, query, role, tokenScopes }); + + expect(response.status).toBe(status); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code }) }), + ); + expect(queries).toHaveLength(0); + }, + ); + + it("returns INVALID_CURSOR for an authorized malformed cursor", async () => { + const response = await invokeRoute({ + id: usedMedia.id, + query: "?cursor=not-a-cursor", + role: Role.CONTRIBUTOR, + }); + + expect(response.status).toBe(400); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "INVALID_CURSOR" }) }), + ); + }); + + it.each([ + ["anonymous caller", null, undefined, 401, "UNAUTHORIZED"], + ["subscriber session", Role.SUBSCRIBER, undefined, 403, "FORBIDDEN"], + ["media-read token", Role.CONTRIBUTOR, ["media:read"], 403, "INSUFFICIENT_SCOPE"], + ] as const)( + "denies a %s before input validation or database reads", + async (_name, role, tokenScopes, status, code) => { + const response = await invokeRoute({ + id: "missing-media", + query: "?cursor=not-a-cursor", + role, + tokenScopes, + }); + + expect(response.status).toBe(status); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code }) }), + ); + expect(queries).toHaveLength(0); + }, + ); + + it("returns NOT_CONFIGURED after authorization", async () => { + const request = new Request(`http://localhost/_emdash/api/media/${usedMedia.id}/usage`); + const response = await usageRoute.GET({ + params: { id: usedMedia.id }, + request, + locals: { emdash: {}, user: { id: "user-1", role: Role.CONTRIBUTOR } }, + } as RouteContext); + + expect(response.status).toBe(500); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "NOT_CONFIGURED" }) }), + ); + }); + + async function invokeRoute(input: { + id?: string; + query: string; + role: RoleLevel | null; + tokenScopes?: readonly string[]; + }): Promise { + const request = new Request( + `http://localhost/_emdash/api/media/${input.id ?? "missing"}/usage${input.query}`, + ); + return usageRoute.GET({ + params: { id: input.id }, + request, + locals: { + emdash: { db }, + user: input.role === null ? null : { id: "user-1", role: input.role }, + tokenScopes: input.tokenScopes ? [...input.tokenScopes] : undefined, + }, + } as RouteContext) as Promise; + } +}); + +async function readSuccess(response: Response): Promise { + return ((await response.json()) as SuccessBody).data; +} + +function contentSource( + contentId: string, + variant: MediaUsageContentSourceVariant, + overrides: Partial[0]> = {}, +): Parameters[0] { + const collectionSlug = overrides.collectionSlug ?? "posts"; + return { + sourceKey: buildContentMediaUsageSourceKey({ + collectionSlug, + contentId, + sourceVariant: variant, + }), + sourceType: "content", + collectionSlug, + contentId, + sourceVariant: variant, + locale: "en", + translationGroup: `tg-${contentId}`, + contentSlug: `slug-${contentId}`, + contentTitle: `Title ${contentId}`, + contentStatus: variant === "columns" ? "published" : "draft", + ...overrides, + }; +} + +function occurrence( + fieldSlug: string, + fieldPath: string, + referenceType: "image_field" | "file_field" | "portable_text_image", + mediaId: string, + occurrenceIndex = 0, +): Parameters[1][number] { + return { + fieldSlug, + fieldPath, + occurrenceIndex, + referenceType, + mediaId, + provider: "local", + providerAssetId: mediaId, + }; +} diff --git a/packages/core/tests/unit/api/media-usage-summary.test.ts b/packages/core/tests/unit/api/media-usage-summary.test.ts new file mode 100644 index 0000000000..6d5f2d65b1 --- /dev/null +++ b/packages/core/tests/unit/api/media-usage-summary.test.ts @@ -0,0 +1,509 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + aggregateMediaUsageCoverageStatus, + handleMediaUsageSummaries, +} from "../../../src/api/handlers/media-usage.js"; +import { handleMediaGet, handleMediaList } from "../../../src/api/handlers/media.js"; +import { + mediaGetQuery, + mediaListQuery, + mediaReadResponseSchema, + mediaResponseSchema, + mediaUsageSummarySchema, +} from "../../../src/api/schemas/index.js"; +import { GET as listMedia } from "../../../src/astro/routes/api/media.js"; +import { GET as getMediaItem } from "../../../src/astro/routes/api/media/[id].js"; +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { MediaRepository, type MediaItem } from "../../../src/database/repositories/media.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { + CONTENT_MEDIA_USAGE_ADAPTER_ID, + CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, +} from "../../../src/media/usage/content-refresh.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../../src/media/usage/content-snapshots.js"; +import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js"; + +type ListRouteContext = Parameters[0]; +type GetRouteContext = Parameters[0]; + +interface SuccessBody { + data: T; +} + +interface ErrorBody { + error: { code: string; message: string }; +} + +interface MediaListBodyItem extends MediaItem { + url: string; + usage?: { + count: number | null; + coverage: { scope: "all_content_collections"; status: string }; + }; +} + +interface MediaGetBodyItem extends MediaItem { + usage?: MediaListBodyItem["usage"]; +} + +describe("media usage summary schemas", () => { + it.each([mediaListQuery, mediaGetQuery])( + "accepts only the exact includeUsage literal", + (schema) => { + expect(schema.parse({ includeUsage: "1" }).includeUsage).toBe("1"); + for (const includeUsage of ["0", "true", "false"]) { + expect(schema.safeParse({ includeUsage }).success).toBe(false); + } + }, + ); + + it("accepts every public coverage state and nullable counts", () => { + for (const status of [ + "complete", + "never", + "running", + "partial", + "failed", + "stale", + "unknown", + ] as const) { + expect( + mediaUsageSummarySchema.parse({ + count: null, + coverage: { scope: "all_content_collections", status }, + }), + ).toEqual({ count: null, coverage: { scope: "all_content_collections", status } }); + } + }); + + it("keeps usage off mutation response schemas", () => { + const item = mediaItemFixture(); + const usage = { + count: 0, + coverage: { scope: "all_content_collections" as const, status: "complete" as const }, + }; + + expect(mediaReadResponseSchema.parse({ item: { ...item, usage } }).item.usage).toEqual(usage); + expect(mediaResponseSchema.parse({ item: { ...item, usage } }).item).not.toHaveProperty( + "usage", + ); + }); +}); + +describe("media usage coverage aggregation", () => { + const scope = (status: string | null, schemaVersion = CONTENT_SOURCE_SCHEMA_VERSION) => ({ + collectionSlug: "posts", + status, + schemaVersion: status === null ? null : schemaVersion, + }); + + it.each([ + ["no collections", [], "complete"], + ["all complete", [scope("complete")], "complete"], + ["all missing", [scope(null), { ...scope(null), collectionSlug: "pages" }], "never"], + ["complete and missing", [scope("complete"), scope(null)], "partial"], + ["old complete", [scope("complete", CONTENT_SOURCE_SCHEMA_VERSION - 1)], "stale"], + ["unknown stored status", [scope("surprise")], "unknown"], + ["unknown before running", [scope("running"), scope("surprise")], "unknown"], + ["homogeneous running", [scope("running"), scope("running")], "running"], + ["running before stale", [scope("stale"), scope("running")], "running"], + ["homogeneous stale", [scope("stale"), scope("stale")], "stale"], + ["stale before partial", [scope("partial"), scope("stale")], "stale"], + ["homogeneous partial", [scope("partial"), scope("partial")], "partial"], + ["homogeneous failed", [scope("failed"), scope("failed")], "failed"], + ["complete and failed", [scope("complete"), scope("failed")], "partial"], + ["mixed failed and never", [scope("failed"), scope(null)], "partial"], + ["running and failed", [scope("running"), scope("failed")], "running"], + ] as const)("returns %s coverage", (_name, scopes, expected) => { + expect(aggregateMediaUsageCoverageStatus(scopes)).toBe(expected); + }); +}); + +describe("media usage summary handler and routes", () => { + let sqlite: Database.Database; + let db: Kysely; + let queries: string[]; + let usedMedia: MediaItem; + let unusedMedia: MediaItem; + + beforeEach(async () => { + queries = []; + sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") queries.push(event.query.sql); + }, + }); + await runMigrations(db); + + const mediaRepository = new MediaRepository(db); + usedMedia = await mediaRepository.create({ + filename: "used.png", + mimeType: "image/png", + storageKey: "used.png", + }); + unusedMedia = await mediaRepository.create({ + filename: "unused.png", + mimeType: "image/png", + storageKey: "unused.png", + }); + + await db + .insertInto("_emdash_collections") + .values({ id: "collection-posts", slug: "posts", label: "Posts", has_seo: 0 }) + .execute(); + const usageRepository = new MediaUsageRepository(db); + await usageRepository.upsertIndexStatus({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey: "posts", + status: "complete", + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + }); + await usageRepository.replaceSource( + { + sourceKey: buildContentMediaUsageSourceKey({ + collectionSlug: "posts", + contentId: "entry-1", + sourceVariant: "columns", + }), + sourceType: "content", + collectionSlug: "posts", + contentId: "entry-1", + sourceVariant: "columns", + contentStatus: "published", + }, + [ + { + fieldSlug: "hero", + fieldPath: "hero", + referenceType: "image_field", + mediaId: usedMedia.id, + provider: "local", + providerAssetId: usedMedia.id, + }, + ], + ); + queries = []; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await db.destroy(); + }); + + it.each([false, true])("skips usage queries for an empty media page", async (includeCount) => { + const result = await handleMediaUsageSummaries(db, [], { includeCount }); + + expect(result).toEqual({ success: true, data: {} }); + expect(queries).toHaveLength(0); + }); + + it("loads coverage once and skips count SQL when counts are redacted", async () => { + const result = await handleMediaUsageSummaries(db, [usedMedia.id, unusedMedia.id], { + includeCount: false, + }); + + expect(result).toEqual({ + success: true, + data: { + [usedMedia.id]: { + count: null, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + [unusedMedia.id]: { + count: null, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }, + }); + expect(queries).toHaveLength(1); + expect(queries[0]).toContain("_emdash_media_usage_index_status"); + }); + + it("loads one coverage query and one batched count query", async () => { + const result = await handleMediaUsageSummaries(db, [usedMedia.id, unusedMedia.id], { + includeCount: true, + }); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: { + [usedMedia.id]: expect.objectContaining({ count: 1 }), + [unusedMedia.id]: expect.objectContaining({ count: 0 }), + }, + }), + ); + expect(queries).toHaveLength(2); + expect(queries.filter((query) => query.includes("visible_entries"))).toHaveLength(1); + }); + + it("chunks more than 50 media IDs without becoming N+1", async () => { + const mediaIds = [ + usedMedia.id, + ...Array.from({ length: 50 }, (_, index) => `unmatched-media-${index}`), + ]; + + const result = await handleMediaUsageSummaries(db, mediaIds, { includeCount: true }); + + expect(result).toEqual(expect.objectContaining({ success: true })); + expect(queries).toHaveLength(3); + expect(queries.filter((query) => query.includes("visible_entries"))).toHaveLength(2); + }); + + it("preserves the list response and query cost without includeUsage", async () => { + const response = await invokeList("", Role.CONTRIBUTOR); + const data = await readSuccess<{ + items: MediaListBodyItem[]; + nextCursor?: string; + }>(response); + + expect(response.status).toBe(200); + expect(data.items).toHaveLength(2); + expect(data.items.every((item) => !("usage" in item))).toBe(true); + expect(data.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: usedMedia.id, + url: `/_emdash/api/media/file/${usedMedia.storageKey}`, + }), + ]), + ); + expect(queries).toHaveLength(1); + }); + + it("attaches numeric usage counts to every list item for an authorized session", async () => { + const response = await invokeList("?includeUsage=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ items: MediaListBodyItem[] }>(response); + const itemsById = new Map(data.items.map((item) => [item.id, item])); + + expect(itemsById.get(usedMedia.id)?.usage).toEqual({ + count: 1, + coverage: { scope: "all_content_collections", status: "complete" }, + }); + expect(itemsById.get(unusedMedia.id)?.usage?.count).toBe(0); + expect(queries).toHaveLength(3); + }); + + it.each([ + ["subscriber session", Role.SUBSCRIBER, undefined], + ["media token owned by an admin", Role.ADMIN, ["media:read"]], + ["admin token owned by a subscriber", Role.SUBSCRIBER, ["admin"]], + ] as const)("redacts counts and skips count SQL for a %s", async (_name, role, tokenScopes) => { + const response = await invokeList("?includeUsage=1", role, tokenScopes); + const data = await readSuccess<{ items: MediaListBodyItem[] }>(response); + + expect(data.items.every((item) => item.usage?.count === null)).toBe(true); + expect(queries).toHaveLength(2); + expect(queries.some((query) => query.includes("visible_entries"))).toBe(false); + }); + + it("allows numeric counts for an admin token owned by a contributor", async () => { + const response = await invokeList("?includeUsage=1", Role.CONTRIBUTOR, ["admin"]); + const data = await readSuccess<{ items: MediaListBodyItem[] }>(response); + + expect(data.items.find((item) => item.id === usedMedia.id)?.usage?.count).toBe(1); + expect(queries).toHaveLength(3); + }); + + it("preserves list URLs and cursors when usage is requested", async () => { + const baselineResponse = await invokeList("?limit=1", Role.CONTRIBUTOR); + const baseline = await readSuccess<{ items: MediaListBodyItem[]; nextCursor?: string }>( + baselineResponse, + ); + queries = []; + + const response = await invokeList("?limit=1&includeUsage=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ items: MediaListBodyItem[]; nextCursor?: string }>(response); + + expect(data.nextCursor).toBeTypeOf("string"); + expect(data.nextCursor).toBe(baseline.nextCursor); + expect(data.items[0]?.url).toBe(`/_emdash/api/media/file/${baseline.items[0]?.storageKey}`); + expect(data.items[0]?.usage).toBeDefined(); + expect(queries).toHaveLength(3); + }); + + it("maps an invalid list includeUsage value to a validation error", async () => { + const response = await invokeList("?includeUsage=0", Role.CONTRIBUTOR); + + expect(response.status).toBe(400); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "VALIDATION_ERROR" }) }), + ); + expect(queries).toHaveLength(0); + }); + + it("adds a summary to media get within the three-query budget", async () => { + const response = await invokeGet(usedMedia.id, "?includeUsage=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(data.item.usage).toEqual({ + count: 1, + coverage: { scope: "all_content_collections", status: "complete" }, + }); + expect(queries).toHaveLength(3); + }); + + it("preserves media get and its query cost without includeUsage", async () => { + const response = await invokeGet(usedMedia.id, "", Role.CONTRIBUTOR); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(response.status).toBe(200); + expect(data.item).not.toHaveProperty("usage"); + expect(queries).toHaveLength(1); + }); + + it("redacts media get counts without executing count SQL", async () => { + const response = await invokeGet(usedMedia.id, "?includeUsage=1", Role.SUBSCRIBER); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(data.item.usage).toEqual({ + count: null, + coverage: { scope: "all_content_collections", status: "complete" }, + }); + expect(queries).toHaveLength(2); + expect(queries.some((query) => query.includes("visible_entries"))).toBe(false); + }); + + it("redacts media get counts for a media-read token", async () => { + const redactedResponse = await invokeGet(usedMedia.id, "?includeUsage=1", Role.ADMIN, [ + "media:read", + ]); + const redacted = await readSuccess<{ item: MediaGetBodyItem }>(redactedResponse); + + expect(redacted.item.usage?.count).toBeNull(); + expect(queries).toHaveLength(2); + }); + + it("uses the last duplicate includeUsage value and ignores unknown get query keys", async () => { + const response = await invokeGet( + usedMedia.id, + "?includeUsage=0&unknown=value&includeUsage=1", + Role.CONTRIBUTOR, + ); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(response.status).toBe(200); + expect(data.item.usage?.count).toBe(1); + expect(queries).toHaveLength(3); + }); + + it("validates recognized media get query values", async () => { + const response = await invokeGet(usedMedia.id, "?includeUsage=false", Role.CONTRIBUTOR); + + expect(response.status).toBe(400); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "VALIDATION_ERROR" }) }), + ); + expect(queries).toHaveLength(0); + }); + + it("returns 404 for missing media without running usage queries", async () => { + const response = await invokeGet("missing", "?includeUsage=1", Role.CONTRIBUTOR); + + expect(response.status).toBe(404); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "NOT_FOUND" }) }), + ); + expect(queries).toHaveLength(1); + expect(queries[0]).not.toContain("_emdash_media_usage"); + }); + + it("returns MEDIA_USAGE_READ_ERROR when requested summary loading fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + await db.schema.dropTable("_emdash_media_usage_index_status").execute(); + queries = []; + + const response = await invokeList("?includeUsage=1", Role.CONTRIBUTOR); + + expect(response.status).toBe(500); + expect((await response.json()) as ErrorBody).toEqual({ + error: { code: "MEDIA_USAGE_READ_ERROR", message: "Failed to read media usage" }, + }); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it("returns MEDIA_USAGE_READ_ERROR when requested media get summary loading fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + await db.schema.dropTable("_emdash_media_usage_index_status").execute(); + queries = []; + + const response = await invokeGet(usedMedia.id, "?includeUsage=1", Role.CONTRIBUTOR); + + expect(response.status).toBe(500); + expect((await response.json()) as ErrorBody).toEqual({ + error: { code: "MEDIA_USAGE_READ_ERROR", message: "Failed to read media usage" }, + }); + // Kysely's query logger records the successful media lookup but not the failed statement. + expect(queries).toHaveLength(1); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + function invokeList( + query: string, + role: RoleLevel, + tokenScopes?: readonly string[], + ): Promise { + return listMedia({ + request: new Request(`http://localhost/_emdash/api/media${query}`), + locals: routeLocals(role, tokenScopes), + } as ListRouteContext) as Promise; + } + + function invokeGet( + id: string, + query: string, + role: RoleLevel, + tokenScopes?: readonly string[], + ): Promise { + return getMediaItem({ + params: { id }, + request: new Request(`http://localhost/_emdash/api/media/${id}${query}`), + locals: routeLocals(role, tokenScopes), + } as GetRouteContext) as Promise; + } + + function routeLocals(role: RoleLevel, tokenScopes?: readonly string[]) { + return { + emdash: { + db, + handleMediaList: (params: Parameters[1]) => + handleMediaList(db, params), + handleMediaGet: (id: string) => handleMediaGet(db, id), + }, + user: { id: "user-1", role }, + tokenScopes: tokenScopes ? [...tokenScopes] : undefined, + }; + } +}); + +async function readSuccess(response: Response): Promise { + return ((await response.json()) as SuccessBody).data; +} + +function mediaItemFixture(): MediaItem { + return { + id: "media-1", + filename: "hero.png", + mimeType: "image/png", + size: null, + width: null, + height: null, + alt: null, + caption: null, + storageKey: "hero.png", + status: "ready", + contentHash: null, + blurhash: null, + dominantColor: null, + createdAt: "2026-07-13T00:00:00.000Z", + authorId: null, + }; +} diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index 093ce8e20e..61d4b3ce16 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -32,11 +32,64 @@ describe("OpenAPI document generation", () => { expect(paths).toContain("/_emdash/api/media"); expect(paths).toContain("/_emdash/api/media/{id}"); + expect(paths).toContain("/_emdash/api/media/{id}/usage"); expect(paths).toContain("/_emdash/api/media/upload-url"); expect(paths).toContain("/_emdash/api/media/{id}/confirm"); expect(paths).toContain("/_emdash/api/admin/media-usage/repair"); }); + it("documents media usage summary opt-in parameters and read responses", () => { + const doc = generateOpenApiDocument(); + const list = doc.paths?.["/_emdash/api/media"]?.get as { + parameters?: Array<{ name?: string; in?: string }>; + responses?: Record; + }; + const get = doc.paths?.["/_emdash/api/media/{id}"]?.get as { + parameters?: Array<{ name?: string; in?: string }>; + responses?: Record; + }; + + expect(list.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "includeUsage", in: "query" })]), + ); + expect(get.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "includeUsage", in: "query" })]), + ); + expect(JSON.stringify(list.responses?.["200"])).toContain("MediaListReadResponse"); + expect(JSON.stringify(get.responses?.["200"])).toContain("MediaReadResponse"); + }); + + it("documents the media usage details operation", () => { + const doc = generateOpenApiDocument(); + const get = doc.paths?.["/_emdash/api/media/{id}/usage"]?.get as { + operationId?: string; + tags?: string[]; + parameters?: Array<{ name?: string; in?: string }>; + responses?: Record; + }; + + expect(get.operationId).toBe("getMediaUsage"); + expect(get.tags).toEqual(["Media"]); + expect(get.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "id", in: "path" }), + expect.objectContaining({ name: "limit", in: "query" }), + expect.objectContaining({ name: "cursor", in: "query" }), + ]), + ); + expect(get.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "400": expect.any(Object), + "401": expect.any(Object), + "403": expect.any(Object), + "404": expect.any(Object), + "500": expect.any(Object), + }), + ); + expect(JSON.stringify(get.responses?.["200"])).toContain("MediaUsageDetailsResponse"); + }); + it("documents the media usage repair operation", () => { const doc = generateOpenApiDocument(); const path = doc.paths?.["/_emdash/api/admin/media-usage/repair"]; @@ -224,6 +277,7 @@ describe("OpenAPI document generation", () => { // Media operations expect(operationIds).toContain("listMedia"); expect(operationIds).toContain("getMedia"); + expect(operationIds).toContain("getMediaUsage"); expect(operationIds).toContain("deleteMedia"); expect(operationIds).toContain("getMediaUploadUrl"); expect(operationIds).toContain("repairMediaUsage"); @@ -295,6 +349,14 @@ describe("OpenAPI document generation", () => { // Media schemas expect(schemas).toHaveProperty("MediaItem"); expect(schemas).toHaveProperty("MediaListResponse"); + expect(schemas).toHaveProperty("MediaReadResponse"); + expect(schemas).toHaveProperty("MediaListReadResponse"); + expect(schemas).toHaveProperty("MediaUsageCoverage"); + expect(schemas).toHaveProperty("MediaUsageSummary"); + expect(schemas).toHaveProperty("MediaUsageOccurrenceDetail"); + expect(schemas).toHaveProperty("MediaUsageSourceDetail"); + expect(schemas).toHaveProperty("MediaUsageEntryDetail"); + expect(schemas).toHaveProperty("MediaUsageDetailsResponse"); expect(schemas).toHaveProperty("MediaUsageRepairBody"); expect(schemas).toHaveProperty("MediaUsageRepairResponse"); diff --git a/packages/core/tests/unit/astro/media-usage-read-auth.test.ts b/packages/core/tests/unit/astro/media-usage-read-auth.test.ts new file mode 100644 index 0000000000..9f4407df5a --- /dev/null +++ b/packages/core/tests/unit/astro/media-usage-read-auth.test.ts @@ -0,0 +1,166 @@ +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); + +vi.mock( + "virtual:emdash/auth", + () => ({ + authenticate: vi.fn(), + }), + { virtual: true }, +); + +vi.mock( + "virtual:emdash/config", + () => ({ + default: {}, + }), + { virtual: true }, +); + +import { handleApiTokenCreate } from "../../../src/api/handlers/api-tokens.js"; +import { onRequest as authMiddleware } from "../../../src/astro/middleware/auth.js"; +import { GET } from "../../../src/astro/routes/api/media/[id]/usage.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +type AuthContext = Parameters[0]; + +interface ApiErrorBody { + error: { + code: string; + message: string; + }; +} + +describe("media usage detail auth middleware", () => { + let db: Kysely | undefined; + let mediaId: string; + + beforeEach(async () => { + db = await setupTestDatabase(); + await db + .insertInto("users") + .values([ + { + id: "contributor-1", + email: "contributor@example.com", + name: "Contributor", + role: Role.CONTRIBUTOR, + email_verified: 1, + }, + { + id: "subscriber-1", + email: "subscriber@example.com", + name: "Subscriber", + role: Role.SUBSCRIBER, + email_verified: 1, + }, + ]) + .execute(); + mediaId = ( + await new MediaRepository(db).create({ + filename: "usage.png", + mimeType: "image/png", + storageKey: "usage.png", + }) + ).id; + }); + + afterEach(async () => { + if (db) await teardownTestDatabase(db); + db = undefined; + }); + + it("allows an admin-scoped token for a contributor to read usage details", async () => { + const response = await invokeThroughAuth(await createToken("contributor-1", ["admin"])); + + expect(response.status).toBe(200); + expect((await response.json()) as { data: { items: unknown[] } }).toEqual( + expect.objectContaining({ data: expect.objectContaining({ items: [] }) }), + ); + }); + + it("lets a media-read token reach the route before rejecting its missing admin scope", async () => { + const context = usageContext(await createToken("contributor-1", ["media:read"])); + const next = vi.fn(() => GET(context as never)); + + const response = await authMiddleware(context, next); + + expect(next).toHaveBeenCalledOnce(); + expect(context.locals.tokenScopes).toEqual(["media:read"]); + await expectError(response, 403, "INSUFFICIENT_SCOPE"); + }); + + it("rejects a token without media-read scope before the route runs", async () => { + const context = usageContext(await createToken("contributor-1", ["content:read"])); + const next = vi.fn(async () => new Response("should not run")); + + const response = await authMiddleware(context, next); + + expect(next).not.toHaveBeenCalled(); + await expectError(response, 403, "INSUFFICIENT_SCOPE"); + }); + + it("rejects an admin-scoped token whose user lacks read permissions", async () => { + const response = await invokeThroughAuth(await createToken("subscriber-1", ["admin"])); + + await expectError(response, 403, "FORBIDDEN"); + }); + + it("rejects an unauthenticated request before the route runs", async () => { + const context = usageContext(); + const next = vi.fn(async () => new Response("should not run")); + + const response = await authMiddleware(context, next); + + expect(next).not.toHaveBeenCalled(); + await expectError(response, 401, "NOT_AUTHENTICATED"); + }); + + async function createToken(userId: string, scopes: string[]): Promise { + const result = await handleApiTokenCreate(db!, userId, { + name: `${userId} token`, + scopes, + }); + if (!result.success) { + throw new Error(`Failed to create token: ${result.error.message}`); + } + return result.data.token; + } + + async function invokeThroughAuth(token: string): Promise { + const context = usageContext(token); + return authMiddleware(context, () => GET(context as never)); + } + + function usageContext(token?: string): AuthContext { + const request = new Request(`http://localhost/_emdash/api/media/${mediaId}/usage`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + + return { + params: { id: mediaId }, + request, + url: new URL(request.url), + locals: { emdash: { db: db! } }, + redirect: vi.fn(), + session: { + get: vi.fn(), + set: vi.fn(), + destroy: vi.fn(), + }, + } as unknown as AuthContext; + } +}); + +async function expectError(response: Response, status: number, code: string): Promise { + expect(response.status).toBe(status); + const body = (await response.json()) as ApiErrorBody; + expect(body.error.code).toBe(code); +} diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index d515c72534..eb80abe4cc 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -681,6 +681,210 @@ describe("EmDashClient", () => { }); }); + describe("media usage reads", () => { + it("opts media list into usage summaries with an exact includeUsage value", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ + items: [ + { + id: "media-1", + usage: { + count: null, + coverage: { scope: "all_content_collections", status: "partial" }, + }, + }, + ], + nextCursor: "next-media", + }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const result = await client.mediaList({ + mimeType: "image/png", + limit: 25, + cursor: "after / one", + includeUsage: true, + }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media"); + expect(Object.fromEntries(capturedUrl?.searchParams ?? [])).toEqual({ + mimeType: "image/png", + limit: "25", + cursor: "after / one", + includeUsage: "1", + }); + expect(result).toEqual({ + items: [ + { + id: "media-1", + usage: { + count: null, + coverage: { scope: "all_content_collections", status: "partial" }, + }, + }, + ], + nextCursor: "next-media", + }); + }); + + it("omits includeUsage from media list when it is false", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ items: [] }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await client.mediaList({ includeUsage: false }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media"); + expect(capturedUrl?.search).toBe(""); + }); + + it("opts media get into a usage summary without changing item unwrapping", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ + item: { + id: "media/one", + usage: { + count: 3, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }, + }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const item = await client.mediaGet("media/one", { includeUsage: true }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media/media%2Fone"); + expect(capturedUrl?.search).toBe("?includeUsage=1"); + expect(item).toEqual({ + id: "media/one", + usage: { + count: 3, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }); + }); + + it("omits includeUsage from media get when it is false", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ item: { id: "media-1" } }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await client.mediaGet("media-1", { includeUsage: false }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media/media-1"); + expect(capturedUrl?.search).toBe(""); + }); + + it("serializes media usage detail pagination and unwraps grouped details", async () => { + let capturedUrl: URL | undefined; + const response = { + items: [ + { + collection: "posts", + contentId: "post-1", + title: "Launch notes", + slug: "launch-notes", + locale: "en", + status: "published", + scheduledAt: null, + deletedAt: null, + sources: [ + { + variant: "columns", + occurrences: [ + { + fieldSlug: "hero", + fieldPath: "hero", + occurrenceIndex: 0, + referenceType: "image_field", + }, + ], + }, + ], + }, + ], + nextCursor: "next / group", + coverage: { scope: "all_content_collections", status: "complete" }, + }; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse(response); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const result = await client.mediaGetUsage("media/one", { + limit: 25, + cursor: "after / group", + }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media/media%2Fone/usage"); + expect(Object.fromEntries(capturedUrl?.searchParams ?? [])).toEqual({ + limit: "25", + cursor: "after / group", + }); + expect(result).toEqual(response); + }); + + it.each([ + [403, "INSUFFICIENT_SCOPE", "Admin scope required"], + [404, "NOT_FOUND", "Media not found"], + ] as const)( + "throws EmDashApiError for media usage detail HTTP %i responses", + async (status, code, message) => { + const backend = createMockBackend([ + { + method: "GET", + path: "/media/media-1/usage", + handler: () => jsonResponse({ error: { code, message } }, status), + }, + ]); + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await expect(client.mediaGetUsage("media-1")).rejects.toMatchObject({ + name: "EmDashApiError", + status, + code, + message, + }); + }, + ); + }); + describe("mediaRepairUsage()", () => { it("sends collection repair requests with the caller-provided body", async () => { let capturedPath = "";