diff --git a/package.json b/package.json index db5aa4ac..83d8051d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makeplane/plane-node-sdk", - "version": "0.3.1", + "version": "0.3.2", "description": "Node SDK for Plane", "author": "Plane ", "repository": { diff --git a/scripts/__fixtures__/types-bundle-exports.snapshot.txt b/scripts/__fixtures__/types-bundle-exports.snapshot.txt index 855d9c41..f40d9d8d 100644 --- a/scripts/__fixtures__/types-bundle-exports.snapshot.txt +++ b/scripts/__fixtures__/types-bundle-exports.snapshot.txt @@ -949,9 +949,10 @@ root WorkItemWorklogShapeParams WorkItemWorklogShapeParams 331380768833d1b4 root WorkLog WorkLog 90f273ede3ad2d88 root WorkLogs WorkLogs$1 222ab67fa4d6bdc6 root WorklogSummaryEntry WorklogSummaryEntry 62a9c9c9b87b025d -root Workspace Workspace$1 7870bade783326a9 +root Workspace Workspace$1 060eb8698c3ccbc7 root WorkspaceAsset WorkspaceAsset 158f63c15d06b6c0 root WorkspaceAssetConfirmRequest WorkspaceAssetConfirmRequest a9b006b236b1b790 +root WorkspaceAssetDownload WorkspaceAssetDownload b387abb5d86fee04 root WorkspaceAssetField WorkspaceAssetField 827cba08b2a129af root WorkspaceAssetFieldsParams WorkspaceAssetFieldsParams c6a2feb8ef25577c root WorkspaceAssetOrderBy WorkspaceAssetOrderBy 8aee13377cf9a336 diff --git a/src/api/Workspace.ts b/src/api/Workspace.ts index ef091fbe..498f064b 100644 --- a/src/api/Workspace.ts +++ b/src/api/Workspace.ts @@ -2,6 +2,7 @@ import { BaseResource } from "./BaseResource"; import { Configuration } from "../Configuration"; import { WorkspaceMember, ListMembersLiteParams, ProjectRoleDistribution } from "../models/Member"; import { PaginatedResponse } from "../models/common"; +import { WorkspaceAssetDownload } from "../models/WorkspaceAsset"; import { UpdateWorkspaceFeatures, WorkspaceFeatures } from "../models/WorkspaceFeatures"; /** @@ -41,6 +42,19 @@ export class Workspace extends BaseResource { return this.get(`/workspaces/${workspaceSlug}/project-role-distribution/`); } + /** + * Get a presigned download URL for a workspace asset. + * Covers any uploaded asset in the workspace, including images embedded in work + * item descriptions, which are not listed as work item attachments. The URL is + * time-limited and needs no Plane auth to fetch. + * + * @param workspaceSlug - The workspace slug + * @param assetId - UUID of the asset (the `src` of an `image-component` tag) + */ + async getAsset(workspaceSlug: string, assetId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/assets/${assetId}/`); + } + /** * Retrieve workspace features */ diff --git a/src/models/WorkspaceAsset.ts b/src/models/WorkspaceAsset.ts new file mode 100644 index 00000000..7fc50ee8 --- /dev/null +++ b/src/models/WorkspaceAsset.ts @@ -0,0 +1,13 @@ +/** + * Presigned download details for a workspace asset. + * + * Returned by `client.workspace.getAsset()`. `asset_url` is time-limited and can + * be fetched without Plane authentication. It is `null` when the server could not + * presign the stored file (the endpoint still answers 200 in that case). + */ +export interface WorkspaceAssetDownload { + asset_id: string; + asset_url: string | null; + asset_name?: string | null; + asset_type?: string | null; +} diff --git a/src/models/index.ts b/src/models/index.ts index 9396393c..8e145a55 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -30,6 +30,7 @@ export * from "./WorkItemPage"; export * from "./WorkItemRelation"; export * from "./WorkItemType"; export * from "./WorkLog"; +export * from "./WorkspaceAsset"; export * from "./WorkspaceFeatures"; export * from "./WorkspaceTemplate"; export * from "./WorkspaceProjectLabel"; diff --git a/tests/e2e/v2/collections.e2e.test.ts b/tests/e2e/v2/collections.e2e.test.ts index 7bc4b8ff..7870617e 100644 --- a/tests/e2e/v2/collections.e2e.test.ts +++ b/tests/e2e/v2/collections.e2e.test.ts @@ -42,7 +42,9 @@ maybe("v2 collections (live)", () => { const collection = await wiki().collections.create(slug(), { name: uniqueName("collection-pages"), }); - const page = await suite.projectRow.pages.create({ + // A collection only holds workspace wiki pages — a project-scoped page is a 400 + // ("Page ... is not a workspace wiki page"), so create this one at the workspace path. + const page = await wiki().pages.create(slug(), { name: uniqueName("page-for-collection"), }); try { @@ -52,7 +54,13 @@ maybe("v2 collections (live)", () => { const removed = await wiki().collections.pages.remove(slug(), collection.id, [page.id]); expect(removed).toContain(page.id); } finally { - await suite.projectRow.pages.delete(page.id).catch(() => undefined); + // A workspace page must be archived (and unlocked) before it can be deleted. + await wiki() + .pages.update(slug(), page.id, { archived_at: new Date().toISOString(), is_locked: false }) + .catch(() => undefined); + await wiki() + .pages.delete(slug(), page.id) + .catch(() => undefined); await wiki() .collections.delete(slug(), collection.id) .catch(() => undefined); diff --git a/tests/e2e/v2/group-sync.e2e.test.ts b/tests/e2e/v2/group-sync.e2e.test.ts index e0de1a17..a8843138 100644 --- a/tests/e2e/v2/group-sync.e2e.test.ts +++ b/tests/e2e/v2/group-sync.e2e.test.ts @@ -27,7 +27,9 @@ maybe("GroupSync (v2, live)", () => { try { const created = await groupSync().projectMappings.create(slug(), { idp_group_name: `sdk-e2e-${Date.now()}`, - role_slug: "member", + // Project roles are admin/contributor/commenter/guest — there is no project `member` + // (that slug is a workspace role), so "member" here is a 400 "Unknown project role slug". + role_slug: "contributor", project_id: suite.projectId, }); createdId = created.id; diff --git a/tests/unit/workspace-assets.test.ts b/tests/unit/workspace-assets.test.ts new file mode 100644 index 00000000..b7a67923 --- /dev/null +++ b/tests/unit/workspace-assets.test.ts @@ -0,0 +1,67 @@ +import nock from "nock"; +import { PlaneClient } from "../../src/client/plane-client"; +import { HttpError } from "../../src/errors"; + +const BASE = "https://plane.test"; +const SLUG = "acme"; +const ASSET_ID = "0b1f6d1e-6f0e-4f0a-9d63-2f3c1f6f2a11"; +const ROUTE = `/api/v1/workspaces/${SLUG}/assets/${ASSET_ID}/`; + +describe("Workspace.getAsset (offline)", () => { + const client = new PlaneClient({ baseUrl: BASE, apiKey: "test-key" }); + + afterEach(() => nock.cleanAll()); + + it("returns the download details", async () => { + const body = { + asset_id: ASSET_ID, + asset_url: "https://bucket.s3.example/signed?X-Amz-Signature=abc", + asset_name: "screenshot.png", + asset_type: "image/png", + }; + nock(BASE).get(ROUTE).reply(200, body); + + const asset = await client.workspace.getAsset(SLUG, ASSET_ID); + + expect(asset).toEqual(body); + }); + + it("hits the workspace asset detail route with the API key header", async () => { + const scope = nock(BASE, { reqheaders: { "x-api-key": "test-key" } }) + .get(ROUTE) + .reply(200, { asset_id: ASSET_ID, asset_url: "https://signed.example/x" }); + + await client.workspace.getAsset(SLUG, ASSET_ID); + + expect(scope.isDone()).toBe(true); + }); + + it("tolerates a null url", async () => { + nock(BASE).get(ROUTE).reply(200, { asset_id: ASSET_ID, asset_url: null }); + + const asset = await client.workspace.getAsset(SLUG, ASSET_ID); + + expect(asset.asset_url).toBeNull(); + }); + + it("leaves optional fields undefined when the server omits them", async () => { + nock(BASE).get(ROUTE).reply(200, { asset_id: ASSET_ID, asset_url: "https://signed.example/x" }); + + const asset = await client.workspace.getAsset(SLUG, ASSET_ID); + + expect(asset.asset_name).toBeUndefined(); + expect(asset.asset_type).toBeUndefined(); + }); + + it.each([ + [400, "Asset not yet uploaded"], + [404, "Asset not found"], + ])("raises HttpError for a %i response", async (status, error) => { + nock(BASE).get(ROUTE).reply(status, { error }); + + const failure = await client.workspace.getAsset(SLUG, ASSET_ID).catch((e: unknown) => e); + + expect(failure).toBeInstanceOf(HttpError); + expect((failure as HttpError).statusCode).toBe(status); + }); +});