From 1f63f8c761e0c1e4957e52e4d0c7f88527cb3735 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 23 Apr 2026 13:19:58 -0700 Subject: [PATCH] feat(nodejs): add folder API support and live e2e coverage Extend the Node.js client with user and team folder APIs, folder-aware note options, and exported folder types. Add an opt-in live e2e test suite and keep non-idempotent retries from masking server-side POST failures. Made-with: Cursor --- .github/workflows/e2e.yml | 36 ++++ nodejs/README.md | 33 ++++ nodejs/jest.config.ts | 1 + nodejs/jest.e2e.config.ts | 14 ++ nodejs/package.json | 3 +- nodejs/src/index.ts | 104 +++++++++++- nodejs/src/type.ts | 70 +++++++- nodejs/tests/api.spec.ts | 76 ++++++++- nodejs/tests/e2e/api.e2e.spec.ts | 276 +++++++++++++++++++++++++++++++ 9 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 nodejs/jest.e2e.config.ts create mode 100644 nodejs/tests/e2e/api.e2e.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..a968464 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,36 @@ +# Optional live API checks. Add repository secret HACKMD_E2E_ACCESS_TOKEN. +# Optionally add HACKMD_E2E_API_ENDPOINT (e.g. https://api-stage.hackmd.io/v1); otherwise production is used. + +name: E2E (live HackMD API) + +on: + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: nodejs/package-lock.json + + - name: Install dependencies + working-directory: nodejs + run: npm ci + + - name: Run e2e tests + working-directory: nodejs + env: + HACKMD_ACCESS_TOKEN: ${{ secrets.HACKMD_E2E_ACCESS_TOKEN }} + HACKMD_API_ENDPOINT: ${{ secrets.HACKMD_E2E_API_ENDPOINT }} + run: | + if [ -z "${HACKMD_ACCESS_TOKEN:-}" ]; then + echo "::error::Add repository secret HACKMD_E2E_ACCESS_TOKEN (a valid API token for the target environment)." + exit 1 + fi + npm run test:e2e diff --git a/nodejs/README.md b/nodejs/README.md index f003c09..56b7d94 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -121,6 +121,39 @@ const updatedNote = await client.getNote('note-id', { etag }) See the [code](./src/index.ts) and [typings](./src/type.ts). The API client is written in TypeScript, so you can get auto-completion and type checking in any TypeScript Language Server powered editor or IDE. +## E2E tests (live API) + +Integration tests call a real HackMD API (staging or production). They are **not** run by `npm test` or the default CI job. + +**Requirements** + +- `HACKMD_ACCESS_TOKEN` — a valid personal access token for the environment you target. +- Optional: `HACKMD_API_ENDPOINT` — defaults to `https://api.hackmd.io/v1`. For staging, use `https://api-stage.hackmd.io/v1`. + +**Read-only (default e2e)** + +```bash +cd nodejs +export HACKMD_ACCESS_TOKEN=your_token +export HACKMD_API_ENDPOINT=https://api-stage.hackmd.io/v1 # optional +npm run test:e2e +``` + +**With CRUD / mutations** + +Set `HACKMD_E2E_MUTATIONS=1` to run write tests against your account: + +- **Notes:** create → get → update (title, content, tags) → list → delete. +- **Folders:** one integration test runs create (root + nested) → get → update → list → folder-order round-trip (skipped if that API returns 404) → delete. If **POST `/folders`** returns 404 (common before full production rollout), the test exits early with a warning; use staging or `HACKMD_E2E_FOLDERS=0`. + +```bash +HACKMD_E2E_MUTATIONS=1 npm run test:e2e +``` + +Folder CRUD touches folder display order briefly, then restores the previous order in an `afterAll` hook. To skip folder mutations (e.g. production without `/folders`), set `HACKMD_E2E_FOLDERS=0`. + +The read-only `getFolderList` test still treats HTTP 404 as “folders not available on this host yet” and passes without failing the suite. + ## License MIT diff --git a/nodejs/jest.config.ts b/nodejs/jest.config.ts index 234ac01..838e7e1 100644 --- a/nodejs/jest.config.ts +++ b/nodejs/jest.config.ts @@ -6,6 +6,7 @@ const customJestConfig: JestConfigWithTsJest = { transformIgnorePatterns: ["/node_modules/"], extensionsToTreatAsEsm: [".ts"], setupFiles: ["dotenv/config"], + testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], } export default customJestConfig diff --git a/nodejs/jest.e2e.config.ts b/nodejs/jest.e2e.config.ts new file mode 100644 index 0000000..c818758 --- /dev/null +++ b/nodejs/jest.e2e.config.ts @@ -0,0 +1,14 @@ +import type { JestConfigWithTsJest } from "ts-jest" + +/** Live API tests; run with `npm run test:e2e` (see nodejs/README.md). */ +const e2eJestConfig: JestConfigWithTsJest = { + preset: "ts-jest", + testEnvironment: "node", + transformIgnorePatterns: ["/node_modules/"], + extensionsToTreatAsEsm: [".ts"], + setupFiles: ["dotenv/config"], + testMatch: ["/tests/e2e/**/*.spec.ts"], + testTimeout: 60_000, +} + +export default e2eJestConfig diff --git a/nodejs/package.json b/nodejs/package.json index 65bc608..eb0bbbc 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -25,7 +25,8 @@ "watch": "npm run clean && rollup -c -w", "prepublishOnly": "npm run build", "lint": "eslint src --fix --ext .ts", - "test": "jest" + "test": "jest", + "test:e2e": "jest --config jest.e2e.config.ts" }, "keywords": [ "HackMD", diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index f816370..3389ee4 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -1,5 +1,32 @@ import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios' -import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote, UpdateNoteOptions } from './type' +import { + CreateNoteOptions, + CreateTeamFolderBody, + CreateUserFolderBody, + GetMe, + GetUserHistory, + GetUserNotes, + GetUserNote, + CreateUserNote, + GetUserTeams, + GetTeamNotes, + CreateTeamNote, + SingleNote, + UpdateNoteOptions, + GetFolders, + GetFolder, + GetFolderOrder, + CreateFolderResult, + UpdateFolderResult, + GetTeamFolders, + GetTeamFolder, + GetTeamFolderOrder, + CreateTeamFolderResult, + UpdateTeamFolderResult, + UpdateFolderOrderBody, + UpdateTeamFolderBody, + UpdateUserFolderBody, +} from './type' import * as HackMDErrors from './error' export type RequestOptions = { @@ -59,6 +86,10 @@ export class API { } ) + if (options.retryConfig) { + this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay) + } + if (options.wrapResponseErrors) { this.axios.interceptors.response.use( (response: AxiosResponse) => { @@ -94,16 +125,21 @@ export class API { } ) } - if (options.retryConfig) { - this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay) - } } private exponentialBackoff (retries: number, baseDelay: number): number { return Math.pow(2, retries) * baseDelay } - private isRetryableError (error: AxiosError): boolean { + private isRetryableMethod (method?: string): boolean { + if (!method) return false + const normalized = method.toLowerCase() + return ['get', 'head', 'options', 'put', 'delete'].includes(normalized) + } + + private isRetryableError (error: unknown): boolean { + if (!axios.isAxiosError(error)) return false + if (!this.isRetryableMethod(error.config?.method)) return false return ( !error.response || (error.response.status >= 500 && error.response.status < 600) || @@ -197,6 +233,62 @@ export class API { return this.axios.delete(`teams/${teamPath}/notes/${noteId}`) } + async getFolderList (options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get('folders'), options.unwrapData) as unknown as OptionReturnType + } + + async createFolder (payload: CreateUserFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.post('folders', payload), options.unwrapData) as unknown as OptionReturnType + } + + async getFolder (folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async updateFolder (folderId: string, payload: UpdateUserFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.patch(`folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType + } + + async deleteFolder (folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.delete(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async getFolderOrder (options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get('folders/folder-order'), options.unwrapData) as unknown as OptionReturnType + } + + async updateFolderOrder (payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.put('folders/folder-order', payload), options.unwrapData) as unknown as OptionReturnType + } + + async getTeamFolderList (teamPath: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`teams/${teamPath}/folders`), options.unwrapData) as unknown as OptionReturnType + } + + async createTeamFolder (teamPath: string, payload: CreateTeamFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.post(`teams/${teamPath}/folders`, payload), options.unwrapData) as unknown as OptionReturnType + } + + async getTeamFolder (teamPath: string, folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async updateTeamFolder (teamPath: string, folderId: string, payload: UpdateTeamFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.patch(`teams/${teamPath}/folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType + } + + async deleteTeamFolder (teamPath: string, folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.delete(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async getTeamFolderOrder (teamPath: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`teams/${teamPath}/folders/folder-order`), options.unwrapData) as unknown as OptionReturnType + } + + async updateTeamFolderOrder (teamPath: string, payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.put(`teams/${teamPath}/folders/folder-order`, payload), options.unwrapData) as unknown as OptionReturnType + } + private unwrapData (reqP: Promise>, unwrap = true, includeEtag = false) { if (!unwrap) { // For raw responses, etag is available via response.headers @@ -211,4 +303,6 @@ export class API { } } +export * from './type' + export default API diff --git a/nodejs/src/type.ts b/nodejs/src/type.ts index 76b24c9..50eb3f3 100644 --- a/nodejs/src/type.ts +++ b/nodejs/src/type.ts @@ -21,10 +21,13 @@ export enum CommentPermissionType { export type CreateNoteOptions = { title?: string content?: string + description?: string + tags?: string[] readPermission?: NotePermissionRole, writePermission?: NotePermissionRole, commentPermission?: CommentPermissionType, permalink?: string + parentFolderId?: string } export type Team = { @@ -62,6 +65,16 @@ export enum NotePermissionRole { GUEST = 'guest' } +/** Folder breadcrumb segment as returned on notes (OpenAPI `FolderPath`). */ +export type FolderPath = { + id: string + name: string + icon: string | null + color: string | null + parentId: string | null + clientId: string +} + export type Note = { id: string title: string @@ -79,13 +92,16 @@ export type Note = { readPermission: NotePermissionRole writePermission: NotePermissionRole + folderPaths?: FolderPath[] } export type SingleNote = Note & { content: string } -export type UpdateNoteOptions = Partial> +export type UpdateNoteOptions = Partial> & { + parentFolderId?: string +} // User export type GetMe = User @@ -107,3 +123,55 @@ export type CreateTeamNote = SingleNote export type UpdateTeamNote = void export type DeleteTeamNote = void +// Folders (user & team workspaces) +export type ApiFolder = { + id: string + name: string + description: string | null + icon: string | null + color: string | null + parentFolderId: string | null + createdAt: number + updatedAt: number +} + +/** Maps each parent folder id or the literal `root` to ordered child folder ids. */ +export type ApiFolderOrder = Record + +export type CreateUserFolderBody = { + name?: string + description?: string + icon?: string + color?: string + parentFolderId?: string +} + +export type UpdateUserFolderBody = { + name?: string + description?: string | null + icon?: string | null + color?: string | null + parentFolderId?: string | null +} + +export type CreateTeamFolderBody = CreateUserFolderBody + +export type UpdateTeamFolderBody = UpdateUserFolderBody + +export type UpdateFolderOrderBody = { + order: ApiFolderOrder +} + +export type GetFolders = ApiFolder[] +export type GetTeamFolders = ApiFolder[] +export type GetFolder = ApiFolder +export type GetTeamFolder = ApiFolder +export type CreateFolderResult = ApiFolder +export type CreateTeamFolderResult = ApiFolder +export type UpdateFolderResult = ApiFolder +export type UpdateTeamFolderResult = ApiFolder +export type DeleteFolderResult = void +export type DeleteTeamFolderResult = void +export type GetFolderOrder = ApiFolderOrder +export type GetTeamFolderOrder = ApiFolderOrder + diff --git a/nodejs/tests/api.spec.ts b/nodejs/tests/api.spec.ts index 2ba704b..9f3f28f 100644 --- a/nodejs/tests/api.spec.ts +++ b/nodejs/tests/api.spec.ts @@ -1,7 +1,7 @@ import { server } from './mock' import { API } from '../src' import { http, HttpResponse } from 'msw' -import { TooManyRequestsError } from '../src/error' +import { InternalServerError, TooManyRequestsError } from '../src/error' let client: API @@ -99,6 +99,50 @@ test('should throw HackMD error object', async () => { } }) +test('getFolderList returns folders from /folders', async () => { + server.use( + http.get('https://api.hackmd.io/v1/folders', () => { + return HttpResponse.json([ + { + id: 'folder-1', + name: 'Research', + description: null, + icon: null, + color: null, + parentFolderId: null, + createdAt: 1700000000, + updatedAt: 1700000001, + }, + ]) + }), + ) + + const folders = await client.getFolderList() + + expect(folders).toHaveLength(1) + expect(folders[0]).toMatchObject({ id: 'folder-1', name: 'Research' }) +}) + +test('updateFolderOrder sends order payload', async () => { + let requestBody: unknown + + server.use( + http.put('https://api.hackmd.io/v1/folders/folder-order', async ({ request }) => { + requestBody = await request.json() + + return HttpResponse.json({}) + }), + ) + + await client.updateFolderOrder({ + order: { root: ['a', 'b'], parent: ['c'] }, + }) + + expect(requestBody).toEqual({ + order: { root: ['a', 'b'], parent: ['c'] }, + }) +}) + test('should support updating team note title and tags metadata', async () => { const updatedTags = ['team', 'metadata'] let requestBody: unknown @@ -128,3 +172,33 @@ test('should support updating team note title and tags metadata', async () => { }) expect(response).toHaveProperty('status', 200) }) + +test('should not retry non-idempotent requests when wrapping errors', async () => { + let requestCount = 0 + const clientWithRetryAndWrap = new API(process.env.HACKMD_ACCESS_TOKEN!, undefined, { + wrapResponseErrors: true, + retryConfig: { + maxRetries: 2, + baseDelay: 1, + }, + }) + + server.use( + http.post('https://api.hackmd.io/v1/folders', () => { + requestCount += 1 + if (requestCount === 1) { + return HttpResponse.json( + { error: 'Folder created but could not be retrieved' }, + { status: 500 }, + ) + } + + return HttpResponse.json({ error: 'Not found' }, { status: 404 }) + }), + ) + + await expect( + clientWithRetryAndWrap.createFolder({ name: 'retry-safety-test' }), + ).rejects.toBeInstanceOf(InternalServerError) + expect(requestCount).toBe(1) +}) diff --git a/nodejs/tests/e2e/api.e2e.spec.ts b/nodejs/tests/e2e/api.e2e.spec.ts new file mode 100644 index 0000000..8729e6b --- /dev/null +++ b/nodejs/tests/e2e/api.e2e.spec.ts @@ -0,0 +1,276 @@ +import type { ApiFolderOrder } from '../../src' +import { API } from '../../src' +import { HttpResponseError } from '../../src/error' + +const mutationsEnabled = process.env.HACKMD_E2E_MUTATIONS === '1' +/** Set to `0` to skip folder CRUD (e.g. host without `/folders`). */ +const folderCrudEnabled = process.env.HACKMD_E2E_FOLDERS !== '0' + +function assertToken (token: string | undefined): asserts token is string { + if (!token?.trim()) { + throw new Error( + 'E2E tests require HACKMD_ACCESS_TOKEN (see nodejs/README.md — "E2E tests").', + ) + } +} + +function isNotFound (err: unknown): boolean { + return err instanceof HttpResponseError && err.code === 404 +} + +describe('HackMD API (live e2e)', () => { + let client: API + + beforeAll(() => { + const token = process.env.HACKMD_ACCESS_TOKEN + assertToken(token) + const endpoint = + process.env.HACKMD_API_ENDPOINT?.trim() || 'https://api.hackmd.io/v1' + client = new API(token.trim(), endpoint, { + wrapResponseErrors: true, + retryConfig: { maxRetries: 2, baseDelay: 250 }, + }) + }) + + describe('read-only', () => { + it('getMe returns the current user profile', async () => { + const me = await client.getMe() + + expect(me).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + userPath: expect.any(String), + }) + expect(Array.isArray(me.teams)).toBe(true) + }) + + it('getNoteList returns an array', async () => { + const notes = await client.getNoteList() + expect(Array.isArray(notes)).toBe(true) + if (notes.length > 0) { + expect(notes[0]).toMatchObject({ + id: expect.any(String), + title: expect.any(String), + }) + } + }) + + it('getTeams returns an array', async () => { + const teams = await client.getTeams() + expect(Array.isArray(teams)).toBe(true) + }) + + it('getHistory accepts limit and returns an array', async () => { + const history = await client.getHistory() + expect(Array.isArray(history)).toBe(true) + }) + + it('getFolderList returns folders when the server exposes /folders', async () => { + try { + const folders = await client.getFolderList() + expect(Array.isArray(folders)).toBe(true) + if (folders.length > 0) { + expect(folders[0]).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + }) + } + } catch (err) { + if (isNotFound(err)) { + // Host may not expose /folders yet (e.g. production before rollout). + return + } + throw err + } + }) + }) + + describe('mutations (optional)', () => { + const describeMutations = mutationsEnabled ? describe : describe.skip + + describeMutations('notes CRUD when HACKMD_E2E_MUTATIONS=1', () => { + const stamp = Date.now() + let noteId: string + + afterAll(async () => { + if (!noteId) return + try { + await client.deleteNote(noteId) + } catch { + /* already removed */ + } + }) + + it('createNote creates a note', async () => { + const title = `e2e-note-${stamp}` + const created = await client.createNote({ + title, + content: '# initial\n', + tags: ['e2e'], + }) + + expect(created.id).toEqual(expect.any(String)) + expect(created.title).toBe(title) + expect(created.tags).toContain('e2e') + noteId = created.id + }) + + it('getNote returns the note', async () => { + const n = await client.getNote(noteId) + expect(n.id).toBe(noteId) + expect(n.title).toBe(`e2e-note-${stamp}`) + expect(n.content).toContain('initial') + }) + + it('updateNote updates title, content, and tags', async () => { + const title = `e2e-note-${stamp}-patched` + const patch = await client.updateNote(noteId, { + title, + content: '# patched\n\nbody', + tags: ['e2e', 'updated'], + }, { unwrapData: false }) + + expect([200, 202]).toContain(patch.status) + const patchedBody = patch.data as { content?: string } + if (typeof patchedBody.content === 'string' && patchedBody.content.length > 0) { + expect(patchedBody.content).toContain('patched') + } + + const n = await client.getNote(noteId) + expect(n.title).toBe(title) + expect(n.tags).toEqual(expect.arrayContaining(['e2e', 'updated'])) + if (typeof n.content === 'string' && n.content.length > 0) { + expect(n.content).toContain('patched') + } + }) + + it('getNoteList includes the note', async () => { + const list = await client.getNoteList() + const found = list.find(n => n.id === noteId) + expect(found).toBeDefined() + expect(found!.title).toBe(`e2e-note-${stamp}-patched`) + }) + + it('deleteNote removes the note', async () => { + await client.deleteNote(noteId) + const list = await client.getNoteList() + expect(list.find(n => n.id === noteId)).toBeUndefined() + noteId = '' + }) + }) + + const describeFolderMutations = + mutationsEnabled && folderCrudEnabled ? describe : describe.skip + + describeFolderMutations('folders CRUD when HACKMD_E2E_MUTATIONS=1', () => { + it('folders: create → get → update → nested folder → list → order round-trip → delete', async () => { + let parentFolderId = '' + let childFolderId = '' + let orderBeforeMutation: ApiFolderOrder | null = null + + const t0 = Date.now() + let created + try { + created = await client.createFolder({ + name: `e2e-parent-${t0}`, + description: 'e2e parent', + }) + } catch (err) { + if (isNotFound(err)) { + console.warn( + '[e2e] POST /folders returned 404 (folder writes not on this host). ' + + 'Use https://api-stage.hackmd.io/v1 or set HACKMD_E2E_FOLDERS=0.', + ) + expect(isNotFound(err)).toBe(true) + return + } + throw err + } + + expect(created.id).toEqual(expect.any(String)) + expect(created.name).toContain('e2e-parent') + parentFolderId = created.id + + try { + const folder = await client.getFolder(parentFolderId) + expect(folder.id).toBe(parentFolderId) + expect(folder.name).toContain('e2e-parent') + expect(folder.description).toBe('e2e parent') + + const renamed = `e2e-parent-renamed-${Date.now()}` + await client.updateFolder(parentFolderId, { + name: renamed, + description: 'renamed', + }) + const updated = await client.getFolder(parentFolderId) + expect(updated.name).toBe(renamed) + expect(updated.description).toBe('renamed') + + const child = await client.createFolder({ + name: `e2e-child-${Date.now()}`, + parentFolderId: parentFolderId, + }) + expect(child.id).toEqual(expect.any(String)) + childFolderId = child.id + const fetchedChild = await client.getFolder(childFolderId) + expect(fetchedChild.parentFolderId).toBe(parentFolderId) + + const list = await client.getFolderList() + const ids = new Set(list.map(f => f.id)) + expect(ids.has(parentFolderId)).toBe(true) + expect(ids.has(childFolderId)).toBe(true) + + try { + orderBeforeMutation = await client.getFolderOrder() + const root = [ + ...new Set([...(orderBeforeMutation.root ?? []), parentFolderId]), + ] + const next: ApiFolderOrder = { + ...orderBeforeMutation, + root, + } + await client.updateFolderOrder({ order: next }) + const mid = await client.getFolderOrder() + expect(mid.root).toContain(parentFolderId) + await client.updateFolderOrder({ order: orderBeforeMutation }) + const after = await client.getFolderOrder() + expect(after).toEqual(orderBeforeMutation) + } catch (err) { + if (!isNotFound(err)) throw err + console.warn( + '[e2e] folder-order API not available; skipped order round-trip.', + ) + expect(isNotFound(err)).toBe(true) + } + + await client.deleteFolder(childFolderId) + const listAfterChild = await client.getFolderList() + expect(listAfterChild.find(f => f.id === childFolderId)).toBeUndefined() + childFolderId = '' + + await client.deleteFolder(parentFolderId) + const listAfterParent = await client.getFolderList() + expect(listAfterParent.find(f => f.id === parentFolderId)).toBeUndefined() + parentFolderId = '' + } catch (err) { + for (const id of [childFolderId, parentFolderId]) { + if (!id) continue + try { + await client.deleteFolder(id) + } catch { + /* ignore */ + } + } + if (orderBeforeMutation) { + try { + await client.updateFolderOrder({ order: orderBeforeMutation }) + } catch { + /* ignore */ + } + } + throw err + } + }) + }) + }) +})