From 2fe03807714b0253ecea885eda0cb5a10be2a692 Mon Sep 17 00:00:00 2001 From: Florian Rath Date: Fri, 9 Oct 2026 16:11:56 +0200 Subject: [PATCH] fix: honor draft option without activating listings --- CONTRIBUTING.md | 3 + docs/commands/publish.md | 11 ++ package.json | 1 + src/api/listings.ts | 29 ++--- src/cli/commands/listings.ts | 7 +- src/types/index.ts | 2 +- tests/listings.test.ts | 199 +++++++++++++++++++++++++++++++++++ 7 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 tests/listings.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 327aa01..71ba15e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,9 @@ bun install # Run in development mode bun run dev -- --help +# Run offline regression tests (synthetic auth, mocked network and cache) +bun test + # Type check bun run typecheck diff --git a/docs/commands/publish.md b/docs/commands/publish.md index 36fcfec..028a1ad 100644 --- a/docs/commands/publish.md +++ b/docs/commands/publish.md @@ -19,6 +19,17 @@ willhaben publish [options] | Option | Description | |--------|-------------| | `--json` | Output as JSON | +| `--draft` | Save a draft without activating/publishing it | + +Without `--draft`, this command saves and then activates the listing as before. +With `--draft`, it only saves the draft; the returned JSON and local cache use +`"status": "draft"`, and the success message is `Draft saved successfully!`. +Drafts still require authentication and the same template validation. + +```bash +willhaben publish my-item.json --draft +willhaben publish my-item.json --draft --json +``` ## Template Format diff --git a/package.json b/package.json index fc34fb1..4814253 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "willhaben": "./src/index.ts" }, "scripts": { + "test": "bun test", "dev": "bun run src/index.ts", "build": "bun build ./src/index.ts --compile --outfile willhaben", "typecheck": "tsc --noEmit", diff --git a/src/api/listings.ts b/src/api/listings.ts index 4776cba..ce89e5f 100644 --- a/src/api/listings.ts +++ b/src/api/listings.ts @@ -378,6 +378,7 @@ interface CreateAdResponse { } export interface PublishListingOptions { + draft?: boolean; // Save without activating; defaults to publishing title: string; description: string; price: number; @@ -574,19 +575,21 @@ export async function createListing( const adId = saveResponse.data.adId; - // Step 2: Activate/publish the draft (uses PUT) - const activateResponse = await apiClient.put( - ENDPOINTS.publish(adId.toString()) - ); + // Step 2: Activate/publish unless only a draft was requested (uses PUT) + if (!options.draft) { + const activateResponse = await apiClient.put( + ENDPOINTS.publish(adId.toString()) + ); - if (!activateResponse.success) { - return { - success: false, - error: activateResponse.error || { - code: "ACTIVATE_FAILED", - message: `Draft created (ID: ${adId}) but failed to publish`, - }, - }; + if (!activateResponse.success) { + return { + success: false, + error: activateResponse.error || { + code: "ACTIVATE_FAILED", + message: `Draft created (ID: ${adId}) but failed to publish`, + }, + }; + } } // Return the created listing @@ -598,7 +601,7 @@ export async function createListing( currency: "EUR", category: options.categoryPath[options.categoryPath.length - 1] || "Unknown", categoryId: 67, - status: "active", + status: options.draft ? "draft" : "active", images: [], attributes: [], createdAt: new Date().toISOString(), diff --git a/src/cli/commands/listings.ts b/src/cli/commands/listings.ts index ed8a75e..914d647 100644 --- a/src/cli/commands/listings.ts +++ b/src/cli/commands/listings.ts @@ -211,9 +211,10 @@ export function registerListingCommands(program: Command): void { process.exit(1); } - const spinner = ora("Publishing listing...").start(); + const spinner = ora(opts.draft ? "Saving draft..." : "Publishing listing...").start(); const publishOptions: PublishListingOptions = { + draft: opts.draft, title: template.title, description: template.description, price: template.price, @@ -232,7 +233,7 @@ export function registerListingCommands(program: Command): void { const result = await createListing(publishOptions, userInfo); if (result.success && result.data) { - spinner.succeed(chalk.green("Listing published successfully!")); + spinner.succeed(chalk.green(result.data.status === "draft" ? "Draft saved successfully!" : "Listing published successfully!")); if (opts.json) { console.log(JSON.stringify(result.data, null, 2)); @@ -242,7 +243,7 @@ export function registerListingCommands(program: Command): void { console.log(` Price: ${chalk.green(`€ ${result.data.price}`)}`); } } else { - spinner.fail(chalk.red("Failed to publish listing")); + spinner.fail(chalk.red(opts.draft ? "Failed to save draft" : "Failed to publish listing")); console.error( chalk.red(result.error?.message || "Unknown error occurred") ); diff --git a/src/types/index.ts b/src/types/index.ts index bbea73e..ef8a0d0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,7 +47,7 @@ export interface Listing { currency: string; category: string; categoryId: number; - status: "active" | "inactive" | "expired" | "deleted"; + status: "active" | "inactive" | "expired" | "deleted" | "draft"; images: ListingImage[]; attributes: ListingAttribute[]; location?: { diff --git a/tests/listings.test.ts b/tests/listings.test.ts new file mode 100644 index 0000000..8bc27e3 --- /dev/null +++ b/tests/listings.test.ts @@ -0,0 +1,199 @@ +import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { Command } from "commander"; +import type { Listing } from "../src/types/index.js"; + +// Synthetic credentials and in-memory cache only; never read the user's config. +const userInfo = { userId: 123, email: "test@example.invalid", firstName: "Test" }; +mock.module("../src/store/config.js", () => ({ + isAuthenticated: async () => true, + getJwtUserInfo: async () => userInfo, + getConfigValue: async () => "pretty", + getAuthTokens: async () => ({ accessToken: "synthetic", refreshToken: "synthetic", expiresAt: Infinity }), + isTokenExpired: async () => false, + setAuthTokens: async () => { throw new Error("Unexpected token refresh"); }, + loadConfig: async () => ({ applicationToken: { value: "synthetic", expiresAt: Infinity } }), + saveConfig: async () => { throw new Error("Unexpected config write"); }, +})); +const cached: Listing[] = []; +mock.module("../src/store/listings.js", () => ({ + updateCachedListing: async (listing: Listing) => { cached.push(structuredClone(listing)); }, + updateCachedListings: async () => { throw new Error("Unexpected bulk cache update"); }, + removeCachedListing: async () => { throw new Error("Unexpected cache removal"); }, + getCachedListing: async (id: string) => cached.find((listing) => listing.id === id), +})); + +// Exercise the real HTTP client; intercept every fetch, including unexpected URLs. +const requests: Array<{ method: string; path: string; body: unknown }> = []; +let saveStatus = 200; +let activateStatus = 200; +let saveData: unknown; +const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(String(input)).pathname; + const method = init?.method || "GET"; + requests.push({ method, path, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (method === "POST" && path === "/restapi/v2/bap/save") { + return Response.json(saveStatus === 200 ? saveData : { message: "Save rejected" }, { status: saveStatus }); + } + if (method === "PUT" && path === "/restapi/v2/bap/activate/456") { + return Response.json(activateStatus === 200 ? {} : { message: "Activation rejected" }, { status: activateStatus }); + } + throw new Error(`Unexpected network request: ${method} ${path}`); +}, { preconnect: () => {} })); +const { createListing } = await import("../src/api/listings.js"); +const { registerListingCommands } = await import("../src/cli/commands/listings.js"); +const directory = await mkdtemp(join(tmpdir(), "willhaben-draft-test-")); +const templatePath = join(directory, "template.json"); +const options = { + title: "Synthetic test listing", description: "Not a real ad", price: 10, + postCode: "1010", locationId: 900, location: "Wien", + categoryPath: ["BOOKSFILMANDMUSIC", "NON_FICTION_BOOKS"], skipValidation: true, +}; +await writeFile(templatePath, JSON.stringify(options)); + +beforeEach(() => { + requests.length = 0; + cached.length = 0; + saveStatus = 200; + activateStatus = 200; + saveData = { adId: 456 }; +}); +afterAll(async () => { + fetchSpy.mockRestore(); + await rm(directory, { recursive: true, force: true }); +}); + +async function runPublish(...args: string[]) { + const stdout: string[] = []; + const stderr: string[] = []; + const log = spyOn(console, "log").mockImplementation((...args) => { stdout.push(args.join(" ") + "\n"); }); + const errorLog = spyOn(console, "error").mockImplementation((...args) => { stderr.push(args.join(" ") + "\n"); }); + const out = spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { stdout.push(String(chunk)); return true; }); + const err = spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { stderr.push(String(chunk)); return true; }); + const exit = spyOn(process, "exit").mockImplementation((code) => { throw new Error(`exit:${code}`); }); + let failure: unknown; + try { + const program = new Command(); + registerListingCommands(program); + await program.parseAsync(["publish", templatePath, ...args], { from: "user" }); + } catch (error) { + failure = error; + } finally { + log.mockRestore(); + errorLog.mockRestore(); + out.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + } + return { stdout: stdout.join(""), stderr: stderr.join(""), failure }; +} +const saveRequest = { method: "POST", path: "/restapi/v2/bap/save" }; +const activationRequest = { method: "PUT", path: "/restapi/v2/bap/activate/456" }; +function expectRequests(expected: typeof saveRequest[]) { + expect(requests.map(({ method, path }) => ({ method, path }))).toEqual(expected); +} + +describe("createListing", () => { + test("draft saves once without activation and caches draft status", async () => { + const result = await createListing({ ...options, draft: true }, userInfo); + expectRequests([saveRequest]); + expect(result.success).toBe(true); + expect(result.data?.status).toBe("draft"); + expect(cached).toEqual([result.data!]); + expect(requests[0].body).toMatchObject({ heading: options.title, userId: userInfo.userId, adId: 0 }); + expect(requests[0].body).not.toHaveProperty("draft"); + }); + for (const draft of [undefined, false]) { + test(`draft=${draft} preserves default publication`, async () => { + const result = await createListing({ ...options, draft }, userInfo); + expectRequests([saveRequest, activationRequest]); + expect(result.success).toBe(true); + expect(result.data?.status).toBe("active"); + expect(cached).toEqual([result.data!]); + }); + } + for (const draft of [true, false]) { + test(`save failure never activates (draft=${draft})`, async () => { + saveStatus = 400; + const result = await createListing({ ...options, draft }, userInfo); + expectRequests([saveRequest]); + expect(result.success).toBe(false); + expect(result.error?.message).toBe("Save rejected"); + expect(cached).toEqual([]); + }); + } + test("missing save data never activates", async () => { + saveData = null; + const result = await createListing(options, userInfo); + expectRequests([saveRequest]); + expect(result.error?.code).toBe("SAVE_FAILED"); + expect(cached).toEqual([]); + }); + test("activation failure is not successful publication or an active cache entry", async () => { + activateStatus = 400; + const result = await createListing(options, userInfo); + expectRequests([saveRequest, activationRequest]); + expect(result.success).toBe(false); + expect(result.error?.message).toBe("Activation rejected"); + expect(cached).toEqual([]); + }); +}); + +describe("real publish CLI action", () => { + test("--draft propagates through the real API and emits draft JSON", async () => { + const result = await runPublish("--draft", "--json"); + expect(result.failure).toBeUndefined(); + expectRequests([saveRequest]); + expect(JSON.parse(result.stdout).status).toBe("draft"); + expect(cached[0].status).toBe("draft"); + expect(result.stderr).toContain("Draft saved successfully!"); + expect(result.stderr).not.toMatch(/publishing|published/i); + }); + test("--draft human output reports saving, not publishing", async () => { + const result = await runPublish("--draft"); + expect(result.failure).toBeUndefined(); + expectRequests([saveRequest]); + expect(result.stdout).toContain("ID: 456"); + expect(result.stderr).toContain("Saving draft..."); + expect(result.stderr).toContain("Draft saved successfully!"); + expect(result.stderr).not.toMatch(/publishing|published/i); + }); + test("without --draft still publishes and returns active JSON", async () => { + const result = await runPublish("--json"); + expect(result.failure).toBeUndefined(); + expectRequests([saveRequest, activationRequest]); + expect(JSON.parse(result.stdout).status).toBe("active"); + expect(result.stderr).toContain("Listing published successfully!"); + }); + test("draft save failure reports failure and never activates", async () => { + saveStatus = 400; + const result = await runPublish("--draft"); + expect(result.failure).toEqual(new Error("exit:1")); + expectRequests([saveRequest]); + expect(result.stderr).toContain("Failed to save draft"); + expect(result.stderr).toContain("Save rejected"); + expect(result.stderr).not.toMatch(/successfully|publish/i); + expect(cached).toEqual([]); + }); + test("publish save failure never activates or reports success", async () => { + saveStatus = 400; + const result = await runPublish(); + expect(result.failure).toEqual(new Error("exit:1")); + expectRequests([saveRequest]); + expect(result.stderr).toContain("Failed to publish listing"); + expect(result.stderr).not.toContain("successfully"); + expect(cached).toEqual([]); + }); + test("publish activation failure reports failure, not publication", async () => { + activateStatus = 400; + const result = await runPublish(); + expect(result.failure).toEqual(new Error("exit:1")); + expectRequests([saveRequest, activationRequest]); + expect(result.stderr).toContain("Failed to publish listing"); + expect(result.stderr).toContain("Activation rejected"); + expect(result.stderr).not.toContain("successfully"); + expect(cached).toEqual([]); + }); +});