diff --git a/.vcr/registry-test.json b/.vcr/registry-test.json index 670576e..c914580 100644 --- a/.vcr/registry-test.json +++ b/.vcr/registry-test.json @@ -223,7 +223,7 @@ { "method": "POST", "url": "https://staging-api.are.na/v3/blocks", - "requestBody": "{\"value\":\"Integration test content\",\"channel_ids\":[4484793]}", + "requestBody": "{\"value\":\"Integration test content\",\"channels\":[{\"id\":4484793}]}", "status": 201, "statusText": "Created", "headers": { @@ -730,7 +730,7 @@ { "method": "POST", "url": "https://staging-api.are.na/v3/connections", - "requestBody": "{\"connectable_id\":41232438,\"channel_ids\":[\"cli-connect-target-_icsu6cvkta\"],\"connectable_type\":\"Block\"}", + "requestBody": "{\"connectable_id\":41232438,\"connectable_type\":\"Block\",\"channels\":[{\"id\":\"cli-connect-target-_icsu6cvkta\"}]}", "status": 201, "statusText": "Created", "headers": { @@ -1296,7 +1296,7 @@ { "method": "POST", "url": "https://staging-api.are.na/v3/blocks", - "requestBody": "{\"value\":\"Block one\",\"channel_ids\":[4484795]}", + "requestBody": "{\"value\":\"Block one\",\"channels\":[{\"id\":4484795}]}", "status": 201, "statusText": "Created", "headers": { @@ -1369,7 +1369,7 @@ { "method": "POST", "url": "https://staging-api.are.na/v3/blocks", - "requestBody": "{\"value\":\"Block two\",\"channel_ids\":[4484795]}", + "requestBody": "{\"value\":\"Block two\",\"channels\":[{\"id\":4484795}]}", "status": 201, "statusText": "Created", "headers": { @@ -1798,7 +1798,7 @@ { "method": "POST", "url": "https://staging-api.are.na/v3/blocks", - "requestBody": "{\"value\":\"Content with metadata\",\"channel_ids\":[4484796],\"title\":\"My Title\",\"description\":\"My Desc\"}", + "requestBody": "{\"value\":\"Content with metadata\",\"channels\":[{\"id\":4484796}],\"title\":\"My Title\",\"description\":\"My Desc\"}", "status": 201, "statusText": "Created", "headers": { @@ -1871,7 +1871,7 @@ { "method": "POST", "url": "https://staging-api.are.na/v3/blocks", - "requestBody": "{\"value\":\"https://example.com\",\"channel_ids\":[4484796]}", + "requestBody": "{\"value\":\"https://example.com\",\"channels\":[{\"id\":4484796}]}", "status": 201, "statusText": "Created", "headers": { diff --git a/package.json b/package.json index eb38a9c..f48e396 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "generate": "openapi-typescript https://api.are.na/v3/openapi -o src/api/schema.d.ts", "typecheck": "tsc --noEmit", "lint": "npm run typecheck", - "test": "tsx --test src/**/*.test.ts", + "test": "node --import tsx --test src/*.test.ts src/*/*.test.ts", "check": "npm run lint && npm run test && npm run build", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..a0cedf6 --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ArenaError, client } from "./client"; +import { formatJsonError } from "../lib/exit-codes"; + +// Exercise the middleware with real Response objects and no network or cassette. +process.env.ARENA_VCR_MODE = "off"; + +for (const { body, status, message } of [ + { + body: { + error: "Bad Request", + details: { message: "No more than 50 blocks permitted" }, + }, + status: 400, + message: "No more than 50 blocks permitted", + }, + { + body: { + error: "Unprocessable Entity", + details: { errors: ["Title is required", "Value is invalid"] }, + }, + status: 422, + message: "Title is required; Value is invalid", + }, + { + body: { + details: { errors: { title: ["is required"], value: "is invalid" } }, + }, + status: 422, + message: "title: is required; value: is invalid", + }, + { + body: { error: { message: "Write budget exhausted" } }, + status: 429, + message: "Write budget exhausted", + }, + { body: { error: "Forbidden" }, status: 403, message: "Forbidden" }, +]) { + test(`API errors retain details: ${message}`, async (t) => { + t.mock.method(globalThis, "fetch", async () => + Response.json(body, { status }), + ); + await assert.rejects(client.GET("/v3/ping"), (error: unknown) => { + assert.ok(error instanceof ArenaError); + assert.equal(error.status, status); + assert.equal(error.message, message); + assert.equal(formatJsonError(error).error, message); + return true; + }); + }); +} + +for (const { headers, retryAfter, expectedDelay, expectedReset } of [ + { + headers: { "Retry-After": "60", "X-RateLimit-Reset": "1700000060" }, + retryAfter: 5, + expectedDelay: 60_000, + expectedReset: 1700000060000, + }, + { + headers: { "Retry-After": "Tue, 14 Nov 2023 22:14:20 GMT" }, + retryAfter: 5, + expectedDelay: 60_000, + }, + { headers: {}, retryAfter: 30, expectedDelay: 30_000 }, + { + headers: { "X-RateLimit-Reset": "1700000060" }, + expectedReset: 1700000060000, + }, + { headers: { "Retry-After": "invalid" }, retryAfter: -1 }, +]) { + test(`rate-limit timing: ${JSON.stringify({ headers, retryAfter })}`, async (t) => { + t.mock.method(Date, "now", () => 1700000000000); + t.mock.method(globalThis, "fetch", async () => + Response.json( + { error: { message: "Rate limited", retry_after: retryAfter } }, + { + status: 429, + headers: { ...headers, "X-RateLimit-Bucket": "write-hour" }, + }, + ), + ); + await assert.rejects(client.GET("/v3/ping"), (error: unknown) => { + assert.ok(error instanceof ArenaError); + assert.equal(error.rateLimit?.retryAfterMs, expectedDelay); + assert.equal(error.rateLimit?.resetAtMs, expectedReset); + assert.equal(error.rateLimit?.bucket, "write-hour"); + assert.equal(formatJsonError(error).retry_after_ms, expectedDelay); + return true; + }); + }); +} + +test("non-JSON errors retain the HTTP fallback", async (t) => { + t.mock.method( + globalThis, + "fetch", + async () => + new Response("unavailable", { + status: 502, + statusText: "Bad Gateway", + }), + ); + await assert.rejects(client.GET("/v3/ping"), /Bad Gateway/); +}); diff --git a/src/api/client.ts b/src/api/client.ts index a935390..8b4a641 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -10,10 +10,29 @@ loadEnv(); export class ArenaError extends Error { status: number; - constructor(message: string, status: number) { + rateLimit?: { + retryAfterMs?: number; + resetAtMs?: number; + limit?: string; + remaining?: string; + tier?: string; + window?: string; + bucket?: string; + cost?: string; + policy?: string; + }; + + constructor( + message: string, + status: number, + options: { + rateLimit?: ArenaError["rateLimit"]; + } = {}, + ) { super(message); this.name = "ArenaError"; this.status = status; + this.rateLimit = options.rateLimit; } } @@ -22,20 +41,120 @@ export async function getData(request: Promise<{ data?: T }>): Promise { return response.data!; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatValidationErrors(errors: unknown): string | undefined { + if (Array.isArray(errors)) { + const messages = errors + .map((error) => { + if (typeof error === "string") return error; + if (isRecord(error) && typeof error["message"] === "string") { + return error["message"]; + } + return undefined; + }) + .filter((message): message is string => Boolean(message)); + return messages.length > 0 ? messages.join("; ") : undefined; + } + + if (!isRecord(errors)) return undefined; + + const messages = Object.entries(errors) + .map(([key, value]) => { + if (typeof value === "string") return `${key}: ${value}`; + if (Array.isArray(value)) return `${key}: ${value.join(", ")}`; + if (isRecord(value) && typeof value["message"] === "string") { + return `${key}: ${value["message"]}`; + } + return undefined; + }) + .filter((message): message is string => Boolean(message)); + + return messages.length > 0 ? messages.join("; ") : undefined; +} + function toArenaMessage(body: unknown, fallback: string): string { - if (typeof body !== "object" || body === null) { - return fallback; + if (!isRecord(body)) return fallback; + + const messages: string[] = []; + const details = body["details"]; + if (isRecord(details)) { + if (typeof details["message"] === "string") { + messages.push(details["message"]); + } + const validation = formatValidationErrors(details["errors"]); + if (validation) messages.push(validation); + } + + const error = body["error"]; + if (isRecord(error) && typeof error["message"] === "string") { + messages.push(error["message"]); } - const errorBody = body as { - error?: unknown; - details?: { - message?: unknown; - }; + + if (typeof body["message"] === "string") { + messages.push(body["message"]); + } + + const unique = [...new Set(messages.filter(Boolean))]; + if (unique.length > 0) return unique.join("; "); + return typeof error === "string" && error ? error : fallback; +} + +function retryAfterHeaderMs(value: string | null): number | undefined { + if (!value) return undefined; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; + + const timestamp = Date.parse(value); + if (!Number.isNaN(timestamp)) return Math.max(0, timestamp - Date.now()); + return undefined; +} + +function unixTimestampHeaderMs(value: string | null): number | undefined { + if (!value) return undefined; + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return seconds * 1000; +} + +function numericBodyRetryAfterMs(body: unknown): number | undefined { + if (!isRecord(body) || !isRecord(body["error"])) return undefined; + const retryAfter = body["error"]["retry_after"]; + if ( + typeof retryAfter !== "number" || + !Number.isFinite(retryAfter) || + retryAfter < 0 + ) + return undefined; + return retryAfter * 1000; +} + +function rateLimitFromResponse( + response: Response, + body: unknown, +): ArenaError["rateLimit"] { + if (response.status !== 429) return undefined; + + const retryAfterMs = + retryAfterHeaderMs(response.headers.get("Retry-After")) ?? + numericBodyRetryAfterMs(body); + const resetAtMs = unixTimestampHeaderMs( + response.headers.get("X-RateLimit-Reset"), + ); + + return { + retryAfterMs, + resetAtMs, + limit: response.headers.get("X-RateLimit-Limit") ?? undefined, + remaining: response.headers.get("X-RateLimit-Remaining") ?? undefined, + tier: response.headers.get("X-RateLimit-Tier") ?? undefined, + window: response.headers.get("X-RateLimit-Window") ?? undefined, + bucket: response.headers.get("X-RateLimit-Bucket") ?? undefined, + cost: response.headers.get("X-RateLimit-Cost") ?? undefined, + policy: response.headers.get("X-RateLimit-Policy") ?? undefined, }; - if (typeof errorBody.error === "string") return errorBody.error; - if (typeof errorBody.details?.message === "string") - return errorBody.details.message; - return fallback; } const baseUrlMiddleware: Middleware = { @@ -75,9 +194,13 @@ const errorMiddleware: Middleware = { .clone() .json() .catch(() => null); + const rateLimit = rateLimitFromResponse(response, body); throw new ArenaError( toArenaMessage(body, response.statusText), response.status, + { + rateLimit, + }, ); } return response; diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index 3f71d7d..4266c24 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -814,7 +814,7 @@ export interface paths { * Get group members * @description Returns a paginated list of group members. The group owner is always * returned as the first entry on page 1 with `role: "owner"`, followed by - * collaborators in newest-first order with `role: "member"`. The total + * collaborators in newest-first order with their membership role. The total * count includes the owner. * * Group managers see all collaborators; other readers only see @@ -881,7 +881,15 @@ export interface paths { delete: operations["removeGroupMember"]; options?: never; head?: never; - patch?: never; + /** + * Update a group member's role + * @description Updates a group member's role. Only group admins and the group owner + * may change roles. The owner has no membership record and cannot be + * updated through this endpoint. + * + * **Authentication required.** + */ + patch: operations["updateGroupMember"]; trace?: never; }; "/v3/groups/{id}/invitations": { @@ -1131,12 +1139,12 @@ export interface components { */ tier: components["schemas"]["UserTier"]; /** - * @description Request limit per minute for this tier + * @description Binding request limit for this rejection * @example 30 */ limit: number; /** - * @description Time window for rate limits + * @description Time window for the binding rate limit bucket * @example 1 minute */ limit_window?: string; @@ -1157,6 +1165,29 @@ export interface components { * } */ limits?: Record; + /** + * @example { + * "guest": { + * "read_minute": 30 + * }, + * "free": { + * "read_minute": 120, + * "write_minute": 30, + * "write_hour": 300 + * }, + * "premium": { + * "read_minute": 300, + * "write_minute": 60, + * "write_hour": 1500 + * }, + * "supporter": { + * "read_minute": 600, + * "write_minute": 120, + * "write_hour": 4000 + * } + * } + */ + detailed_limits?: Record; upgrade_path?: { /** @example Guest (30 req/min) */ current?: string; @@ -1254,6 +1285,11 @@ export interface components { * @example john-doe */ slug: string; + /** + * @description User's username (either automatically generated or customized) + * @example john-doe + */ + readonly username?: string | null; /** * Format: uri * @description URL to user's avatar image @@ -1435,6 +1471,8 @@ export interface components { Group: components["schemas"]["EmbeddedGroup"] & { /** @description Group description with markdown, HTML, and plain text renderings */ description?: components["schemas"]["MarkdownContent"] | null; + /** @description Role assigned to new members joining the group */ + default_role: components["schemas"]["GroupMembershipRole"]; /** * Format: date-time * @description When the group was created @@ -1569,15 +1607,25 @@ export interface components { /** @description HATEOAS links for navigation */ _links: components["schemas"]["Links"]; }; + /** + * @description The role a member has within a group. `viewer` can see the group's + * channels (including private ones) but not add to or edit them; + * `member` has full write access to the group's channels and can invite + * new members; `admin` can additionally manage the group and its members. + * @example member + * @enum {string} + */ + GroupMembershipRole: "viewer" | "member" | "admin"; /** @description Group member entry, including the group owner and any collaborators */ GroupMember: components["schemas"]["EmbeddedUser"] & { /** * @description The user's role on the group. `owner` is the group's creator; - * `member` is anyone else with a group membership. + * everyone else with a group membership has a `viewer`, `member`, + * or `admin` role. * @example member * @enum {string} */ - role: "owner" | "member"; + role: "owner" | "viewer" | "member" | "admin"; }; /** @description Data payload containing an array of group members */ GroupMemberList: { @@ -2001,6 +2049,12 @@ export interface components { * @example Beige flags */ alt_text?: string; + /** + * Format: uri + * @description Optional cover image URL to use as the visual preview for Link, Attachment, and Embed blocks (overrides the auto-generated screenshot/thumbnail). Ignored for Image and Text blocks. + * @example https://example.com/cover.jpg + */ + cover_url?: string; /** @description Custom key-value metadata to set on the new block. */ metadata?: components["schemas"]["Metadata"]; }; @@ -3021,25 +3075,50 @@ export interface components { RateLimitResponse: { headers: { /** - * @description Seconds to wait before retrying - * @example 65 + * @description Seconds until the binding rate limit bucket resets + * @example 37 */ "Retry-After"?: string; /** - * @description Request limit per minute + * @description Binding bucket limit * @example 30 */ "X-RateLimit-Limit"?: string; + /** + * @description Units left in the binding bucket + * @example 0 + */ + "X-RateLimit-Remaining"?: string; /** * @description User's current tier * @example guest */ "X-RateLimit-Tier"?: string; /** - * @description Time window in seconds + * @description Binding bucket window in seconds * @example 60 */ "X-RateLimit-Window"?: string; + /** + * @description Unix timestamp when the binding bucket resets + * @example 1700000100 + */ + "X-RateLimit-Reset"?: string; + /** + * @description Binding bucket name + * @example read-minute + */ + "X-RateLimit-Bucket"?: string; + /** + * @description Cost of the current request in budget units + * @example 1 + */ + "X-RateLimit-Cost"?: string; + /** + * @description Budget policy for this request class + * @example 30;w=60 + */ + "X-RateLimit-Policy"?: string; [name: string]: unknown; }; content: { @@ -3273,11 +3352,26 @@ export interface operations { * @example 120 */ "X-RateLimit-Limit"?: string; + /** + * @description Requests left in the current window + * @example 119 + */ + "X-RateLimit-Remaining"?: string; /** * @description User's current tier * @example free */ "X-RateLimit-Tier"?: string; + /** + * @description Time window in seconds + * @example 60 + */ + "X-RateLimit-Window"?: string; + /** + * @description Unix timestamp when the current window resets + * @example 1700000100 + */ + "X-RateLimit-Reset"?: string; [name: string]: unknown; }; content: { @@ -4591,6 +4685,8 @@ export interface operations { * @example https://example.com/avatar.jpg */ avatar_url?: string; + /** @description Role assigned to new members joining the group. Defaults to `member`. */ + default_role?: components["schemas"]["GroupMembershipRole"]; }; }; }; @@ -4669,6 +4765,8 @@ export interface operations { * @example https://example.com/avatar.jpg */ avatar_url?: string; + /** @description Role assigned to new members joining the group */ + default_role?: components["schemas"]["GroupMembershipRole"]; }; }; }; @@ -4860,6 +4958,46 @@ export interface operations { 429: components["responses"]["RateLimitResponse"]; }; }; + updateGroupMember: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource ID or slug */ + id: components["parameters"]["SlugOrIdParam"]; + /** + * @description User ID of the group member to update + * @example 12345 + */ + user_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + role: components["schemas"]["GroupMembershipRole"]; + }; + }; + }; + responses: { + /** @description Group member updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupMember"]; + }; + }; + 400: components["responses"]["BadRequestResponse"]; + 401: components["responses"]["UnauthorizedResponse"]; + 403: components["responses"]["ForbiddenResponse"]; + 404: components["responses"]["NotFoundResponse"]; + 408: components["responses"]["RequestTimeoutResponse"]; + 429: components["responses"]["RateLimitResponse"]; + }; + }; getGroupInvitations: { parameters: { query?: { diff --git a/src/api/types.ts b/src/api/types.ts index d282295..a542882 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -10,6 +10,7 @@ export type Connection = Schemas["Connection"]; export type Group = Schemas["Group"]; export type GroupInvite = Schemas["GroupInvite"]; export type GroupMember = Schemas["GroupMember"]; +export type GroupMembershipRole = Schemas["GroupMembershipRole"]; export type GroupMemberInviteResponse = Schemas["GroupMemberInviteResponse"]; export type MembershipInvitation = Schemas["MembershipInvitation"]; export type User = Schemas["User"]; diff --git a/src/commands/add.tsx b/src/commands/add.tsx index 86ea0ba..4e8f8de 100644 --- a/src/commands/add.tsx +++ b/src/commands/add.tsx @@ -13,6 +13,7 @@ interface Props { altText?: string; originalSourceUrl?: string; originalSourceTitle?: string; + coverUrl?: string; insertAt?: number; metadata?: Metadata; connectionMetadata?: Metadata; @@ -26,6 +27,7 @@ export function AddCommand({ altText, originalSourceUrl, originalSourceTitle, + coverUrl, insertAt, metadata, connectionMetadata, @@ -55,6 +57,7 @@ export function AddCommand({ alt_text: altText, original_source_url: originalSourceUrl, original_source_title: originalSourceTitle, + cover_url: coverUrl, metadata, }, }), diff --git a/src/commands/group.tsx b/src/commands/group.tsx index 4e690f8..5bd92a3 100644 --- a/src/commands/group.tsx +++ b/src/commands/group.tsx @@ -6,6 +6,7 @@ import type { ContentTypeFilter, GroupInvite, GroupMember, + GroupMembershipRole, GroupMemberInviteResponse, MembershipInvitation, } from "../api/types"; @@ -192,15 +193,22 @@ export function GroupCreateCommand({ name, description, avatarUrl, + defaultRole, }: { name: string; description?: string; avatarUrl?: string; + defaultRole?: GroupMembershipRole; }) { const { data, error, loading } = useCommand(() => getData( client.POST("/v3/groups", { - body: { name, description, avatar_url: avatarUrl }, + body: { + name, + description, + avatar_url: avatarUrl, + default_role: defaultRole, + }, }), ), ); @@ -224,17 +232,24 @@ export function GroupUpdateCommand({ name, description, avatarUrl, + defaultRole, }: { slug: string; name?: string; description?: string; avatarUrl?: string; + defaultRole?: GroupMembershipRole; }) { const { data, error, loading } = useCommand(() => getData( client.PUT("/v3/groups/{id}", { params: { path: { id: slug } }, - body: { name, description, avatar_url: avatarUrl }, + body: { + name, + description, + avatar_url: avatarUrl, + default_role: defaultRole, + }, }), ), ); @@ -387,6 +402,36 @@ export function GroupRemoveMemberCommand({ ); } +export function GroupMemberRoleCommand({ + slug, + userId, + role, +}: { + slug: string; + userId: number; + role: GroupMembershipRole; +}) { + const { data, error, loading } = useCommand(() => + getData( + client.PATCH("/v3/groups/{id}/members/{user_id}", { + params: { path: { id: slug, user_id: userId } }, + body: { role }, + }), + ), + ); + + if (loading) return ; + if (error) return ✕ {error}; + if (!data) return null; + + return ( + + ✓ + Updated {formatGroupMember(data)} + + ); +} + export function GroupInvitationsCommand({ slug, page = 1, diff --git a/src/commands/import.tsx b/src/commands/import.tsx index aba308d..ceb22dd 100644 --- a/src/commands/import.tsx +++ b/src/commands/import.tsx @@ -7,6 +7,8 @@ import { discoverImportFiles, executeImport, importExitCode, + MAX_IMPORT_BATCH_SIZE, + validateImportBatchSize, type ExecuteImportOptions, type ImportEvent, type ImportFile, @@ -29,12 +31,15 @@ export function parseImportOptions( args: string[], flags: Flags, ): ImportCommandOptions { + const batchSize = intFlag(flags, "batch-size") ?? MAX_IMPORT_BATCH_SIZE; + validateImportBatchSize(batchSize); + return { channel: requireArg(args, 0, "channel"), directory: flag(flags, "dir") ?? ".", recursive: flags.recursive !== undefined, interactive: flags.interactive !== undefined, - batchSize: intFlag(flags, "batch-size") ?? 100, + batchSize, uploadConcurrency: intFlag(flags, "upload-concurrency") ?? 4, pollIntervalMs: intFlag(flags, "poll-interval") ?? 2000, }; diff --git a/src/commands/upload.tsx b/src/commands/upload.tsx index d3e6ab5..0c5f16a 100644 --- a/src/commands/upload.tsx +++ b/src/commands/upload.tsx @@ -14,6 +14,7 @@ interface Props { altText?: string; originalSourceUrl?: string; originalSourceTitle?: string; + coverUrl?: string; insertAt?: number; metadata?: Metadata; connectionMetadata?: Metadata; @@ -27,6 +28,7 @@ export function UploadCommand({ altText, originalSourceUrl, originalSourceTitle, + coverUrl, insertAt, metadata, connectionMetadata, @@ -54,6 +56,7 @@ export function UploadCommand({ alt_text: altText, original_source_url: originalSourceUrl, original_source_title: originalSourceTitle, + cover_url: coverUrl, metadata, }, }), diff --git a/src/lib/exit-codes.ts b/src/lib/exit-codes.ts index 6bf0bb8..48308df 100644 --- a/src/lib/exit-codes.ts +++ b/src/lib/exit-codes.ts @@ -43,9 +43,17 @@ export function formatJsonError(err: unknown): { error: string; code: number | null; type: string; + retry_after_ms?: number; + rate_limit?: ArenaError["rateLimit"]; } { if (err instanceof ArenaError) { - return { error: err.message, code: err.status, type: errorType(err) }; + return { + error: err.message, + code: err.status, + type: errorType(err), + retry_after_ms: err.rateLimit?.retryAfterMs, + rate_limit: err.rateLimit, + }; } const message = err instanceof Error ? err.message : String(err); return { error: message, code: null, type: errorType(err) }; diff --git a/src/lib/import.test.ts b/src/lib/import.test.ts index b5eef4b..02cfcb1 100644 --- a/src/lib/import.test.ts +++ b/src/lib/import.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { ArenaError } from "../api/client"; +import { parseImportOptions } from "../commands/import"; import { chunkItems, discoverImportFiles, @@ -104,7 +106,7 @@ test("executeImport handles success path and emits terminal completed event", as channel: "test-channel", directory: ".", recursive: false, - batchSize: 100, + batchSize: 50, uploadConcurrency: 1, pollIntervalMs: 1, adapter, @@ -167,7 +169,7 @@ test("executeImport captures upload and batch failures", async () => { channel: "test-channel", directory: ".", recursive: false, - batchSize: 100, + batchSize: 50, uploadConcurrency: 1, pollIntervalMs: 1, adapter, @@ -219,7 +221,7 @@ test("executeImport retries transient upload failures", async () => { channel: "test-channel", directory: ".", recursive: false, - batchSize: 100, + batchSize: 50, uploadConcurrency: 1, pollIntervalMs: 1, adapter, @@ -269,7 +271,7 @@ test("executeImport tolerates transient batch status errors", async () => { channel: "test-channel", directory: ".", recursive: false, - batchSize: 100, + batchSize: 50, uploadConcurrency: 1, pollIntervalMs: 1, adapter, @@ -282,3 +284,288 @@ test("executeImport tolerates transient batch status errors", async () => { assert.equal(summary.batch_failed, 0); assert.equal(importExitCode(summary), 0); }); + +test("executeImport rejects oversized batch size before discovery or upload", async () => { + let discovered = false; + let uploaded = false; + + const adapter: ImportAdapter = { + async uploadFile(file) { + uploaded = true; + return { s3Url: `https://s3/${file.relativePath}` }; + }, + async submitBatch() { + return { batch_id: "batch-1" }; + }, + async getBatchStatus(batchId) { + return { + batch_id: batchId, + status: "completed", + total: 1, + successful_count: 1, + failed_count: 0, + }; + }, + async sleep() {}, + }; + + await assert.rejects( + executeImport({ + channel: "test-channel", + directory: ".", + recursive: false, + batchSize: 51, + uploadConcurrency: 1, + pollIntervalMs: 1, + adapter, + discover: async () => { + discovered = true; + return [{ absolutePath: "/tmp/a.txt", relativePath: "a.txt" }]; + }, + }), + /permits at most 50/, + ); + + assert.equal(discovered, false); + assert.equal(uploaded, false); +}); + +test("executeImport does not retry ambiguous batch submission failures", async () => { + const files: ImportFile[] = [ + { absolutePath: "/tmp/a.txt", relativePath: "a.txt" }, + ]; + let submitAttempts = 0; + + const adapter: ImportAdapter = { + async uploadFile(file) { + return { s3Url: `https://s3/${file.relativePath}` }; + }, + async submitBatch() { + submitAttempts += 1; + throw new ArenaError("response lost after write", 500); + }, + async getBatchStatus() { + throw new Error("status should not be checked"); + }, + async sleep() {}, + }; + + await assert.rejects( + executeImport({ + channel: "test-channel", + directory: ".", + recursive: false, + batchSize: 50, + uploadConcurrency: 1, + pollIntervalMs: 1, + adapter, + discover: async () => files, + }), + /response lost after write/, + ); + + assert.equal(submitAttempts, 1); +}); + +test("executeImport honors Retry-After for upload rate limits", async (t) => { + let now = 0; + t.mock.method(Date, "now", () => now); + const files: ImportFile[] = [ + { absolutePath: "/tmp/rate-limited.txt", relativePath: "rate-limited.txt" }, + ]; + const sleepCalls: number[] = []; + let uploadAttempts = 0; + + const adapter: ImportAdapter = { + async uploadFile(file) { + uploadAttempts += 1; + if (uploadAttempts === 1) { + throw new ArenaError("write rate limit exceeded", 429, { + rateLimit: { retryAfterMs: 1_200 }, + }); + } + return { s3Url: `https://s3/${file.relativePath}` }; + }, + async submitBatch() { + return { batch_id: "batch-1" }; + }, + async getBatchStatus(batchId) { + return { + batch_id: batchId, + status: "completed", + total: 1, + successful_count: 1, + failed_count: 0, + }; + }, + async sleep(ms) { + sleepCalls.push(ms); + now += ms; + }, + }; + + const summary = await executeImport({ + channel: "test-channel", + directory: ".", + recursive: false, + batchSize: 50, + uploadConcurrency: 1, + pollIntervalMs: 1, + adapter, + discover: async () => files, + }); + + assert.equal(uploadAttempts, 2); + assert.ok(sleepCalls.includes(1_200)); + assert.equal(summary.upload_failed, 0); +}); + +test("batch submission retries only explicit rate-limit rejections", async () => { + const sleeps: number[] = []; + let submissions = 0; + const summary = await executeImport({ + channel: "test", + directory: ".", + recursive: false, + batchSize: 50, + uploadConcurrency: 1, + pollIntervalMs: 1, + selectedFiles: [{ absolutePath: "/mock/a", relativePath: "a" }], + adapter: { + uploadFile: async () => ({ s3Url: "https://example.com/a" }), + submitBatch: async () => { + submissions++; + if (submissions === 1) + throw new ArenaError("rate limited", 429, { + rateLimit: { retryAfterMs: 60_000 }, + }); + return { batch_id: "batch" }; + }, + getBatchStatus: async () => ({ + batch_id: "batch", + status: "completed", + total: 1, + successful_count: 1, + failed_count: 0, + }), + sleep: async (ms) => { + sleeps.push(ms); + }, + }, + }); + assert.equal(submissions, 2); + assert.deepEqual(sleeps, [60_000]); + assert.equal(summary.batch_successful, 1); +}); + +test("workers respect a cooldown extended while they are asleep", async (t) => { + let now = 0; + t.mock.method(Date, "now", () => now); + const pending: Array<{ at: number; resolve: () => void }> = []; + const calls: Array<{ file: number; attempt: number; at: number }> = []; + const attempts = new Map(); + const sleep = (ms: number) => + new Promise((resolve) => { + pending.push({ at: now + ms, resolve }); + }); + let settled = false; + const job = executeImport({ + channel: "test", + directory: ".", + recursive: false, + batchSize: 50, + uploadConcurrency: 3, + pollIntervalMs: 1, + selectedFiles: Array.from({ length: 4 }, (_, i) => ({ + absolutePath: `/mock/${i}`, + relativePath: String(i), + })), + adapter: { + sleep, + uploadFile: async (file) => { + const id = Number(file.relativePath); + const attempt = (attempts.get(id) ?? 0) + 1; + attempts.set(id, attempt); + calls.push({ file: id, attempt, at: now }); + if (id === 0 && attempt === 1) + throw new ArenaError("limited", 429, { + rateLimit: { retryAfterMs: 100 }, + }); + if (id === 1 && attempt === 1) { + await sleep(50); + throw new ArenaError("limited", 429, { + rateLimit: { retryAfterMs: 200 }, + }); + } + return { s3Url: `https://example.com/${id}` }; + }, + submitBatch: async () => ({ batch_id: "batch" }), + getBatchStatus: async () => ({ + batch_id: "batch", + status: "completed", + total: 4, + successful_count: 4, + failed_count: 0, + }), + }, + }).finally(() => { + settled = true; + }); + + // Drain promise continuations before advancing the controlled clock. + for (let step = 0; step < 20 && !settled; step++) { + await new Promise((resolve) => setImmediate(resolve)); + pending.sort((a, b) => a.at - b.at); + const next = pending.shift(); + if (next) { + now = next.at; + next.resolve(); + } + } + assert.equal(settled, true, "import should finish with the controlled clock"); + assert.equal((await job).batch_successful, 4); + assert.deepEqual( + calls.filter((call) => call.at > 0).map((call) => call.at), + [250, 250, 250], + ); +}); + +test("51 files are submitted as batches of 50 and 1", async () => { + const sizes: number[] = []; + await executeImport({ + channel: "test", + directory: ".", + recursive: false, + batchSize: 50, + uploadConcurrency: 4, + pollIntervalMs: 1, + selectedFiles: Array.from({ length: 51 }, (_, i) => ({ + absolutePath: `/mock/${i}`, + relativePath: String(i), + })), + adapter: { + uploadFile: async () => ({ s3Url: "https://example.com/file" }), + submitBatch: async (_channel, blocks) => { + sizes.push(blocks.length); + return { batch_id: String(blocks.length) }; + }, + getBatchStatus: async (id) => ({ + batch_id: id, + status: "completed", + total: Number(id), + successful_count: Number(id), + failed_count: 0, + }), + sleep: async () => {}, + }, + }); + assert.deepEqual(sizes, [50, 1]); +}); + +test("import options default to 50 and reject oversized batches", () => { + assert.equal(parseImportOptions(["example"], {}).batchSize, 50); + assert.throws( + () => parseImportOptions(["example"], { "batch-size": "51" }), + /at most 50/, + ); +}); diff --git a/src/lib/import.ts b/src/lib/import.ts index 5950e34..3d0a30b 100644 --- a/src/lib/import.ts +++ b/src/lib/import.ts @@ -111,6 +111,7 @@ const MAX_BATCH_SUBMIT_ATTEMPTS = 3; const MAX_BATCH_STATUS_ATTEMPTS = 4; const MAX_STATUS_FAILURE_STREAK = 5; const RETRY_BASE_DELAY_MS = 300; +export const MAX_IMPORT_BATCH_SIZE = 50; export interface ImportAdapter { uploadFile: (file: ImportFile) => Promise<{ s3Url: string }>; @@ -141,6 +142,17 @@ export function importExitCode(summary: ImportSummary): number { return summary.upload_failed > 0 || summary.batch_failed > 0 ? 1 : 0; } +export function validateImportBatchSize(batchSize: number): void { + if (!Number.isInteger(batchSize) || batchSize <= 0) { + throw new Error(`Invalid --batch-size: ${batchSize}`); + } + if (batchSize > MAX_IMPORT_BATCH_SIZE) { + throw new Error( + `Invalid --batch-size: ${batchSize}. The v3 batch API permits at most ${MAX_IMPORT_BATCH_SIZE} blocks per request.`, + ); + } +} + export function chunkItems(items: T[], size: number): T[][] { if (!Number.isInteger(size) || size <= 0) { throw new Error(`Invalid chunk size: ${size}`); @@ -310,26 +322,66 @@ function isTransientError(err: unknown): boolean { ); } +function rateLimitDelayMs(err: unknown): number | undefined { + if (!(err instanceof ArenaError) || err.status !== 429) return undefined; + + const retryAfterMs = err.rateLimit?.retryAfterMs; + if (retryAfterMs !== undefined) return Math.max(0, retryAfterMs); + + const resetAtMs = err.rateLimit?.resetAtMs; + if (resetAtMs === undefined) return undefined; + return Math.max(0, resetAtMs - Date.now()); +} + +class RateLimitCoordinator { + private nextAttemptAtMs = 0; + + async wait(sleep: (ms: number) => Promise): Promise { + while (this.nextAttemptAtMs > Date.now()) { + await sleep(this.nextAttemptAtMs - Date.now()); + } + } + + defer(delayMs: number): void { + this.nextAttemptAtMs = Math.max(this.nextAttemptAtMs, Date.now() + delayMs); + } +} + async function retryWithBackoff(options: { attempts: number; baseDelayMs: number; sleep: (ms: number) => Promise; run: () => Promise; shouldRetry: (err: unknown) => boolean; + rateLimitCoordinator?: RateLimitCoordinator; }): Promise { - const { attempts, baseDelayMs, sleep, run, shouldRetry } = options; + const { + attempts, + baseDelayMs, + sleep, + run, + shouldRetry, + rateLimitCoordinator, + } = options; let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { try { + if (rateLimitCoordinator) await rateLimitCoordinator.wait(sleep); return await run(); } catch (err: unknown) { lastError = err; + const waitMs = + rateLimitDelayMs(err) ?? + Math.min(baseDelayMs * 2 ** (attempt - 1), 5_000); + const sharedCooldown = + rateLimitCoordinator && err instanceof ArenaError && err.status === 429; + // Publish even the final rejection so other workers respect the cooldown. + if (sharedCooldown) rateLimitCoordinator.defer(waitMs); if (attempt >= attempts || !shouldRetry(err)) { throw err; } - const waitMs = Math.min(baseDelayMs * 2 ** (attempt - 1), 5_000); - await sleep(waitMs); + if (!sharedCooldown) await sleep(waitMs); } } @@ -377,6 +429,7 @@ async function runImportPipeline(options: { let failedUploads = 0; let cursor = 0; + const uploadRateLimitCoordinator = new RateLimitCoordinator(); const workerCount = Math.max( 1, Math.min(uploadConcurrency, Math.max(1, selectedFiles.length)), @@ -398,6 +451,7 @@ async function runImportPipeline(options: { sleep: adapter.sleep, run: () => adapter.uploadFile(file), shouldRetry: isTransientError, + rateLimitCoordinator: uploadRateLimitCoordinator, }); uploadedByIndex[index] = { index, file, s3Url: uploaded.s3Url }; successfulUploads += 1; @@ -453,7 +507,8 @@ async function runImportPipeline(options: { channel, chunk.map((entry) => ({ value: entry.s3Url })), ), - shouldRetry: isTransientError, + // Only a rate-limit rejection guarantees the batch was not created. + shouldRetry: (err) => err instanceof ArenaError && err.status === 429, }); batches.push({ @@ -577,6 +632,7 @@ export async function executeImport( } = options; throwIfAborted(signal); + validateImportBatchSize(options.batchSize); let files = selectedFiles; let totalDiscovered = discoveredCount; diff --git a/src/lib/registry-v3.test.ts b/src/lib/registry-v3.test.ts new file mode 100644 index 0000000..230d81f --- /dev/null +++ b/src/lib/registry-v3.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { commandMap } from "./registry"; + +process.env.ARENA_VCR_MODE = "off"; + +for (const role of ["viewer", "member", "admin"]) { + for (const subcommand of ["create", "update", "member-role"]) { + test(`group ${subcommand} sends the ${role} role`, async (t) => { + const calls: Array<{ method: string; path: string; body: unknown }> = []; + t.mock.method( + globalThis, + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + calls.push({ + method: request.method, + path: new URL(request.url).pathname, + body: await request.json(), + }); + return Response.json({ id: 123, role }); + }, + ); + const args = [subcommand, "example"]; + const roleFlag = subcommand === "member-role" ? "role" : "default-role"; + if (subcommand === "member-role") args.push("123"); + const command = commandMap.get("group")!; + await command.json!(args, { [roleFlag]: role }); + assert.deepEqual(calls, [ + { + method: + subcommand === "create" + ? "POST" + : subcommand === "update" + ? "PUT" + : "PATCH", + path: + subcommand === "create" + ? "/v3/groups" + : subcommand === "update" + ? "/v3/groups/example" + : "/v3/groups/example/members/123", + body: + subcommand === "create" + ? { name: "example", default_role: role } + : subcommand === "update" + ? { default_role: role } + : { role }, + }, + ]); + const props = command.render(args, { [roleFlag]: role }).props; + assert.equal( + props[subcommand === "member-role" ? "role" : "defaultRole"], + role, + ); + }); + } +} + +for (const value of [true, "", "owner"]) { + test(`group rejects an invalid default role ${JSON.stringify(value)} before writing`, async (t) => { + const fetch = t.mock.method(globalThis, "fetch", () => { + throw new Error("Unexpected network access"); + }); + await assert.rejects( + commandMap.get("group")!.json!(["create", "example"], { + "default-role": value, + }), + /requires a value|Invalid --default-role/, + ); + assert.equal(fetch.mock.callCount(), 0); + }); +} + +for (const name of ["add", "upload", "batch"]) { + test(`${name} sends cover_url in the block payload`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "arena-cover-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = join(root, "document.pdf"); + await writeFile(file, "mock upload"); + const writes: Array<{ path: string; body: Record }> = []; + t.mock.method( + globalThis, + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const path = new URL(request.url).pathname; + if (request.method === "GET") return Response.json({ id: 42 }); + if (path === "/v3/uploads/presign") + return Response.json({ + files: [ + { + upload_url: "https://upload.test/file", + key: "document.pdf", + content_type: "application/pdf", + }, + ], + }); + if (request.method === "PUT") + return new Response(null, { status: 200 }); + writes.push({ path, body: await request.json() }); + return Response.json({ id: 1, batch_id: "batch" }); + }, + ); + const command = commandMap.get(name)!; + const args = + name === "upload" ? [file] : ["example", "https://example.com/page"]; + const flags = { + channel: "example", + "cover-url": "https://example.com/cover.jpg", + }; + await command.json!(args, flags); + assert.equal(writes.length, 1); + assert.equal( + writes[0]!.path, + name === "batch" ? "/v3/blocks/batch" : "/v3/blocks", + ); + const body = writes[0]!.body; + const blocks = name === "batch" ? body.blocks : [body]; + assert.ok(Array.isArray(blocks)); + assert.equal(blocks[0].cover_url, flags["cover-url"]); + if (name !== "batch") + assert.equal( + command.render(args, flags).props.coverUrl, + flags["cover-url"], + ); + }); +} diff --git a/src/lib/registry.test.ts b/src/lib/registry.test.ts index d459abe..7560b93 100644 --- a/src/lib/registry.test.ts +++ b/src/lib/registry.test.ts @@ -531,12 +531,12 @@ describe("error handling", () => { test("non-existent channel returns Not Found", async () => { await assert.rejects( json("channel", "this-channel-definitely-does-not-exist-xyz-999"), - /Not Found/, + /Not Found|not exist/i, ); }); test("non-existent block returns Not Found", async () => { - await assert.rejects(json("block", "999999999"), /Not Found/); + await assert.rejects(json("block", "999999999"), /Not Found|not exist/i); }); test("exit code 0 on success", async () => { diff --git a/src/lib/registry.tsx b/src/lib/registry.tsx index b0d6b73..862d5d6 100644 --- a/src/lib/registry.tsx +++ b/src/lib/registry.tsx @@ -8,6 +8,7 @@ import type { ContentTypeFilter, FileExtension, FollowableType, + GroupMembershipRole, GroupSort, Movement, SearchScope, @@ -66,6 +67,7 @@ import { GroupInviteLinkCommand, GroupJoinCommand, GroupLeaveCommand, + GroupMemberRoleCommand, GroupMembersCommand, GroupRemoveMemberCommand, GroupRevokeInvitationCommand, @@ -159,6 +161,37 @@ function groupInviteBody( return userId ? { user_id: userId } : { email: email! }; } +const GROUP_MEMBERSHIP_ROLES: GroupMembershipRole[] = [ + "viewer", + "member", + "admin", +]; + +function groupMembershipRoleFlag( + flags: Flags, + key: string, +): GroupMembershipRole | undefined { + const value = flags[key]; + if (value === undefined) return undefined; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`--${key} requires a value`); + } + if (!GROUP_MEMBERSHIP_ROLES.includes(value as GroupMembershipRole)) { + throw new Error( + `Invalid --${key}: ${value}. Expected viewer, member, or admin.`, + ); + } + return value as GroupMembershipRole; +} + +function requireGroupMembershipRoleFlag( + flags: Flags, + key: string, +): GroupMembershipRole { + requireFlag(flags, key); + return groupMembershipRoleFlag(flags, key)!; +} + // ── Registry ── export const commands: CommandDefinition[] = [ @@ -600,7 +633,7 @@ export const commands: CommandDefinition[] = [ help: [ { usage: - "add [--title ] [--description ] [--alt-text ] [--original-source-url ] [--original-source-title ] [--insert-at ]", + "add [--title ] [--description ] [--alt-text ] [--original-source-url ] [--original-source-title ] [--cover-url ] [--insert-at ]", description: "Options", }, { @@ -640,6 +673,7 @@ export const commands: CommandDefinition[] = [ altText={flag(flags, "alt-text")} originalSourceUrl={flag(flags, "original-source-url")} originalSourceTitle={flag(flags, "original-source-title")} + coverUrl={flag(flags, "cover-url")} insertAt={intFlag(flags, "insert-at")} metadata={entityMetadataFlag(flags)} connectionMetadata={entityMetadataFlag(flags, "connection-metadata")} @@ -679,6 +713,7 @@ export const commands: CommandDefinition[] = [ alt_text: flag(flags, "alt-text"), original_source_url: flag(flags, "original-source-url"), original_source_title: flag(flags, "original-source-title"), + cover_url: flag(flags, "cover-url"), metadata, }, }), @@ -693,7 +728,7 @@ export const commands: CommandDefinition[] = [ help: [ { usage: - "upload --channel [--title ] [--description ] [--alt-text ] [--original-source-url ] [--original-source-title ] [--insert-at ]", + "upload --channel [--title ] [--description ] [--alt-text ] [--original-source-url ] [--original-source-title ] [--cover-url ] [--insert-at ]", description: "Options", }, { @@ -734,6 +769,7 @@ export const commands: CommandDefinition[] = [ altText={flag(flags, "alt-text")} originalSourceUrl={flag(flags, "original-source-url")} originalSourceTitle={flag(flags, "original-source-title")} + coverUrl={flag(flags, "cover-url")} insertAt={intFlag(flags, "insert-at")} metadata={entityMetadataFlag(flags)} connectionMetadata={entityMetadataFlag(flags, "connection-metadata")} @@ -772,6 +808,7 @@ export const commands: CommandDefinition[] = [ alt_text: flag(flags, "alt-text"), original_source_url: flag(flags, "original-source-url"), original_source_title: flag(flags, "original-source-title"), + cover_url: flag(flags, "cover-url"), metadata, }, }), @@ -786,7 +823,7 @@ export const commands: CommandDefinition[] = [ help: [ { usage: - "batch [values...] [--title ] [--description ]", + "batch [values...] [--title ] [--description ] [--cover-url ]", description: "Options", }, { @@ -834,6 +871,7 @@ export const commands: CommandDefinition[] = [ value, title: flag(flags, "title"), description: flag(flags, "description"), + cover_url: flag(flags, "cover-url"), })), }, }), @@ -1224,13 +1262,13 @@ export const commands: CommandDefinition[] = [ { usage: "group are-na-team", description: "Example" }, { usage: - "group create [--description ] [--avatar-url ]", + "group create [--description ] [--avatar-url ] [--default-role ]", description: "Options", }, { usage: 'group create "Research Studio"', description: "Example" }, { usage: - "group update [--name ] [--description ] [--avatar-url ]", + "group update [--name ] [--description ] [--avatar-url ] [--default-role ]", description: "Options", }, { @@ -1262,6 +1300,15 @@ export const commands: CommandDefinition[] = [ usage: "group remove-member research-studio 12345", description: "Example", }, + { + usage: + "group member-role --role ", + description: "Options", + }, + { + usage: "group member-role research-studio 12345 --role admin", + description: "Example", + }, { usage: "group invitations [--page ] [--per ]", description: "Options", @@ -1332,6 +1379,7 @@ export const commands: CommandDefinition[] = [ name={requireArg(args, 1, "name")} description={flag(flags, "description")} avatarUrl={flag(flags, "avatar-url")} + defaultRole={groupMembershipRoleFlag(flags, "default-role")} /> ); case "update": @@ -1341,6 +1389,7 @@ export const commands: CommandDefinition[] = [ name={flag(flags, "name")} description={flag(flags, "description")} avatarUrl={flag(flags, "avatar-url")} + defaultRole={groupMembershipRoleFlag(flags, "default-role")} /> ); case "delete": @@ -1369,6 +1418,14 @@ export const commands: CommandDefinition[] = [ userId={idArg(args, 2, "user id")} /> ); + case "member-role": + return ( + + ); case "invitations": return ( = { flag: "--original-source-title ", description: "Original source title", }, + { + flag: "--cover-url ", + description: "Preview image URL for Link, Attachment, and Embed blocks", + }, { flag: "--insert-at ", description: "Insert position within the channel", @@ -2098,6 +2173,10 @@ export const commandHelpDocs: Record = { flag: "--original-source-title ", description: "Optional original source title", }, + { + flag: "--cover-url ", + description: "Preview image URL for Link, Attachment, and Embed blocks", + }, { flag: "--insert-at ", description: "Optional insert position within the channel", @@ -2123,6 +2202,10 @@ export const commandHelpDocs: Record = { flag: "--description ", description: "Default description for each block", }, + { + flag: "--cover-url ", + description: "Preview image URL for Link, Attachment, and Embed blocks", + }, ], examples: [ 'arena batch my-channel "https://a.com" "https://b.com"', @@ -2142,7 +2225,7 @@ export const commandHelpDocs: Record = { }, { flag: "--batch-size ", - description: "Batch size for async create calls", + description: "Batch size for async create calls (default/max: 50)", }, { flag: "--upload-concurrency ", @@ -2287,6 +2370,10 @@ export const commandHelpDocs: Record = { flag: "--avatar-url ", description: "Optional avatar image URL", }, + { + flag: "--default-role ", + description: "Role assigned to new members (default: member)", + }, ], examples: ['arena group create "Research Studio"'], }, @@ -2297,6 +2384,10 @@ export const commandHelpDocs: Record = { { flag: "--name ", description: "New group name" }, { flag: "--description ", description: "New description" }, { flag: "--avatar-url ", description: "New avatar image URL" }, + { + flag: "--default-role ", + description: "Role assigned to new members", + }, ], examples: ['arena group update research-studio --name "Studio Notes"'], }, @@ -2332,6 +2423,21 @@ export const commandHelpDocs: Record = { usage: ["arena group remove-member "], examples: ["arena group remove-member research-studio 12345"], }, + "member-role": { + summary: "Update a group member's role.", + usage: [ + "arena group member-role --role ", + ], + options: [ + { + flag: "--role ", + description: "New role for the member", + }, + ], + examples: [ + "arena group member-role research-studio 12345 --role admin", + ], + }, invitations: { summary: "List pending group invitations.", usage: ["arena group invitations [flags]"], diff --git a/src/lib/vcr.test.ts b/src/lib/vcr.test.ts new file mode 100644 index 0000000..b5d6095 --- /dev/null +++ b/src/lib/vcr.test.ts @@ -0,0 +1,118 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +test("vcr replay requires request body to match", async () => { + const root = await mkdtemp(join(tmpdir(), "arena-vcr-")); + const cassetteName = "request-body"; + const vcrPath = fileURLToPath(new URL("./vcr.ts", import.meta.url)); + const projectRoot = resolve(dirname(vcrPath), "../.."); + + try { + await writeFile( + join(root, `${cassetteName}.json`), + JSON.stringify({ + entries: [ + { + method: "POST", + url: "https://api.test/v3/channels", + requestBody: JSON.stringify({ title: "Recorded" }), + status: 201, + statusText: "Created", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: 1 }), + }, + ], + }), + ); + + const script = ` + import assert from "node:assert/strict"; + import { vcrFetch } from ${JSON.stringify(pathToFileURL(vcrPath).href)}; + + await assert.rejects( + vcrFetch("https://api.test/v3/channels", { + method: "POST", + body: JSON.stringify({}), + }), + /VCR replay miss/, + ); + + const response = await vcrFetch("https://api.test/v3/channels", { + method: "POST", + body: JSON.stringify({ title: "Recorded" }), + }); + assert.equal(response.status, 201); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--eval", script], + { + cwd: projectRoot, + encoding: "utf8", + env: { + ...process.env, + ARENA_VCR_MODE: "replay", + ARENA_VCR_DIR: root, + ARENA_VCR_NAME: cassetteName, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +for (const cassette of ["missing", "empty"]) { + test(`strict replay never fetches with a ${cassette} cassette`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "arena-vcr-empty-")); + t.after(() => rm(root, { recursive: true, force: true })); + if (cassette === "empty") { + await writeFile( + join(root, "empty.json"), + JSON.stringify({ entries: [] }), + ); + } + const vcrUrl = new URL("./vcr.ts", import.meta.url); + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` + import assert from "node:assert/strict"; + import { vcrFetch } from ${JSON.stringify(vcrUrl.href)}; + let fetched = false; + globalThis.fetch = async () => { + fetched = true; + return new Response("{}"); + }; + await assert.rejects(vcrFetch("https://api.test/v3/channels", { + method: "POST", body: "{}", + }), /VCR replay miss/); + assert.equal(fetched, false); + `, + ], + { + cwd: resolve(dirname(fileURLToPath(vcrUrl)), "../.."), + encoding: "utf8", + env: { + ...process.env, + ARENA_VCR_MODE: "replay", + ARENA_VCR_DIR: root, + ARENA_VCR_NAME: cassette, + }, + }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + }); +} diff --git a/src/lib/vcr.ts b/src/lib/vcr.ts index 7348fd0..fbbd5e7 100644 --- a/src/lib/vcr.ts +++ b/src/lib/vcr.ts @@ -85,6 +85,23 @@ function buildResponse( return new Response(nullBody ? null : body, { status, statusText, headers }); } +function requestMatches( + entry: RecordedEntry, + request: Pick, +): boolean { + return ( + entry.method === request.method && + entry.url === request.url && + entry.requestBody === request.requestBody + ); +} + +function describeRequest( + entry?: Pick, +): string { + return entry ? `${entry.method} ${entry.url}` : "end of cassette"; +} + export async function vcrFetch( input: RequestInfo | URL, init?: RequestInit, @@ -97,12 +114,17 @@ export async function vcrFetch( const url = normalized.url; const requestBody = method === "GET" || method === "HEAD" ? null : await normalized.text(); + const replayRequest = { + method, + url, + requestBody, + }; const tape = ensureCassette(); - if (canReplay && (mode === "replay" || mode === "auto")) { + if (mode === "replay" || (canReplay && mode === "auto")) { const entry = tape.entries[replayIndex]; - if (entry && entry.method === method && entry.url === url) { + if (entry && requestMatches(entry, replayRequest)) { replayIndex++; return buildResponse( entry.body, @@ -113,7 +135,7 @@ export async function vcrFetch( } if (mode === "replay") { throw new Error( - `VCR replay miss at index ${replayIndex}: expected ${entry?.method} ${entry?.url}, got ${method} ${url}`, + `VCR replay miss at index ${replayIndex}: expected ${describeRequest(entry)}, got ${method} ${url}${entry && entry.requestBody !== requestBody ? " (request body differs)" : ""}`, ); } }