diff --git a/.changeset/order-from-quote-schemas.md b/.changeset/order-from-quote-schemas.md new file mode 100644 index 00000000..609fdb86 --- /dev/null +++ b/.changeset/order-from-quote-schemas.md @@ -0,0 +1,11 @@ +--- +"@labdigital/commercetools-mock": patch +--- + +Regenerate the zod draft schemas from the current commercetools OpenAPI spec. + +Picks up optional fields added upstream since the last run — extension +dependencies, expansion paths and additional context, inventory entry stock +levels and reservation expiry, shipping method stores and carrier, store +storefront URLs — plus the `reservation` and `variant` reference type ids and +the `ReserveOnCart` inventory mode. diff --git a/.changeset/order-from-quote.md b/.changeset/order-from-quote.md new file mode 100644 index 00000000..2c53fc7f --- /dev/null +++ b/.changeset/order-from-quote.md @@ -0,0 +1,15 @@ +--- +"@labdigital/commercetools-mock": minor +--- + +Support creating an order from a quote, on `POST /{projectKey}/orders/quotes` +and `POST /{projectKey}/me/orders/quotes`. + +Turning a quote into an order is the endpoint of the B2B quote flow and was the +one step that could not be exercised. The guards around it are modelled too, +since those are what a consumer wants to assert: the quote must be `Pending` +and not past its `validTo`, a stale `version` raises `ConcurrentModification`, +and `quoteStateToAccepted: true` moves the quote to `Accepted` as part of +creating the order. The order carries the quote's line items and prices — +nothing is re-priced — and references the quote it came from with +`origin: "Quote"`. diff --git a/AGENTS.md b/AGENTS.md index 910efd5c..2a6b1904 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,4 +6,6 @@ - Always run `pnpm test` after making changes and verify all tests pass before considering your work done. Fix any test failures you introduce. - Always run `pnpm biome check` after making changes and verify it reports no errors before considering your work done. Use `pnpm biome check --write --unsafe` to auto-fix formatting, import ordering, and unused import issues. - Update the changesets after making impactful changes. If your change is a bug fix, add a changeset with type "patch". If your change is a new feature, add a changeset with type "minor". If your change is a breaking change, add a changeset with type "major". Create the files directly, as `pnpm changeset` doesn't work well with agents. -- If you are making API changes, make sure to update the documentation and examples listed in `README.md` \ No newline at end of file +- If you are making API changes, make sure to update the documentation and examples listed in `README.md` +- When you add an endpoint that accepts a new draft type, add that draft to `DRAFT_SCHEMAS` and `DRAFT_FILE_MAP` in `scripts/generate-schemas.ts` and run `pnpm generate:schemas`, then validate the request body against the generated schema (see `AbstractService.post`, which does this for the standard create route). Don't hand-write a schema in `src/schemas/generated/`, and don't hand-roll the validation in the handler. The generator needs the commercetools OpenAPI spec checked out at `../commercetools-api-reference`; run `pnpm biome check --write --unsafe src/schemas/generated` afterwards, because the generator emits unformatted output with unsorted imports. +- Hand-written schemas belong in `src/schemas/`, next to the generated directory. They are for the cases the generator cannot express — for example a union the spec flattens to its base type. \ No newline at end of file diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts index 8525e08d..3cfcc5e1 100644 --- a/scripts/generate-schemas.ts +++ b/scripts/generate-schemas.ts @@ -43,9 +43,11 @@ const DRAFT_SCHEMAS = [ "DiscountGroupDraft", "ExtensionDraft", "InventoryEntryDraft", + "MyOrderFromQuoteDraft", "MyQuoteRequestDraft", "OrderEditDraft", "OrderFromCartDraft", + "OrderFromQuoteDraft", "PaymentDraft", "ProductDraft", "ProductDiscountDraft", @@ -85,9 +87,11 @@ const DRAFT_FILE_MAP: Record = { DiscountGroupDraft: "discount-group", ExtensionDraft: "extension", InventoryEntryDraft: "inventory-entry", + MyOrderFromQuoteDraft: "my-order-from-quote", MyQuoteRequestDraft: "my-quote-request", OrderEditDraft: "order-edit", OrderFromCartDraft: "order-from-cart", + OrderFromQuoteDraft: "order-from-quote", PaymentDraft: "payment", ProductDraft: "product", ProductDiscountDraft: "product-discount", diff --git a/src/repositories/order/index.ts b/src/repositories/order/index.ts index 9327d1ec..38a5eee9 100644 --- a/src/repositories/order/index.ts +++ b/src/repositories/order/index.ts @@ -7,15 +7,18 @@ import type { Delivery, DuplicateFieldError, GeneralError, + InvalidOperationError, LineItem, LineItemImportDraft, Order, OrderFromCartDraft, + OrderFromQuoteDraft, OrderImportDraft, OrderPagedSearchResponse, OrderSearchRequest, Product, ProductVariant, + Quote, ReferencedResourceNotFoundError, ResourceNotFoundError, ShippingInfo, @@ -42,6 +45,7 @@ import { import type { Writable } from "#src/types.ts"; import type { RepositoryContext } from "../abstract.ts"; import { AbstractResourceRepository, type QueryParams } from "../abstract.ts"; +import { checkConcurrentModification } from "../errors.ts"; import { calculateMoneyTotalCentAmount, createAddress, @@ -145,6 +149,91 @@ export class OrderRepository extends AbstractResourceRepository<"order"> { return await this.saveNew(context, resource); } + async createFromQuote( + context: RepositoryContext, + draft: OrderFromQuoteDraft, + ): Promise { + // Resolving the reference raises ReferencedResourceNotFound when the quote + // does not exist + const quote = await this._storage.getByResourceIdentifier<"quote">( + context.projectKey, + draft.quote, + ); + + checkConcurrentModification(quote.version, draft.version, quote.id); + + if (quote.quoteState !== "Pending") { + throw new CommercetoolsError( + { + code: "InvalidOperation", + message: `The quote with ID '${quote.id}' cannot be ordered because it is in state '${quote.quoteState}'.`, + }, + 400, + ); + } + + if (quote.validTo && new Date(quote.validTo) < new Date()) { + throw new CommercetoolsError( + { + code: "InvalidOperation", + message: `The quote with ID '${quote.id}' cannot be ordered because it expired on ${quote.validTo}.`, + }, + 400, + ); + } + + // Converting a quote reuses its prices; nothing is recalculated + const resource: Writable = { + ...getBaseResourceProperties(context.clientId), + billingAddress: quote.billingAddress, + businessUnit: quote.businessUnit, + country: quote.country, + custom: quote.custom, + customerGroup: quote.customerGroup, + customerId: quote.customer?.id, + customLineItems: quote.customLineItems, + directDiscounts: quote.directDiscounts, + itemShippingAddresses: quote.itemShippingAddresses, + lastMessageSequenceNumber: 0, + lineItems: quote.lineItems, + orderNumber: draft.orderNumber ?? generateRandomString(10), + orderState: draft.orderState ?? "Open", + origin: "Quote", + paymentState: draft.paymentState, + purchaseOrderNumber: quote.purchaseOrderNumber, + quote: { + typeId: "quote", + id: quote.id, + }, + refusedGifts: [], + shipmentState: draft.shipmentState, + shipping: [], + shippingAddress: quote.shippingAddress, + shippingInfo: quote.shippingInfo, + shippingMode: "Single", + store: quote.store, + syncInfo: [], + taxCalculationMode: quote.taxCalculationMode, + taxedPrice: quote.taxedPrice, + taxMode: quote.taxMode, + taxRoundingMode: quote.taxRoundingMode, + totalPrice: createCentPrecisionMoney(quote.totalPrice), + }; + + const order = await this.saveNew(context, resource); + + if (draft.quoteStateToAccepted) { + const accepted = { + ...quote, + quoteState: "Accepted", + version: quote.version + 1, + } as Writable; + await this._storage.add(context.projectKey, "quote", accepted); + } + + return order; + } + async import( context: RepositoryContext, draft: OrderImportDraft, diff --git a/src/schemas/generated/common.ts b/src/schemas/generated/common.ts index 4ed2994d..8b6e7609 100644 --- a/src/schemas/generated/common.ts +++ b/src/schemas/generated/common.ts @@ -87,6 +87,7 @@ export const ReferenceTypeIdSchema = z.enum([ "quote-request", "recurrence-policy", "recurring-order", + "reservation", "review", "shipping-method", "shopping-list", @@ -97,6 +98,7 @@ export const ReferenceTypeIdSchema = z.enum([ "subscription", "tax-category", "type", + "variant", "zone", ]); @@ -127,6 +129,7 @@ export const InventoryModeSchema = z.enum([ "None", "TrackOnly", "ReserveOnOrder", + "ReserveOnCart", ]); export const PriceSelectionModeSchema = z.enum(["Fixed", "Dynamic"]); @@ -402,6 +405,7 @@ export const ResourceTypeIdSchema = z.enum([ "product-selection", "product-tailoring", "quote", + "reservation", "review", "recurring-order", "shipping", @@ -1031,6 +1035,25 @@ export const ExtensionTriggerSchema = z.object({ condition: z.string().nullish(), }); +export const ExtensionResourceIdentifierSchema = z + .object({ + typeId: ReferenceTypeIdSchema, + id: z.string().nullish(), + key: z.string().nullish(), + }) + .refine((data) => data.id !== undefined || data.key !== undefined, { + message: "Either 'id' or 'key' must be provided", + }); + +export const ExtensionAdditionalContextDraftSchema = z.object({ + includeOldResource: z.boolean().nullish(), +}); + +export const InventoryEntryStockLevelsSchema = z.object({ + reorderPoint: z.number().int().nullish(), + safetyStock: z.number().int().nullish(), +}); + export const OrderReferenceSchema = z.object({ typeId: ReferenceTypeIdSchema, id: z.string(), @@ -1050,6 +1073,16 @@ export const StateResourceIdentifierSchema = z message: "Either 'id' or 'key' must be provided", }); +export const QuoteResourceIdentifierSchema = z + .object({ + typeId: ReferenceTypeIdSchema, + id: z.string().nullish(), + key: z.string().nullish(), + }) + .refine((data) => data.id !== undefined || data.key !== undefined, { + message: "Either 'id' or 'key' must be provided", + }); + export const PaymentMethodTokenSchema = z.object({ value: z.string(), }); @@ -1311,6 +1344,19 @@ export const ProductSelectionSettingDraftSchema = z.object({ active: z.boolean().nullish(), }); +export const StorefrontSchema = z.object({ + checkoutUrlTemplate: z.string().nullish(), + orderUrlTemplate: z.string().nullish(), + termsOfServiceUrl: z.string().nullish(), + privacyPolicyUrl: z.string().nullish(), + refundPolicyUrl: z.string().nullish(), + shippingPolicyUrl: z.string().nullish(), + cookiePolicyUrl: z.string().nullish(), + imprintUrl: z.string().nullish(), + faqUrl: z.string().nullish(), + contactUrl: z.string().nullish(), +}); + export const DestinationSchema = z.object({ type: z.string(), }); diff --git a/src/schemas/generated/extension.ts b/src/schemas/generated/extension.ts index 1ca0851c..80f0e322 100644 --- a/src/schemas/generated/extension.ts +++ b/src/schemas/generated/extension.ts @@ -3,7 +3,9 @@ import { z } from "zod"; import { + ExtensionAdditionalContextDraftSchema, ExtensionDestinationSchema, + ExtensionResourceIdentifierSchema, ExtensionTriggerSchema, } from "./common.ts"; @@ -12,4 +14,7 @@ export const ExtensionDraftSchema = z.object({ destination: ExtensionDestinationSchema, triggers: z.array(ExtensionTriggerSchema), timeoutInMs: z.number().int().nullish(), + dependencies: z.array(ExtensionResourceIdentifierSchema).nullish(), + expansionPaths: z.array(z.string()).nullish(), + additionalContext: ExtensionAdditionalContextDraftSchema.nullish(), }); diff --git a/src/schemas/generated/index.ts b/src/schemas/generated/index.ts index 8a75d1c0..38672e2f 100644 --- a/src/schemas/generated/index.ts +++ b/src/schemas/generated/index.ts @@ -16,9 +16,11 @@ export * from "./discount-code.ts"; export * from "./discount-group.ts"; export * from "./extension.ts"; export * from "./inventory-entry.ts"; +export * from "./my-order-from-quote.ts"; export * from "./my-quote-request.ts"; export * from "./order-edit.ts"; export * from "./order-from-cart.ts"; +export * from "./order-from-quote.ts"; export * from "./payment.ts"; export * from "./product.ts"; export * from "./product-discount.ts"; diff --git a/src/schemas/generated/inventory-entry.ts b/src/schemas/generated/inventory-entry.ts index ab85d69e..29e0436f 100644 --- a/src/schemas/generated/inventory-entry.ts +++ b/src/schemas/generated/inventory-entry.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { ChannelResourceIdentifierSchema, CustomFieldsDraftSchema, + InventoryEntryStockLevelsSchema, } from "./common.ts"; export const InventoryEntryDraftSchema = z.object({ @@ -16,5 +17,7 @@ export const InventoryEntryDraftSchema = z.object({ maxCartQuantity: z.number().int().nullish(), restockableInDays: z.number().int().nullish(), expectedDelivery: z.string().nullish(), + reservationExpirationInMinutes: z.number().int().nullish(), + stockLevels: InventoryEntryStockLevelsSchema.nullish(), custom: CustomFieldsDraftSchema.nullish(), }); diff --git a/src/schemas/generated/my-order-from-quote.ts b/src/schemas/generated/my-order-from-quote.ts new file mode 100644 index 00000000..1a577267 --- /dev/null +++ b/src/schemas/generated/my-order-from-quote.ts @@ -0,0 +1,10 @@ +// This file is auto-generated by scripts/generate-schemas.ts +// Do not edit manually. + +import { z } from "zod"; + +export const MyOrderFromQuoteDraftSchema = z.object({ + id: z.string(), + version: z.number().int(), + quoteStateToAccepted: z.boolean().nullish(), +}); diff --git a/src/schemas/generated/order-from-quote.ts b/src/schemas/generated/order-from-quote.ts new file mode 100644 index 00000000..7a6289c7 --- /dev/null +++ b/src/schemas/generated/order-from-quote.ts @@ -0,0 +1,22 @@ +// This file is auto-generated by scripts/generate-schemas.ts +// Do not edit manually. + +import { z } from "zod"; +import { + OrderStateSchema, + PaymentStateSchema, + QuoteResourceIdentifierSchema, + ShipmentStateSchema, + StateResourceIdentifierSchema, +} from "./common.ts"; + +export const OrderFromQuoteDraftSchema = z.object({ + quote: QuoteResourceIdentifierSchema, + version: z.number().int(), + quoteStateToAccepted: z.boolean().nullish(), + orderNumber: z.string().nullish(), + paymentState: PaymentStateSchema.nullish(), + shipmentState: ShipmentStateSchema.nullish(), + orderState: OrderStateSchema.nullish(), + state: StateResourceIdentifierSchema.nullish(), +}); diff --git a/src/schemas/generated/shipping-method.ts b/src/schemas/generated/shipping-method.ts index 6060d0fb..9875074f 100644 --- a/src/schemas/generated/shipping-method.ts +++ b/src/schemas/generated/shipping-method.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { CustomFieldsDraftSchema, LocalizedStringSchema, + StoreResourceIdentifierSchema, TaxCategoryResourceIdentifierSchema, ZoneRateDraftSchema, } from "./common.ts"; @@ -21,4 +22,6 @@ export const ShippingMethodDraftSchema = z.object({ isDefault: z.boolean(), predicate: z.string().nullish(), custom: CustomFieldsDraftSchema.nullish(), + stores: z.array(StoreResourceIdentifierSchema).nullish(), + carrier: z.string().nullish(), }); diff --git a/src/schemas/generated/store.ts b/src/schemas/generated/store.ts index 061c8280..d93fb349 100644 --- a/src/schemas/generated/store.ts +++ b/src/schemas/generated/store.ts @@ -9,6 +9,7 @@ import { LocalizedStringSchema, ProductSelectionSettingDraftSchema, StoreCountrySchema, + StorefrontSchema, } from "./common.ts"; export const StoreDraftSchema = z.object({ @@ -20,4 +21,5 @@ export const StoreDraftSchema = z.object({ supplyChannels: z.array(ChannelResourceIdentifierSchema).nullish(), productSelections: z.array(ProductSelectionSettingDraftSchema).nullish(), custom: CustomFieldsDraftSchema.nullish(), + storefront: StorefrontSchema.nullish(), }); diff --git a/src/services/my-order.ts b/src/services/my-order.ts index b1e30c9c..755b5aac 100644 --- a/src/services/my-order.ts +++ b/src/services/my-order.ts @@ -1,4 +1,8 @@ -import type { FastifyInstance } from "fastify"; +import type { MyOrderFromQuoteDraft } from "@commercetools/platform-sdk"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { MyOrderFromQuoteDraftSchema } from "#src/schemas/generated/my-order-from-quote.ts"; +import { validateDraft } from "#src/validate.ts"; +import { getRepositoryContext } from "../repositories/helpers.ts"; import type { MyOrderRepository } from "../repositories/my-order.ts"; import AbstractService from "./abstract.ts"; @@ -26,6 +30,7 @@ export class MyOrderService extends AbstractService { instance.delete("/orders/:id", this.deleteWithId.bind(this)); + instance.post("/orders/quotes", this.createFromQuote.bind(this)); instance.post("/orders", this.post.bind(this)); instance.post("/orders/:id", this.postWithId.bind(this)); @@ -34,4 +39,27 @@ export class MyOrderService extends AbstractService { { prefix: `/${basePath}` }, ); } + + async createFromQuote( + request: FastifyRequest<{ + Params: Record; + Body: MyOrderFromQuoteDraft; + }>, + reply: FastifyReply, + ) { + if (this.repository.strict) { + validateDraft(request.body, MyOrderFromQuoteDraftSchema); + } + + const { id, version, quoteStateToAccepted } = request.body; + const resource = await this.repository.createFromQuote( + getRepositoryContext(request), + { + quote: { typeId: "quote", id }, + version, + quoteStateToAccepted, + }, + ); + return reply.status(this.createStatusCode).send(resource); + } } diff --git a/src/services/order-from-quote.test.ts b/src/services/order-from-quote.test.ts new file mode 100644 index 00000000..d4d87918 --- /dev/null +++ b/src/services/order-from-quote.test.ts @@ -0,0 +1,256 @@ +import type { LineItem, Quote } from "@commercetools/platform-sdk"; +import { afterEach, describe, expect, test } from "vitest"; +import { CommercetoolsMock, getBaseResourceProperties } from "../index.ts"; + +const ctMock = new CommercetoolsMock({ defaultProjectKey: "dummy" }); +const customerId = "8d4d2b1a-6d90-4f0f-9e34-3c1a01e6b3f1"; + +const lineItem = { + id: "0a2a1c4e-1e33-4c19-9c6c-27a9c3f0a9d1", + productId: "3d1f0f7a-6f1c-4f10-9a3c-9a9d0f1b2c33", + name: { en: "Test product" }, + productType: { + typeId: "product-type", + id: "2b3c4d5e-6f70-4819-9a2b-3c4d5e6f7081", + }, + variant: { id: 1, sku: "1337" }, + price: { + id: "5f6e7d8c-9b0a-4c1d-8e2f-3a4b5c6d7e8f", + value: { + type: "centPrecision", + currencyCode: "EUR", + centAmount: 14900, + fractionDigits: 2, + }, + }, + quantity: 2, + totalPrice: { + type: "centPrecision", + currencyCode: "EUR", + centAmount: 29800, + fractionDigits: 2, + }, + discountedPricePerQuantity: [], + taxedPricePortions: [], + perMethodTaxRate: [], + state: [], + priceMode: "Platform", + lineItemMode: "Standard", +} as unknown as LineItem; + +const createQuote = async (overrides: Partial = {}) => { + const quote: Quote = { + ...getBaseResourceProperties(), + key: "quote-1", + quoteState: "Pending", + customer: { typeId: "customer", id: customerId }, + quoteRequest: { + typeId: "quote-request", + id: "0f0f0a3b-6a2f-4a4c-9a05-6a6b0a5f7d3e", + }, + stagedQuote: { + typeId: "staged-quote", + id: "1ac6f1a0-6f4b-4a8e-9f9e-2ac4f6a1b7c2", + }, + lineItems: [lineItem], + customLineItems: [], + taxMode: "Platform", + priceRoundingMode: "HalfEven", + taxRoundingMode: "HalfEven", + taxCalculationMode: "LineItemLevel", + totalPrice: { + type: "centPrecision", + currencyCode: "EUR", + centAmount: 29800, + fractionDigits: 2, + }, + ...overrides, + }; + + await ctMock.project().unsafeAdd("quote", quote); + return quote; +}; + +describe("Order from Quote", () => { + afterEach(() => { + ctMock.clear(); + }); + + test("create order from quote", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + expect(response.statusCode).toBe(201); + + const order = response.json(); + expect(order.quote).toEqual({ typeId: "quote", id: quote.id }); + expect(order.origin).toBe("Quote"); + expect(order.orderState).toBe("Open"); + expect(order.customerId).toBe(customerId); + expect(order.lineItems).toHaveLength(1); + expect(order.totalPrice.centAmount).toBe(29800); + }); + + test("quoteStateToAccepted transitions the quote", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + quoteStateToAccepted: true, + }, + }); + expect(response.statusCode).toBe(201); + + const updated = await ctMock.app.inject({ + method: "GET", + url: `/dummy/quotes/${quote.id}`, + }); + expect(updated.json().quoteState).toBe("Accepted"); + expect(updated.json().version).toBe(quote.version + 1); + }); + + test("the quote is left alone without quoteStateToAccepted", async () => { + const quote = await createQuote(); + + await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + const updated = await ctMock.app.inject({ + method: "GET", + url: `/dummy/quotes/${quote.id}`, + }); + expect(updated.json().quoteState).toBe("Pending"); + }); + + test("a quote that is not pending cannot be ordered", async () => { + const quote = await createQuote({ quoteState: "Declined" }); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidOperation"); + }); + + test("an expired quote cannot be ordered", async () => { + const quote = await createQuote({ validTo: "2020-01-01T00:00:00.000Z" }); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidOperation"); + }); + + test("a stale version is rejected", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version + 1, + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().errors[0].code).toBe("ConcurrentModification"); + }); + + test("an unknown quote is rejected", async () => { + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: "2c4bb2c1-0f4f-4c1e-9d2f-9a1d3e4b5c6a" }, + version: 1, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("ReferencedResourceNotFound"); + }); + + test("create order from quote via /me", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/me/orders/quotes", + payload: { + id: quote.id, + version: quote.version, + quoteStateToAccepted: true, + }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json().quote).toEqual({ typeId: "quote", id: quote.id }); + + const updated = await ctMock.app.inject({ + method: "GET", + url: `/dummy/quotes/${quote.id}`, + }); + expect(updated.json().quoteState).toBe("Accepted"); + }); +}); + +describe("Order from Quote draft validation", () => { + const strictMock = new CommercetoolsMock({ + defaultProjectKey: "dummy", + strict: true, + }); + + test("a draft without a quote reference is rejected", async () => { + const response = await strictMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { version: 1 }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidJsonInput"); + }); + + test("a /me draft without a version is rejected", async () => { + const response = await strictMock.app.inject({ + method: "POST", + url: "/dummy/me/orders/quotes", + payload: { id: "2c4bb2c1-0f4f-4c1e-9d2f-9a1d3e4b5c6a" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].detailedErrorMessage).toContain("version"); + }); +}); diff --git a/src/services/order.ts b/src/services/order.ts index 4f8405d0..9f9a8027 100644 --- a/src/services/order.ts +++ b/src/services/order.ts @@ -1,10 +1,13 @@ import type { + OrderFromQuoteDraft, OrderImportDraft, OrderSearchRequest, ResourceNotFoundError, } from "@commercetools/platform-sdk"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { CommercetoolsError } from "#src/exceptions.ts"; +import { OrderFromQuoteDraftSchema } from "#src/schemas/generated/order-from-quote.ts"; +import { validateDraft } from "#src/validate.ts"; import { getRepositoryContext } from "../repositories/helpers.ts"; import type { OrderRepository } from "../repositories/order/index.ts"; import AbstractService from "./abstract.ts"; @@ -22,6 +25,7 @@ export class OrderService extends AbstractService { } extraRoutes(instance: FastifyInstance) { + instance.post("/quotes", this.createFromQuote.bind(this)); instance.post("/import", this.import.bind(this)); instance.post("/search", this.search.bind(this)); instance.get( @@ -30,6 +34,24 @@ export class OrderService extends AbstractService { ); } + async createFromQuote( + request: FastifyRequest<{ + Params: Record; + Body: OrderFromQuoteDraft; + }>, + reply: FastifyReply, + ) { + if (this.repository.strict) { + validateDraft(request.body, OrderFromQuoteDraftSchema); + } + + const resource = await this.repository.createFromQuote( + getRepositoryContext(request), + request.body, + ); + return reply.status(this.createStatusCode).send(resource); + } + async import( request: FastifyRequest<{ Params: Record;