diff --git a/README.md b/README.md index d228285..541480c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ npm test - `queryV1.query` / `queryV1.queries` - reads through query.v1: the same query bodies as `assets.query`, rows translated the same way, but the endpoint is read-only and answers with rows alone, so it is the safe place to explore a query - `resolve.numbers` / `resolve.ref` - asset numbers and OID tokens to assets, in one request - `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped` + - `attachments.list` / `info` / `get` / `add` / `put` / `remove` / `url` - files attached to an asset: metadata on the Attachment asset, bytes through attachment.img; `add` sends a small file inline (Base64, one request) and a large one in two steps (create, then upload), and a failed upload throws `AttachmentUploadError` naming the empty attachment it left + - `embeddedImages.upload` / `url` / `normalize` - images in rich text: upload returns the `` URL; `normalize` rewrites `embedded.img` references to this instance's URL. They cannot be deleted - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. Undo (`batch.reversible`) reactivates a closed asset before deleting it, and a second command on one asset is sent as a second guarded pass. `confirm` sees the touched assets' names; aliased updates/executes are sent as written with `guard: false`; a failure after the send throws `BatchRecordError` carrying the receipt - `TypeCache` - one instance's loaded type definitions, shared by many clients (`new AgilityApiClient(config, { types })`): a server that builds a client per user fetches each type once - `createFetchTransport` - configurable HTTP transport: `credentials` for same-origin cookie auth, `onUnauthorized` to re-authenticate on a 401 (resends idempotent requests once), and a per-request `signal`; `client.withSignal(signal)` ties a whole client to one diff --git a/plans/001-sdk-adoption-campaign.md b/plans/001-sdk-adoption-campaign.md index bd575db..8bdf4de 100644 --- a/plans/001-sdk-adoption-campaign.md +++ b/plans/001-sdk-adoption-campaign.md @@ -251,6 +251,16 @@ moves. Integration tests on v1sdktesting with `sdk-integration` fixtures, deleted by OID. + Verified live (2026-11-07): an Attachment create **requires** `Content` + (empty is allowed; absent is `Violation'Required'…'Content'`); Base64 + `Content` in the create stores the bytes in one request; `attachment.img` + GET answers the bytes with their type and `Content-Disposition`, 404 when + missing, and POST replaces them. `EmbeddedImage.mvc/Upload` accepts token + auth and answers ``. + **An EmbeddedImage cannot be deleted** (`Unknown Operation + 'EmbeddedImage.Delete'`), so its live test is opt-in + (`SDK_TEST_EMBEDDED_IMAGES=1`) and leaves one image per run. + What rooms does today (survey 2026-11-07), which A7 must cover: - **Upload** is one api/asset create with the bytes inline: `{ AssetType: "Attachment", Asset, ContentType, Filename, Name, Content: }` @@ -462,7 +472,7 @@ ESM" here means finishing the edges: - [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language) - [x] A4 slack-shaped reads — `queryV1` wire module, `client.queryV1.query`/`queries` (read-only, idempotent, same row translation as api/asset) - [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`) -- [ ] A7 attachments and embedded images (`attachment` wire module, `client.attachments`; survey rooms first) +- [x] A7 attachments and embedded images — `attachment` wire module (`attachment.img` bytes, `EmbeddedImage.mvc/Upload`, URL helpers, browser-safe Base64), `client.attachments` (list/info/get/add/put/remove/url; inline up to `INLINE_LIMIT`, two-step above) and `client.embeddedImages` (upload/url/normalize) - [ ] A6 CI + prerelease publish — CI (`.github/workflows/ci.yml`: verify on Node 20/22/24, `npm pack` artifact), git-SHA installs (`prepare`) and the dependency audit done; publish `@versionone/sdk@1.0.0-alpha.0` to npmjs with `--tag alpha` next (needs an org owner's token or trusted publisher) - [x] G0–G3 gh-v1 migrations — versionone/gh-v1#29–#32 - [x] S1–S4 slack-bot migrations — digital-ai/k6i-agility-slack-bot#21–#24 (on `bolt-migration`) @@ -474,5 +484,5 @@ ESM" here means finishing the edges: - [x] C1 gh-v1 seam swap — versionone/gh-v1#33, with the vendored SDK's hashes, CI and Node 22 floor in #35 - [x] C2 gh-v1 rows translated through the schema; `isTrue`/`oidOf` gone — versionone/gh-v1#36 - [x] C3 gh-v1 resolve and batch on `client.resolve`/`client.batch` — versionone/gh-v1#37 (with the SDK fixes from #43) -- [ ] C4 gh-v1 attachments on `client.attachments` (after A7); `raw` stays CLI-local +- [ ] C4 gh-v1 attachments on `client.attachments` (unblocked: A7 done); `raw` stays CLI-local - [ ] D1–D4 agility-rooms diff --git a/src/client.ts b/src/client.ts index ad3a2cf..519d94a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import type { ClientConfig } from "./core/clientConfig"; import { createFetchTransport } from "./core/fetchTransport"; import type { Transport } from "./core/transport"; +import * as attachments from "./services/attachments"; import * as batch from "./services/batch"; import * as guard from "./services/guard"; import * as resolve from "./services/resolve"; @@ -8,6 +9,7 @@ import { Schema, type TypeCache } from "./services/schema"; import type { Asset } from "./types/asset"; import type { Metamodel } from "./types/metamodel"; import * as assetApi from "./wire/assetApi"; +import * as attachment from "./wire/attachment"; import * as loc2 from "./wire/loc2"; import * as meta from "./wire/meta"; import * as queryV1 from "./wire/queryV1"; @@ -114,6 +116,33 @@ export class AgilityApiClient { reversible: (receipt: Pick, options?: { force?: boolean }) => batch.reversible(this.transport, receipt, options) }; + /** + * The files attached to assets: metadata on Attachment assets, bytes + * through attachment.img. `add` sends a small file inline (one request) + * and a large one in two steps; `url` is where a browser opens one. + */ + readonly attachments = { + list: (asset: string, options?: { page?: { size: number; start: number } }) => attachments.list(this.transport, asset, options), + info: (id: string | number) => attachments.info(this.transport, id), + get: (id: string | number) => attachments.get(this.transport, id), + add: (asset: string, data: attachment.Bytes, options: attachments.AddOptions) => attachments.add(this.transport, asset, data, options), + put: (id: string | number, data: attachment.Bytes, contentType?: string) => attachments.put(this.transport, id, data, contentType), + remove: (id: string | number) => attachments.remove(this.transport, id), + url: (id: string | number) => attachment.attachmentUrl(this.config.baseUrl, id) + }; + + /** + * Images embedded in an asset's rich text (Description and other long + * text). `upload` returns the URL to put in an ; `normalize` + * rewrites every embedded.img reference in some HTML to this instance's + * absolute URL, as Agility's own editor stores it. They cannot be deleted. + */ + readonly embeddedImages = { + upload: (asset: string, data: attachment.Bytes, options?: { filename?: string; contentType?: string }) => attachment.uploadEmbeddedImage(this.transport, asset, data, options), + url: (id: string | number) => attachment.embeddedImageUrl(this.config.baseUrl, id), + normalize: (html: string) => attachment.normalizeEmbeddedImageUrls(html, this.config.baseUrl) + }; + /** rest-1.v1: bounded reads, one asset, history. */ readonly data = { list: (type: string, params: rest1.ReadParams) => rest1.list(this.transport, type, params), diff --git a/src/core/fetchTransport.ts b/src/core/fetchTransport.ts index fbab5f0..d020ae4 100644 --- a/src/core/fetchTransport.ts +++ b/src/core/fetchTransport.ts @@ -89,6 +89,8 @@ function encodeBody(body: RequestBody | undefined, contentType: string | undefin } // A form is handed to fetch as is: it writes the multipart boundary itself. if (typeof FormData !== "undefined" && body instanceof FormData) return { body }; + // A Blob (a browser File, say) is bytes with a type of its own. + if (typeof Blob !== "undefined" && body instanceof Blob) return { body, contentType: contentType ?? (body.type || "application/octet-stream") }; return { body: JSON.stringify(body), contentType: contentType ?? "application/json" }; } diff --git a/src/core/transport.ts b/src/core/transport.ts index 523a5a4..aa68361 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -1,7 +1,7 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; -/** A JSON value, a text body (YAML for api/asset, say), bytes (attachments), or a multipart form. */ -export type RequestBody = unknown | string | Uint8Array | ArrayBuffer | FormData; +/** A JSON value, a text body (YAML for api/asset, say), bytes or a Blob (attachments), or a multipart form. */ +export type RequestBody = unknown | string | Uint8Array | ArrayBuffer | Blob | FormData; /** * How to read the response: "auto" parses JSON when the server says JSON and diff --git a/src/index.ts b/src/index.ts index 504b76f..01022d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,8 @@ export type { AssetType, AttributeDefinition, AttributeLookup, AttributeType, At export type * from "./types/assets/index"; // The wire layer: one module per endpoint. -export { assetApi, rest1, meta, loc2, queryV1 } from "./wire"; +export { assetApi, rest1, meta, loc2, queryV1, attachment } from "./wire"; +export type { Bytes, EmbeddedImage } from "./wire/attachment"; export * as select from "./wire/select"; export type { AssetApiResponse, Command, CommandBody, CommandFailureDetail, Outcome, CommandValue, CreateBody, ExecuteBody, QueryBody, UpdateBody, ReadOptions, RowContext, SendOptions } from "./wire/assetApi"; export { CommandFailureError } from "./wire/assetApi"; @@ -24,6 +25,8 @@ export type { Localizations, LocalizeOptions } from "./wire/loc2"; // Services: what to do. export { Schema, TypeCache } from "./services/schema"; +export { AttachmentUploadError, INLINE_LIMIT } from "./services/attachments"; +export type { AddOptions as AttachmentAddOptions, AttachmentInfo } from "./services/attachments"; export { CHUNK as RESOLVE_CHUNK, NUMBER_ROOTS, isAssetNumber, resolveNumbers, resolveRef } from "./services/resolve"; export type { Resolution, ResolvedAsset } from "./services/resolve"; export { UNGUARDABLE_TYPES, UnguardableError, guard, sendGuarded, stampsOf, touchedBy, unguardable } from "./services/guard"; diff --git a/src/services/attachments.ts b/src/services/attachments.ts new file mode 100644 index 0000000..3cd7b0e --- /dev/null +++ b/src/services/attachments.ts @@ -0,0 +1,112 @@ +/** + * The attachments service: the files attached to an asset, as part of + * changing that asset like any other attribute. The metadata lives on an + * Attachment asset (api/asset); the bytes live behind attachment.img + * (wire/attachment). Consumers never call either directly. + * + * Two ways in for the bytes, chosen by size: + * - inline: one api/asset create with Content as Base64. One request, + * what agility-rooms does; the JSON is a third larger than the file. + * - two-step, as the docs prescribe for anything not tiny: create the + * Attachment with empty Content, then POST the raw bytes. A failed + * upload leaves an empty Attachment, which is reported, not hidden. + * + * Listing never selects Content: across many attachments that would Base64 + * every file into one response. Reading one file's bytes goes through + * attachment.img. + */ +import { ApiError } from "../core/errors"; +import type { Transport } from "../core/transport"; +import type { Oid } from "../types/asset"; +import { momentless } from "../types/oid"; +import * as assetApi from "../wire/assetApi"; +import { getBytes, numericId, putBytes, toBase64, toUint8Array, type Bytes } from "../wire/attachment"; + +/** Files up to this size go inline by default; larger ones in two steps. */ +export const INLINE_LIMIT = 1_000_000; + +export interface AttachmentInfo { + oid: Oid; + /** The numeric id attachment.img addresses it by. */ + id: string; + name: string | null; + filename: string | null; + contentType: string | null; + description: string | null; + createDate: string | null; + createdBy: string | null; +} + +type Row = { _oid: string; Name?: string | null; Filename?: string | null; ContentType?: string | null; Description?: string | null; CreateDate?: string | null; "CreatedBy.Name"?: string | null }; +const SELECT = ["Name", "Filename", "ContentType", "Description", "CreateDate", "CreatedBy.Name"]; +const infoOf = (r: Row): AttachmentInfo => { + const oid = momentless(r._oid); + return { oid, id: numericId(oid), name: r.Name ?? null, filename: r.Filename ?? null, contentType: r.ContentType ?? null, description: r.Description ?? null, createDate: r.CreateDate ?? null, createdBy: r["CreatedBy.Name"] ?? null }; +}; + +/** The files attached to an asset, newest first, metadata only. */ +export async function list(transport: Transport, asset: Oid, options: { page?: { size: number; start: number } } = {}): Promise { + const rows = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["Asset=$asset"], with: { $asset: momentless(asset) }, sort: ["-CreateDate"], page: options.page ?? { size: 50, start: 0 } }); + return rows.map((r) => infoOf(r as unknown as Row)); +} + +/** One attachment's metadata, or null when there is none with that id. */ +export async function info(transport: Transport, id: Oid | string | number): Promise { + const [row] = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["ID=$id"], with: { $id: `Attachment:${numericId(id)}` }, page: { size: 1, start: 0 } }); + return row ? infoOf(row as unknown as Row) : null; +} + +/** One attachment: its metadata and its bytes. */ +export async function get(transport: Transport, id: Oid | string | number): Promise { + const meta = await info(transport, id); + if (!meta) throw new ApiError(`attachment ${numericId(id)}: not found`, 404, "NOT_FOUND"); + return { ...meta, bytes: await getBytes(transport, meta.id) }; +} + +export interface AddOptions { + filename: string; + /** Defaults to the Blob's type, then application/octet-stream. */ + contentType?: string; + /** Defaults to the filename. */ + name?: string; + description?: string; + /** Inline (one request) up to this many bytes, two-step above. Default INLINE_LIMIT. */ + inlineLimit?: number; +} + +/** An Attachment was created but its bytes could not be uploaded: it exists, empty. Remove it or upload again with `put`. */ +export class AttachmentUploadError extends Error { + constructor(readonly attachment: AttachmentInfo, cause: unknown) { + super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + this.name = "AttachmentUploadError"; + } +} + +/** Attach a file to an asset. Inline up to `inlineLimit` bytes, two-step above it. */ +export async function add(transport: Transport, asset: Oid, data: Bytes, options: AddOptions): Promise { + const bytes = await toUint8Array(data); + const contentType = options.contentType ?? (typeof Blob !== "undefined" && data instanceof Blob && data.type ? data.type : "application/octet-stream"); + const inline = bytes.length <= (options.inlineLimit ?? INLINE_LIMIT); + const create: assetApi.CreateBody = { AssetType: "Attachment", Asset: momentless(asset), Name: options.name ?? options.filename, Filename: options.filename, ContentType: contentType, Content: inline ? toBase64(bytes) : "" }; + if (options.description) create.Description = options.description; + const oid = assetApi.outcomeOf(await assetApi.send(transport, [create], { throwOnFailure: true })).created[0]!; + const created: AttachmentInfo = { oid, id: numericId(oid), name: String(create.Name), filename: options.filename, contentType, description: options.description ?? null, createDate: null, createdBy: null }; + if (!inline) { + try { + await putBytes(transport, created.id, bytes, contentType); + } catch (error) { + throw new AttachmentUploadError(created, error); + } + } + return created; +} + +/** Replace an attachment's bytes. */ +export async function put(transport: Transport, id: Oid | string | number, data: Bytes, contentType?: string): Promise { + await putBytes(transport, id, data, contentType); +} + +/** Delete an attachment. */ +export async function remove(transport: Transport, id: Oid | string | number): Promise { + await assetApi.send(transport, [{ from: `Attachment:${numericId(id)}`, execute: "Delete" }], { throwOnFailure: true }); +} diff --git a/src/wire/attachment.ts b/src/wire/attachment.ts new file mode 100644 index 0000000..60de955 --- /dev/null +++ b/src/wire/attachment.ts @@ -0,0 +1,132 @@ +/** + * The attachment wire module: the files an asset carries. Two endpoints and + * one MVC route, none of them the data API: + * + * attachment.img/ GET the bytes; POST bytes to replace them + * embedded.img/ GET an image embedded in rich text + * EmbeddedImage.mvc/Upload POST multipart { image, oidToken } → an EmbeddedImage + * + * Addressed by the numeric id, not the OID token — the one place in the API + * that does. Bytes travel as Uint8Array, ArrayBuffer or Blob, so this works + * in Node and the browser alike (no Buffer). + * + * Verified against the API-testing instance (2026-11-07): + * - GET attachment.img/ answers the bytes with the Attachment's + * ContentType and `Content-Disposition: inline; filename="…"`; 404 for + * an id that does not exist. POST raw bytes (any content type) answers + * 200 with an empty body and replaces them. + * - An Attachment create requires Content (empty is allowed, absent is + * Violation'Required'…'Content'); Base64 Content in the create stores + * the bytes in one request, and selecting Content reads them back as + * Base64. + * - EmbeddedImage.mvc/Upload takes token auth and answers text/html: + * ``. + * An EmbeddedImage cannot be deleted (no Delete operation). + */ +import { ApiError } from "../core/errors"; +import type { Transport } from "../core/transport"; +import type { Oid } from "../types/asset"; + +/** Bytes as a caller has them: a Uint8Array, an ArrayBuffer, or a Blob (a browser File). */ +export type Bytes = Uint8Array | ArrayBuffer | Blob; + +/** Bytes as a Uint8Array, whatever they came as. */ +export async function toUint8Array(data: Bytes): Promise { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + return new Uint8Array(await data.arrayBuffer()); +} + +/** Base64 without Buffer, in chunks so a large file does not overflow the call stack. */ +export function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(binary); +} + +export function fromBase64(text: string): Uint8Array { + const binary = atob(text); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +/** The numeric id from "Attachment:5678", "Attachment:5678:999" (a moment) or "5678". */ +export function numericId(spec: Oid | string | number): string { + const m = /^(?:[A-Za-z]+:)?(\d+)(?::\d+)?$/.exec(String(spec).trim()); + if (!m) throw new TypeError(`"${spec}" is not an asset id (5678, or an OID token such as Attachment:5678)`); + return m[1]!; +} + +const join = (baseUrl: string, path: string): string => `${baseUrl.replace(/\/+$/, "")}/${path}`; + +/** Where a browser opens or downloads an attachment's bytes. */ +export const attachmentUrl = (baseUrl: string, id: Oid | string | number): string => join(baseUrl, `attachment.img/${numericId(id)}`); +/** Where an embedded image is served from. */ +export const embeddedImageUrl = (baseUrl: string, id: Oid | string | number): string => join(baseUrl, `embedded.img/${numericId(id)}`); + +/** + * Rich text with every `embedded.img/` image reference rewritten to the + * instance's absolute URL — what Agility's own editor stores — whatever + * host, path or quoting the reference had before. + */ +export function normalizeEmbeddedImageUrls(html: string, baseUrl: string): string { + return html.replace(/(\bsrc\s*=\s*)(["'])([^"']*?embedded\.img\/(\d+)[^"']*)\2/gi, (_m, attr: string, quote: string, _url: string, id: string) => `${attr}${quote}${embeddedImageUrl(baseUrl, id)}${quote}`); +} + +/** The endpoint's statuses, named: 404 missing, 413 larger than the server's maximum request length. */ +function named(error: unknown, what: string): unknown { + if (!(error instanceof ApiError)) return error; + if (error.status === 404) return new ApiError(`${what}: not found`, 404, "NOT_FOUND", error.details); + if (error.status === 413) return new ApiError(`${what}: larger than the server's maximum request length`, 413, "TOO_LARGE", error.details); + return error; +} + +/** An attachment's bytes. */ +export async function getBytes(transport: Transport, id: Oid | string | number): Promise { + const n = numericId(id); + try { + return await transport.request({ method: "GET", path: `/attachment.img/${n}`, responseType: "bytes" }); + } catch (error) { + throw named(error, `attachment ${n}`); + } +} + +/** Replace an attachment's bytes. The Attachment asset (name, filename, content type) is unchanged. */ +export async function putBytes(transport: Transport, id: Oid | string | number, data: Bytes, contentType = "application/octet-stream"): Promise { + const n = numericId(id); + const body = data instanceof Blob ? data : await toUint8Array(data); + try { + await transport.request({ method: "POST", path: `/attachment.img/${n}`, body, contentType: data instanceof Blob && data.type ? data.type : contentType, responseType: "text" }); + } catch (error) { + throw named(error, `attachment ${n}`); + } +} + +export interface EmbeddedImage { oid: Oid; id: string; url: string } + +/** + * Upload an image to embed in an asset's rich text. The answer is the + * image's URL — put it in an `` — and its EmbeddedImage OID. + */ +export async function uploadEmbeddedImage(transport: Transport, asset: Oid, data: Bytes, options: { filename?: string; contentType?: string } = {}): Promise { + const blob = data instanceof Blob ? data : new Blob([(await toUint8Array(data)) as Uint8Array], { type: options.contentType ?? "application/octet-stream" }); + const form = new FormData(); + form.append("image", blob, options.filename ?? "image"); + form.append("oidToken", asset); + let html: string; + try { + html = await transport.request({ method: "POST", path: "/EmbeddedImage.mvc/Upload", body: form, responseType: "text" }); + } catch (error) { + throw named(error, `embedded image for ${asset}`); + } + const inner = /]*>([\s\S]*?)<\/textarea>/i.exec(html)?.[1]; + let payload: { Url?: unknown; Key?: unknown } | undefined; + try { + payload = inner ? JSON.parse(inner.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")) : undefined; + } catch { /* reported below */ } + if (!payload || typeof payload.Url !== "string") throw new ApiError("EmbeddedImage.mvc/Upload did not answer with an image URL", 200, "BAD_RESPONSE", html.slice(0, 500)); + const id = /embedded\.img\/(\d+)/.exec(payload.Url)?.[1] ?? (typeof payload.Key === "string" ? numericId(payload.Key) : undefined); + if (!id) throw new ApiError("EmbeddedImage.mvc/Upload answered a URL without an image id", 200, "BAD_RESPONSE", payload); + return { oid: typeof payload.Key === "string" ? payload.Key : `EmbeddedImage:${id}`, id, url: payload.Url }; +} diff --git a/src/wire/index.ts b/src/wire/index.ts index 5aede19..7c11747 100644 --- a/src/wire/index.ts +++ b/src/wire/index.ts @@ -8,6 +8,7 @@ export * as rest1 from "./rest1"; export * as meta from "./meta"; export * as loc2 from "./loc2"; export * as queryV1 from "./queryV1"; +export * as attachment from "./attachment"; export * as select from "./select"; export type { AssetDocument, AssetsDocument, ReadParams, ReadResult } from "./rest1"; export type { AssetApiResponse, Command, CommandBody, CommandValue, CreateBody, ExecuteBody, QueryBody, UpdateBody, CommandFailure, CommandError, OidTokens, SendOptions } from "./assetApi"; diff --git a/test/integration/attachments.test.ts b/test/integration/attachments.test.ts new file mode 100644 index 0000000..8873ef6 --- /dev/null +++ b/test/integration/attachments.test.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeAll, expect, it } from "vitest"; +import { AgilityApiClient } from "../../src/client"; +import { assetApi } from "../../src/wire"; +import { describeInstance, fixtureName, instance } from "./instance"; + +/** + * Attachments against a real instance: add inline and in two steps, list, + * read the bytes back, replace them, delete. Brings its own Story and + * deletes everything it made by exact OID. + * + * Embedded images cannot be deleted (Agility has no Delete operation for + * them), so that test leaves one tiny image behind on every run. It runs + * only with SDK_TEST_EMBEDDED_IMAGES=1. + */ +describeInstance("attachments (live)", () => { + const t = instance!.transport; + const client = new AgilityApiClient({ baseUrl: instance!.url }, { transport: t }); + const made: string[] = []; + let story = ""; + const text = (s: string) => new TextEncoder().encode(s); + const decode = (b: Uint8Array) => new TextDecoder().decode(b); + + beforeAll(async () => { + const res = await assetApi.send(t, [{ AssetType: "Story", Name: fixtureName("attachments"), Scope: { _oid: "Scope:0" } }], { throwOnFailure: true }); + story = assetApi.momentless(res.assetsCreated.oidTokens[0]!); + made.push(story); + }); + afterAll(async () => { + for (const oid of [...made].reverse()) await assetApi.send(t, [{ from: oid, execute: "Delete" }]); + }); + + it("a small file inline and a large one in two steps both store their bytes; list sees both, metadata only", async () => { + const small = await client.attachments.add(story, text("hello attachment\n"), { filename: "sdk-integration small.txt", contentType: "text/plain" }); + const large = await client.attachments.add(story, new Blob([text("two-step bytes\n")], { type: "text/plain" }), { filename: "sdk-integration large.txt", inlineLimit: 0, description: "two-step" }); + made.push(small.oid, large.oid); + expect(decode((await client.attachments.get(small.id)).bytes)).toBe("hello attachment\n"); + const got = await client.attachments.get(large.oid); + expect(decode(got.bytes)).toBe("two-step bytes\n"); + expect(got).toMatchObject({ filename: "sdk-integration large.txt", contentType: "text/plain", description: "two-step" }); + const listed = await client.attachments.list(story); + expect(listed.map((a) => a.oid).sort()).toEqual([small.oid, large.oid].sort()); + }); + + it("put replaces the bytes; remove deletes the attachment; a missing one is NOT_FOUND", async () => { + const a = await client.attachments.add(story, text("before\n"), { filename: "sdk-integration replace.txt", contentType: "text/plain" }); + made.push(a.oid); + await client.attachments.put(a.id, text("after\n"), "text/plain"); + expect(decode((await client.attachments.get(a.id)).bytes)).toBe("after\n"); + await client.attachments.remove(a.id); + expect(await client.attachments.info(a.id)).toBeNull(); + await expect(client.attachments.get(a.id)).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it.runIf(process.env.SDK_TEST_EMBEDDED_IMAGES === "1")("an embedded image uploads with token auth and is served from its URL (leaves one image behind: they cannot be deleted)", async () => { + const png = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="), (c) => c.charCodeAt(0)); + const image = await client.embeddedImages.upload(story, png, { filename: "sdk-integration pixel.png", contentType: "image/png" }); + expect(image.url).toBe(client.embeddedImages.url(image.id)); + const served = await t.request({ method: "GET", path: `/embedded.img/${image.id}`, responseType: "bytes" }); + expect(served.length).toBe(png.length); + }); +}); diff --git a/test/unit/attachment.test.ts b/test/unit/attachment.test.ts new file mode 100644 index 0000000..8a81937 --- /dev/null +++ b/test/unit/attachment.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { AgilityApiClient } from "../../src/client"; +import { ApiError } from "../../src/core/errors"; +import { createFetchTransport } from "../../src/core/fetchTransport"; +import type { RequestOptions, Transport } from "../../src/core/transport"; +import { AttachmentUploadError, add, list } from "../../src/services/attachments"; +import { fromBase64, getBytes, normalizeEmbeddedImageUrls, numericId, toBase64, uploadEmbeddedImage } from "../../src/wire/attachment"; + +const envelope = (over: Record = {}) => ({ + requestId: "r", assetsCreated: { oidTokens: [], count: 0 }, assetsModified: { oidTokens: [], count: 0 }, assetsOperatedOn: { oidTokens: [], count: 0 }, + commandFailures: { commands: [], count: 0 }, queryResult: { results: [], resultMetaData: [], count: -1 }, ...over +}); +function fake(...answers: unknown[]): Transport & { calls: RequestOptions[] } { + const calls: RequestOptions[] = []; + const request = vi.fn(async (o: RequestOptions) => { calls.push(o); const a = answers.shift(); if (a instanceof Error) throw a; return a; }); + return { request: request as Transport["request"], calls }; +} + +describe("attachment wire module", () => { + it("Base64 without Buffer round-trips, including a file larger than one chunk", () => { + const big = new Uint8Array(100_000).map((_, i) => i % 256); + expect(fromBase64(toBase64(big))).toEqual(big); + expect(toBase64(new TextEncoder().encode("hello attachment\n"))).toBe("aGVsbG8gYXR0YWNobWVudAo="); // as the server stored it (2026-11-07) + }); + + it("numericId takes an id, an OID token, or a momented one; anything else is a TypeError", () => { + expect([numericId(5678), numericId("5678"), numericId("Attachment:5678"), numericId("Attachment:5678:900")]).toEqual(["5678", "5678", "5678", "5678"]); + expect(() => numericId("S-1")).toThrow(TypeError); + }); + + it("normalizeEmbeddedImageUrls rewrites every embedded.img reference to this instance's absolute URL and leaves other images alone", () => { + const html = `

`; + expect(normalizeEmbeddedImageUrls(html, "https://www14.v1host.com/v1sdktesting/")).toBe( + `

` + ); + }); + + it("a missing attachment is a NOT_FOUND ApiError; too large is TOO_LARGE", async () => { + await expect(getBytes(fake(new ApiError("x", 404)), 5)).rejects.toMatchObject({ code: "NOT_FOUND", status: 404 }); + await expect(getBytes(fake(new ApiError("x", 413)), 5)).rejects.toMatchObject({ code: "TOO_LARGE", status: 413 }); + }); + + it("uploadEmbeddedImage posts multipart { image, oidToken } and reads the URL out of the HTML the route answers", async () => { + // The route's answer, verbatim (v1sdktesting, 2026-11-07). + const t = fake(''); + const image = await uploadEmbeddedImage(t, "Story:146408", new Uint8Array([1, 2, 3]), { filename: "pixel.png", contentType: "image/png" }); + expect(image).toEqual({ oid: "EmbeddedImage:146412", id: "146412", url: "https://www14.v1host.com/v1sdktesting/embedded.img/146412" }); + const form = t.calls[0]!.body as FormData; + expect(t.calls[0]).toMatchObject({ method: "POST", path: "/EmbeddedImage.mvc/Upload", responseType: "text" }); + expect(form.get("oidToken")).toBe("Story:146408"); + expect((form.get("image") as File).name).toBe("pixel.png"); + expect((form.get("image") as File).type).toBe("image/png"); + await expect(uploadEmbeddedImage(fake("Sign in"), "Story:1", new Uint8Array([1]))).rejects.toMatchObject({ code: "BAD_RESPONSE" }); + }); + + it("the transport sends a Blob as is, with its own type", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + await createFetchTransport({ baseUrl: "https://x", fetchImpl }).request({ method: "POST", path: "/attachment.img/5", body: new Blob(["hi"], { type: "text/plain" }), responseType: "text" }); + const init = fetchImpl.mock.calls[0]![1]; + expect(init.body).toBeInstanceOf(Blob); + expect(init.headers["content-type"]).toBe("text/plain"); + }); +}); + +describe("attachments service", () => { + it("a small file goes inline: one create with Content as Base64", async () => { + const t = fake(envelope({ assetsCreated: { oidTokens: ["Attachment:77:900"], count: 1 } })); + const created = await add(t, "Story:1", new TextEncoder().encode("hello attachment\n"), { filename: "a.txt", contentType: "text/plain" }); + expect(t.calls).toHaveLength(1); + expect(t.calls[0]!.body).toEqual([{ AssetType: "Attachment", Asset: "Story:1", Name: "a.txt", Filename: "a.txt", ContentType: "text/plain", Content: "aGVsbG8gYXR0YWNobWVudAo=" }]); + expect(created).toMatchObject({ oid: "Attachment:77", id: "77", filename: "a.txt" }); + }); + + it("a large file goes in two steps: create with empty Content, then POST the bytes", async () => { + const t = fake(envelope({ assetsCreated: { oidTokens: ["Attachment:78"], count: 1 } }), ""); + const bytes = new Uint8Array(20); + await add(t, "Story:1", bytes, { filename: "b.bin", inlineLimit: 10, description: "why" }); + expect((t.calls[0]!.body as Record[])[0]).toMatchObject({ Content: "", ContentType: "application/octet-stream", Description: "why" }); + expect(t.calls[1]).toMatchObject({ method: "POST", path: "/attachment.img/78", contentType: "application/octet-stream" }); + expect(t.calls[1]!.body).toEqual(bytes); + }); + + it("a failed upload reports the created, empty attachment instead of hiding it", async () => { + const t = fake(envelope({ assetsCreated: { oidTokens: ["Attachment:79"], count: 1 } }), new ApiError("too big", 413)); + const err = (await add(t, "Story:1", new Uint8Array(20), { filename: "c.bin", inlineLimit: 10 }).catch((e: unknown) => e)) as AttachmentUploadError; + expect(err).toBeInstanceOf(AttachmentUploadError); + expect(err.attachment.oid).toBe("Attachment:79"); + expect(err.message).toMatch(/Attachment:79 was created .* no content.*maximum request length/); + }); + + it("list asks for metadata only — never Content — paged, newest first", async () => { + const t = fake(envelope({ queryResult: { results: [[{ _oid: "Attachment:5", Name: "n", Filename: "f.png", ContentType: "image/png", Description: null, CreateDate: "2026-11-07T10:00:00", "CreatedBy.Name": "Jim" }]], resultMetaData: [], count: 1 } })); + const rows = await list(t, "Story:1:44"); + const q = (t.calls[0]!.body as Record[])[0]!; + expect(q).toMatchObject({ from: "Attachment", filter: ["Asset=$asset"], with: { $asset: "Story:1" }, sort: ["-CreateDate"], page: { size: 50, start: 0 } }); + expect(q.select).not.toContain("Content"); + expect(rows).toEqual([{ oid: "Attachment:5", id: "5", name: "n", filename: "f.png", contentType: "image/png", description: null, createDate: "2026-11-07T10:00:00", createdBy: "Jim" }]); + }); + + it("the client exposes attachments and embedded images, with URLs on its own base", () => { + const client = new AgilityApiClient({ baseUrl: "https://www14.v1host.com/v1sdktesting/" }, { transport: fake() }); + expect(client.attachments.url("Attachment:5")).toBe("https://www14.v1host.com/v1sdktesting/attachment.img/5"); + expect(client.embeddedImages.url(12)).toBe("https://www14.v1host.com/v1sdktesting/embedded.img/12"); + }); +});