diff --git a/src/api/Releases/Comments.ts b/src/api/Releases/Comments.ts new file mode 100644 index 0000000..c4ffec0 --- /dev/null +++ b/src/api/Releases/Comments.ts @@ -0,0 +1,76 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + ReleaseComment, + CreateReleaseComment, + UpdateReleaseComment, + ListReleaseCommentsParams, +} from "../../models/ReleaseComment"; +import { PaginatedResponse } from "../../models/common"; + +/** + * Release Comments API resource + * Handles comments on a specific release. + */ +export class Comments extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * Create a comment on a release + */ + async create( + workspaceSlug: string, + releaseId: string, + createReleaseComment: CreateReleaseComment + ): Promise { + return this.post( + `/workspaces/${workspaceSlug}/releases/${releaseId}/comments/`, + createReleaseComment + ); + } + + /** + * Retrieve a release comment by ID + */ + async retrieve(workspaceSlug: string, releaseId: string, commentId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/releases/${releaseId}/comments/${commentId}/`); + } + + /** + * Update a release comment + */ + async update( + workspaceSlug: string, + releaseId: string, + commentId: string, + updateReleaseComment: UpdateReleaseComment + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/releases/${releaseId}/comments/${commentId}/`, + updateReleaseComment + ); + } + + /** + * Delete a release comment + */ + async delete(workspaceSlug: string, releaseId: string, commentId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/releases/${releaseId}/comments/${commentId}/`); + } + + /** + * List comments on a release + */ + async list( + workspaceSlug: string, + releaseId: string, + params?: ListReleaseCommentsParams + ): Promise> { + return this.get>( + `/workspaces/${workspaceSlug}/releases/${releaseId}/comments/`, + params + ); + } +} diff --git a/src/api/Releases/Labels.ts b/src/api/Releases/Labels.ts new file mode 100644 index 0000000..0b64df4 --- /dev/null +++ b/src/api/Releases/Labels.ts @@ -0,0 +1,98 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + ReleaseLabel, + CreateReleaseLabel, + UpdateReleaseLabel, + ListReleaseLabelsParams, +} from "../../models/ReleaseLabel"; +import { PaginatedResponse } from "../../models/common"; +import { AddReleaseLabelsRequest, RemoveReleaseLabelsRequest } from "../../models/Release"; + +/** + * Release Labels API resource + * Handles workspace-level release label CRUD and the label relationships on a release. + */ +export class Labels extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * Create a new release label + */ + async create(workspaceSlug: string, createReleaseLabel: CreateReleaseLabel): Promise { + return this.post(`/workspaces/${workspaceSlug}/releases/labels/`, createReleaseLabel); + } + + /** + * Retrieve a release label by ID + */ + async retrieve(workspaceSlug: string, releaseLabelId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/releases/labels/${releaseLabelId}/`); + } + + /** + * Update a release label + */ + async update( + workspaceSlug: string, + releaseLabelId: string, + updateReleaseLabel: UpdateReleaseLabel + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/releases/labels/${releaseLabelId}/`, + updateReleaseLabel + ); + } + + /** + * Delete a release label + */ + async delete(workspaceSlug: string, releaseLabelId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/releases/labels/${releaseLabelId}/`); + } + + /** + * List release labels with optional filtering + */ + async list(workspaceSlug: string, params?: ListReleaseLabelsParams): Promise> { + return this.get>(`/workspaces/${workspaceSlug}/releases/labels/`, params); + } + + /** + * Add labels to a release + */ + async addLabels( + workspaceSlug: string, + releaseId: string, + addLabels: AddReleaseLabelsRequest + ): Promise { + return this.post(`/workspaces/${workspaceSlug}/releases/${releaseId}/labels/`, addLabels); + } + + /** + * Remove labels from a release + */ + async removeLabels( + workspaceSlug: string, + releaseId: string, + removeLabels: RemoveReleaseLabelsRequest + ): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/releases/${releaseId}/labels/`, removeLabels); + } + + /** + * Get labels associated with a release + */ + async listLabels( + workspaceSlug: string, + releaseId: string, + params?: ListReleaseLabelsParams + ): Promise> { + return this.get>( + `/workspaces/${workspaceSlug}/releases/${releaseId}/labels/`, + params + ); + } +} diff --git a/src/api/Releases/Links.ts b/src/api/Releases/Links.ts new file mode 100644 index 0000000..0f0916b --- /dev/null +++ b/src/api/Releases/Links.ts @@ -0,0 +1,64 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { ReleaseLink, CreateReleaseLink, UpdateReleaseLink, ListReleaseLinksParams } from "../../models/ReleaseLink"; +import { PaginatedResponse } from "../../models/common"; + +/** + * Release Links API resource + * Handles external links attached to a specific release. + */ +export class Links extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * Create a link on a release + */ + async create(workspaceSlug: string, releaseId: string, createReleaseLink: CreateReleaseLink): Promise { + return this.post(`/workspaces/${workspaceSlug}/releases/${releaseId}/links/`, createReleaseLink); + } + + /** + * Retrieve a release link by ID + */ + async retrieve(workspaceSlug: string, releaseId: string, linkId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/releases/${releaseId}/links/${linkId}/`); + } + + /** + * Update a release link + */ + async update( + workspaceSlug: string, + releaseId: string, + linkId: string, + updateReleaseLink: UpdateReleaseLink + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/releases/${releaseId}/links/${linkId}/`, + updateReleaseLink + ); + } + + /** + * Delete a release link + */ + async delete(workspaceSlug: string, releaseId: string, linkId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/releases/${releaseId}/links/${linkId}/`); + } + + /** + * List links on a release + */ + async list( + workspaceSlug: string, + releaseId: string, + params?: ListReleaseLinksParams + ): Promise> { + return this.get>( + `/workspaces/${workspaceSlug}/releases/${releaseId}/links/`, + params + ); + } +} diff --git a/src/api/Releases/Tags.ts b/src/api/Releases/Tags.ts new file mode 100644 index 0000000..14a18aa --- /dev/null +++ b/src/api/Releases/Tags.ts @@ -0,0 +1,49 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { ReleaseTag, CreateReleaseTag, UpdateReleaseTag, ListReleaseTagsParams } from "../../models/ReleaseTag"; +import { PaginatedResponse } from "../../models/common"; + +/** + * Release Tags API resource + * Handles workspace-level release tag (version identifier) CRUD. + */ +export class Tags extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * Create a new release tag + */ + async create(workspaceSlug: string, createReleaseTag: CreateReleaseTag): Promise { + return this.post(`/workspaces/${workspaceSlug}/releases/tags/`, createReleaseTag); + } + + /** + * Retrieve a release tag by ID + */ + async retrieve(workspaceSlug: string, releaseTagId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/releases/tags/${releaseTagId}/`); + } + + /** + * Update a release tag + */ + async update(workspaceSlug: string, releaseTagId: string, updateReleaseTag: UpdateReleaseTag): Promise { + return this.patch(`/workspaces/${workspaceSlug}/releases/tags/${releaseTagId}/`, updateReleaseTag); + } + + /** + * Delete a release tag + */ + async delete(workspaceSlug: string, releaseTagId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/releases/tags/${releaseTagId}/`); + } + + /** + * List release tags with optional filtering + */ + async list(workspaceSlug: string, params?: ListReleaseTagsParams): Promise> { + return this.get>(`/workspaces/${workspaceSlug}/releases/tags/`, params); + } +} diff --git a/src/api/Releases/WorkItems.ts b/src/api/Releases/WorkItems.ts new file mode 100644 index 0000000..25ffa71 --- /dev/null +++ b/src/api/Releases/WorkItems.ts @@ -0,0 +1,59 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + ReleaseWorkItem, + AddReleaseWorkItemsRequest, + RemoveReleaseWorkItemsRequest, + AddReleaseWorkItemsResponse, +} from "../../models/Release"; +import { PaginatedResponse } from "../../models/common"; + +/** + * Release Work Items API resource + * Handles the work item relationships on a release. Work items can be pulled in + * from multiple projects across the workspace. + */ +export class WorkItems extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List work items in a release + */ + async list( + workspaceSlug: string, + releaseId: string, + params?: { per_page?: number; cursor?: string; [key: string]: any } + ): Promise> { + return this.get>( + `/workspaces/${workspaceSlug}/releases/${releaseId}/work-items/`, + params + ); + } + + /** + * Add work items to a release + */ + async add( + workspaceSlug: string, + releaseId: string, + addWorkItems: AddReleaseWorkItemsRequest + ): Promise { + return this.post( + `/workspaces/${workspaceSlug}/releases/${releaseId}/work-items/`, + addWorkItems + ); + } + + /** + * Remove work items from a release + */ + async remove( + workspaceSlug: string, + releaseId: string, + removeWorkItems: RemoveReleaseWorkItemsRequest + ): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/releases/${releaseId}/work-items/`, removeWorkItems); + } +} diff --git a/src/api/Releases/index.ts b/src/api/Releases/index.ts new file mode 100644 index 0000000..8ca7e81 --- /dev/null +++ b/src/api/Releases/index.ts @@ -0,0 +1,65 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { Release, CreateRelease, UpdateRelease, ListReleasesParams } from "../../models/Release"; +import { PaginatedResponse } from "../../models/common"; +import { Labels } from "./Labels"; +import { Tags } from "./Tags"; +import { WorkItems } from "./WorkItems"; +import { Comments } from "./Comments"; +import { Links } from "./Links"; + +/** + * Releases API resource + * Handles all release-related operations. Releases are workspace-scoped. + */ +export class Releases extends BaseResource { + public labels: Labels; + public tags: Tags; + public workItems: WorkItems; + public comments: Comments; + public links: Links; + + constructor(config: Configuration) { + super(config); + this.labels = new Labels(config); + this.tags = new Tags(config); + this.workItems = new WorkItems(config); + this.comments = new Comments(config); + this.links = new Links(config); + } + + /** + * Create a new release + */ + async create(workspaceSlug: string, createRelease: CreateRelease): Promise { + return this.post(`/workspaces/${workspaceSlug}/releases/`, createRelease); + } + + /** + * Retrieve a release by ID + */ + async retrieve(workspaceSlug: string, releaseId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/releases/${releaseId}/`); + } + + /** + * Update a release + */ + async update(workspaceSlug: string, releaseId: string, updateRelease: UpdateRelease): Promise { + return this.patch(`/workspaces/${workspaceSlug}/releases/${releaseId}/`, updateRelease); + } + + /** + * Delete a release + */ + async delete(workspaceSlug: string, releaseId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/releases/${releaseId}/`); + } + + /** + * List releases with optional filtering + */ + async list(workspaceSlug: string, params?: ListReleasesParams): Promise> { + return this.get>(`/workspaces/${workspaceSlug}/releases/`, params); + } +} diff --git a/src/client/plane-client.ts b/src/client/plane-client.ts index 8245222..cba6bc4 100644 --- a/src/client/plane-client.ts +++ b/src/client/plane-client.ts @@ -19,6 +19,7 @@ import { Stickies } from "../api/Stickies"; import { Teamspaces } from "../api/Teamspaces"; import { Initiatives } from "../api/Initiatives"; import { Milestones } from "../api/Milestones"; +import { Releases } from "../api/Releases"; import { AgentRuns } from "../api/AgentRuns"; /** @@ -47,6 +48,7 @@ export class PlaneClient { public teamspaces: Teamspaces; public milestones: Milestones; public initiatives: Initiatives; + public releases: Releases; public agentRuns: AgentRuns; constructor(config: { baseUrl?: string; apiKey?: string; accessToken?: string; enableLogging?: boolean }) { @@ -81,6 +83,7 @@ export class PlaneClient { this.teamspaces = new Teamspaces(this.config); this.milestones = new Milestones(this.config); this.initiatives = new Initiatives(this.config); + this.releases = new Releases(this.config); this.agentRuns = new AgentRuns(this.config); } } diff --git a/src/index.ts b/src/index.ts index 4b17201..be0ff35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export { Stickies } from "./api/Stickies"; export { Teamspaces } from "./api/Teamspaces"; export { Milestones } from "./api/Milestones"; export { Initiatives } from "./api/Initiatives"; +export { Releases } from "./api/Releases"; export { AgentRuns } from "./api/AgentRuns"; // Sub-resources @@ -48,6 +49,11 @@ export { Members as TeamspaceMembers } from "./api/Teamspaces/Members"; export { Labels as InitiativeLabels } from "./api/Initiatives/Labels"; export { Projects as InitiativeProjects } from "./api/Initiatives/Projects"; export { Epics as InitiativeEpics } from "./api/Initiatives/Epics"; +export { Labels as ReleaseLabels } from "./api/Releases/Labels"; +export { Tags as ReleaseTags } from "./api/Releases/Tags"; +export { WorkItems as ReleaseWorkItems } from "./api/Releases/WorkItems"; +export { Comments as ReleaseComments } from "./api/Releases/Comments"; +export { Links as ReleaseLinks } from "./api/Releases/Links"; export { Activities as AgentRunActivities } from "./api/AgentRuns/Activities"; // Models diff --git a/src/models/Release.ts b/src/models/Release.ts new file mode 100644 index 0000000..159600c --- /dev/null +++ b/src/models/Release.ts @@ -0,0 +1,106 @@ +import { BaseModel } from "./common"; + +/** + * Release status lifecycle values + */ +export enum ReleaseStatus { + UNRELEASED = "unreleased", + RELEASED = "released", + CANCELLED = "cancelled", +} + +/** + * Rich-text description payload returned by release endpoints. + * Written via `description_html` / `description_json`, read back nested. + */ +export interface ReleaseDescription { + description_html?: string; + description_json?: Record; + description_binary?: string | null; + description_stripped?: string | null; +} + +/** + * Release model interfaces + * Releases are workspace-scoped, versioned deliverables. + */ +export interface Release extends BaseModel { + name: string; + description?: ReleaseDescription; + status: ReleaseStatus; + // YYYY-MM-DD format + target_date?: string | null; + // YYYY-MM-DD format + release_date?: string | null; + // User id of the release lead + lead?: string | null; + // Release tag id + tag?: string | null; + is_latest: boolean; + is_prerelease: boolean; + workspace: string; +} + +/** + * Payload for creating a release. + * Note: rich text is written through `description_html` / `description_json`, + * which differs from the nested `description` field returned in responses. + */ +export interface CreateRelease { + name: string; + description_html?: string; + description_json?: Record; + status?: ReleaseStatus; + // YYYY-MM-DD format + target_date?: string | null; + // YYYY-MM-DD format + release_date?: string | null; + // User id of the release lead + lead?: string | null; + // Release tag id + tag?: string | null; + is_latest?: boolean; + is_prerelease?: boolean; + external_id?: string; + external_source?: string; +} + +export type UpdateRelease = Partial; + +export interface ListReleasesParams { + per_page?: number; + cursor?: string; + [key: string]: any; +} + +export interface AddReleaseLabelsRequest { + label_ids: string[]; +} + +export interface RemoveReleaseLabelsRequest { + label_ids: string[]; +} + +export interface AddReleaseWorkItemsRequest { + work_item_ids: string[]; +} + +export interface RemoveReleaseWorkItemsRequest { + work_item_ids: string[]; +} + +/** + * Response returned when work items are added to a release. + */ +export interface AddReleaseWorkItemsResponse { + message: string; +} + +/** + * Work item row returned when listing the work items in a release. + */ +export interface ReleaseWorkItem { + id: string; + project_id: string; + name: string; +} diff --git a/src/models/ReleaseComment.ts b/src/models/ReleaseComment.ts new file mode 100644 index 0000000..ee2d07f --- /dev/null +++ b/src/models/ReleaseComment.ts @@ -0,0 +1,34 @@ +import { BaseModel } from "./common"; +import { ReleaseDescription } from "./Release"; + +/** + * ReleaseComment model interfaces + * Comments are attached to a specific release. The comment body is written + * through `comment_html` and read back as a nested `comment` description. + */ +export interface ReleaseComment extends BaseModel { + release: string; + comment?: ReleaseDescription; + parent?: string | null; + edited_at?: string | null; + is_resolved: boolean; + is_hidden: boolean; + hidden_reason?: string | null; + hidden_at?: string | null; + hidden_by?: string | null; + workspace: string; +} + +export interface CreateReleaseComment { + comment_html?: string; + parent?: string | null; + is_resolved?: boolean; +} + +export type UpdateReleaseComment = Partial; + +export interface ListReleaseCommentsParams { + per_page?: number; + cursor?: string; + [key: string]: any; +} diff --git a/src/models/ReleaseLabel.ts b/src/models/ReleaseLabel.ts new file mode 100644 index 0000000..66f4037 --- /dev/null +++ b/src/models/ReleaseLabel.ts @@ -0,0 +1,28 @@ +/** + * ReleaseLabel model interfaces + * Labels categorize releases across the workspace (e.g. by team or platform). + * Names are unique per workspace. + * + * The API serializer exposes exactly these fields. + */ +export interface ReleaseLabel { + id: string; + name: string; + color: string; + sort_order: number; + workspace: string; +} + +export interface CreateReleaseLabel { + name: string; + color?: string; + sort_order?: number; +} + +export type UpdateReleaseLabel = Partial; + +export interface ListReleaseLabelsParams { + per_page?: number; + cursor?: string; + [key: string]: any; +} diff --git a/src/models/ReleaseLink.ts b/src/models/ReleaseLink.ts new file mode 100644 index 0000000..cc62bd7 --- /dev/null +++ b/src/models/ReleaseLink.ts @@ -0,0 +1,27 @@ +import { BaseModel } from "./common"; + +/** + * ReleaseLink model interfaces + * Links are external URLs attached to a release. URLs are unique per release. + */ +export interface ReleaseLink extends BaseModel { + release: string; + title: string; + url: string; + metadata?: Record; + workspace: string; +} + +export interface CreateReleaseLink { + title: string; + url: string; + metadata?: Record; +} + +export type UpdateReleaseLink = Partial; + +export interface ListReleaseLinksParams { + per_page?: number; + cursor?: string; + [key: string]: any; +} diff --git a/src/models/ReleaseTag.ts b/src/models/ReleaseTag.ts new file mode 100644 index 0000000..bc783e9 --- /dev/null +++ b/src/models/ReleaseTag.ts @@ -0,0 +1,29 @@ +import { BaseModel } from "./common"; + +/** + * ReleaseTag model interfaces + * Tags are workspace-shared version identifiers (e.g. v2.3.0) that a release + * can reference. Versions are unique per workspace. + */ +export interface ReleaseTag extends BaseModel { + version: string; + description?: string | null; + commit_hash?: string | null; + git_tag?: string | null; + workspace: string; +} + +export interface CreateReleaseTag { + version: string; + description?: string | null; + commit_hash?: string | null; + git_tag?: string | null; +} + +export type UpdateReleaseTag = Partial; + +export interface ListReleaseTagsParams { + per_page?: number; + cursor?: string; + [key: string]: any; +} diff --git a/src/models/index.ts b/src/models/index.ts index ce61a01..b6697c5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -16,6 +16,11 @@ export * from "./OAuth"; export * from "./Page"; export * from "./Project"; export * from "./ProjectFeatures"; +export * from "./Release"; +export * from "./ReleaseComment"; +export * from "./ReleaseLabel"; +export * from "./ReleaseLink"; +export * from "./ReleaseTag"; export * from "./State"; export * from "./Sticky"; export * from "./Teamspace"; diff --git a/tests/helpers/mock-axios.ts b/tests/helpers/mock-axios.ts new file mode 100644 index 0000000..7cb3658 --- /dev/null +++ b/tests/helpers/mock-axios.ts @@ -0,0 +1,31 @@ +import axios, { AxiosResponse } from "axios"; +import { PlaneClient } from "../../src/client/plane-client"; + +/** + * Shared helpers for the mocked (no-network) release unit tests. + * + * IMPORTANT: every test file that imports from here MUST call + * `jest.mock("axios")` at the top level so the `axios` import below resolves + * to the auto-mock. These tests never hit the network and require no creds. + */ +export const mockedAxios = axios as jest.Mocked; + +export const MOCK_BASE_URL = "https://api.example.com"; +export const MOCK_API_KEY = "test-api-key"; +export const MOCK_SLUG = "test-workspace"; + +/** Build the fully-qualified URL the SDK is expected to call (base + /api/v1 + path). */ +export const mockUrl = (path: string): string => `${MOCK_BASE_URL}/api/v1${path}`; + +/** Wrap a payload as a minimal AxiosResponse-shaped object. */ +export const axiosResponse = (data: T): AxiosResponse => + ({ data, status: 200, statusText: "OK", headers: {}, config: {} }) as unknown as AxiosResponse; + +/** A fresh PlaneClient wired to the mocked axios. */ +export const createMockClient = (): PlaneClient => new PlaneClient({ apiKey: MOCK_API_KEY, baseUrl: MOCK_BASE_URL }); + +/** Matcher for the request-options object carrying the auth header. */ +export const withAuthHeaders = (): unknown => + expect.objectContaining({ + headers: expect.objectContaining({ "X-Api-Key": MOCK_API_KEY }), + }); diff --git a/tests/unit/release.test.ts b/tests/unit/release.test.ts new file mode 100644 index 0000000..a2254d6 --- /dev/null +++ b/tests/unit/release.test.ts @@ -0,0 +1,359 @@ +import { PlaneClient } from "../../src/client/plane-client"; +import { Release, ReleaseStatus, UpdateRelease } from "../../src/models/Release"; +import { ReleaseTag } from "../../src/models/ReleaseTag"; +import { ReleaseLabel } from "../../src/models/ReleaseLabel"; +import { ReleaseComment } from "../../src/models/ReleaseComment"; +import { ReleaseLink } from "../../src/models/ReleaseLink"; +import { config } from "./constants"; +import { createTestClient, randomizeName } from "../helpers/test-utils"; +import { describeIf } from "../helpers/conditional-tests"; + +describeIf(!!config.workspaceSlug, "Release API Tests", () => { + let client: PlaneClient; + let workspaceSlug: string; + let release: Release; + let releaseTag: ReleaseTag; + let releaseLabel: ReleaseLabel; + let releaseComment: ReleaseComment; + let releaseLink: ReleaseLink; + + beforeAll(async () => { + client = createTestClient(); + workspaceSlug = config.workspaceSlug; + + // Create a release tag (version must be unique per workspace) + releaseTag = await client.releases.tags.create(workspaceSlug, { + version: `v${randomizeName("")}`, + description: "Test Release Tag", + }); + + // Create a release label + releaseLabel = await client.releases.labels.create(workspaceSlug, { + name: randomizeName("Test Release Label "), + color: "#FF5733", + sort_order: 100, + }); + }); + + afterAll(async () => { + if (release?.id) { + try { + await client.releases.delete(workspaceSlug, release.id); + } catch (error) { + console.warn("Failed to delete release:", error); + } + } + if (releaseLabel?.id) { + try { + await client.releases.labels.delete(workspaceSlug, releaseLabel.id); + } catch (error) { + console.warn("Failed to delete release label:", error); + } + } + if (releaseTag?.id) { + try { + await client.releases.tags.delete(workspaceSlug, releaseTag.id); + } catch (error) { + console.warn("Failed to delete release tag:", error); + } + } + }); + + it("should create a release", async () => { + release = await client.releases.create(workspaceSlug, { + name: randomizeName("Test Release "), + description_html: "

Test Release Description

", + status: ReleaseStatus.UNRELEASED, + tag: releaseTag.id, + }); + + expect(release).toBeDefined(); + expect(release.id).toBeDefined(); + expect(release.name).toContain("Test Release"); + expect(release.status).toBe(ReleaseStatus.UNRELEASED); + expect(release.tag).toBe(releaseTag.id); + }); + + it("should retrieve a release", async () => { + const retrieved = await client.releases.retrieve(workspaceSlug, release.id); + + expect(retrieved).toBeDefined(); + expect(retrieved.id).toBe(release.id); + expect(retrieved.name).toBe(release.name); + expect(retrieved.description).toBeDefined(); + }); + + it("should update a release", async () => { + const updateData: UpdateRelease = { + name: randomizeName("Updated Test Release "), + status: ReleaseStatus.RELEASED, + }; + + const updated = await client.releases.update(workspaceSlug, release.id, updateData); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(release.id); + expect(updated.name).toContain("Updated Test Release"); + expect(updated.status).toBe(ReleaseStatus.RELEASED); + }); + + it("should list releases", async () => { + const releases = await client.releases.list(workspaceSlug); + + expect(releases).toBeDefined(); + expect(Array.isArray(releases.results)).toBe(true); + expect(releases.results.length).toBeGreaterThan(0); + + const found = releases.results.find((r) => r.id === release.id); + expect(found).toBeDefined(); + }); + + describe("Release Tags", () => { + it("should create a release tag", () => { + expect(releaseTag).toBeDefined(); + expect(releaseTag.id).toBeDefined(); + expect(releaseTag.version).toContain("v"); + }); + + it("should retrieve a release tag", async () => { + const retrieved = await client.releases.tags.retrieve(workspaceSlug, releaseTag.id); + + expect(retrieved).toBeDefined(); + expect(retrieved.id).toBe(releaseTag.id); + expect(retrieved.version).toBe(releaseTag.version); + }); + + it("should update a release tag", async () => { + const updated = await client.releases.tags.update(workspaceSlug, releaseTag.id, { + description: "Updated Release Tag Description", + git_tag: "refs/tags/v1.0.0", + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(releaseTag.id); + expect(updated.description).toBe("Updated Release Tag Description"); + expect(updated.git_tag).toBe("refs/tags/v1.0.0"); + }); + + it("should list release tags", async () => { + const tags = await client.releases.tags.list(workspaceSlug); + + expect(tags).toBeDefined(); + expect(Array.isArray(tags.results)).toBe(true); + + const found = tags.results.find((t) => t.id === releaseTag.id); + expect(found).toBeDefined(); + }); + }); + + describe("Release Labels", () => { + it("should create a release label", () => { + expect(releaseLabel).toBeDefined(); + expect(releaseLabel.id).toBeDefined(); + expect(releaseLabel.name).toContain("Test Release Label"); + expect(releaseLabel.color).toBe("#FF5733"); + }); + + it("should retrieve a release label", async () => { + const retrieved = await client.releases.labels.retrieve(workspaceSlug, releaseLabel.id); + + expect(retrieved).toBeDefined(); + expect(retrieved.id).toBe(releaseLabel.id); + expect(retrieved.name).toBe(releaseLabel.name); + }); + + it("should update a release label", async () => { + const updated = await client.releases.labels.update(workspaceSlug, releaseLabel.id, { + name: randomizeName("Updated Test Release Label "), + color: "#33FF57", + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(releaseLabel.id); + expect(updated.name).toContain("Updated Test Release Label"); + expect(updated.color).toBe("#33FF57"); + }); + + it("should list release labels", async () => { + const labels = await client.releases.labels.list(workspaceSlug); + + expect(labels).toBeDefined(); + expect(Array.isArray(labels.results)).toBe(true); + + const found = labels.results.find((l) => l.id === releaseLabel.id); + expect(found).toBeDefined(); + }); + + it("should add labels to a release", async () => { + const labels = await client.releases.labels.addLabels(workspaceSlug, release.id, { + label_ids: [releaseLabel.id], + }); + + expect(labels).toBeDefined(); + expect(Array.isArray(labels)).toBe(true); + expect(labels.length).toBeGreaterThan(0); + }); + + it("should list labels on a release", async () => { + const labels = await client.releases.labels.listLabels(workspaceSlug, release.id); + + expect(labels).toBeDefined(); + expect(Array.isArray(labels.results)).toBe(true); + + const found = labels.results.find((l) => l.id === releaseLabel.id); + expect(found).toBeDefined(); + }); + + it("should remove labels from a release", async () => { + await client.releases.labels.removeLabels(workspaceSlug, release.id, { + label_ids: [releaseLabel.id], + }); + + const labels = await client.releases.labels.listLabels(workspaceSlug, release.id); + const found = labels.results.find((l) => l.id === releaseLabel.id); + expect(found).toBeUndefined(); + }); + }); + + describe("Release Work Items", () => { + it("should add work items to a release", async () => { + if (!config.workItemId) { + return; + } + + const result = await client.releases.workItems.add(workspaceSlug, release.id, { + work_item_ids: [config.workItemId], + }); + + expect(result).toBeDefined(); + expect(result.message).toBeDefined(); + }); + + it("should list work items in a release", async () => { + if (!config.workItemId) { + return; + } + + const workItems = await client.releases.workItems.list(workspaceSlug, release.id); + + expect(workItems).toBeDefined(); + expect(Array.isArray(workItems.results)).toBe(true); + + const found = workItems.results.find((wi) => wi.id === config.workItemId); + expect(found).toBeDefined(); + expect(found?.project_id).toBeDefined(); + expect(found?.name).toBeDefined(); + }); + + it("should remove work items from a release", async () => { + if (!config.workItemId) { + return; + } + + await client.releases.workItems.remove(workspaceSlug, release.id, { + work_item_ids: [config.workItemId], + }); + + const workItems = await client.releases.workItems.list(workspaceSlug, release.id); + const found = workItems.results.find((wi) => wi.id === config.workItemId); + expect(found).toBeUndefined(); + }); + }); + + describe("Release Comments", () => { + it("should create a release comment", async () => { + releaseComment = await client.releases.comments.create(workspaceSlug, release.id, { + comment_html: "

Test Release Comment

", + }); + + expect(releaseComment).toBeDefined(); + expect(releaseComment.id).toBeDefined(); + expect(releaseComment.release).toBe(release.id); + expect(releaseComment.comment).toBeDefined(); + }); + + it("should retrieve a release comment", async () => { + const retrieved = await client.releases.comments.retrieve(workspaceSlug, release.id, releaseComment.id); + + expect(retrieved).toBeDefined(); + expect(retrieved.id).toBe(releaseComment.id); + }); + + it("should update a release comment", async () => { + const updated = await client.releases.comments.update(workspaceSlug, release.id, releaseComment.id, { + comment_html: "

Updated Release Comment

", + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(releaseComment.id); + }); + + it("should list release comments", async () => { + const comments = await client.releases.comments.list(workspaceSlug, release.id); + + expect(comments).toBeDefined(); + expect(Array.isArray(comments.results)).toBe(true); + + const found = comments.results.find((c) => c.id === releaseComment.id); + expect(found).toBeDefined(); + }); + + it("should delete a release comment", async () => { + await client.releases.comments.delete(workspaceSlug, release.id, releaseComment.id); + + const comments = await client.releases.comments.list(workspaceSlug, release.id); + const found = comments.results.find((c) => c.id === releaseComment.id); + expect(found).toBeUndefined(); + }); + }); + + describe("Release Links", () => { + it("should create a release link", async () => { + releaseLink = await client.releases.links.create(workspaceSlug, release.id, { + title: "Test Release Link", + url: `https://example.com/${randomizeName("")}`, + }); + + expect(releaseLink).toBeDefined(); + expect(releaseLink.id).toBeDefined(); + expect(releaseLink.release).toBe(release.id); + expect(releaseLink.title).toBe("Test Release Link"); + }); + + it("should retrieve a release link", async () => { + const retrieved = await client.releases.links.retrieve(workspaceSlug, release.id, releaseLink.id); + + expect(retrieved).toBeDefined(); + expect(retrieved.id).toBe(releaseLink.id); + expect(retrieved.url).toBe(releaseLink.url); + }); + + it("should update a release link", async () => { + const updated = await client.releases.links.update(workspaceSlug, release.id, releaseLink.id, { + title: "Updated Release Link", + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(releaseLink.id); + expect(updated.title).toBe("Updated Release Link"); + }); + + it("should list release links", async () => { + const links = await client.releases.links.list(workspaceSlug, release.id); + + expect(links).toBeDefined(); + expect(Array.isArray(links.results)).toBe(true); + + const found = links.results.find((l) => l.id === releaseLink.id); + expect(found).toBeDefined(); + }); + + it("should delete a release link", async () => { + await client.releases.links.delete(workspaceSlug, release.id, releaseLink.id); + + const links = await client.releases.links.list(workspaceSlug, release.id); + const found = links.results.find((l) => l.id === releaseLink.id); + expect(found).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/releases/comments.test.ts b/tests/unit/releases/comments.test.ts new file mode 100644 index 0000000..0ea1ecb --- /dev/null +++ b/tests/unit/releases/comments.test.ts @@ -0,0 +1,91 @@ +jest.mock("axios"); + +import type { PlaneClient } from "../../../src/client/plane-client"; +import { + mockedAxios, + MOCK_SLUG, + mockUrl, + axiosResponse, + createMockClient, + withAuthHeaders, +} from "../../helpers/mock-axios"; + +/** + * Mocked unit tests for Release Comments. Every endpoint is nested under a + * specific release, so release_id must appear in the path. + */ +describe("Release Comments (mocked unit)", () => { + let client: PlaneClient; + const releaseId = "rel-1"; + const commentId = "cmt-1"; + + beforeEach(() => { + jest.clearAllMocks(); + client = createMockClient(); + }); + + it("create POSTs comment_html to the comments collection", async () => { + const comment = { id: commentId, release: releaseId, comment: { description_html: "

hi

" } }; + mockedAxios.post.mockResolvedValueOnce(axiosResponse(comment)); + + const result = await client.releases.comments.create(MOCK_SLUG, releaseId, { + comment_html: "

hi

", + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/comments/`), + { comment_html: "

hi

" }, + withAuthHeaders() + ); + expect(result).toEqual(comment); + }); + + it("retrieve GETs the comment detail URL", async () => { + mockedAxios.get.mockResolvedValueOnce(axiosResponse({ id: commentId })); + + await client.releases.comments.retrieve(MOCK_SLUG, releaseId, commentId); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/comments/${commentId}/`), + expect.anything() + ); + }); + + it("update PATCHes the comment detail URL with the body", async () => { + mockedAxios.patch.mockResolvedValueOnce(axiosResponse({ id: commentId })); + + await client.releases.comments.update(MOCK_SLUG, releaseId, commentId, { + comment_html: "

edited

", + }); + + expect(mockedAxios.patch).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/comments/${commentId}/`), + { comment_html: "

edited

" }, + withAuthHeaders() + ); + }); + + it("delete DELETEs the comment detail URL", async () => { + mockedAxios.delete.mockResolvedValueOnce(axiosResponse(undefined)); + + await client.releases.comments.delete(MOCK_SLUG, releaseId, commentId); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/comments/${commentId}/`), + expect.anything() + ); + }); + + it("list GETs the comments collection", async () => { + const page = { results: [{ id: commentId }], count: 1 }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page)); + + const result = await client.releases.comments.list(MOCK_SLUG, releaseId); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/comments/`), + expect.anything() + ); + expect(result).toEqual(page); + }); +}); diff --git a/tests/unit/releases/labels.test.ts b/tests/unit/releases/labels.test.ts new file mode 100644 index 0000000..adfd901 --- /dev/null +++ b/tests/unit/releases/labels.test.ts @@ -0,0 +1,131 @@ +jest.mock("axios"); + +import type { PlaneClient } from "../../../src/client/plane-client"; +import { + mockedAxios, + MOCK_SLUG, + mockUrl, + axiosResponse, + createMockClient, + withAuthHeaders, +} from "../../helpers/mock-axios"; + +/** + * Mocked unit tests for Release Labels: workspace-level CRUD plus the + * add/remove/list operations that attach labels to a specific release. + */ +describe("Release Labels (mocked unit)", () => { + let client: PlaneClient; + const releaseId = "rel-1"; + + beforeEach(() => { + jest.clearAllMocks(); + client = createMockClient(); + }); + + it("create POSTs to the labels collection", async () => { + const label = { id: "lbl-1", name: "Bugfix", color: "#FF5733", sort_order: 1, workspace: "ws-1" }; + mockedAxios.post.mockResolvedValueOnce(axiosResponse(label)); + + const result = await client.releases.labels.create(MOCK_SLUG, { + name: "Bugfix", + color: "#FF5733", + sort_order: 1, + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/labels/`), + { name: "Bugfix", color: "#FF5733", sort_order: 1 }, + withAuthHeaders() + ); + expect(result).toEqual(label); + }); + + it("retrieve GETs the label detail URL", async () => { + mockedAxios.get.mockResolvedValueOnce(axiosResponse({ id: "lbl-1" })); + + await client.releases.labels.retrieve(MOCK_SLUG, "lbl-1"); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/labels/lbl-1/`), + expect.anything() + ); + }); + + it("update PATCHes the label detail URL with the body", async () => { + mockedAxios.patch.mockResolvedValueOnce(axiosResponse({ id: "lbl-1", color: "#33FF57" })); + + await client.releases.labels.update(MOCK_SLUG, "lbl-1", { color: "#33FF57" }); + + expect(mockedAxios.patch).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/labels/lbl-1/`), + { color: "#33FF57" }, + withAuthHeaders() + ); + }); + + it("delete DELETEs the label detail URL", async () => { + mockedAxios.delete.mockResolvedValueOnce(axiosResponse(undefined)); + + await client.releases.labels.delete(MOCK_SLUG, "lbl-1"); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/labels/lbl-1/`), + expect.anything() + ); + }); + + it("list GETs the labels collection", async () => { + const page = { results: [{ id: "lbl-1" }], count: 1 }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page)); + + const result = await client.releases.labels.list(MOCK_SLUG, { per_page: 5 }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/labels/`), + expect.objectContaining({ params: { per_page: 5 } }) + ); + expect(result).toEqual(page); + }); + + it("addLabels POSTs label_ids to the release labels URL and returns an array", async () => { + const labels = [{ id: "lbl-1", name: "Bugfix" }]; + mockedAxios.post.mockResolvedValueOnce(axiosResponse(labels)); + + const result = await client.releases.labels.addLabels(MOCK_SLUG, releaseId, { + label_ids: ["lbl-1"], + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/labels/`), + { label_ids: ["lbl-1"] }, + withAuthHeaders() + ); + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(labels); + }); + + it("removeLabels DELETEs the release labels URL with label_ids in the body", async () => { + mockedAxios.delete.mockResolvedValueOnce(axiosResponse(undefined)); + + await client.releases.labels.removeLabels(MOCK_SLUG, releaseId, { label_ids: ["lbl-1"] }); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/labels/`), + expect.objectContaining({ data: { label_ids: ["lbl-1"] } }) + ); + }); + + it("listLabels GETs the release labels URL", async () => { + const page = { results: [{ id: "lbl-1" }], count: 1 }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page)); + + const result = await client.releases.labels.listLabels(MOCK_SLUG, releaseId); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/labels/`), + expect.anything() + ); + expect(result).toEqual(page); + }); +}); diff --git a/tests/unit/releases/links.test.ts b/tests/unit/releases/links.test.ts new file mode 100644 index 0000000..1218d9c --- /dev/null +++ b/tests/unit/releases/links.test.ts @@ -0,0 +1,90 @@ +jest.mock("axios"); + +import type { PlaneClient } from "../../../src/client/plane-client"; +import { + mockedAxios, + MOCK_SLUG, + mockUrl, + axiosResponse, + createMockClient, + withAuthHeaders, +} from "../../helpers/mock-axios"; + +/** + * Mocked unit tests for Release Links. Every endpoint is nested under a + * specific release, so release_id must appear in the path. + */ +describe("Release Links (mocked unit)", () => { + let client: PlaneClient; + const releaseId = "rel-1"; + const linkId = "lnk-1"; + + beforeEach(() => { + jest.clearAllMocks(); + client = createMockClient(); + }); + + it("create POSTs the link body to the links collection", async () => { + const link = { id: linkId, release: releaseId, title: "Changelog", url: "https://example.com/cl" }; + mockedAxios.post.mockResolvedValueOnce(axiosResponse(link)); + + const result = await client.releases.links.create(MOCK_SLUG, releaseId, { + title: "Changelog", + url: "https://example.com/cl", + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/links/`), + { title: "Changelog", url: "https://example.com/cl" }, + withAuthHeaders() + ); + expect(result).toEqual(link); + }); + + it("retrieve GETs the link detail URL", async () => { + mockedAxios.get.mockResolvedValueOnce(axiosResponse({ id: linkId })); + + await client.releases.links.retrieve(MOCK_SLUG, releaseId, linkId); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/links/${linkId}/`), + expect.anything() + ); + }); + + it("update PATCHes the link detail URL with the body", async () => { + mockedAxios.patch.mockResolvedValueOnce(axiosResponse({ id: linkId, title: "Updated" })); + + await client.releases.links.update(MOCK_SLUG, releaseId, linkId, { title: "Updated" }); + + expect(mockedAxios.patch).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/links/${linkId}/`), + { title: "Updated" }, + withAuthHeaders() + ); + }); + + it("delete DELETEs the link detail URL", async () => { + mockedAxios.delete.mockResolvedValueOnce(axiosResponse(undefined)); + + await client.releases.links.delete(MOCK_SLUG, releaseId, linkId); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/links/${linkId}/`), + expect.anything() + ); + }); + + it("list GETs the links collection", async () => { + const page = { results: [{ id: linkId }], count: 1 }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page)); + + const result = await client.releases.links.list(MOCK_SLUG, releaseId, { per_page: 10 }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/links/`), + expect.objectContaining({ params: { per_page: 10 } }) + ); + expect(result).toEqual(page); + }); +}); diff --git a/tests/unit/releases/releases.test.ts b/tests/unit/releases/releases.test.ts new file mode 100644 index 0000000..93d5729 --- /dev/null +++ b/tests/unit/releases/releases.test.ts @@ -0,0 +1,156 @@ +jest.mock("axios"); + +import type { PlaneClient } from "../../../src/client/plane-client"; +import { HttpError } from "../../../src/errors"; +import { ReleaseStatus } from "../../../src/models/Release"; +import { + mockedAxios, + MOCK_SLUG, + mockUrl, + axiosResponse, + createMockClient, + withAuthHeaders, +} from "../../helpers/mock-axios"; + +/** + * Mocked unit tests for the Releases resource. axios is mocked, so these run + * without any credentials and assert the exact request the SDK builds + * (URL, verb, body, query) and that it returns the parsed response body. + * Live-integration counterparts live in tests/unit/release.test.ts. + */ +describe("Releases (mocked unit)", () => { + let client: PlaneClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = createMockClient(); + }); + + it("exposes the label/tag/work-item/comment/link sub-resources", () => { + expect(client.releases.labels).toBeDefined(); + expect(client.releases.tags).toBeDefined(); + expect(client.releases.workItems).toBeDefined(); + expect(client.releases.comments).toBeDefined(); + expect(client.releases.links).toBeDefined(); + }); + + describe("create", () => { + it("POSTs to the releases collection with the given body and returns the release", async () => { + const release = { id: "rel-1", name: "v1.0.0", status: "unreleased" }; + mockedAxios.post.mockResolvedValueOnce(axiosResponse(release)); + + const result = await client.releases.create(MOCK_SLUG, { + name: "v1.0.0", + status: ReleaseStatus.UNRELEASED, + description_html: "

notes

", + tag: "tag-1", + }); + + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/`), + { name: "v1.0.0", status: "unreleased", description_html: "

notes

", tag: "tag-1" }, + withAuthHeaders() + ); + expect(result).toEqual(release); + }); + + it("sends the X-Api-Key auth header", async () => { + mockedAxios.post.mockResolvedValueOnce(axiosResponse({ id: "rel-1" })); + + await client.releases.create(MOCK_SLUG, { name: "v1.0.0" }); + + const options = mockedAxios.post.mock.calls[0][2] as { headers: Record }; + expect(options.headers["X-Api-Key"]).toBe("test-api-key"); + }); + }); + + describe("retrieve", () => { + it("GETs the release detail URL", async () => { + const release = { id: "rel-1", name: "v1.0.0" }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(release)); + + const result = await client.releases.retrieve(MOCK_SLUG, "rel-1"); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/rel-1/`), + expect.anything() + ); + expect(result).toEqual(release); + }); + }); + + describe("update", () => { + it("PATCHes the release detail URL with the update body", async () => { + const release = { id: "rel-1", name: "v1.0.1", status: "released" }; + mockedAxios.patch.mockResolvedValueOnce(axiosResponse(release)); + + const result = await client.releases.update(MOCK_SLUG, "rel-1", { + name: "v1.0.1", + status: ReleaseStatus.RELEASED, + }); + + expect(mockedAxios.patch).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/rel-1/`), + { name: "v1.0.1", status: "released" }, + withAuthHeaders() + ); + expect(result).toEqual(release); + }); + }); + + describe("delete", () => { + it("DELETEs the release detail URL", async () => { + mockedAxios.delete.mockResolvedValueOnce(axiosResponse(undefined)); + + await client.releases.delete(MOCK_SLUG, "rel-1"); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/rel-1/`), + expect.anything() + ); + }); + }); + + describe("list", () => { + it("GETs the releases collection and forwards pagination params", async () => { + const page = { results: [{ id: "rel-1" }], count: 1, total_count: 1 }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page)); + + const result = await client.releases.list(MOCK_SLUG, { per_page: 10, cursor: "10:0:0" }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/`), + expect.objectContaining({ params: { per_page: 10, cursor: "10:0:0" } }) + ); + expect(result).toEqual(page); + }); + + it("GETs the releases collection with undefined params when none are given", async () => { + mockedAxios.get.mockResolvedValueOnce(axiosResponse({ results: [] })); + + await client.releases.list(MOCK_SLUG); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/`), + expect.objectContaining({ params: undefined }) + ); + }); + }); + + describe("error handling", () => { + it("wraps axios errors in an HttpError carrying the status code", async () => { + mockedAxios.isAxiosError.mockReturnValue(true); + mockedAxios.get.mockRejectedValue({ + response: { status: 404, data: { message: "Not found" } }, + message: "Request failed with status code 404", + }); + + await expect(client.releases.retrieve(MOCK_SLUG, "missing")).rejects.toMatchObject({ + name: "HttpError", + statusCode: 404, + }); + await expect(client.releases.retrieve(MOCK_SLUG, "missing")).rejects.toBeInstanceOf(HttpError); + }); + }); +}); diff --git a/tests/unit/releases/tags.test.ts b/tests/unit/releases/tags.test.ts new file mode 100644 index 0000000..aad3c0c --- /dev/null +++ b/tests/unit/releases/tags.test.ts @@ -0,0 +1,87 @@ +jest.mock("axios"); + +import type { PlaneClient } from "../../../src/client/plane-client"; +import { + mockedAxios, + MOCK_SLUG, + mockUrl, + axiosResponse, + createMockClient, + withAuthHeaders, +} from "../../helpers/mock-axios"; + +/** + * Mocked unit tests for Release Tags (workspace-level version identifiers). + */ +describe("Release Tags (mocked unit)", () => { + let client: PlaneClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = createMockClient(); + }); + + it("create POSTs to the tags collection", async () => { + const tag = { id: "tag-1", version: "v2.3.0", workspace: "ws-1" }; + mockedAxios.post.mockResolvedValueOnce(axiosResponse(tag)); + + const result = await client.releases.tags.create(MOCK_SLUG, { + version: "v2.3.0", + description: "GA", + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/tags/`), + { version: "v2.3.0", description: "GA" }, + withAuthHeaders() + ); + expect(result).toEqual(tag); + }); + + it("retrieve GETs the tag detail URL", async () => { + mockedAxios.get.mockResolvedValueOnce(axiosResponse({ id: "tag-1" })); + + await client.releases.tags.retrieve(MOCK_SLUG, "tag-1"); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/tags/tag-1/`), + expect.anything() + ); + }); + + it("update PATCHes the tag detail URL with the body", async () => { + mockedAxios.patch.mockResolvedValueOnce(axiosResponse({ id: "tag-1", git_tag: "refs/tags/v2.3.0" })); + + await client.releases.tags.update(MOCK_SLUG, "tag-1", { git_tag: "refs/tags/v2.3.0" }); + + expect(mockedAxios.patch).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/tags/tag-1/`), + { git_tag: "refs/tags/v2.3.0" }, + withAuthHeaders() + ); + }); + + it("delete DELETEs the tag detail URL", async () => { + mockedAxios.delete.mockResolvedValueOnce(axiosResponse(undefined)); + + await client.releases.tags.delete(MOCK_SLUG, "tag-1"); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/tags/tag-1/`), + expect.anything() + ); + }); + + it("list GETs the tags collection and forwards params", async () => { + const page = { results: [{ id: "tag-1" }], count: 1 }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page)); + + const result = await client.releases.tags.list(MOCK_SLUG, { per_page: 20 }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/tags/`), + expect.objectContaining({ params: { per_page: 20 } }) + ); + expect(result).toEqual(page); + }); +}); diff --git a/tests/unit/releases/work-items.test.ts b/tests/unit/releases/work-items.test.ts new file mode 100644 index 0000000..629a9d6 --- /dev/null +++ b/tests/unit/releases/work-items.test.ts @@ -0,0 +1,65 @@ +jest.mock("axios"); + +import type { PlaneClient } from "../../../src/client/plane-client"; +import { + mockedAxios, + MOCK_SLUG, + mockUrl, + axiosResponse, + createMockClient, + withAuthHeaders, +} from "../../helpers/mock-axios"; + +/** + * Mocked unit tests for Release Work Items. Verifies the `work_item_ids` + * payload key and the distinct return shapes (add -> {message}, list -> + * paginated {id, project_id, name}, remove -> void). + */ +describe("Release Work Items (mocked unit)", () => { + let client: PlaneClient; + const releaseId = "rel-1"; + + beforeEach(() => { + jest.clearAllMocks(); + client = createMockClient(); + }); + + it("list GETs the work-items URL and returns the paginated rows", async () => { + const page = { results: [{ id: "wi-1", project_id: "proj-1", name: "Fix login" }], count: 1 }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page)); + + const result = await client.releases.workItems.list(MOCK_SLUG, releaseId, { per_page: 20 }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/work-items/`), + expect.objectContaining({ params: { per_page: 20 } }) + ); + expect(result).toEqual(page); + }); + + it("add POSTs work_item_ids and returns the message body", async () => { + mockedAxios.post.mockResolvedValueOnce(axiosResponse({ message: "Work items added successfully" })); + + const result = await client.releases.workItems.add(MOCK_SLUG, releaseId, { + work_item_ids: ["wi-1", "wi-2"], + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/work-items/`), + { work_item_ids: ["wi-1", "wi-2"] }, + withAuthHeaders() + ); + expect(result).toEqual({ message: "Work items added successfully" }); + }); + + it("remove DELETEs the work-items URL with work_item_ids in the body", async () => { + mockedAxios.delete.mockResolvedValueOnce(axiosResponse(undefined)); + + await client.releases.workItems.remove(MOCK_SLUG, releaseId, { work_item_ids: ["wi-1"] }); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + mockUrl(`/workspaces/${MOCK_SLUG}/releases/${releaseId}/work-items/`), + expect.objectContaining({ data: { work_item_ids: ["wi-1"] } }) + ); + }); +});