Skip to content

Merge the Customer endpoints into one domain PR - #428

Open
PrestaEdit wants to merge 10 commits into
PrestaShop:devfrom
PrestaEdit:domain/customer
Open

PrestaEdit wants to merge 10 commits into
PrestaShop:devfrom
PrestaEdit:domain/customer

Conversation

@PrestaEdit

Copy link
Copy Markdown
Contributor
Questions Answers
Branch? dev
Description? Consolidates the pending Customer PRs into one domain PR, and drops one endpoint the team had already flagged as a duplicate
Type? new feature
BC breaks? no
Deprecations? no
Fixed ticket? Related to PrestaShop/PrestaShop#39630
How to test? See below
Sponsor company PrestaEdit

What this PR does

Merges #218, #225, #243 and #342 into one PR, following the mutualisation done on the Product domain in #410.

Endpoints added

Method URI CQRS Scope
PATCH /customers/{customerId}/private-notes SetPrivateNoteAboutCustomerCommand customer_write
GET /customers/{customerId}/orders GetCustomerOrders customer_read
GET /customers/{customerId}/carts GetCustomerCarts customer_read
PUT /customers/{customerId}/transform-to-customers TransformGuestToCustomerCommand customer_write

#218 and #225 both wrote tests/Integration/ApiPlatform/CustomerEndpointTest.php and conflict on cherry-pick — the usual sign that they belonged in one PR.

#342 is dropped, not merged

It exposed GetCustomerForAddressCreation as GET /customers/address-creation-infos?customerEmail=, returning {customerId, firstName, lastName, company}.

That query is already listed in EXCLUDED_CQRS_CLASSES on dev, with the USELESS_DUPLICATE reason — the team had recorded the decision not to expose it. The exclusion holds up: GET /customers/search takes phrases[] that match "first name, last name, email, company name and id" and returns idCustomer, firstname, lastname, company and more — a strict superset of the four fields, reachable by the same email lookup.

Since the exclusion entry already exists on dev, this PR does not need to add anything for it; the resource and its test are simply not carried over.

Tests

The private-note test now asserts its work. It used to check the 204 and stop there, because the write had no read side inside its own PR:

$this->assertNull($return);

GET /customers/{customerId}/details — already on dev — returns the note as generalInformation.privateNote, so the test now reads it back.

CustomerTransformGuestEndpointTest is kept as its own class: it already builds every fixture through POST /customers and asserts through GET /customers/{id}, and it needs a customer / customer_group reset that the main suite does not want.

How to test

PATCH /customers/{id}/private-notes         -> 204, then the details show the note
GET   /customers/{id}/orders                -> 200 {customerId, orders}
GET   /customers/{id}/carts                 -> 200 {customerId, carts}
PUT   /customers/{id}/transform-to-customers -> 204, then the GET shows guest: false

Covered by CustomerEndpointTest and CustomerTransformGuestEndpointTest.

Supersedes

and closes #342 as a duplicate (its query is already excluded on dev).

All four will be closed once the CI is green here.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude AI Pre-Review — Automated analysis. Does not replace human review.

📋 Summary of changes

This PR consolidates four previously separate Customer PRs into one domain merge. It adds four new endpoints: PATCH /customers/{customerId}/private-notes (write a private note via SetPrivateNoteAboutCustomerCommand), GET /customers/{customerId}/orders and GET /customers/{customerId}/carts (collection queries via GetCustomerOrders / GetCustomerCarts), and PUT /customers/{customerId}/transform-to-customers (state transition via TransformGuestToCustomerCommand). It also deliberately excludes PR #342 (already listed in EXCLUDED_CQRS_CLASSES). The existing CustomerEndpointTest is extended and a new CustomerTransformGuestEndpointTest class is added.

⏱️ Estimated review time

10–15 minutes — four small resource classes, one test class extension, one new test class; the main complexity is verifying field alignment for the two collection endpoints.

🎯 Scope

  • Exposed operations: GET (×2 collection), PATCH, PUT
  • CQRS entity: GetCustomerCarts, GetCustomerOrders, SetPrivateNoteAboutCustomerCommand, TransformGuestToCustomerCommand
  • Integration test: yes (partial — the collection endpoints can only test empty lists for a freshly created customer)
🧱 API Platform / CQRS architecture compliance

CustomerPrivateNote.php — missing validationContext

CQRSPartialUpdate is declared without validationContext:

new CQRSPartialUpdate(
    uriTemplate: '/customers/{customerId}/private-notes',
    requirements: ['customerId' => '\d+'],
    output: false,
    read: false,
    CQRSCommand: SetPrivateNoteAboutCustomerCommand::class,
    scopes: ['customer_write'],
    // validationContext is absent
),

CONTEXT.md is explicit: "Use validationContext: ['groups' => ['Default', 'Update']] on CQRSPartialUpdate." Without it, any constraint annotated with groups: ['Update'] will silently not fire. Today $privateNote only carries #[Assert\NotNull] (no group restriction), so there is no runtime failure — but the omission is a maintenance trap. A reviewer who later adds an Update-group constraint expecting it to fire on PATCH will find it never does.

Note: the existing CQRSPartialUpdate on Customer.php (line 64) also omits validationContext — that is a pre-existing gap, not a reason to repeat it here.

Suggested fix:

validationContext: ['groups' => ['Default', 'Update']],

CustomerCart / CustomerOrder — no QUERY_MAPPING; field alignment unverifiable locally

Neither CustomerCart nor CustomerOrder declares a CQRSQueryMapping or ApiResourceMapping. This is fine if the Core's GetCustomerCarts / GetCustomerOrders query result uses exactly cartId, creationDate, totalPrice / orderId, orderPlacedDate, paymentMethodName, orderStatus, orderProductsCount, totalPaid as field names. If any name differs, the DTO property will always be null with no error.

The PR comment mentions these follow the ProductImageList pattern — that class also omits a mapping, but the Core GetProductImages result fields were verified to match at the time. Reviewers should confirm the same alignment here by reading GetCustomerCarts / GetCustomerOrders in Core.

Specific items to verify:

  • Does the Core return cartId or id_cart / idCart?
  • Does the Core return creationDate or date_add / dateAdd? If DateTimeImmutable, the type string is wrong.
  • Does GetCustomerOrders return orderPlacedDate or date_add? Same date-type concern.
  • Does the Core return totalPrice / totalPaid as a pre-formatted string, or as a DecimalNumber? (CONTEXT.md: "use DecimalNumber, never float" — but string is fine for a pre-formatted currency string.)

TransformGuestToCustomer.php — command-style URI, Rector check

/customers/{customerId}/transform-to-customers is a command-style segment. CONTEXT.md states: "Any such segment must be listed in ApiResourceUriTemplateRector::SKIPPED_KEYWORDS". The segment already ends in a plural (customers), so the Rector job may not mangle it — but it is worth confirming explicitly.

CustomerPrivateNote.php — CQRSPartialUpdate without a read-back query

CONTEXT.md now recommends: "prefer returning the updated resource over answering 204 with output: false, so the caller does not need a follow-up GET." The test itself proves this is an issue: it has to call GET /customers/{id}/details to verify the note was saved. A CQRSQuery: GetCustomerForEditing with appropriate mapping would let the PATCH return the note directly and remove the need for a follow-up GET. This is a suggestion, not a blocker — the current approach works.

requirements regex consistency

CustomerCart and CustomerOrder omit requirements: ['customerId' => '\d+'], while CustomerPrivateNote, TransformGuestToCustomer, Customer, and CustomerDetails all include it. Minor inconsistency worth aligning.

💡 Improvement suggestions
  1. Add a testInvalidSetPrivateNote test. $privateNote is annotated #[Assert\NotNull]. A test sending null (or omitting the field) and asserting HTTP_UNPROCESSABLE_ENTITY would cover the validation path. The checklist expects testInvalid* + assertValidationErrors for every endpoint that has constraints.

  2. Consider DateTimeImmutable for date properties. $creationDate and $orderPlacedDate are typed string. If the Core query results return a DateTimeImmutable, the framework auto-converts it to ISO-8601 when the DTO property is typed DateTimeImmutable — which is both more expressive and guaranteed to produce a parseable date format. Verify the Core return type.

  3. CQRSPartialUpdate with output: false vs returning the resource. Addressed above under architecture; worth discussing whether the follow-up GET in the test is acceptable long-term.

  4. requirements on collection sub-resources. Add requirements: ['customerId' => '\d+'] to CustomerCart and CustomerOrder to match the pattern of the other Customer endpoints.

✅ Pre-review checklist

URI & routing

  • URI is plural, lowercase, kebab-case
  • Identifier uses domain name + Id suffix (customerId)
  • Sub-resources follow parent path (/customers/{customerId}/carts, etc.)
  • Bulk operation URI — N/A (no bulk ops in this PR)

Operations & scopes

  • Correct operation attribute per HTTP method (CQRSGetCollection for GET, CQRSPartialUpdate for PATCH, CQRSUpdate for PUT)
  • Scope format: customer_read / customer_write, singular form

API Resource properties

  • All properties strictly typed, scalars/arrays only (no Value Objects)
  • Naming conventions respected (no is prefix, enabled not active, no localized prefix)
  • #[ApiProperty(identifier: true)] on ID property — present on CustomerPrivateNote.$customerId and TransformGuestToCustomer.$customerId; collection resources (CustomerCart, CustomerOrder) follow the ProductImageList pattern which omits it
  • No localized fields in this PR — N/A

CQRS mapping

  • QUERY_MAPPING direction — N/A (no explicit mapping; field-name alignment must be verified against Core)
  • CQRSCommandMapping direction — N/A (no explicit mapping on command endpoints)
  • CQRSQuery present on CQRSCreate/CQRSPartialUpdate when full object must be returned — CustomerPrivateNote uses output: false, so no query needed; however, returning the resource would be preferable (see suggestions)
  • No SerializedName — none used

Forbidden practices (CI-enforced)

  • No custom normalizers or processors in the module
  • No Value Objects in properties

Exception handling & validation

  • ConstraintException → 422 (CustomerConstraintException, CustomerTransformationException)
  • NotFoundException → 404 (CustomerNotFoundException)
  • validationContext: ['groups' => ['Default', 'Update']] missing on CustomerPrivateNote's CQRSPartialUpdate

Multi-shop

  • No $shopIds — Customer does not appear to be a shop-associated entity in the API context; absent correctly

Listing field alignment (collection endpoints)

  • DTO properties match fields from CQRS query result — cannot be confirmed without reading GetCustomerCarts / GetCustomerOrders in Core; no mapping present to cover any name mismatches
  • ApiResourceMapping covers name mismatches — N/A if field names match; risk if they don't
  • No filtersMapping needed — no filterable parameters on these collection endpoints
  • No orphan DTO property — unverifiable without Core; see field alignment note above

Integration test

  • Extends ApiTestCase, @depends chain, asserts all fields
  • testInvalid* with assertValidationErrors — missing for CustomerPrivateNote (has #[Assert\NotNull] but no invalid-payload test)
  • getProtectedEndpoints() lists all URIs (both CustomerEndpointTest and CustomerTransformGuestEndpointTest)
  • DatabaseDump::restoreTables() covers affected tables (customer, customer_group)
  • declare(strict_types=1) present in all new files

@github-actions github-actions Bot added AI reviewed Status: Claude AI has already pre-reviewed this PR and removed Need AI review Trigger: Request an AI pre-review from Claude labels Sep 4, 2026

@mattgoud mattgoud left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against a local install with the branch mounted and every endpoint called. The error mapping is the best of the three Shop/ImageType/Customer merges I have looked at today: 422 on the transformation of a non-guest, 404 on an unknown customer id for all four endpoints, and the Assert\NotNull does fire on the private note. One blocking point, and a few things worth tightening.

Blocking: two routes are reserved and answer nothing

_api_/customer_private_notes/{customerId}{._format}_get        GET  /customer_private_notes/{customerId}.{_format}
_api_/transform_guest_to_customers/{customerId}{._format}_get  GET  /transform_guest_to_customers/{customerId}.{_format}

_controller: api_platform.action.not_exposed()
GET /admin-api/customer_private_notes/2
404 {"detail":"This route does not aim to be called.","class":"ApiPlatform\\Metadata\\Exception\\NotExposedHttpException"}

CustomerPrivateNote and TransformGuestToCustomer both carry #[ApiProperty(identifier: true)] while exposing only a PATCH and a PUT. With no item GET declared, API Platform registers one itself so the resource stays addressable, and it answers that 404. Two public URIs end up reserved for nothing, and they are snake_case on top of it, which the URI convention of this repository does not allow anywhere else.

Removing the attribute from both classes is enough here, and I checked that it costs nothing:

routes after removal   only /customers/{customerId}/private-notes and /customers/{customerId}/transform-to-customers remain

PATCH /customers/2/private-notes            204   and ps_customer.note really holds the new value
PUT   /customers/2/transform-to-customers   422   still, on a customer who is not a guest
PATCH /customers/999999/private-notes       404   still
GET   /customer_private_notes/2             404   and now it is a real "no route", not a reserved one

Worth noting for next time: this is not the same fix as the one on #422. There the property is named id, which API Platform treats as the identifier by convention, so it needed an explicit identifier: false. Here the property is $customerId, so dropping the attribute is enough.

Worth fixing

Two ways to say "no private note", two different answers.

PATCH /customers/2/private-notes   {}                     422   privateNote: This value should not be null.
PATCH /customers/2/private-notes   {"privateNote":null}   400   The type of the "privateNote" attribute ... must be string, NULL given

Omitting the field gives the clean validation error, sending an explicit null hits the denormalizer first and surfaces its message. Typing the property ?string and keeping the NotNull would route both through the same 422.

Two semantics an API consumer will misread, and neither is visible from the payload.

totalPaid is total_paid_real, the amount actually received, not the order total. On the demo fixtures it reads "€0.00" on every order while ps_orders.total_paid holds 61.80, 169.90 and so on. That is what GetCustomerOrdersHandler passes and it matches the BO, so nothing to change in the behaviour, but a caller reading totalPaid will assume it is the order total.

/customers/{id}/carts excludes carts that already became an order, because the core calls Cart::getCustomerCarts($id, false). On a customer with five carts, all five converted, the endpoint answers []. That is also the real reason the test can only ever cover the empty case, which is worth writing down next to it.

A one-line docblock on each of the two properties is enough.

requirements: ['customerId' => '\d+'] is on CustomerPrivateNote, TransformGuestToCustomer, Customer and CustomerDetails, but not on CustomerCart and CustomerOrder.

Tests

There is no testInvalid* in either class. One asserting 422 on PATCH {} would lock in the behaviour I verified above, and a second on the null payload would lock in whichever answer you settle on for it.

On the automated pre-review

Its main ask was to verify the field alignment against the core, which it could not do. Done, and it is exact on all nine properties, names and types:

  • CartSummary::getCartId(): int, getCreationDate(): string, getTotalPrice(): string
  • OrderSummary::getOrderId(): int, getOrderPlacedDate(): string, getPaymentMethodName(): string, getOrderStatus(): string, getOrderProductsCount(): int, getTotalPaid(): string

customerId is not an orphan either, the live response carries it on every row.

Three of its other points I would set aside:

  • DateTimeImmutable for the two date properties: no. The core returns them already formatted as strings, so string is the correct type. The format is 2026-09-18 09:44:06 rather than ISO-8601, but that comes from the core and changing it here would not be faithful.
  • transform-to-customers in ApiResourceUriTemplateRector::SKIPPED_KEYWORDS: not needed, the route registers exactly as declared and the Rector job is green.
  • The missing validationContext: real, but it changes nothing today, and the report says so itself. Assert\NotNull carries no group, so it fires on the PATCH regardless, which the 422 above confirms.

PrestaEdit and others added 8 commits September 23, 2026 18:53
Expose SetPrivateNoteAboutCustomerCommand through
PATCH /customers/{customerId}/private-note, in a dedicated resource class so
the rich Customer resource is left untouched. The body carries the privateNote
string (mapped by matching field name).

Adds an integration test and a scopes entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose two customer read endpoints:

- GET /customers/{customerId}/orders  (GetCustomerOrders)  -> order summaries
- GET /customers/{customerId}/carts   (GetCustomerCarts)   -> cart summaries

The query results (arrays of summary DTOs) are mapped through the resource via
[_queryResult], like the ShowcaseCard endpoint.

Adds integration tests and reuses the customer_read scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PUT /customers/{customerId}/transform-to-customers (TransformGuestToCustomerCommand)
turns a guest customer into a registered one. Added as a standalone resource class
and a separate test to avoid colliding with the in-progress Customer PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose GetCustomerForAddressCreation as GET /customers/address-creation-info
?customerEmail=... (scope customer_read): return the minimal customer info the
address creation flow needs (customerId, firstName, lastName, company). Experimental
combination of CQRSGet + QueryParameter (previously only exercised on
CQRSGetCollection).

Related to PrestaShop/PrestaShop#39630

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidates PrestaShop#218, PrestaShop#225, PrestaShop#243 and PrestaShop#342.

PrestaShop#218 and PrestaShop#225 both wrote CustomerEndpointTest.php and conflict on cherry-pick, which
is the usual sign that they belonged in one PR.

PrestaShop#342 is dropped rather than merged. It exposed GetCustomerForAddressCreation as
GET /customers/address-creation-infos?customerEmail=, but that query is already
listed in EXCLUDED_CQRS_CLASSES on dev with the USELESS_DUPLICATE reason — and the
exclusion is right: GET /customers/search matches on email among other fields and
returns idCustomer, firstname, lastname and company, a strict superset of the four
fields the dropped endpoint returned. The exclusion entry already exists, so this PR
adds nothing for it.

The private-note test now asserts its work: the note is the privateNote of the
generalInformation of GET /customers/{customerId}/details, so the write finally has a
read side to check against. It previously asserted the 204 and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetCustomerOrders and GetCustomerCarts both return a list of row objects, not a
scalar. QueryResultSerializerTrait only wraps a query result behind the
"_queryResult" key when the result is a scalar, so the
['[_queryResult]' => '[orders]'] / '[carts]' mappings the source PRs relied on
never fired and the responses came back with customerId alone.

Turn both into CQRSGetCollection operations describing one row, which is the
idiomatic shape for /customers/{customerId}/orders and /carts anyway, and rename
the resource classes to the singular accordingly.
The identifier and the requirements kept the two collection routes from being
registered at all, so both endpoints answered 404. ProductImageList declares the
same shape — a sub-collection under a parent id — with neither, and it works.
- Drop the identifier attribute on CustomerPrivateNote and
  TransformGuestToCustomer, which made API Platform reserve
  /customer_private_notes/{id} and /transform_guest_to_customers/{id}
  as not-exposed routes
- Type privateNote as ?string so an explicit null gets the same 422
  as an omitted field instead of a 400
- Add the customerId requirement on the orders and carts collections
- Document totalPaid (total_paid_real) and the carts filter
- Cover the invalid private note payloads
@mattgoud

mattgoud commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Pre-QA OK

I pushed the review fixes on this branch myself, so this is a pre-QA: same process as a QA, but I am not setting the QA label. Another QA has to sign off.

Tested on: core 9.2.x at 8103e04c6b7, module at 9d23e65, Docker dev env, shop installed from 9.2.0 (PS_VERSION_DB 9.2.0)
Shop: single shop, demo data
Client: curl against http://localhost:8001/admin-api, API client with customer_read customer_write

No linked issue: the How to test section of the description was concrete enough to test directly.

Compared states

Both states come from this branch. Without the fix = the four resource classes put back to their state before the review commit (git checkout HEAD~1 -- src/ApiPlatform/Resources/Customer/), Symfony cache deleted between switches. The states really differ: debug:router --app-id=admin-api lists 6 Customer routes without the fix and 4 with it.

Result

Call Without the fix With the fix
GET /customer_private_notes/2 404 NotExposedHttpException (reserved route) 404 No route found
GET /transform_guest_to_customers/2 404 NotExposedHttpException (reserved route) 404 No route found
PATCH /customers/2/private-notes {"privateNote":"QA 428 note"} 204 204, ps_customer.note = 'QA 428 note', /details returns it
PATCH .../private-notes {} 422 privateNote: This value should not be null. 422, same violation
PATCH .../private-notes {"privateNote":null} 400 must be one of "string" ("null" given) 422, same violation as {}
PATCH .../private-notes {"privateNote":""} 204 (clears the note) 204 (clears the note)
PATCH /customers/999999/private-notes 404 404
GET /customers/2/orders 200, 5 orders 200, 5 orders
GET /customers/abc/orders 500 Customer id 0 is invalid. 404 No route found
GET /customers/2/carts 200 [] 200 []
GET /customers/abc/carts 500 Customer id 0 is invalid. 404 No route found
GET /customers/999999/orders and /carts 404 404
PUT /customers/2/transform-to-customers (registered customer) 422 already exists as non-guest 422, same
PUT /customers/999999/transform-to-customers 404 404
POST /customers guest, then PUT .../transform-to-customers 204, guest true → false 204, guest true → false
Same PUT a second time 422 422
GET /customers/2/orders without token 401 401

The missing customerId requirement on the two collections was worse than an inconsistency: without it a non numeric id reached the handler and answered 500. It now stops at routing.

The two semantics documented in the review commit hold on the demo data:

  • totalPaid is €0.00 on all 5 orders of customer 2, while ps_orders.total_paid holds 61.80, 169.90, 14.90, 14.90 and 20.90. It is total_paid_real, as the new docblock says.
  • /customers/2/carts answers [] although customer 2 has 5 carts: each of them became order 1 to 5.

Automated tests

  • CI ✅ on 9d23e65: 19 checks green, including the 8 integration jobs (PHP 8.1 to 8.5 against core 9.0.3, 9.1.5, 9.2.x and develop).
  • CustomerEndpointTest and CustomerTransformGuestEndpointTest run locally in the container ✅: 55 tests, 307 assertions. testInvalidCustomerPrivateNote covers both {} and {"privateNote":null}.

Before merge

Raw transcripts

Without the fix:

phantom item route (private note)            GET    /customer_private_notes/2                      404  {"detail": "This route does not aim to be called.", "class": "ApiPlatform\\Metadata\\Exception\\NotExposedHttpException"}
phantom item route (transform)               GET    /transform_guest_to_customers/2                404  {"detail": "This route does not aim to be called.", "class": "ApiPlatform\\Metadata\\Exception\\NotExposedHttpException"}
set private note                             PATCH  /customers/2/private-notes                     204  
  -> details.generalInformation.privateNote = "QA 428 note"
private note, field omitted                  PATCH  /customers/2/private-notes                     422  [{"propertyPath": "privateNote", "message": "This value should not be null.", "code": "ad32d13f-c3d4-423b-909a-857b961eb720"}]
private note, explicit null                  PATCH  /customers/2/private-notes                     400  {"detail": "The type of the \"privateNote\" attribute for class \"PrestaShop\\Module\\APIResources\\ApiPlatform\\Resources\\Customer\\CustomerPrivateNote\" must be one of \"string\" (\"null\" given).", "class": "Symfony\\Component\\Serializer\\Exception\\NotNormalizableValueException"}
private note, empty string                   PATCH  /customers/2/private-notes                     204  
private note, unknown customer               PATCH  /customers/999999/private-notes                404  {"detail": "Customer with id \"999999\" was not found.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
orders                                       GET    /customers/2/orders                            200  [{"customerId": 2, "orderId": 1, "orderPlacedDate": "2026-09-23 15:32:45", "paymentMethodName": "Payment by check", "orderStatus": "Canceled", "orderProductsCount": 2, "totalPaid": "€0.00"}, {"customerId": 2, "orderId": 2, "orderPlacedDate": "2026-09-23 15:32:45", "paymentMethodName": "Payment by ch
orders, unknown customer                     GET    /customers/999999/orders                       404  {"detail": "Customer with id \"999999\" was not found.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
orders, non numeric id                       GET    /customers/abc/orders                          500  {"detail": "Customer id 0 is invalid.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerConstraintException"}
carts (5 carts, all ordered)                 GET    /customers/2/carts                             200  []
carts, unknown customer                      GET    /customers/999999/carts                        404  {"detail": "Customer with id \"999999\" was not found.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
carts, non numeric id                        GET    /customers/abc/carts                           500  {"detail": "Customer id 0 is invalid.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerConstraintException"}
transform a registered customer              PUT    /customers/2/transform-to-customers            422  {"detail": "Customer with id \"2\" already exists as non-guest", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerTransformationException"}
transform, unknown customer                  PUT    /customers/999999/transform-to-customers       404  {"detail": "Customer with id \"999999\" was not found", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
  -> guest created: id 4, guest=True
transform the guest                          PUT    /customers/4/transform-to-customers            204  
  -> after transform: guest=False
transform it again                           PUT    /customers/4/transform-to-customers            422  {"detail": "Customer with id \"4\" already exists as non-guest", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerTransformationException"}
orders, no token                             GET    /customers/2/orders                            401  "No Authorization header provided"

With the fix:

phantom item route (private note)            GET    /customer_private_notes/2                      404  {"detail": "No route found for \"GET http://localhost:8001/admin-api/customer_private_notes/2\"", "class": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException"}
phantom item route (transform)               GET    /transform_guest_to_customers/2                404  {"detail": "No route found for \"GET http://localhost:8001/admin-api/transform_guest_to_customers/2\"", "class": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException"}
set private note                             PATCH  /customers/2/private-notes                     204  
  -> details.generalInformation.privateNote = "QA 428 note"
private note, field omitted                  PATCH  /customers/2/private-notes                     422  [{"propertyPath": "privateNote", "message": "This value should not be null.", "code": "ad32d13f-c3d4-423b-909a-857b961eb720"}]
private note, explicit null                  PATCH  /customers/2/private-notes                     422  [{"propertyPath": "privateNote", "message": "This value should not be null.", "code": "ad32d13f-c3d4-423b-909a-857b961eb720"}]
private note, empty string                   PATCH  /customers/2/private-notes                     204  
private note, unknown customer               PATCH  /customers/999999/private-notes                404  {"detail": "Customer with id \"999999\" was not found.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
orders                                       GET    /customers/2/orders                            200  [{"customerId": 2, "orderId": 1, "orderPlacedDate": "2026-09-23 15:32:45", "paymentMethodName": "Payment by check", "orderStatus": "Canceled", "orderProductsCount": 2, "totalPaid": "€0.00"}, {"customerId": 2, "orderId": 2, "orderPlacedDate": "2026-09-23 15:32:45", "paymentMethodName": "Payment by ch
orders, unknown customer                     GET    /customers/999999/orders                       404  {"detail": "Customer with id \"999999\" was not found.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
orders, non numeric id                       GET    /customers/abc/orders                          404  {"detail": "No route found for \"GET http://localhost:8001/admin-api/customers/abc/orders\"", "class": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException"}
carts (5 carts, all ordered)                 GET    /customers/2/carts                             200  []
carts, unknown customer                      GET    /customers/999999/carts                        404  {"detail": "Customer with id \"999999\" was not found.", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
carts, non numeric id                        GET    /customers/abc/carts                           404  {"detail": "No route found for \"GET http://localhost:8001/admin-api/customers/abc/carts\"", "class": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException"}
transform a registered customer              PUT    /customers/2/transform-to-customers            422  {"detail": "Customer with id \"2\" already exists as non-guest", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerTransformationException"}
transform, unknown customer                  PUT    /customers/999999/transform-to-customers       404  {"detail": "Customer with id \"999999\" was not found", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerNotFoundException"}
  -> guest created: id 3, guest=True
transform the guest                          PUT    /customers/3/transform-to-customers            204  
  -> after transform: guest=False
transform it again                           PUT    /customers/3/transform-to-customers            422  {"detail": "Customer with id \"3\" already exists as non-guest", "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Exception\\CustomerTransformationException"}
orders, no token                             GET    /customers/2/orders                            401  "No Authorization header provided"

@mattgoud

Copy link
Copy Markdown
Contributor

Pre-QA, Swagger evidence (follow-up to the pre-QA comment above)

Replayed through the back-office Swagger UI (Configure > Advanced > Admin API docs) on the same 9.2.0 shop, authorized with a customer_read customer_write client.

The 4 endpoints, with the fix

PATCH /customers/2/private-notes answers 204. GET /customers/2/orders answers 200 with the 5 orders of customer 2 (totalPaid is total_paid_real, hence €0.00).

nominal-1

GET /customers/2/carts answers 200 []: customer 2 has 5 carts, and each became an order. PUT /customers/5/transform-to-customers on a guest created through POST /customers answers 204.

nominal-2

Read back with GET /customers/5: guest is now false.

nominal-3

What changes between the two states

Without the fix = the four resource classes put back to their state before the review commit, cache deleted, same page and authorization.

PATCH /customers/2/private-notes with {"privateNote": null}: 400 from the denormalizer without the fix, the same 422 violation as an omitted field with it.

note-null

GET /customers/abc/orders and GET /customers/abc/carts: 500 Customer id 0 is invalid. without the fix, 404 at routing with it.

orders-abc

carts-abc

Every other call answers the same in both states, as listed in the table of the comment above. The two reserved routes (/customer_private_notes/{id}, /transform_guest_to_customers/{id}) cannot be shown in Swagger, since API Platform keeps not exposed operations out of the OpenAPI document in both states: the route listing above is the evidence for that part.

The curl block was hidden before capturing, response headers are cropped out, and the BO _token echoed in one 404 detail is masked.

@mattgoud

Copy link
Copy Markdown
Contributor

thanks @PrestaEdit for merging the Customer PRs into one, and for writing down why #342 was dropped.

I took the last round of my review myself to move this forward: rebased on dev (the branch was 26 commits behind) and pushed 9d23e65 on top of your commits. heads up, that was a force push, so reset your local branch before pushing anything else.

9d23e65 covers every point of my review:

  • no more identifier: true on CustomerPrivateNote and TransformGuestToCustomer, the two reserved routes are gone
  • privateNote is ?string, an explicit null now gets the same 422 as an omitted field
  • customerId requirement on the orders and carts collections, a non numeric id was a 500 and is now a 404
  • docblocks on totalPaid and on the carts filter
  • testInvalidCustomerPrivateNote covers both {} and null

evidence is in the two pre-QA comments above. since I wrote the fix, I'm dismissing my review instead of approving, so this needs another reviewer.

@mattgoud
mattgoud dismissed their stale review September 23, 2026 18:14

addressed in 9d23e65, see the comment above. dismissing instead of approving since I pushed the fix myself.

Comment thread src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php Outdated
Comment thread src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php
Comment thread src/ApiPlatform/Resources/Customer/CustomerCart.php
Comment thread src/ApiPlatform/Resources/Customer/CustomerOrder.php
Comment thread tests/Integration/ApiPlatform/CustomerEndpointTest.php Outdated
Comment thread src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php
Comment thread tests/Integration/ApiPlatform/CustomerEndpointTest.php Outdated
- Map CustomerConstraintException to 422 on the orders, carts and
  transform endpoints: customer id 0 passes the route requirement and
  was answering 500
- Apply the CleanHtml constraint of the back-office note form
- Singular transform-to-customer URI, skipped by the Rector rule
- validationContext on the transform operation
- Test the carts endpoint on a real cart, the 404 and the invalid id
  of the transform endpoint, and the invalid id of orders and carts
- Rename tearDownBeforeClass, which PHPUnit never called, so the
  customer tables are really restored
@mattgoud mattgoud added the Waiting for QA Status: Action required, Waiting for test feedback label Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI reviewed Status: Claude AI has already pre-reviewed this PR Waiting for QA Status: Action required, Waiting for test feedback

Projects

Status: To be tested

Development

Successfully merging this pull request may close these issues.

4 participants