diff --git a/.claude/REVIEW_PROMPT.md b/.claude/REVIEW_PROMPT.md new file mode 100644 index 000000000..1d0e3a287 --- /dev/null +++ b/.claude/REVIEW_PROMPT.md @@ -0,0 +1,156 @@ +REPO: ${REPO} +PR NUMBER: ${PR_NUMBER} +PR TITLE: ${PR_TITLE} +PR AUTHOR: ${PR_AUTHOR} + +You are performing an AI-assisted **pre-review** of a pull request on the `ps_apiresources` module. +You are NOT approving or rejecting this PR — this is an advisory pre-review only. + +## Critical: read the repository guidelines first + +The file `CONTEXT.md` at the repository root is the **source of truth** for all +conventions, architecture rules, property naming, mapping directions, multi-shop +handling, forbidden practices, testing expectations, and canonical examples. +**You must read `CONTEXT.md` before starting your review.** Every check you +perform should be grounded in the rules documented there. + +## How to inspect the PR + +**Your review must focus on the code changed in the PR — not the entire codebase.** +Start by fetching the diff and PR metadata: +- `gh pr diff $PR_NUMBER --repo $REPO` — the changed lines are your primary review scope +- `gh pr view $PR_NUMBER --repo $REPO` — PR description and metadata + +Only read other files when directly referenced or imported by the changed code. +Focus on PHP and YAML files under `src/ApiPlatform/**`, +`tests/Integration/ApiPlatform/**`, and `config/**`. +Ignore `vendor/`, `*.lock`, binary files, and asset files. + +**Do not explore the full repository.** Use `Grep` or `Glob` only to verify +specific conventions or check related code referenced by the diff. + +## Additional references + +Beyond `CONTEXT.md`, consult these external references when needed: +- Official contribution guide: https://devdocs.prestashop-project.org/9/admin-api/contribute-to-core-api/ +- CQRS API guidelines ADR: https://github.com/PrestaShop/ADR/blob/master/0023-cqrs-api-guidelines.md + +## Common pitfalls to watch for + +These are frequent mistakes that are easy to miss — flag them explicitly: +- `QUERY_MAPPING` keys inverted (using API field name as key instead of QueryResult field name) +- Missing `CQRSQuery` on `CQRSCreate` when the full object must be returned +- Using `Response::HTTP_BAD_REQUEST` (400) instead of `HTTP_UNPROCESSABLE_ENTITY` (422) for constraint violations +- Identifier property named `$id` instead of `${entity}Id` +- Missing `#[ApiProperty(identifier: true)]` on the ID property +- `$isEnabled`, `$isActive`, `$active` instead of `$enabled` +- `$localizedNames` instead of `$names` (with `#[LocalizedValue]`) +- Custom normalizer or processor added in the module (always flag as a **hard blocker**) +- Test asserting only the identifier without checking the rest of the response fields + +### Listing endpoints (`PaginatedList` / `CQRSPaginate`) + +When the PR adds or modifies a list endpoint, cross-check the DTO against +the data source (see "Field alignment" in `CONTEXT.md`): + +- Trace the `gridDataFactory` to its query builder; compare SQL SELECT + fields with DTO properties. Flag missing DTO properties (data the grid + returns but the API silently drops) and orphan DTO properties (no + matching query field → always `null`). +- When a SQL column name differs from the DTO property, verify an + `ApiResourceMapping` entry covers the rename. +- Check `filtersMapping` covers every filterable field whose API name + differs from the grid filter name. +- For `CQRSPaginate`: same checks, but against the CQRS query result DTO + instead of the query builder. + +## Output format + +Post a **single comment** using `gh pr comment $PR_NUMBER --repo $REPO` with the following +structured format. Use Markdown with `
` for the longer sections. +Start the comment body with `` on the very first line. + +```markdown + +> 🤖 **Claude AI Pre-Review** — Automated analysis. Does not replace human review. + +## 📋 Summary of changes +[2–4 sentences: which endpoint(s) are added/modified, which CQRS entity is exposed] + +## ⏱️ Estimated review time +[X–Y minutes — brief justification] + +## 🎯 Scope +- **Exposed operations:** GET / POST / PATCH / DELETE / list +- **CQRS entity:** [Command/Query name from the Core] +- **Integration test:** yes / no / partial + +
+🧱 API Platform / CQRS architecture compliance + +[Verify against CONTEXT.md rules: URI conventions, operation attributes, property +naming, scope naming, mapping directions, exception handling, validation groups, +multilang handling, multi-shop handling, forbidden practices] + +
+ +
+💡 Improvement suggestions + +[Actionable suggestions: naming, null guards, missing fields, etc.] + +
+ +## ✅ Pre-review checklist + +Check each item against the PR. Mark items as checked when compliant, +leave unchecked when violated or not applicable. +Base every check on the rules from `CONTEXT.md`. + +**URI & routing** +- [ ] URI is plural, lowercase, kebab-case +- [ ] Identifier uses domain name + `Id` suffix +- [ ] Sub-resources follow parent path +- [ ] Bulk operation URI uses `bulk-` prefix and plural `Ids` parameter + +**Operations & scopes** +- [ ] Correct operation attribute per HTTP method +- [ ] Scope format: `{entity_snake_case}_read` / `_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 +- [ ] `#[LocalizedValue]` on localized fields, `#[DefaultLanguage]` with correct `fieldName` + +**CQRS mapping** +- [ ] `QUERY_MAPPING` direction: QueryResult field → API field +- [ ] `CQRSCommandMapping` direction: API field → Command parameter +- [ ] `CQRSQuery` present on `CQRSCreate`/`CQRSPartialUpdate` when full object must be returned +- [ ] No `SerializedName` — mappings only + +**Forbidden practices (CI-enforced)** +- [ ] No custom normalizers or processors in the module +- [ ] No Value Objects in properties + +**Exception handling & validation** +- [ ] `ConstraintException` → 422, `NotFoundException` → 404 +- [ ] Correct `validationContext` groups on Create / Update operations + +**Multi-shop** +- [ ] `shopIds` present and mapped if entity is shop-associated, absent otherwise +- [ ] Shop context (`[_context][shopId]`, `[_context][shopConstraint]`) passed when needed + +**Listing field alignment** (when PR includes a list endpoint) +- [ ] DTO properties match fields from the grid query builder / CQRS query result +- [ ] `ApiResourceMapping` covers every name mismatch between source fields and DTO +- [ ] `filtersMapping` covers every filter name that differs from the API field name +- [ ] No orphan DTO property (property with no matching source field → always `null`) + +**Integration test** +- [ ] Extends `ApiTestCase`, `@depends` chain, asserts all fields +- [ ] `testInvalid*` with `assertValidationErrors` +- [ ] `getProtectedEndpoints()` lists all URIs +- [ ] `DatabaseDump::restoreTables()` covers all affected tables +- [ ] `declare(strict_types=1)` present +``` diff --git a/.claude/skills/ps-api-endpoint/README.md b/.claude/skills/ps-api-endpoint/README.md new file mode 100644 index 000000000..73680dc19 --- /dev/null +++ b/.claude/skills/ps-api-endpoint/README.md @@ -0,0 +1,68 @@ +# ps-api-endpoint skill + +Generates new PrestaShop Admin API endpoints for the `ps_apiresources` module — specifically, an **ApiResource PHP class** and a matching **integration test** — following the module's CQRS patterns and contribution guidelines. + +## Trigger + +Claude invokes this skill automatically when you express intent to: + +- Add a new REST endpoint to the Admin API +- Expose a new entity via the Admin API +- Contribute a new resource to `ps_apiresources` +- Wire up a CQRS command or query to an API endpoint in PrestaShop 9 + +Example phrases: `"I want to add TaxRule to the API"`, `"create an endpoint for Warehouse"`, `"how do I expose AttributeGroup via the Admin API?"` + +## Prerequisites + +Have the following ready before invoking: + +1. **Entity name** — PascalCase singular (e.g. `TaxRule`, `Warehouse`) +2. **Operations** — which HTTP methods are needed: GET, POST, PATCH, DELETE, list (paginated), bulk operations +3. **PrestaShop core path** — absolute path to a local PS 9.x checkout (e.g. `/home/user/prestashop-90x`), so the skill can discover CQRS classes. You can skip this if you already know the CQRS class names. + +## What it generates + +| File | Path | +|------|------| +| ApiResource class | `src/ApiPlatform/Resources/{Entity}/{Entity}.php` | +| Integration test | `tests/Integration/ApiPlatform/{Entity}EndpointTest.php` | + +The **ApiResource class** contains: +- `#[ApiResource]` attribute with all requested operations (`CQRSGet`, `CQRSCreate`, `CQRSPartialUpdate`, `CQRSDelete`, `PaginatedList`) +- Public properties with strict types and Symfony validation constraints +- `QUERY_MAPPING` — maps CQRS query result fields to API response fields +- `CREATE_COMMAND_MAPPING` / `UPDATE_COMMAND_MAPPING` (or a shared `COMMAND_MAPPING`) — maps API request fields to CQRS command parameters +- OAuth2 scopes (`{entity}_read`, `{entity}_write`) and exception-to-HTTP-status mappings + +The **integration test** contains: +- Full CRUD test methods chained via `@depends` +- A `testInvalid*` method covering validation errors +- `getProtectedEndpoints()` verifying all endpoints require authentication +- `setUpBeforeClass`/`tearDownAfterClass` with table restoration via `DatabaseDump` + +## Usage example + +``` +User: I want to add a GET and POST endpoint for TaxRule. + My PS core is at /home/dev/prestashop. + +Claude: [invokes ps-api-endpoint skill] + — Asks for confirmation of operations and fields + — Reads /home/dev/prestashop/src/Core/Domain/TaxRule/... + — Generates src/ApiPlatform/Resources/TaxRule/TaxRule.php + — Generates tests/Integration/ApiPlatform/TaxRuleEndpointTest.php + — Reports file locations and how to run the tests +``` + +## Skill file structure + +``` +.claude/skills/ps-api-endpoint/ +├── README.md # This file — human-facing orientation +├── SKILL.md # Skill logic executed by Claude (step-by-step instructions, +│ # templates for ApiResource class and integration test) +└── references/ + └── conventions.md # Naming and structural conventions (URI format, scope naming, + # property rules, mapping patterns, forbidden practices) +``` diff --git a/.claude/skills/ps-api-endpoint/SKILL.md b/.claude/skills/ps-api-endpoint/SKILL.md new file mode 100644 index 000000000..de296251a --- /dev/null +++ b/.claude/skills/ps-api-endpoint/SKILL.md @@ -0,0 +1,333 @@ +--- +name: ps-api-endpoint +description: > + Generates new PrestaShop Admin API endpoints (ApiResource PHP class + integration test) for the ps_apiresources module, following CQRS patterns and contribution guidelines. Invoke this skill whenever the user wants to add a new REST endpoint, expose a new entity via the Admin API, contribute a new resource to ps_apiresources, or asks how to wire up a CQRS command/query to an API endpoint in PrestaShop 9. Use it even when the user just says "I want to add X to the API" or "how do I create an endpoint for Y". +--- + +# PrestaShop Admin API Endpoint Generator + +This skill walks through creating a new endpoint in `ps_apiresources`, from discovery to generated code. + +## Step 1: Gather requirements + +Ask the user (all in one message if not already specified): + +1. **Entity name** — e.g. `TaxRule`, `Warehouse` (PascalCase, singular) +2. **Operations needed** — GET single, POST (create), PATCH (partial update), PUT (full update), DELETE, GET list (paginated). Also ask about bulk operations (e.g. bulk delete) or custom sub-resource actions (e.g. status toggle). +3. **PrestaShop core path** — absolute path to the PS root, e.g. `/home/user/prestashop-90x`. Needed to look up CQRS classes. Offer to skip this step if the user already knows the class names. + +## Step 2: Discover CQRS classes + +Search the PS core for the entity's domain classes. The standard layout is: + +``` +{PS_ROOT}/src/Core/Domain/{Entity}/ + Command/Add{Entity}Command.php + Command/Edit{Entity}Command.php + Command/Delete{Entity}Command.php + Command/BulkDelete{Entity}Command.php + Query/Get{Entity}ForEditing.php + Query/Get{Entity}ListForEditing.php ← may not exist; Grid is used for lists + QueryResult/{Entity}ForEditing.php ← shows what fields the query returns + Exception/{Entity}NotFoundException.php + Exception/{Entity}ConstraintException.php +``` + +Use Glob/Grep to find these files. Then **read the QueryResult class** (e.g. `{Entity}ForEditing.php`) — its constructor arguments and getters reveal the exact field names returned by the query. This is the ground truth for `QUERY_MAPPING`. + +Also read the Command constructors to learn the parameter names needed for `CQRSCommandMapping`. + +If some of these classes don't exist (e.g. no Add command), note it — only include operations that have backing CQRS classes. + +## Step 3: Gather field information + +After reading the QueryResult, confirm with the user: +- Which fields should be exposed in the API (not all internal fields need to be public) +- Which fields are **localized** (arrays keyed by locale like `'en-US' => 'value'`) → need `#[LocalizedValue]` +- Which localized fields are required in the default language on create → need `#[DefaultLanguage(groups: ['Create'], fieldName: '...')]` +- Which localized fields are optional on update → `#[DefaultLanguage(groups: ['Update'], fieldName: '...', allowNull: true)]` + +## Step 4: Generate the ApiResource class + +**File location:** `src/ApiPlatform/Resources/{Entity}/{Entity}.php` + +Use the template below. Adapt based on the selected operations. + +```php + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\{Entity}; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\{Entity}\Command\Add{Entity}Command; +use PrestaShop\PrestaShop\Core\Domain\{Entity}\Command\Edit{Entity}Command; +use PrestaShop\PrestaShop\Core\Domain\{Entity}\Command\Delete{Entity}Command; +use PrestaShop\PrestaShop\Core\Domain\{Entity}\Exception\{Entity}ConstraintException; +use PrestaShop\PrestaShop\Core\Domain\{Entity}\Exception\{Entity}NotFoundException; +use PrestaShop\PrestaShop\Core\Domain\{Entity}\Query\Get{Entity}ForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/{entities}/{entityId}', + CQRSQuery: Get{Entity}ForEditing::class, + scopes: ['{entity}_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/{entities}', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: Add{Entity}Command::class, + CQRSQuery: Get{Entity}ForEditing::class, + scopes: ['{entity}_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::CREATE_COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/{entities}/{entityId}', + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: Edit{Entity}Command::class, + CQRSQuery: Get{Entity}ForEditing::class, + scopes: ['{entity}_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::UPDATE_COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/{entities}/{entityId}', + requirements: ['{entityId}' => '\d+'], + CQRSCommand: Delete{Entity}Command::class, + scopes: ['{entity}_write'], + ), + ], + exceptionToStatus: [ + {Entity}ConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + {Entity}NotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class {Entity} +{ + #[ApiProperty(identifier: true)] + public int ${entityId}; + + // Add other properties here with appropriate types and constraints + + public const QUERY_MAPPING = [ + // '[sourceFieldFromQueryResult]' => '[apiFieldName]', + ]; + + public const CREATE_COMMAND_MAPPING = [ + // '[apiFieldName]' => '[commandConstructorParam]', + ]; + + public const UPDATE_COMMAND_MAPPING = [ + // '[apiFieldName]' => '[commandConstructorParam]', + ]; +} +``` + +### Key rules while filling in the template + +See `references/conventions.md` for the full ruleset. The most important ones: + +- **URI**: plural, lowercase, kebab-case. `featureId` in URI, not `id`. E.g. `/tax-rules/{taxRuleId}`. +- **Scopes**: `{entity_snake_case}_read` and `{entity_snake_case}_write`. Multi-word entities: `tax_rule_read`. +- **Boolean properties**: no `is` prefix. Use `$enabled`, not `$isEnabled`. +- **Localized properties**: no "localized" prefix. Use `$names`, not `$localizedNames`. Mark with `#[LocalizedValue]`. +- **Mapping**: `QUERY_MAPPING` maps `[queryResultFieldName] => [apiFieldName]`. `CREATE_COMMAND_MAPPING` maps `[apiFieldName] => [commandParamName]`. When Create and Update commands share the same param names, use a single `COMMAND_MAPPING` constant. +- **Forbidden**: no custom normalizers, no custom processors, no Value Objects as properties (only scalar types and arrays). +- **Strict typing**: every property needs an explicit type. + +### List endpoints (PaginatedList) + +List operations live in a **separate class file** named `{Entity}List.php` next to the main resource (e.g. `src/ApiPlatform/Resources/Contact/ContactList.php`, `src/ApiPlatform/Resources/Attribute/AttributeGroupList.php`). Use `PaginatedList` with the Grid's data factory service and a `filtersClass`. The URI is the plural base path (e.g. `/contacts`) without an ID. Field mapping is done via `ApiResourceMapping`. + +### Bulk operations + +Bulk operations also live in a **separate class file** named `Bulk{Entities}.php` (or `BulkDelete{Entities}.php` / `BulkUpdateStatus{Entities}.php` when more specific) next to the main resource. Real examples in the repo: + +- `src/ApiPlatform/Resources/Attribute/BulkAttributeGroups.php` +- `src/ApiPlatform/Resources/Category/BulkDeleteCategories.php` +- `src/ApiPlatform/Resources/Category/BulkUpdateStatusCategories.php` + +URI uses `bulk-` prefix + plural: `/tax-rules/bulk-delete`. The single public property is the array of IDs, named with the singular entity + "Ids" (e.g. `$taxRuleIds`, `$attributeGroupIds`), typed as `array`, and annotated with `#[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer']])]` and `#[Assert\NotBlank]`. + +## Step 5: Generate the integration test + +**File location:** `tests/Integration/ApiPlatform/{Entity}EndpointTest.php` + +```php + ['GET', '/{entities}/1']; + yield 'create endpoint' => ['POST', '/{entities}']; + yield 'patch endpoint' => ['PATCH', '/{entities}/1']; + yield 'delete endpoint' => ['DELETE', '/{entities}/1']; + yield 'list endpoint' => ['GET', '/{entities}']; + } + + public function testAdd{Entity}(): int + { + $postData = [ + // all required fields with valid values + // for localized fields: ['en-US' => 'value', 'fr-FR' => 'valeur'] + ]; + + $response = $this->createItem('/{entities}', $postData, ['{entity}_write']); + $this->assertArrayHasKey('{entityId}', $response); + + return $response['{entityId}']; + } + + /** @depends testAdd{Entity} */ + public function testGet{Entity}(int ${entityId}): int + { + $response = $this->getItem('/{entities}/' . ${entityId}, ['{entity}_read']); + $this->assertEquals(${entityId}, $response['{entityId}']); + // assert all exposed fields are present and correct + + return ${entityId}; + } + + /** @depends testGet{Entity} */ + public function testPartialUpdate{Entity}(int ${entityId}): int + { + $patchData = [ + // fields to update + ]; + + $updated = $this->partialUpdateItem('/{entities}/' . ${entityId}, $patchData, ['{entity}_write']); + // assert updated fields match + + // Verify the GET also reflects the changes + $fetched = $this->getItem('/{entities}/' . ${entityId}, ['{entity}_read']); + // assert same fields + + return ${entityId}; + } + + /** @depends testPartialUpdate{Entity} */ + public function testDelete{Entity}(int ${entityId}): void + { + $this->deleteItem('/{entities}/' . ${entityId}, ['{entity}_write']); + $this->getItem('/{entities}/' . ${entityId}, ['{entity}_read'], Response::HTTP_NOT_FOUND); + } + + public function testInvalid{Entity}(): void + { + $invalidData = [ + // invalid values to trigger validation errors + ]; + + $response = $this->createItem( + '/{entities}', + $invalidData, + ['{entity}_write'], + Response::HTTP_UNPROCESSABLE_ENTITY + ); + + $this->assertValidationErrors([ + ['propertyPath' => 'fieldName', 'message' => 'expected error message'], + ], $response); + } +} +``` + +### Test rules + +- Tests must assert **complete response data** — all fields, not just the identifier. +- Chain tests using `@depends` so each test builds on the created entity. +- Always include a `testInvalid*` test that covers validation constraints. +- Include `'skip_null_values' => false` in assertions when using `assertEquals` on the full response array. +- Drop `LanguageResetter::resetLanguages()` from setUp/tearDown if the entity has no localized fields. + +## Step 6: Write the files + +Create both files at their correct paths. Then tell the user: + +1. **Where the files were written** +2. **How to register the resource** — if the ApiResource isn't auto-discovered, a service definition may be needed (usually not required for standard resources) +3. **How to run the tests:** + ```bash + composer setup-local-tests # first time only + composer run-module-tests + ``` +4. **What to double-check**: mappings between CQRS fields and API fields — these are the most common source of errors. Ask the user to run the endpoint manually via Swagger UI to verify. + +## Common pitfalls to flag + +- If the `Add*Command` takes its ID from an `EntityId` value object in the result (not a raw int), the `CQRSCreate` block may need to fetch the result via a separate `CQRSQuery` after creation. +- If a command and its edit counterpart share the same constructor signature, use one `COMMAND_MAPPING` constant. +- For entities without shop association, omit `shopIds` and its mapping. +- `#[DefaultLanguage]` needs the `fieldName` argument set to the **API field name** (e.g. `fieldName: 'names'`), not the internal query field name. + +## Reference files + +- `references/conventions.md` — full naming and structural conventions +- `../../../CONTEXT.md` (repo root) — module-wide AI context: purpose, architecture, Do/Don't, canonical examples. This skill must stay aligned with it. diff --git a/.claude/skills/ps-api-endpoint/references/conventions.md b/.claude/skills/ps-api-endpoint/references/conventions.md new file mode 100644 index 000000000..3ad696778 --- /dev/null +++ b/.claude/skills/ps-api-endpoint/references/conventions.md @@ -0,0 +1,108 @@ +# PrestaShop API Endpoint Naming & Structural Conventions + +Source: https://devdocs.prestashop-project.org/9/admin-api/contribute-to-core-api/ and ADR-0023. + +## URI conventions + +- **Plural paths**: `/attributes/groups` not `/attribute/group` +- **kebab-case** for compound names: `/bulk-delete`, `/assign-to-category` +- **Domain identifier in URI parameter**: `attributeGroupId` not `id` +- Sub-resources use the parent ID: `/categories/{categoryId}/cover` + +## HTTP method → CQRS operation mapping + +| HTTP Method | Operation | Use case | +|-------------|-------------------|-----------------------------------| +| GET | `CQRSGet` | Fetch single entity | +| GET | `CQRSGetCollection` / `PaginatedList` | Fetch list | +| POST | `CQRSCreate` | Create entity | +| PATCH | `CQRSPartialUpdate` | Update subset of fields (unspecified fields unchanged) | +| PUT | `CQRSUpdate` | Full update (all fields required) | +| DELETE | `CQRSDelete` | Delete entity (no response body) | + +## Scope naming + +Format: `{entity_snake_case}_{action}` + +- Single-word entity: `contact_read`, `contact_write` +- Multi-word entity: `attribute_group_read`, `attribute_group_write`, `tax_rule_read` +- Always `_read` for GET operations, `_write` for POST/PATCH/PUT/DELETE + +## Property naming + +| Rule | Correct | Wrong | +|------|---------|-------| +| Localized fields — no "localized" prefix | `$names` | `$localizedNames` | +| Boolean — no "is" prefix | `$enabled`, `$ready` | `$isEnabled`, `$isReady` | +| Status — use "enabled" not "active/status" | `$enabled` | `$active`, `$status` | +| All fields strictly typed | `public int $id` | `public $id` | +| ID field — domain name + "Id" | `$contactId` | `$id` | + +## Multilanguage fields + +- Single-entity responses: ALL languages, indexed by locale + ```json + { "names": { "en-US": "Color", "fr-FR": "Couleur" } } + ``` +- List responses: single language, as plain strings +- Use `#[LocalizedValue]` attribute on the property +- Use `#[DefaultLanguage(groups: ['Create'], fieldName: 'names')]` to require default lang on create +- Use `#[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)]` for optional on update + +## Field mapping conventions + +### QUERY_MAPPING +Maps from query result field names to API response field names. +```php +public const QUERY_MAPPING = [ + '[localisedTitles]' => '[names]', // query result key → API key + '[shopAssociation]' => '[shopIds]', +]; +``` +Read the `{Entity}ForEditing.php` QueryResult class to find source field names (getter names minus "get", lowercased first letter). + +### CQRSCommandMapping +Maps from API request field names to command constructor parameter names. +```php +public const CREATE_COMMAND_MAPPING = [ + '[names]' => '[localisedTitles]', // API key → command param name + '[shopIds]' => '[shopAssociation]', +]; +``` +If Create and Edit commands share the same param names, use a single `COMMAND_MAPPING`. + +### Nested fields +Use bracket notation for nested paths: +```php +'[basicInformation][localizedNames]' => '[names]' +``` + +## Bulk operations + +- URI prefix: `bulk-`, e.g. `/contacts/bulk-delete` +- Parameter: plural entity name + "Ids", e.g. `contactIds`, `attributeGroupIds` + +## Forbidden practices (CI enforced) + +1. **No custom normalizers** — use `#[LocalizedValue]`, `ApiResourceMapping`, `CQRSQueryMapping` instead +2. **No custom processors** — core processors handle standard CRUD flows +3. **No Value Objects as properties** — only scalar types (`int`, `string`, `bool`, `float`) and `array` + +## Exception mapping + +Always map at minimum: +```php +exceptionToStatus: [ + {Entity}ConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + {Entity}NotFoundException::class => Response::HTTP_NOT_FOUND, +] +``` + +Add `Response::HTTP_NOT_FOUND` for any custom "not found" exceptions (e.g. parent entity missing). + +## File placement + +| File type | Path | +|-----------|------| +| ApiResource class | `src/ApiPlatform/Resources/{Entity}/{Entity}.php` | +| Integration test | `tests/Integration/ApiPlatform/{Entity}EndpointTest.php` | diff --git a/.cursor/rules/ps-apiresources.mdc b/.cursor/rules/ps-apiresources.mdc new file mode 100644 index 000000000..0812b566b --- /dev/null +++ b/.cursor/rules/ps-apiresources.mdc @@ -0,0 +1,10 @@ +--- +description: ps_apiresources module conventions (Admin API resources) +alwaysApply: true +--- + +Read [`CONTEXT.md`](../../CONTEXT.md) at the repo root before +suggesting any code in this repository. It is the single source of +truth for the module's architecture, URI / scope / property +conventions, mapping rules, Do/Don't list, testing expectations, and +canonical examples. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..f9aac0d3d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,7 @@ +# GitHub Copilot — ps_apiresources + +Before suggesting code in this repository, read +[`CONTEXT.md`](../CONTEXT.md) at the repo root. It defines this +module's purpose (declarative API resources only — no business logic), +URI / scope / property conventions, mapping rules, Do/Don't list, +testing expectations, and canonical examples to follow. diff --git a/.github/workflows/add-issue-to-project.yml b/.github/workflows/add-issue-to-project.yml new file mode 100644 index 000000000..cfa4ba3f2 --- /dev/null +++ b/.github/workflows/add-issue-to-project.yml @@ -0,0 +1,13 @@ +name: Add Issue to Project + +on: + issues: + types: [ opened ] + +jobs: + add-to-project: + uses: PrestaShop/.github/.github/workflows/reusable-add-to-project.yml@master + with: + project-number: 47 + secrets: + TOKEN: ${{ secrets.JARVIS_TOKEN }} diff --git a/.github/workflows/ai-prereview.yml b/.github/workflows/ai-prereview.yml new file mode 100644 index 000000000..6946448a8 --- /dev/null +++ b/.github/workflows/ai-prereview.yml @@ -0,0 +1,75 @@ +name: Claude Pre-Review + +on: + pull_request_target: + types: [labeled] + +concurrency: + # Scope the group to the label too: the workflow fires on any `labeled` event but + # only does work for `Need AI review`. Keying on the PR number alone let an unrelated + # label (e.g. `Waiting for author`) cancel an in-progress review via cancel-in-progress. + group: ai-prereview-${{ github.event.pull_request.number }}-${{ github.event.label.name }} + cancel-in-progress: true + +jobs: + check-diff: + name: Check for code changes + if: github.event.label.name == 'Need AI review' + runs-on: ubuntu-latest + outputs: + has-code-changes: ${{ steps.check.outputs.has-code-changes }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Check for code changes + id: check + run: | + CODE=$(gh pr diff ${{ github.event.pull_request.number }} --repo "${{ github.repository }}" --name-only | \ + grep -vE '\.(lock|md)$|^vendor/' | wc -l | tr -d ' ') + if [ "$CODE" = "0" ]; then + echo "has-code-changes=false" >> "$GITHUB_OUTPUT" + echo "No code changes — skipping Claude." >> "$GITHUB_STEP_SUMMARY" + else + echo "has-code-changes=true" >> "$GITHUB_OUTPUT" + echo "$CODE file(s) with code changes detected." >> "$GITHUB_STEP_SUMMARY" + fi + + load-prompt: + name: Load review prompt + needs: check-diff + if: needs.check-diff.outputs.has-code-changes == 'true' + runs-on: ubuntu-latest + outputs: + prompt: ${{ steps.prompt.outputs.content }} + steps: + - uses: actions/checkout@v6 + + - name: Load review prompt + id: prompt + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + PROMPT=$(envsubst '${PR_NUMBER} ${REPO} ${PR_TITLE} ${PR_AUTHOR}' < .claude/REVIEW_PROMPT.md) + { + echo "content<> "$GITHUB_OUTPUT" + + ai-guarded-review: + name: AI guarded review + needs: load-prompt + uses: PrestaShop/.github/.github/workflows/ai-guarded-review.yml@master + with: + team-slug: prestashop-sa + actor: ${{ github.event.sender.login }} + pr-number: ${{ github.event.pull_request.number }} + repository: ${{ github.repository }} + prompt: ${{ needs.load-prompt.outputs.prompt }} + secrets: + org-read-token: ${{ secrets.JARVIS_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e46901b0b..c737ca732 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -4,3 +4,5 @@ on: [push, pull_request] jobs: build-and-release-draft: uses: PrestaShop/.github/.github/workflows/build-release.yml@master + with: + autoindex_excluded_folders: src/ApiPlatform/Resources diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 328bb3a9f..e6d7e25f3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,28 +10,42 @@ concurrency: cancel-in-progress: true jobs: + workflow_matrix_generator: + name: PHP matrix + uses: ./.github/workflows/workflow-matrix-generator.yml integration: permissions: contents: read # to clone the repos and get release assets (shivammathur/setup-php) name: Integration tests runs-on: ubuntu-latest + needs: workflow_matrix_generator strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] - prestashop_version: ['develop', '9.0.x'] + php: ${{ fromJSON(needs.workflow_matrix_generator.outputs.dynamic_matrix) }} + prestashop_version: ['develop', '9.2.x', '9.0.3', '9.1.4'] + exclude: + # 9.0.3 doesn't support PHP 8.5 + - php: '8.5' + prestashop_version: '9.0.3' + include: + # Since 8.4 is the boundary gor 9.0.3 it should be included despite the dynamic matrix + - php: '8.4' + prestashop_version: '9.0.3' fail-fast: false steps: - - name: Checkout module code + - name: Checkout Module uses: actions/checkout@v3 with: path: ps_apiresources - - uses: actions/checkout@v3 - name: Checkout PrestaShop repository + + - name: Checkout PrestaShop repository + uses: actions/checkout@v3 with: fetch-depth: 0 repository: PrestaShop/PrestaShop path: prestashop ref: ${{ matrix.prestashop_version }} + - name: Build Docker timeout-minutes: 15 env: @@ -44,16 +58,59 @@ jobs: exit fi bash -l -c 'while [[ "$(curl -L -s -o /dev/null -w %{http_code} 'http://localhost:8001/install-dev/')" != "200" ]]; do echo "waiting for shop install"; sleep 5; done' - USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 php bin/console prestashop:module uninstall ps_apiresources + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + php bin/console prestashop:module uninstall ps_apiresources + - name: Install Module run: | rm -rf prestashop/modules/ps_apiresources mkdir -p prestashop/modules/ps_apiresources cp -r ps_apiresources/* prestashop/modules/ps_apiresources ls -l prestashop/modules/ps_apiresources - USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 composer install --no-interaction --working-dir=/var/www/html/modules/ps_apiresources - USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 php bin/console prestashop:module install ps_apiresources - USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 composer create-test-db + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + composer install --no-interaction --working-dir=/var/www/html/modules/ps_apiresources + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + php bin/console prestashop:module install ps_apiresources + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + composer create-test-db + + - name: Install pcov & git + if: matrix.php == '8.1' && matrix.prestashop_version == 'develop' + run: | + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + pecl install pcov + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + docker-php-ext-enable pcov + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + apt install -y git + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + git config --global --add safe.directory /var/www/html + - name: Run integration tests + env: + CLI_PHP: ${{ (matrix.php == '8.1' && matrix.prestashop_version == 'develop') && '-d extension=pcov.so -d pcov.enabled=1 -d pcov.directory=./modules/ps_apiresources/ -d pcov.exclude="~(tests|vendor)~" -d memory_limit=-1' || ' ' }} + CLI_PHPUNIT: ${{ (matrix.php == '8.1' && matrix.prestashop_version == 'develop') && ' ' || ' --no-coverage' }} run : | - USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 vendor/bin/phpunit -c modules/ps_apiresources/tests/Integration/phpunit-ci.xml + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop-prestashop-git-1 \ + php ${{env.CLI_PHP}} vendor/bin/phpunit -c modules/ps_apiresources/tests/Integration/phpunit-ci.xml${{ env.CLI_PHPUNIT }} + + - name: Upload coverage results to Coveralls + if: matrix.php == '8.1' && matrix.prestashop_version == 'develop' + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec -w /var/www/html/modules/ps_apiresources prestashop-prestashop-git-1 \ + wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.9.1/php-coveralls.phar + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec -w /var/www/html/modules/ps_apiresources prestashop-prestashop-git-1 \ + chmod +x php-coveralls.phar + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec \ + -e COVERALLS_REPO_TOKEN=${{env.COVERALLS_REPO_TOKEN}} \ + -e GITHUB_ACTIONS=true \ + -e GITHUB_EVENT_NAME=${{github.event_name}} \ + -e GITHUB_REF=${{github.ref}} \ + -e GITHUB_RUN_ID=${{github.run_id}} \ + -w /var/www/html/modules/ps_apiresources prestashop-prestashop-git-1 \ + php php-coveralls.phar \ + --coverage_clover=tests/Integration/clover.xml \ + --json_path=tests/Integration/coveralls-upload.json \ + -v diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index bc21dce1a..e3eb6934c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -24,6 +24,9 @@ jobs: - name: PHP syntax checker 8.4 uses: prestashop/github-action-php-lint/8.4@master + - name: PHP syntax checker 8.5 + uses: prestashop/github-action-php-lint/8.5@master + # Check the PHP code follow the coding standards php-cs-fixer: name: PHP-CS-Fixer @@ -71,4 +74,85 @@ jobs: run: composer install - name: Run header-stamp - run: php vendor/bin/header-stamp --license=vendor/prestashop/header-stamp/assets/afl.txt --exclude=vendor --dry-run + run: composer header-stamp + + phpunit: + name: PHPUnit Tests + runs-on: ubuntu-latest + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: vendor + key: php-${{ hashFiles('composer.lock') }} + + - name: Install dependencies + run: composer install + + - name: Run PHPUnit tests + run: php vendor/bin/phpunit -c tests/Unit/phpunit.xml + + rector: + name: Rector Dry Run + runs-on: ubuntu-latest + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: vendor + key: php-${{ hashFiles('composer.lock') }} + + - name: Install dependencies + run: composer install + + - name: Run Rector dry-run + run: composer rector + + phpstan: + name: PHPStan (${{ matrix.presta_version }}) + runs-on: ubuntu-latest + strategy: + matrix: + presta_version: ['9.0.3', '9.1.4', 'develop', '9.2.x'] + fail-fast: false + env: + PHPRC: ${{ github.workspace }}/${{ github.event.repository.name }}/.phpstan-php-ini + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + path: ${{ github.event.repository.name }} + + - name: Prepare PHP env (define constants before bootstrap) + run: | + mkdir -p ${{ github.event.repository.name }}/.phpstan-php-ini + { + echo "auto_prepend_file=$GITHUB_WORKSPACE/${{ github.event.repository.name }}/tests/PHPStan/prepend-constants.php" + echo "memory_limit=512M" + } > ${{ github.event.repository.name }}/.phpstan-php-ini/php.ini + + - name: Run PHPStan + uses: PrestaShop/.github/.github/actions/php-ci/phpstan@master + with: + php-version: '8.1' + presta-version: ${{ matrix.presta_version }} + module-name: ${{ github.event.repository.name }} + phpstan-level: '5' + phpstan-config: tests/PHPStan/phpstan-${{ matrix.presta_version }}.neon + phpstan-config-merge: 'false' diff --git a/.github/workflows/workflow-matrix-generator.yml b/.github/workflows/workflow-matrix-generator.yml new file mode 100644 index 000000000..c1dc5bec7 --- /dev/null +++ b/.github/workflows/workflow-matrix-generator.yml @@ -0,0 +1,81 @@ +name: Workflow matrix +on: + workflow_call: + outputs: + dynamic_matrix: + description: Dynamic matrix, defined in a JSON payload that needs to be parsed + value: ${{ jobs.workflow_matrix_generator.outputs.dynamic_matrix }} + +jobs: + workflow_matrix_generator: + name: Build workflow matrix + runs-on: ubuntu-latest + steps: + # First detect if we want the whole matrix or the default minimalist ones + # So far the cases when we want the whole matrix are: + # - on PR that targets main branch + # - when the label "Run all versions" is associated on the PR + - name: Detect complete or default values + id: use_all_versions + env: + PR_LABEL_LIST: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + echo base ref ${{ github.base_ref }} + # Put label list in one line JSON style to avoid parse error + labelList=$( echo '${{ env.PR_LABEL_LIST }}' | jq -c ) + if [[ "${{ github.base_ref }}" = "main" ]]; then + echo Using all versions for PR that targets main + echo "use_all_versions=1" >> $GITHUB_OUTPUT + elif [[ "$labelList" == *"Run all versions"* ]]; then + echo Using all versions because of the label + echo "use_all_versions=1" >> $GITHUB_OUTPUT + else + echo Using default PHP versions + echo "use_all_versions=0" >> $GITHUB_OUTPUT + fi + + # It is not possible to define arrays nor arrays of objects as env variables + # To make maintenance of the matrix easier they are store in JSON files in the .github/workflows/workflow-matrix + # folder, so if you need to update the settings simply update the associated JSON files + # To optimize this workflow only one file is read though, depending on: + # - use_all_versions + + # Read appropriate versions for PHP + - name: Read default PHP versions + if: ${{ steps.use_all_versions.outputs.use_all_versions == 0 }} + id: default_php + uses: jaywcjlove/github-action-read-file@main + with: + # For pull_request event this is pull request merge branch, for push event this is the pushed branch + branch: ${{ github.ref }} + path: .github/workflows/workflow-matrix/default-php-versions.json + - name: Read complete PHP versions + if: ${{ steps.use_all_versions.outputs.use_all_versions == 1 }} + id: complete_php + uses: jaywcjlove/github-action-read-file@main + with: + # For pull_request event this is pull request merge branch, for push event this is the pushed branch + branch: ${{ github.ref }} + path: .github/workflows/workflow-matrix/complete-php-versions.json + + # Finally we need one step that outputs the proper value because IDs cannot be duplicated, and we need + # one final source of truth for the job's output This final step re-checks the parameter and prints the + # proper value in the dynamic_matrix output variable + # Since multiline JSON is not well adapted to pass data between jobs the full JSON is printed as one line + - name: Build workflow matrix + id: workflow_matrix + run: | + if [[ "${{ steps.use_all_versions.outputs.use_all_versions }}" == "0" ]]; then + dynamicMatrix=$( echo '${{ steps.default_php.outputs.content }}' | jq -c ) + elif [[ "${{ steps.use_all_versions.outputs.use_all_versions }}" == "1" ]]; then + dynamicMatrix=$( echo '${{ steps.complete_php.outputs.content }}' | jq -c ) + else + echo Unknown matrix + exit 1 + fi + + echo One line matrix: $dynamicMatrix + echo "dynamic_matrix=$dynamicMatrix" >> $GITHUB_OUTPUT + # Pass the step outputs on the job scope, so the workflow can then reuse it and pass it outside the workflow + outputs: + dynamic_matrix: ${{ steps.workflow_matrix.outputs.dynamic_matrix }} diff --git a/.github/workflows/workflow-matrix/complete-php-versions.json b/.github/workflows/workflow-matrix/complete-php-versions.json new file mode 100644 index 000000000..a3daa37a6 --- /dev/null +++ b/.github/workflows/workflow-matrix/complete-php-versions.json @@ -0,0 +1,7 @@ +[ + "8.1", + "8.2", + "8.3", + "8.4", + "8.5" +] diff --git a/.github/workflows/workflow-matrix/default-php-versions.json b/.github/workflows/workflow-matrix/default-php-versions.json new file mode 100644 index 000000000..ed699eecd --- /dev/null +++ b/.github/workflows/workflow-matrix/default-php-versions.json @@ -0,0 +1,4 @@ +[ + "8.1", + "8.5" +] diff --git a/.gitignore b/.gitignore index caca8748e..c3dea8220 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ +## Directories /vendor +/var + +## Files .php-cs-fixer.cache .phpunit.result.cache +tests/Integration/clover.xml tests/local-parameters/parameters.php tests/local-parameters/parameters.yml diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 5b524f81f..276b6ce52 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -4,6 +4,10 @@ /** @var \Symfony\Component\Finder\Finder $finder */ $finder = $config->setUsingCache(true)->getFinder(); -$finder->in(__DIR__)->exclude('vendor'); +$finder->in(__DIR__)->exclude([ + 'var', + 'vendor', +]); +$config->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()); return $config; diff --git a/.windsurf/rules/ps-apiresources.md b/.windsurf/rules/ps-apiresources.md new file mode 100644 index 000000000..060a2d43b --- /dev/null +++ b/.windsurf/rules/ps-apiresources.md @@ -0,0 +1,10 @@ +--- +trigger: always_on +description: ps_apiresources module conventions (Admin API resources) +--- + +Read [`CONTEXT.md`](../../CONTEXT.md) at the repo root before +suggesting any code in this repository. It is the single source of +truth for the module's architecture, URI / scope / property +conventions, mapping rules, Do/Don't list, testing expectations, and +canonical examples. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..6ac81f2ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Agents — ps_apiresources + +All agents working in this repository must read +**[`CONTEXT.md`](./CONTEXT.md)** before proposing or generating code. +It is the single source of truth for module purpose, architecture, URI +and scope conventions, coding standards, Do/Don't rules, testing +expectations, and canonical examples. + +This file exists so that any agent framework looking for an `AGENTS.md` +entry point gets pointed at the same context every other tool uses. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7442c262d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,10 @@ +# Claude Code — ps_apiresources + +The conventions, architecture, and Do/Don't list for this repository +live in **[`CONTEXT.md`](./CONTEXT.md)**. Read it before suggesting or +generating any code. + +When the user asks to add, expose, or wire up a new Admin API endpoint, +invoke the [`ps-api-endpoint`](./.claude/skills/ps-api-endpoint/SKILL.md) +skill — it generates the resource class and integration test in line +with `CONTEXT.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..b83ad74b0 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,414 @@ +# ps_apiresources — AI Context + +This file is the AI-agnostic source of truth for working in the +`ps_apiresources` module. Tool-specific pointer files at the repo root +(`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/*`, +`.github/copilot-instructions.md`, `.windsurf/rules/*`) all defer here. + +## Purpose + +`ps_apiresources` declares the PrestaShop **Admin API** endpoints. It is +**purely declarative**: no business logic lives here. Each endpoint is a +plain PHP class decorated with `#[ApiResource]` whose operations delegate +to PrestaShop Core CQRS commands and queries. The module is scanned by +the PrestaShop Core at runtime; there is no controller layer to write. + +If you find yourself adding business logic here, stop — it belongs in +PrestaShop Core, not in this module. + +All resources live under `src/ApiPlatform/Resources/{Domain}/`. + +## Architecture overview + +A resource class binds an HTTP operation to a CQRS class from the Core, +using one of the operation attributes provided by `PrestaShopBundle`: + +| Attribute | HTTP | Wraps | Use case | +|----------------------|--------|------------------------------|-------------------------------------------| +| `CQRSGet` | GET | A `Get…ForEditing` query | Fetch a single entity | +| `CQRSCreate` | POST | An `Add…Command` | Create a new entity | +| `CQRSPartialUpdate` | PATCH | An `Edit…Command` | Update a subset of fields | +| `CQRSUpdate` | PUT | An `Edit…Command` | Full update (all fields required) | +| `CQRSDelete` | DELETE | A `Delete…Command` | Delete an entity (no response body) | +| `PaginatedList` | GET | A Grid data factory + Filters | List endpoint sourced from an admin Grid | +| `CQRSPaginate` | GET | A `…List` CQRS query + Filters | List endpoint sourced from a CQRS query (preferred when a Grid doesn't exist) | +| `CQRSGetCollection` | GET | A `…Query` returning a list | Non-paginated collection | + +Each operation declares: + +- A `uriTemplate` (kebab-case, plural — see below) +- One or more OAuth2 `scopes` required to call it +- Field mappings (`CQRSQueryMapping`, `CQRSCommandMapping`, + `ApiResourceMapping`) when DTO field names differ from CQRS field names +- An `exceptionToStatus` map at the class level so domain exceptions are + translated into proper HTTP responses + +Single-entity, list, and bulk operations are split across **separate +classes** in the same domain folder. For example: + +``` +src/ApiPlatform/Resources/Contact/ +├── Contact.php ← CQRSGet / CQRSCreate / CQRSPartialUpdate / CQRSDelete +└── ContactList.php ← PaginatedList + +src/ApiPlatform/Resources/Attribute/ +├── AttributeGroup.php +├── AttributeGroupList.php +└── BulkAttributeGroups.php ← bulk-delete operation +``` + +## URI conventions + +- **Plural, lowercase, kebab-case**: `/contacts`, `/attributes/groups`, + `/customer-groups` — never `/contact`, `/attributeGroups`, or + `/customerGroups`. +- **Sub-resources** nest under the parent ID: + `/products/{productId}/combinations`, + `/categories/{categoryId}/cover`. +- **Bulk operations** use the `bulk-` prefix: `/contacts/bulk-delete`, + `/attributes/groups/bulk-delete`. The bulk parameter name must be + plural domain + `Ids` (e.g. `attributeGroupIds`). +- **URI parameters** use the domain identifier name, not `id`: + `{contactId}`, `{attributeGroupId}`, `{taxRuleId}`. +- The DTO property exposed as the identifier must match the URI + parameter and be marked `#[ApiProperty(identifier: true)]`. + +## Scopes + +OAuth2 scope naming: `{entity_snake_case}_{action}`. + +- Single-word entity: `contact_read`, `contact_write` +- Multi-word entity: `attribute_group_read`, `tax_rule_write` +- `_read` for GET operations, `_write` for POST / PATCH / PUT / DELETE +- Every operation must declare at least one scope. There are no + unscoped endpoints. + +## Property naming + +| Rule | Correct | Wrong | +|-----------------------------------------------|--------------------------|--------------------------------| +| Identifier — domain name + `Id`, with `#[ApiProperty(identifier: true)]` | `$contactId` | `$id` | +| Boolean — no `is` prefix | `$enabled`, `$ready` | `$isEnabled`, `$isReady` | +| Status uses `enabled`, not `active` / `status`| `$enabled` | `$active`, `$status` | +| Localized fields — no `localized` prefix | `$names` | `$localizedNames` | +| All public properties strictly typed | `public int $contactId` | `public $contactId` | +| Decimals — use `DecimalNumber`, never `float` | `public DecimalNumber $price` | `public float $price` | +| Dates — `DateTimeImmutable` is allowed | `public DateTimeImmutable $dateAdd` | `public string $dateAdd` | + +Array fields representing complex structures must include +`#[ApiProperty(openapiContext: ['type' => 'array', 'items' => [...]])]` +for OpenAPI schema documentation. + +Localized properties must be marked with `#[LocalizedValue]` — this +triggers automatic locale↔languageId conversion on both read and write. +Single-entity endpoints return localized fields as locale-indexed arrays +(e.g. `{"names": {"en-US": "Size", "fr-FR": "Taille"}}`); list endpoints +return a single language string (default shop language, or via `langId` +query param). Never return language IDs as keys — always locale strings +like `en-US`. + +To require the default language on create / update, add +`#[DefaultLanguage]` with the appropriate validation `groups`. Set the +`fieldName` argument to the **API field name** so the validation error +message includes a meaningful field reference (the auto-detection +fallback only works in Form contexts, not on ApiResource attributes). +Use `allowNull: true` on the Update group so the field is optional on +partial update. See `src/ApiPlatform/Resources/Contact/Contact.php` for +the current usage pattern. + +## Field mapping + +- `CQRSQueryMapping` (often defined as a `QUERY_MAPPING` constant) maps + **query result field → API field**. A common mistake is inverting the + direction (using API field name as key instead of QueryResult field + name). Read the `Get{Entity}ForEditing` query result class in + PrestaShop Core to find source field names. +- `CQRSCommandMapping` (often `CREATE_COMMAND_MAPPING` / + `UPDATE_COMMAND_MAPPING`, or a shared `COMMAND_MAPPING` when both + commands accept the same parameters) maps **API field → command + constructor parameter**. +- `ApiResourceMapping` plays the same role for `PaginatedList` / + `CQRSPaginate`, mapping Grid filter / data factory fields (or CQRS + query result fields) to API fields. For `PaginatedList`, the source + field names can often be read directly from the related query builder + service wired via `gridDataFactory` — you only need to map the fields + whose names don't already match the DTO. `filtersMapping` must be + provided when filter parameter names differ from API field names. +- To find the correct `gridDataFactory` service name: look at the + entity's Symfony controller → Grid factory definition → Grid data + factory service. +- **Field alignment for `PaginatedList` endpoints** — the DTO properties + must mirror the fields selected by the grid's underlying query builder. + To verify alignment: + 1. Trace the `gridDataFactory` service to its query builder class in + PrestaShop Core (the class implementing `DoctrineQueryBuilderInterface` + or similar). + 2. Read the SQL `SELECT` clause — each selected column is a field the + grid can return. + 3. Every selected field should have a corresponding DTO property. If a + field is intentionally omitted, it should be a conscious decision + (not an oversight). + 4. When the SQL column name differs from the DTO property name (e.g. + `id_contact` vs `contactId`, `firstname` vs `firstName`), an + `ApiResourceMapping` entry must cover the rename. + 5. A DTO property with no matching query field will always be `null` at + runtime — this is almost certainly a bug. + The same principle applies to `CQRSPaginate`: compare the CQRS query's + result DTO fields against the ApiResource properties. +- Do NOT use `SerializedName` — always use `CQRSQueryMapping`, + `CQRSCommandMapping`, or `ApiResourceMapping`. +- Nested fields use bracket notation: + `'[basicInformation][localizedNames]' => '[names]'`. + +## Multi-shop + +> **Experimental — feature flag required.** Admin API support for +> multistore is gated behind the `admin_api_multistore` feature flag +> (Advanced Parameters → New & Experimental Features). The conventions +> below describe how endpoints must be written so they behave correctly +> in multistore, but they only take effect at runtime once the flag is +> enabled. Without the flag, multistore context parameters are ignored +> and the API falls back to single-shop behavior. + +When PrestaShop's multistore feature is **disabled**, the API +automatically uses the default shop — no extra parameters are needed. + +When multistore is **enabled** (and the `admin_api_multistore` feature +flag is on), every API request **must** include a shop context +parameter (otherwise the API returns 400). The `ShopContextListener` in +the Core reads these parameters from the request and builds a +`ShopConstraint` that CQRS commands/queries use: + +| Request parameter | Context built | Use case | +|---|---|---| +| `shopId` | `ShopConstraint::shop($id)` | Target a single specific shop | +| `shopGroupId` | `ShopConstraint::shopGroup($id)` | Target all shops in a group | +| `shopIds` (comma-separated or array) | `ShopCollection::shops([...])` | Target a specific set of shops | +| `allShops` (presence is enough, value ignored) | `ShopConstraint::allShops()` | Target all shops | + +These are **request-level context parameters** (query string or request +body) that determine *which shop(s) the operation runs against*. They +are distinct from the `shopIds` DTO property described below. + +### Shop association property + +Entities that have a shop association (e.g. contacts, attributes, +categories) expose a `public array $shopIds` property on the DTO. This +represents the list of shops the entity is associated with, and must be +mapped in both directions: + +- **Query mapping** (read): map the Core field (often `associatedShops`, + `associatedShopIds`, or `shopAssociation`) → API field `shopIds`. +- **Command mapping** (write): map API field `shopIds` → Core parameter + (often `shopAssociation` or `associatedShopIds`). + +If the entity has no shop association, the `shopIds` property must be +absent from the DTO. + +### Passing shop context to CQRS commands/queries + +Some commands/queries need the current shop context (e.g. to filter +results by shop). This is done via the special `[_context]` prefix in +mappings, which injects the request's shop context parameters: + +```php +// In a CQRSCommandMapping or CQRSQueryMapping: +'[_context][shopId]' => '[shopId]', // single shop ID (int) +'[_context][shopIds]' => '[shopIds]', // multiple shop IDs (array) +'[_context][shopConstraint]' => '[shopConstraint]', // full ShopConstraint object +``` + +Use `[_context][shopConstraint]` when the CQRS command/query accepts a +`ShopConstraint` value object directly (common for Product-related +commands). Use `[_context][shopId]` or `[_context][shopIds]` when the +command expects plain integer IDs. + +See `Product.php`, `Combination.php`, `CombinationList.php`, and +`CustomerGroup.php` for examples of these patterns. + +## Do / Don't + +### Do + +- Define `exceptionToStatus` for every domain exception the operations + can throw — at minimum the entity's `…ConstraintException` → + `HTTP_UNPROCESSABLE_ENTITY` (422) and `…NotFoundException` → + `HTTP_NOT_FOUND` (404). Do not add other exceptions unless clearly + justified. Never use `HTTP_BAD_REQUEST` (400) for constraint + violations — always 422. +- Use a `CQRSQuery` on `CQRSCreate` and `CQRSPartialUpdate` when the + endpoint should return the full updated state, not just an identifier. +- Use `#[LocalizedValue]` for any field stored as `array`. +- Split single / list / bulk operations into separate classes inside the + same domain folder. +- Add an integration test for every new endpoint (see Testing below). +- Use `validationContext: ['groups' => ['Default', 'Create']]` on + `CQRSCreate` and `validationContext: ['groups' => ['Default', 'Update']]` + on `CQRSPartialUpdate`. `#[Assert\NotBlank]` must be present on + required fields for the Create group. Constraints should match the + associated Symfony form type (check the entity's FormType for + reference). + +### Don't + +- Don't add business logic in a resource class. Delegate to a Core CQRS + command or query — always. +- Don't write custom normalizers or custom processors. Use + `#[LocalizedValue]`, `ApiResourceMapping`, `CQRSQueryMapping`, and + `CQRSCommandMapping` instead. CI enforces this. +- Don't expose Value Objects as DTO properties. Public properties must + be scalar (`int`, `string`, `bool`) or `array`. Two exceptions are + allowed: + - `PrestaShop\Decimal\DecimalNumber` — which **must** be used instead + of `float` for any decimal / monetary value (never `float`). + - `DateTimeImmutable` — allowed for date / datetime properties. + + This is enforced by the `ApiResourcePropertyTypeRule` PHPStan rule in + CI. +- Don't return raw command results. If the endpoint should return data, + define proper DTO properties and wire a `CQRSQuery`. +- Don't declare an operation without a scope. +- Don't use plural names for boolean / status fields, or `id` as a + property name — see Property naming. + +## Testing expectations + +Every new endpoint requires an integration test. The test class lives at +`tests/Integration/ApiPlatform/{Entity}EndpointTest.php` and extends +`ApiTestCase` (same directory). Tests should focus on **API behaviour, +contract, and response format** — authentication and OAuth token setup +are handled automatically by the base class. + +### Always use the `ApiTestCase` helper methods + +Do not call `static::createClient()->request(...)` directly. Use the +helpers below — they build the request, request a Bearer token for the +given scopes (auto-creating an API client that holds them, cached +across tests), and assert the expected HTTP status code. + +| Helper | HTTP | Purpose | +|---------------------------|--------|-----------------------------------------------| +| `getItem($url, $scopes)` | GET | Fetch a single item | +| `createItem($url, $data, $scopes)` | POST | Create an item | +| `partialUpdateItem($url, $data, $scopes)` | PATCH | Partial update | +| `updateItem($url, $data, $scopes)` | PUT | Full update | +| `deleteItem($url, $scopes)` | DELETE | Delete a single item | +| `bulkDeleteItems($url, $data, $scopes)` | DELETE | Bulk delete with a payload | +| `listItems($url, $scopes, $filters)` | GET | Paginated list (asserts envelope) | +| `countItems($url, $scopes, $filters)` | GET | Paginated list → totalItems only | +| `requestApi(...)` | any | Last resort for cases the others don't fit | + +Each helper also accepts an `$expectedHttpCode` parameter when asserting +a specific status (e.g. `HTTP_UNPROCESSABLE_ENTITY` on invalid payloads, +`HTTP_NOT_FOUND` after a delete). + +### Default test environment + +`ApiTestCase::setUpBeforeClass()` already: + +- Forces `PS_ADMIN_API_FORCE_DEBUG_SECURED = 0` +- Resets API clients and languages +- **Installs `fr-FR` as a second language** so every endpoint is tested + against a multi-language environment + +Because of this, test data for localized fields should include both +`en-US` and `fr-FR` values — otherwise the test won't exercise the +multi-language behaviour the module is expected to handle. + +Static helpers available for additional setup (call from +`setUpBeforeClass` when needed): + +- `addLanguageByLocale($locale)` — install another language +- `addShopGroup($name, $color?)` / `addShop($name, $groupId, $color?)` + — build a multi-shop fixture +- `updateConfiguration($key, $value, $shopConstraint?)` — override a + configuration value for the test +- `createApiClient($scopes)` — only needed in special cases; + `getBearerToken` already creates one on the fly for each scope set + +### Test structure + +- Implement `getProtectedEndpoints()` listing every operation that + requires authentication so the base class can verify scope + enforcement via `testProtectedEndpoints`. +- Chain CRUD test methods with `@depends` so each step builds on the + previous one's created entity. +- Always include a `testInvalid…` method covering validation errors + (expecting `HTTP_UNPROCESSABLE_ENTITY`) and use + `$this->assertValidationErrors([...], $response)` to check the error + shape. +- Include `LanguageResetter` only if the entity has localized fields. +- `declare(strict_types=1)` must be present in the test file. +- Restore relevant DB tables via `DatabaseDump::restoreTables([...])` + in `setUpBeforeClass` / `tearDownAfterClass` (include `_lang` table + if localized, `_shop` table if shop-associated). + +Run the suite with: + +```bash +composer setup-local-tests # first time only +composer run-module-tests +``` + +## Canonical examples + +Read these before adding new endpoints — they are the source of truth +for the patterns above: + +- **Full CRUD on a localized entity** — + `src/ApiPlatform/Resources/Contact/Contact.php` + (`CQRSGet`, `CQRSCreate`, `CQRSPartialUpdate` with `LocalizedValue`, + `DefaultLanguage`, `QUERY_MAPPING`, `CREATE_COMMAND_MAPPING`, + `exceptionToStatus`). +- **Grid-sourced paginated list** — + `src/ApiPlatform/Resources/Contact/ContactList.php` + (`PaginatedList`, `gridDataFactory`, `filtersClass`, + `ApiResourceMapping`). +- **CQRS-sourced paginated list** — + `src/ApiPlatform/Resources/Product/CombinationList.php` + (`CQRSPaginate` with `CQRSQuery`, `CQRSQueryMapping`, + `ApiResourceMapping`, `itemsField`, `countField`, and `DecimalNumber` + properties). +- **Sub-resource** — + `src/ApiPlatform/Resources/Product/ProductCombination.php`. +- **Bulk operation** — + `src/ApiPlatform/Resources/Attribute/BulkAttributeGroups.php` + (URI `/attributes/groups/bulk-delete`, `attributeGroupIds` input). +- **Matching test** — + `tests/Integration/ApiPlatform/ContactEndpointTest.php`. + +## Coding standards + +- Coding style is enforced by `php-cs-fixer` (`.php-cs-fixer.dist.php`). +- All resource classes live in the namespace + `PrestaShop\Module\APIResources\ApiPlatform\Resources\{Domain}`. +- Every file must carry the AFL 3.0 license header used across the + module (run `composer header-stamp-fix` if missing). +- Static analysis: `composer phpstan` (requires `_PS_ROOT_DIR_` and + `_PS_BRANCH_` env vars). +- Automated refactor / upgrade rules: `composer rector`. + +## Related skills + +- `.claude/skills/ps-api-endpoint/` — Claude Code skill that walks + through creating a new endpoint end-to-end (resource class + + integration test) following the conventions above. + See `.claude/skills/ps-api-endpoint/SKILL.md` and + `.claude/skills/ps-api-endpoint/references/conventions.md`. + +## Authoritative external references + +- Module repository: +- Devdocs — Contribute to the Core API: + +- Devdocs — API Platform & CQRS integration: + +- Core — `PrestaShopBundle/ApiPlatform` (the infrastructure this module + plugs into): + +- Core — custom operation attributes (`CQRSGet`, `CQRSCreate`, + `CQRSPartialUpdate`, `CQRSUpdate`, `CQRSDelete`, `PaginatedList`, + `CQRSPaginate`, `CQRSGetCollection`, …) and related metadata: + + + \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..3f0abe23e --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,5 @@ +# Gemini CLI — ps_apiresources + +The conventions, architecture, and Do/Don't list for this repository +live in **[`CONTEXT.md`](./CONTEXT.md)**. Read it before generating any +code or answering questions about this module. diff --git a/README.md b/README.md index e0a556e11..3292f6eff 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # PrestaShop API Resources +[![Coverage Status](https://coveralls.io/repos/github/PrestaShop/ps_apiresources/badge.svg)](https://coveralls.io/github/PrestaShop/ps_apiresources) + ## About -Includes the resources allowing using the API for the PrestaShop domain, all endpoints are based on CQRS commands/queries from the Core and we APIPlatform framework is used as a base. +Includes the resources allowing using the API for the PrestaShop domain, all endpoints are based on CQRS commands/queries from the Core and we [API Platform](https://api-platform.com/) framework is used as a base. This module contains no code only some resource files that are automatically scanned and integrated by the Core, these resources are in [this folder](src/ApiPlatform/Resources). @@ -10,6 +12,14 @@ This module contains no code only some resource files that are automatically sca You can report issues with this module in the main PrestaShop repository. [Click here to report an issue][report-issue]. +## Under development + +Please be aware that this module is still under development, which is why its version is only `0.*`. We haven't released a major version `1.0` yet because we anticipate some breaking changes may happen while we add all the missing endpoints. + +We aim to maintain backward compatibility as much as possible and never introduce breaking changes, but we also want this API contract to be a fixed standard once it's finalized, so we prefer to polish and refine it before a major version is released. + +After the `1.0` version is released it won't include other breaking changes, any breaking change will require a new major version (`2.0`) and will follow semver convention. + ## Requirements Required only for development: @@ -27,7 +37,7 @@ composer install ### Initialize tmp shop environment -First initialize the test environment with this command that will install a PrestaShop shop in a temporary folder so that the integration tests can run: +First, initialize the test environment with this command that will install a PrestaShop shop in a temporary folder so that the integration tests can run: ```bash composer create-test-db @@ -41,20 +51,20 @@ You can define custom values when setting up the tmp shop: composer clear-test-cache composer setup-local-tests -- [arguments] arguments: - --force-clone Force cloning the repository even if cloned repository is detected (when no repository is detected the clone is automatic) - --build-assets Force building assets even if they are already built (when no assets are detected the build is automatic) - --build-db Force building DB by installing the default shop data (when no DB is detected the DB shop is installed automatically) + --force-clone Force cloning the repository even if a cloned repository is detected (when no repository is detected, the clone is automatic) + --build-assets Force building assets even if they are already built (when no assets are detected, the build is automatic) + --build-db Force building DB by installing the default shop data (when no DB is detected, the DB shop is installed automatically) --update-local-parameters Force copying parameters from the `test/local-parameters` folder (when no parameter file is detectec they are automatically copied) --force Force all the previous arguments - --core-branch Use a specific branch, you can use a branch from the original repository (ex: `develop`, `9.0.x`, ...) or from a fork (ex: `myfork:my-dev-branch`) (By default branch develop is used) + --core-branch Use a specific branch, you can use a branch from the original repository (ex: `develop`, `9.1.x`, ...) or from a fork (ex: `myfork:my-dev-branch`) (By default branch develop is used) ``` Example: ```bash -# To test with 9.0.x branch -composer setup-local-tests -- --force --core-branch=9.0.x +# To test with 9.1.x branch +composer setup-local-tests -- --force --core-branch=9.1.x -# To test with a branch from your fork (in this example fork: jolelievre branch: product-api) +# To test with a branch from your fork (in this example, fork: jolelievre, branch: product-api) composer setup-local-tests -- --force --core-branch=jolelievre:product-api ``` @@ -66,11 +76,47 @@ Then you can run the tests with this command: composer run-module-tests ``` +## Run PHPStan locally + +You can run PHPStan locally using the composer script, you will need to specify two variables though: +- `_PS_ROOT_DIR_`: path to a local PrestaShop root folder +- `_PS_BRANCH_`: which branch you are testing, this impacts the PHPStan config file used which contains specific rules and exceptions, accepted values are `develop|9.1.x` + +```bash +# To test with the develop branch +_PS_ROOT_DIR_=/path/to/prestashop-develop _PS_BRANCH_=develop composer phpstan + +# To test with the 9.1.x branch +_PS_ROOT_DIR_=/path/to/prestashop-91x _PS_BRANCH_=9.1.x composer phpstan +``` + +Each config uses a différent cache folders stored in `var/phpstan-tmp` but you can easily pollute it if: +- you run the command multiple times with mixed variables (`_PS_ROOT_DIR_` and `_PS_BRANCH_` don't match) +- you change your branch in `_PS_ROOT_DIR_` but the cache was already generated with the previous branch + +You can remove the `var/phpstan-tmp` cache to reset the cache. + +You can also use the composer script to update the baseline to include exceptions for specific versions: + +```bash +# To test with the develop branch (this should updated phpstan-9.1.x.neon only) +_PS_ROOT_DIR_=/path/to/prestashop-91x _PS_BRANCH_=9.1.x composer phpstan-baseline +``` + +Make sure you use the correct two variables and your cache is correct ro you will add the wrong exceptions for the wrong branch. +This composer script will generate a dedicated baseline file, but the rules should be included in the existing phpstan config for the branch. + ## Contributing -PrestaShop modules are open source extensions to the [PrestaShop e-commerce platform][prestashop]. Everyone is welcome and even encouraged to contribute with their own improvements! +The PrestaShop Admin API provides RESTful endpoints for managing your store programmatically. Everyone is welcome to contribute by adding new API endpoints! + +**Please read our contributing guide before starting:** +- **[Create new API endpoints](https://devdocs.prestashop-project.org/9/admin-api/contribute-to-core-api/)** - Complete step-by-step guide +- **[Admin API Documentation](https://devdocs.prestashop-project.org/9/admin-api/)** - Full API reference + +Need help or believe core improvements are needed? Reach out to the team on [Slack][slack], and `#cfc-adminapi` channel - we're here to help! -Just make sure to follow our [contribution guidelines][contribution-guidelines]. +Also, follow our general [contribution guidelines][contribution-guidelines]. ## License @@ -80,3 +126,4 @@ This module is released under the [Academic Free License 3.0][AFL-3.0] [prestashop]: https://www.prestashop-project.org/ [contribution-guidelines]: https://devdocs.prestashop-project.org/9/contribute/contribution-guidelines/project-modules/ [AFL-3.0]: https://opensource.org/licenses/AFL-3.0 +[slack]: https://prestashop-project.org/slack diff --git a/composer.json b/composer.json index e71d05cd9..3641c431a 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,9 @@ "prestashop/php-dev-tools": "^4.3", "czproject/git-php": "^4.2", "phpunit/phpunit": "^10", - "friendsofphp/php-cs-fixer": "^v3" + "friendsofphp/php-cs-fixer": "^v3", + "rector/rector": "^2.0", + "doctrine/inflector": "^2.0" }, "autoload": { "psr-4": { @@ -43,11 +45,20 @@ "scripts": { "clear-test-cache": "PsApiResourcesTest\\EnvironmentBuilder::clearCache", "create-test-db": [ + "Composer\\Config::disableProcessTimeout", "@composer clear-test-cache", "@composer setup-local-tests -- --build-db" ], + "header-stamp": "@php vendor/bin/header-stamp --license=vendor/prestashop/header-stamp/assets/afl.txt --exclude=vendor --dry-run", + "header-stamp-fix": "@php vendor/bin/header-stamp --license=vendor/prestashop/header-stamp/assets/afl.txt --exclude=vendor", "run-module-tests": "@php -d date.timezone=UTC ./vendor/bin/phpunit -c tests/Integration/phpunit-local.xml", - "setup-local-tests": "PsApiResourcesTest\\EnvironmentBuilder::setupLocalTests" + "run-unit-tests": "@php -d date.timezone=UTC ./vendor/bin/phpunit -c tests/Unit/phpunit.xml", + "setup-local-tests": "PsApiResourcesTest\\EnvironmentBuilder::setupLocalTests", + "rector": "vendor/bin/rector process --clear-cache --dry-run", + "rector-fix": "vendor/bin/rector process", + "phpstan": "_PS_ROOT_DIR_=${_PS_ROOT_DIR_:?'Set _PS_ROOT_DIR_ to your PrestaShop installation path'} _PS_BRANCH_=${_PS_BRANCH_:?'Set _PS_BRANCH_ to 9.1.x|develop'} php -d memory_limit=-1 vendor/bin/phpstan analyse --configuration=tests/PHPStan/phpstan-${_PS_BRANCH_}.neon --no-progress", + "phpstan-baseline": "_PS_ROOT_DIR_=${_PS_ROOT_DIR_:?'Set _PS_ROOT_DIR_ to your PrestaShop installation path'} _PS_BRANCH_=${_PS_BRANCH_:?'Set _PS_BRANCH_ to 9.1.x|develop'} php -d memory_limit=-1 vendor/bin/phpstan analyse --configuration=tests/PHPStan/phpstan-${_PS_BRANCH_}.neon --no-progress --generate-baseline=tests/PHPStan/phpstan-${_PS_BRANCH_}-baseline.neon", + "phpstan:docker": "bash tests/phpstan.sh" }, "type": "prestashop-module", "author": "PrestaShop" diff --git a/composer.lock b/composer.lock index 0b738e0ca..4cbf1ff56 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "64d51e2e8da51fa202c44f7ef2883e91", + "content-hash": "2e4446ac3b8810166c649835d009bddf", "packages": [], "packages-dev": [ { @@ -341,6 +341,96 @@ ], "time": "2023-07-12T09:14:30+00:00" }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -554,16 +644,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -602,7 +692,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -610,20 +700,20 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v4.19.4", + "version": "v4.19.5", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2" + "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/715f4d25e225bc47b293a8b997fe6ce99bf987d2", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", + "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", "shasum": "" }, "require": { @@ -638,11 +728,6 @@ "bin/php-parse" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, "autoload": { "psr-4": { "PhpParser\\": "lib/PhpParser" @@ -664,9 +749,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.5" }, - "time": "2024-09-29T15:01:53+00:00" + "time": "2025-12-06T11:45:25+00:00" }, { "name": "phar-io/manifest", @@ -786,6 +871,59 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", + "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-11-11T15:18:17+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "10.1.16", @@ -1109,16 +1247,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.38", + "version": "10.5.62", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132" + "reference": "3f7dd5066ebde5809296a81f0b19e8b00e5aab49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a86773b9e887a67bc53efa9da9ad6e3f2498c132", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3f7dd5066ebde5809296a81f0b19e8b00e5aab49", + "reference": "3f7dd5066ebde5809296a81f0b19e8b00e5aab49", "shasum": "" }, "require": { @@ -1128,7 +1266,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -1139,13 +1277,13 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.3", + "sebastian/comparator": "^5.0.5", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.2", + "sebastian/exporter": "^5.1.4", "sebastian/global-state": "^6.0.2", "sebastian/object-enumerator": "^5.0.0", - "sebastian/recursion-context": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", "sebastian/type": "^4.0.0", "sebastian/version": "^4.0.1" }, @@ -1190,7 +1328,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.38" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.62" }, "funding": [ { @@ -1201,12 +1339,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-10-28T13:06:21+00:00" + "time": "2026-01-27T05:32:38+00:00" }, { "name": "prestashop/autoindex", @@ -2035,6 +2181,66 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "rector/rector", + "version": "2.2.8", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "303aa811649ccd1d32e51e62d5c85949d01b5f1b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/303aa811649ccd1d32e51e62d5c85949d01b5f1b", + "reference": "303aa811649ccd1d32e51e62d5c85949d01b5f1b", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.2.8" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2025-11-12T18:38:00+00:00" + }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -2205,16 +2411,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.3", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", "shasum": "" }, "require": { @@ -2270,15 +2476,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2024-10-18T14:56:07+00:00" + "time": "2026-01-24T09:25:16+00:00" }, { "name": "sebastian/complexity", @@ -2471,16 +2689,16 @@ }, { "name": "sebastian/exporter", - "version": "5.1.2", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf" + "reference": "0735b90f4da94969541dac1da743446e276defa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", "shasum": "" }, "require": { @@ -2489,7 +2707,7 @@ "sebastian/recursion-context": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -2537,15 +2755,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T07:17:12+00:00" + "time": "2025-09-24T06:09:11+00:00" }, { "name": "sebastian/global-state", @@ -2781,23 +3011,23 @@ }, { "name": "sebastian/recursion-context", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", "shasum": "" }, "require": { "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -2832,15 +3062,28 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T07:05:40+00:00" + "time": "2025-08-10T07:50:56+00:00" }, { "name": "sebastian/type", @@ -2953,16 +3196,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.2", + "version": "3.13.6", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", "shasum": "" }, "require": { @@ -2972,18 +3215,13 @@ "php": ">=5.4.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" }, "bin": [ - "bin/phpcs", - "bin/phpcbf" + "bin/phpcbf", + "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -2991,22 +3229,49 @@ "authors": [ { "name": "Greg Sherwood", - "role": "lead" + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", "standards", "static analysis" ], "support": { - "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", - "source": "https://github.com/squizlabs/PHP_CodeSniffer", - "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" }, - "time": "2023-02-22T23:07:41+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T00:17:32+00:00" }, { "name": "symfony/console", @@ -3847,29 +4112,26 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.28.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -3910,7 +4172,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -3921,12 +4183,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php81", @@ -4009,16 +4275,16 @@ }, { "name": "symfony/process", - "version": "v6.3.4", + "version": "v6.4.33", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0b5c29118f2e980d455d2e34a5659f4579847c54" + "reference": "c46e854e79b52d07666e43924a20cb6dc546644e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0b5c29118f2e980d455d2e34a5659f4579847c54", - "reference": "0b5c29118f2e980d455d2e34a5659f4579847c54", + "url": "https://api.github.com/repos/symfony/process/zipball/c46e854e79b52d07666e43924a20cb6dc546644e", + "reference": "c46e854e79b52d07666e43924a20cb6dc546644e", "shasum": "" }, "require": { @@ -4050,7 +4316,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.3.4" + "source": "https://github.com/symfony/process/tree/v6.4.33" }, "funding": [ { @@ -4061,12 +4327,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-08-07T10:39:22+00:00" + "time": "2026-01-23T16:02:12+00:00" }, { "name": "symfony/service-contracts", @@ -4300,16 +4570,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -4338,7 +4608,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -4346,7 +4616,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], @@ -4361,5 +4631,5 @@ "platform-overrides": { "php": "8.1.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config.xml b/config.xml index fe06524d1..e783e9f2d 100644 --- a/config.xml +++ b/config.xml @@ -2,10 +2,10 @@ ps_apiresources - + 0 0 - + \ No newline at end of file diff --git a/config/admin/services.yml b/config/admin/services.yml index 423b52ee1..04614557e 100644 --- a/config/admin/services.yml +++ b/config/admin/services.yml @@ -24,3 +24,55 @@ services: - '@prestashop.core.hook.dispatcher' - '@prestashop.core.grid.query.doctrine_query_parser' - 'module' + + PrestaShop\Module\APIResources\Validation\IframeValidationGroupsResolver: + arguments: + - '@prestashop.adapter.legacy.configuration' + + # Validates the addressFormat string on the Country API resource. + # Delegates to the core AddressFormatCheckerInterface when present (PS 9.2+); + # on older PrestaShop versions the alias is missing, the argument resolves + # to null and the validator becomes a no-op (backward-compatible behavior). + PrestaShop\Module\APIResources\Validation\Constraints\ValidAddressFormatValidator: + autoconfigure: true + arguments: + $checker: '@?PrestaShop\PrestaShop\Core\Domain\Country\AddressFormat\AddressFormatCheckerInterface' + + PrestaShop\Module\APIResources\Command\GenerateApiTrackingTableCommand: + tags: + - { name: 'console.command' } + + # Custom normalizers for commands/queries that are very specific and cannot rely on generic solutions from the core + PrestaShop\Module\APIResources\ApiPlatform\Normalizer\GenerateCombinationsSerializer: + autowire: true + autoconfigure: true + public: false + + PrestaShop\Module\APIResources\ApiPlatform\Normalizer\GetSearchTermAliasesQuerySerializer: + autoconfigure: true + + # See class docblock: post-processes attributes[].localizedNames on the + # /attributes/groups-with-attributes endpoint (nested locale keys). + PrestaShop\Module\APIResources\ApiPlatform\Normalizer\AttributeGroupWithAttributesNormalizer: + autowire: true + autoconfigure: true + public: false + + PrestaShopBundle\ApiPlatform\Serializer\CQRSApiSerializer: + class: PrestaShop\Module\APIResources\Serializer\QueryParameterTypeCastSerializer + decorates: 'api_platform.serializer' + autowire: true + arguments: + $decorated: '@.inner' + + # Filters Admin API operations whose minVersion/maxVersion extra properties don't match the running core + # version. On PrestaShop >= 9.2 the equivalent core decorator class exists (checked via class_exists, NOT + # injected as a service which would create a circular reference in the decoration chain): when detected + # this module decorator is a pure pass-through. + # Note: module resources must declare the gating via extraProperties (['minVersion' => 'x.y.z']), NOT via + # the named constructor arguments which only exist in the core operation classes since PrestaShop 9.2. + # @deprecated remove together with the class once the module requires PrestaShop >= 9.2. + PrestaShop\Module\APIResources\ApiPlatform\Metadata\Resource\Factory\CoreVersionCompatibilityMetadataCollectionFactoryDecorator: + decorates: api_platform.metadata.resource.metadata_collection_factory + arguments: + $decorated: '@.inner' diff --git a/ps_apiresources.php b/ps_apiresources.php index a72f16be6..06fe7576d 100644 --- a/ps_apiresources.php +++ b/ps_apiresources.php @@ -36,8 +36,8 @@ public function __construct() $this->displayName = $this->trans('PrestaShop API Resources', [], 'Modules.Apiresources.Admin'); $this->description = $this->trans('Includes the resources allowing using the API for the PrestaShop domain, all endpoints are based on CQRS commands/queries from the Core and we APIPlatform framework is used as a base.', [], 'Modules.Apiresources.Admin'); $this->author = 'PrestaShop'; - $this->version = '0.2.0'; - $this->ps_versions_compliancy = ['min' => '9.0.1', 'max' => _PS_VERSION_]; + $this->version = '0.7.0'; + $this->ps_versions_compliancy = ['min' => '9.0.3', 'max' => _PS_VERSION_]; $this->need_instance = 0; $this->tab = 'administration'; parent::__construct(); diff --git a/rector.php b/rector.php new file mode 100644 index 000000000..10e8b637b --- /dev/null +++ b/rector.php @@ -0,0 +1,42 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +use PsApiResourcesTest\Rector\ApiResourceBooleanFieldsRector; +use PsApiResourcesTest\Rector\ApiResourceScalarTypesRector; +use PsApiResourcesTest\Rector\ApiResourceUriTemplateRector; +use Rector\Config\RectorConfig; + +return RectorConfig::configure() + ->withPaths([ + __DIR__ . '/src/ApiPlatform/Resources', + ]) + ->withAutoloadPaths([ + __DIR__ . '/tests/Rector', + ]) + ->withSkip([ + // Skip standard rector rules if you only want to run custom rules + ]) + ->withRules([ + ApiResourceUriTemplateRector::class, + ApiResourceBooleanFieldsRector::class, + ApiResourceScalarTypesRector::class, + ]); diff --git a/src/ApiPlatform/Metadata/Resource/Factory/CoreVersionCompatibilityMetadataCollectionFactoryDecorator.php b/src/ApiPlatform/Metadata/Resource/Factory/CoreVersionCompatibilityMetadataCollectionFactoryDecorator.php new file mode 100644 index 000000000..ee63041f2 --- /dev/null +++ b/src/ApiPlatform/Metadata/Resource/Factory/CoreVersionCompatibilityMetadataCollectionFactoryDecorator.php @@ -0,0 +1,121 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagSettings; +use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagStateCheckerInterface; +use PrestaShop\PrestaShop\Core\Version; + +/** + * This factory decorates the ApiPlatform default resource factory. It looks into each operation and checks + * if the extra properties minVersion and/or maxVersion are defined, if the running core version is out of the + * declared bounds the operation is removed. This means the operation is not visible in Swagger, and it's not + * used to generate the api routing, so it's not usable at all and returns a 404. + * + * Both bounds are inclusive and compared with version_compare, so its semantics apply as-is (note for example + * that 9.2.0-beta.1 < 9.2.0). No validation is performed on the version strings. + * + * The filtering applies in both prod and debug mode, unless the experimental endpoints feature flag is + * enabled. Note that the core version constant lags behind the actual content of the development branches + * (Version::VERSION is only bumped at release time), so enable the feature flag to work with an endpoint + * gated on a not-yet-released version. + * + * @deprecated This class is a duplicate of + * PrestaShopBundle\ApiPlatform\Metadata\Resource\Factory\CoreVersionCompatibilityMetadataCollectionFactoryDecorator + * introduced in PrestaShop 9.2.0. It only exists so that the minVersion/maxVersion extra properties are honored + * when the module runs on PrestaShop < 9.2 (it is a pure pass-through when the core decorator is detected, to + * avoid double filtering). Remove this class and its service declaration in config/admin/services.yml as soon + * as the module requires PrestaShop >= 9.2. + */ +class CoreVersionCompatibilityMetadataCollectionFactoryDecorator implements ResourceMetadataCollectionFactoryInterface +{ + public function __construct( + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + private readonly FeatureFlagStateCheckerInterface $featureFlagStateChecker, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + // We call the original method since we only want to alter the result of this method. + $resourceMetadataCollection = $this->decorated->create($resourceClass); + + // The core twin decorator exists on PrestaShop >= 9.2 and already handles the filtering, so this + // decorator is a pure pass-through. The check relies on class_exists instead of injecting the core + // service, which would create a circular reference in the decoration chain. + if (class_exists(\PrestaShopBundle\ApiPlatform\Metadata\Resource\Factory\CoreVersionCompatibilityMetadataCollectionFactoryDecorator::class)) { + return $resourceMetadataCollection; + } + + // In debug and prod mode we always hide the incompatible endpoints, unless the experimental endpoints are forcefully enabled + if ($this->areExperimentalEndpointsEnabled()) { + return $resourceMetadataCollection; + } + + /** @var ApiResource $resourceMetadata */ + foreach ($resourceMetadataCollection as $resourceMetadata) { + $operations = $resourceMetadata->getOperations(); + /** @var Operation $operation */ + foreach ($operations as $key => $operation) { + if (!$this->isCompatibleWithCoreVersion($operation)) { + $operations->remove($key); + } + } + } + + return $resourceMetadataCollection; + } + + private function isCompatibleWithCoreVersion(Operation $operation): bool + { + $extraProperties = $operation->getExtraProperties(); + if (!empty($extraProperties['minVersion']) && version_compare(Version::VERSION, $extraProperties['minVersion'], '<')) { + return false; + } + if (!empty($extraProperties['maxVersion']) && version_compare(Version::VERSION, $extraProperties['maxVersion'], '>')) { + return false; + } + + return true; + } + + /** + * This decorator is implied during cache clearing which would fail when the shop is not installed + * because the DB config is not set up yet. So we protected the feature flag fetching in a try/catch + * and return false (default value) in case of an error. + * + * @return bool + */ + private function areExperimentalEndpointsEnabled(): bool + { + try { + return $this->featureFlagStateChecker->isEnabled(FeatureFlagSettings::FEATURE_FLAG_ADMIN_API_EXPERIMENTAL_ENDPOINTS); + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/ApiPlatform/Normalizer/AttributeGroupWithAttributesNormalizer.php b/src/ApiPlatform/Normalizer/AttributeGroupWithAttributesNormalizer.php new file mode 100644 index 000000000..52c09b588 --- /dev/null +++ b/src/ApiPlatform/Normalizer/AttributeGroupWithAttributesNormalizer.php @@ -0,0 +1,103 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Normalizer; + +use PrestaShop\Module\APIResources\ApiPlatform\Resources\Attribute\AttributeGroupWithAttributes; +use PrestaShopBundle\ApiPlatform\LocalizedValueUpdater; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Rewrites the nested `attributes[*].localizedNames` keys from language IDs to + * language locales for the AttributeGroupWithAttributes API resource. + * + * Background: + * CQRSApiSerializer::normalizeLocalizedValues() converts id_lang → locale for + * properties carrying #[LocalizedValue] on the top-level API resource. It does + * NOT recurse into nested arrays of scalar payloads, so the per-attribute + * `localizedNames` returned inside `$attributes[]` stayed indexed by id_lang. + * Every other localized value in the public API is indexed by locale, and + * consumers must not care about internal language IDs — see + * PR https://github.com/PrestaShop/ps_apiresources/pull/390 review discussion. + * + * Scope: + * This normalizer is intentionally narrow: it triggers only for + * AttributeGroupWithAttributes and only rewrites the `attributes[].localizedNames` + * sub-arrays. The `names` / `publicNames` top-level fields keep going through + * the standard #[LocalizedValue] path. + */ +class AttributeGroupWithAttributesNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + private const ALREADY_CALLED = 'ATTRIBUTE_GROUP_WITH_ATTRIBUTES_NORMALIZER_ALREADY_CALLED'; + + public function __construct( + private readonly LocalizedValueUpdater $localizedValueUpdater, + ) { + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): mixed + { + $context[self::ALREADY_CALLED] = true; + + $data = $this->normalizer->normalize($object, $format, $context); + + if (!is_array($data) || empty($data['attributes']) || !is_array($data['attributes'])) { + return $data; + } + + foreach ($data['attributes'] as &$attribute) { + if (!is_array($attribute) || empty($attribute['localizedNames']) || !is_array($attribute['localizedNames'])) { + continue; + } + + // We replace the ID index with locale index using the core service + $attribute['names'] = $this->localizedValueUpdater->denormalizeLocalizedValue( + $attribute['localizedNames'], + 'localizedNames', + [LocalizedValue::LOCALIZED_VALUE_PARAMETERS => ['localizedNames' => true]] + ); + + // We also change the field name to match the convention (no localized prefix), localizedName becomes names + unset($attribute['localizedNames']); + } + unset($attribute); + + return $data; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof AttributeGroupWithAttributes && empty($context[self::ALREADY_CALLED]); + } + + public function getSupportedTypes(?string $format): array + { + return [ + AttributeGroupWithAttributes::class => false, + ]; + } +} diff --git a/src/ApiPlatform/Normalizer/GenerateCombinationsSerializer.php b/src/ApiPlatform/Normalizer/GenerateCombinationsSerializer.php new file mode 100644 index 000000000..785b2f015 --- /dev/null +++ b/src/ApiPlatform/Normalizer/GenerateCombinationsSerializer.php @@ -0,0 +1,64 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Normalizer; + +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Command\GenerateProductCombinationsCommand; +use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; +use PrestaShopBundle\ApiPlatform\Normalizer\ShopConstraintNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class GenerateCombinationsSerializer implements DenormalizerInterface +{ + public function __construct( + private readonly ShopConstraintNormalizer $shopConstraintNormalizer, + ) { + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []) + { + $groupedAttributes = []; + foreach ($data['groupedAttributes'] as $attributeGroup) { + $groupedAttributes[$attributeGroup['attributeGroupId']] = array_map(static function ($attributeId): int { + return (int) $attributeId; + }, $attributeGroup['attributeIds']); + } + + return new GenerateProductCombinationsCommand( + $data['productId'], + $groupedAttributes, + $this->shopConstraintNormalizer->denormalize($data['_context']['shopConstraint'], ShopConstraint::class), + ); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null) + { + return $type === GenerateProductCombinationsCommand::class; + } + + public function getSupportedTypes(?string $format): array + { + return [ + GenerateProductCombinationsCommand::class => true, + 'object' => null, + '*' => null, + ]; + } +} diff --git a/src/ApiPlatform/Normalizer/GetSearchTermAliasesQuerySerializer.php b/src/ApiPlatform/Normalizer/GetSearchTermAliasesQuerySerializer.php new file mode 100644 index 000000000..cbf4585c9 --- /dev/null +++ b/src/ApiPlatform/Normalizer/GetSearchTermAliasesQuerySerializer.php @@ -0,0 +1,64 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Normalizer; + +use PrestaShop\PrestaShop\Core\Domain\Alias\Query\GetAliasesBySearchTermForEditing; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class GetSearchTermAliasesQuerySerializer implements DenormalizerInterface +{ + public function __construct( + private readonly RequestStack $requestStack, + ) { + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []) + { + // We need to get the new search term from the request body if exists then from the data. + // This is because the search term in the URL is used for filtering the aliases, but if we want to update + // the search term of the aliases to the new one provided in the request body, we need to use the new search + // term instead of the one in the URL. + $request = $this->requestStack->getCurrentRequest(); + $body = json_decode($request->getContent(), true); + $searchTerm = $body['searchTerm'] ?? $data['searchTerm'] ?? ''; + // For update operation with a new search term + if (!empty($body['newSearchTerm'])) { + $searchTerm = $body['newSearchTerm']; + } + + return new GetAliasesBySearchTermForEditing($searchTerm); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null) + { + return $type === GetAliasesBySearchTermForEditing::class; + } + + public function getSupportedTypes(?string $format): array + { + return [ + GetAliasesBySearchTermForEditing::class => true, + 'object' => null, + '*' => null, + ]; + } +} diff --git a/src/ApiPlatform/Resources/Address/Address.php b/src/ApiPlatform/Resources/Address/Address.php new file mode 100644 index 000000000..a5f8cfb60 --- /dev/null +++ b/src/ApiPlatform/Resources/Address/Address.php @@ -0,0 +1,51 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Address; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\DeleteAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/addresses/{addressId}', + requirements: ['addressId' => '\d+'], + CQRSCommand: DeleteAddressCommand::class, + scopes: [ + 'address_write', + ], + ), + ], + exceptionToStatus: [ + AddressConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + AddressNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Address +{ + #[ApiProperty(identifier: true)] + public int $addressId; +} diff --git a/src/ApiPlatform/Resources/Address/AddressList.php b/src/ApiPlatform/Resources/Address/AddressList.php new file mode 100644 index 000000000..18659f453 --- /dev/null +++ b/src/ApiPlatform/Resources/Address/AddressList.php @@ -0,0 +1,70 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Address; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\AddressFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/addresses', + scopes: [ + 'address_read', + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data.factory.address', + filtersClass: AddressFilters::class, + filtersMapping: [ + '[addressId]' => '[id_address]', + ], + ), + ], + exceptionToStatus: [ + AddressNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class AddressList +{ + #[ApiProperty(identifier: true)] + public int $addressId; + + public string $firstname; + + public string $lastname; + + public string $address1; + + public string $postcode; + + public string $city; + + public string $country_name; + + public const MAPPING = [ + '[id_address]' => '[addressId]', + ]; +} diff --git a/src/ApiPlatform/Resources/Address/BulkAddresses.php b/src/ApiPlatform/Resources/Address/BulkAddresses.php new file mode 100644 index 000000000..a0ddf33aa --- /dev/null +++ b/src/ApiPlatform/Resources/Address/BulkAddresses.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Address; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\BulkDeleteAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/addresses/bulk-delete', + CQRSCommand: BulkDeleteAddressCommand::class, + scopes: [ + 'address_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + AddressNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkAddresses +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $addressIds; +} diff --git a/src/ApiPlatform/Resources/Address/CartAddress.php b/src/ApiPlatform/Resources/Address/CartAddress.php new file mode 100644 index 000000000..5ae68f13c --- /dev/null +++ b/src/ApiPlatform/Resources/Address/CartAddress.php @@ -0,0 +1,135 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Address; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\EditCartAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Address\Query\GetCustomerAddressForEditing; +use PrestaShop\PrestaShop\Core\Domain\Cart\Exception\CartNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Cart\Exception\InvalidAddressTypeException; +use PrestaShop\PrestaShop\Core\Domain\Country\Exception\CountryConstraintException; +use PrestaShop\PrestaShop\Core\Domain\State\Exception\StateConstraintException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSPartialUpdate( + uriTemplate: '/addresses/carts/{cartId}', + CQRSCommand: EditCartAddressCommand::class, + CQRSQuery: GetCustomerAddressForEditing::class, + scopes: [ + 'address_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + validationContext: ['groups' => ['Default', 'Update']], + ), + ], + exceptionToStatus: [ + AddressConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CountryConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + StateConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + InvalidAddressTypeException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CartNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CartAddress +{ + // Identifiers from URI + #[ApiProperty(identifier: true)] + public int $cartId = 0; + + public int $addressId; + + public int $customerId; + + public string $addressType; + + // Optional address fields for update + public ?string $addressAlias = null; + + public ?string $firstName = null; + + public ?string $lastName = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public ?string $address = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public ?string $address2 = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_CITY_NAME, + ])] + public ?string $city = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_POST_CODE, + ])] + public ?string $postCode = null; + + public ?int $countryId = null; + + public ?int $stateId = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $homePhone = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $mobilePhone = null; + + public ?string $company = null; + + public ?string $vatNumber = null; + + public ?string $other = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_DNI_LITE, + ])] + public ?string $dni = null; + + public const QUERY_MAPPING = [ + // This is to handle NoStateId that is not normalized properly, it was fixed in 9.1 with + // https://github.com/PrestaShop/PrestaShop/pull/40912 + '[stateId][value]' => '[stateId]', + ]; + + public const COMMAND_MAPPING = [ + '[postCode]' => '[postcode]', + '[homePhone]' => '[phone]', + '[mobilePhone]' => '[phone_mobile]', + '[vatNumber]' => '[vat_number]', + '[stateId]' => '[id_state]', + ]; +} diff --git a/src/ApiPlatform/Resources/Address/CustomerAddress.php b/src/ApiPlatform/Resources/Address/CustomerAddress.php new file mode 100644 index 000000000..65d5bc9fd --- /dev/null +++ b/src/ApiPlatform/Resources/Address/CustomerAddress.php @@ -0,0 +1,153 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Address; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\AddCustomerAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\EditCustomerAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Address\Query\GetCustomerAddressForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/addresses/customers/{addressId}', + CQRSQuery: GetCustomerAddressForEditing::class, + scopes: [ + 'address_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/addresses/customers', + CQRSCommand: AddCustomerAddressCommand::class, + CQRSQuery: GetCustomerAddressForEditing::class, + scopes: [ + 'address_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + validationContext: ['groups' => ['Default', 'Create']], + ), + new CQRSPartialUpdate( + uriTemplate: '/addresses/customers/{addressId}', + CQRSCommand: EditCustomerAddressCommand::class, + CQRSQuery: GetCustomerAddressForEditing::class, + scopes: [ + 'address_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + validationContext: ['groups' => ['Default', 'Update']], + ), + ], + exceptionToStatus: [ + AddressConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + AddressNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CustomerAddress +{ + #[ApiProperty(identifier: true)] + public int $addressId; + + public int $customerId; + + #[Assert\NotBlank(groups: ['Create'])] + public string $addressAlias; + + #[Assert\NotBlank(groups: ['Create'])] + public string $firstName; + + #[Assert\NotBlank(groups: ['Create'])] + public string $lastName; + + #[Assert\NotBlank(groups: ['Create'])] + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public string $address; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public ?string $address2; + + #[Assert\NotBlank(groups: ['Create'])] + #[TypedRegex([ + 'type' => TypedRegex::TYPE_CITY_NAME, + ])] + public string $city; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_POST_CODE, + ])] + public string $postCode; + + #[Assert\NotBlank(groups: ['Create'])] + public int $countryId; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_DNI_LITE, + ])] + public ?string $dni; + + public ?string $company; + + public ?string $vatNumber; + + public int $stateId; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $homePhone; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $mobilePhone; + + public ?string $other; + + public const QUERY_MAPPING = [ + '[id]' => '[addressId]', + // This is to handle NoStateId that is not normalized properly, it was fixed in 9.1 with + // https://github.com/PrestaShop/PrestaShop/pull/40912 + '[stateId][value]' => '[stateId]', + ]; + + public const COMMAND_MAPPING = [ + '[postCode]' => '[postcode]', + '[homePhone]' => '[phone]', + '[mobilePhone]' => '[phone_mobile]', + '[vatNumber]' => '[vat_number]', + '[stateId]' => '[id_state]', + ]; +} diff --git a/src/ApiPlatform/Resources/Address/ManufacturerAddress.php b/src/ApiPlatform/Resources/Address/ManufacturerAddress.php new file mode 100644 index 000000000..b5fa651b8 --- /dev/null +++ b/src/ApiPlatform/Resources/Address/ManufacturerAddress.php @@ -0,0 +1,139 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Address; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\AddManufacturerAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\EditManufacturerAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Address\Query\GetManufacturerAddressForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/addresses/manufacturers/{addressId}', + CQRSQuery: GetManufacturerAddressForEditing::class, + scopes: [ + 'address_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/addresses/manufacturers', + CQRSCommand: AddManufacturerAddressCommand::class, + CQRSQuery: GetManufacturerAddressForEditing::class, + scopes: [ + 'address_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + validationContext: ['groups' => ['Default', 'Create']], + ), + new CQRSPartialUpdate( + uriTemplate: '/addresses/manufacturers/{addressId}', + CQRSCommand: EditManufacturerAddressCommand::class, + CQRSQuery: GetManufacturerAddressForEditing::class, + scopes: [ + 'address_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + validationContext: ['groups' => ['Default', 'Update']], + ), + ], + exceptionToStatus: [ + AddressConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + AddressNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class ManufacturerAddress +{ + #[ApiProperty(identifier: true)] + public int $addressId; + + public ?int $manufacturerId = null; + + #[Assert\NotBlank(groups: ['Create'])] + public string $lastName; + + #[Assert\NotBlank(groups: ['Create'])] + public string $firstName; + + #[Assert\NotBlank(groups: ['Create'])] + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public string $address; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public ?string $address2 = null; + + #[Assert\NotBlank(groups: ['Create'])] + #[TypedRegex([ + 'type' => TypedRegex::TYPE_CITY_NAME, + ])] + public string $city; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_POST_CODE, + ])] + public ?string $postCode = null; + + #[Assert\NotBlank(groups: ['Create'])] + public int $countryId; + + public ?int $stateId = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $homePhone = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $mobilePhone = null; + + public ?string $other = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_DNI_LITE, + ])] + public ?string $dni = null; + + public const QUERY_MAPPING = [ + '[id]' => '[addressId]', + ]; + + public const COMMAND_MAPPING = [ + // No remapping needed for manufacturer address command + ]; +} diff --git a/src/ApiPlatform/Resources/Address/OrderAddress.php b/src/ApiPlatform/Resources/Address/OrderAddress.php new file mode 100644 index 000000000..8c6eb89d9 --- /dev/null +++ b/src/ApiPlatform/Resources/Address/OrderAddress.php @@ -0,0 +1,135 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Address; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Address\Command\EditOrderAddressCommand; +use PrestaShop\PrestaShop\Core\Domain\Address\Exception\AddressConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Address\Query\GetCustomerAddressForEditing; +use PrestaShop\PrestaShop\Core\Domain\Country\Exception\CountryConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Order\Exception\InvalidAddressTypeException; +use PrestaShop\PrestaShop\Core\Domain\Order\Exception\OrderNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\State\Exception\StateConstraintException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSPartialUpdate( + uriTemplate: '/addresses/orders/{orderId}', + CQRSCommand: EditOrderAddressCommand::class, + CQRSQuery: GetCustomerAddressForEditing::class, + scopes: [ + 'address_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + validationContext: ['groups' => ['Default', 'Update']], + ), + ], + exceptionToStatus: [ + AddressConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CountryConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + StateConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + InvalidAddressTypeException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + OrderNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class OrderAddress +{ + // Identifiers from URI + #[ApiProperty(identifier: true)] + public int $orderId = 0; + + public int $addressId; + + public int $customerId; + + public string $addressType; + + // Optional address fields for update + public ?string $addressAlias = null; + + public ?string $firstName = null; + + public ?string $lastName = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public ?string $address = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_ADDRESS, + ])] + public ?string $address2 = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_CITY_NAME, + ])] + public ?string $city = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_POST_CODE, + ])] + public ?string $postCode = null; + + public ?int $countryId = null; + + public ?int $stateId = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $homePhone = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_PHONE_NUMBER, + ])] + public ?string $mobilePhone = null; + + public ?string $company = null; + + public ?string $vatNumber = null; + + public ?string $other = null; + + #[TypedRegex([ + 'type' => TypedRegex::TYPE_DNI_LITE, + ])] + public ?string $dni = null; + + public const QUERY_MAPPING = [ + // This is to handle NoStateId that is not normalized properly, it was fixed in 9.1 with + // https://github.com/PrestaShop/PrestaShop/pull/40912 + '[stateId][value]' => '[stateId]', + ]; + + public const COMMAND_MAPPING = [ + '[postCode]' => '[postcode]', + '[homePhone]' => '[phone]', + '[mobilePhone]' => '[phone_mobile]', + '[vatNumber]' => '[vat_number]', + '[stateId]' => '[id_state]', + ]; +} diff --git a/src/ApiPlatform/Resources/ApiClient/ApiClient.php b/src/ApiPlatform/Resources/ApiClient/ApiClient.php index a0154b0a1..006cce8f7 100644 --- a/src/ApiPlatform/Resources/ApiClient/ApiClient.php +++ b/src/ApiPlatform/Resources/ApiClient/ApiClient.php @@ -41,26 +41,26 @@ #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/api-client/{apiClientId}', + uriTemplate: '/api-clients/{apiClientId}', requirements: ['apiClientId' => '\d+'], CQRSQuery: GetApiClientForEditing::class, scopes: ['api_client_read'] ), new CQRSDelete( - uriTemplate: '/api-client/{apiClientId}', + uriTemplate: '/api-clients/{apiClientId}', requirements: ['apiClientId' => '\d+'], output: false, CQRSCommand: DeleteApiClientCommand::class, scopes: ['api_client_write'] ), new CQRSCreate( - uriTemplate: '/api-client', + uriTemplate: '/api-clients', validationContext: ['groups' => ['Default', 'Create']], CQRSCommand: AddApiClientCommand::class, scopes: ['api_client_write'], ), new CQRSPartialUpdate( - uriTemplate: '/api-client/{apiClientId}', + uriTemplate: '/api-clients/{apiClientId}', read: false, CQRSCommand: EditApiClientCommand::class, CQRSQuery: GetApiClientForEditing::class, diff --git a/src/ApiPlatform/Resources/Attachment/AttachmentFile.php b/src/ApiPlatform/Resources/Attachment/AttachmentFile.php new file mode 100644 index 000000000..71dad7b93 --- /dev/null +++ b/src/ApiPlatform/Resources/Attachment/AttachmentFile.php @@ -0,0 +1,55 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attachment; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Exception\AttachmentNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Query\GetAttachment; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/attachments/{attachmentId}/files', + requirements: ['attachmentId' => '\d+'], + CQRSQuery: GetAttachment::class, + scopes: [ + 'attachment_read', + ], + ), + ], + exceptionToStatus: [ + AttachmentNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class AttachmentFile +{ + #[ApiProperty(identifier: true)] + public int $attachmentId; + + public string $path; + + public string $name; +} diff --git a/src/ApiPlatform/Resources/Attachment/AttachmentInformation.php b/src/ApiPlatform/Resources/Attachment/AttachmentInformation.php new file mode 100644 index 000000000..fdf3cad62 --- /dev/null +++ b/src/ApiPlatform/Resources/Attachment/AttachmentInformation.php @@ -0,0 +1,71 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attachment; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Exception\AttachmentNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Query\GetAttachmentInformation as GetAttachmentInformationQuery; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/attachments/{attachmentId}/information', + requirements: ['attachmentId' => '\d+'], + CQRSQuery: GetAttachmentInformationQuery::class, + scopes: [ + 'attachment_read', + ], + ApiResourceMapping: self::API_RESOURCE_MAPPING, + ), + ], + exceptionToStatus: [ + AttachmentNotFoundException::class => Response::HTTP_NOT_FOUND, + ], + normalizationContext: ['skip_null_values' => false], +)] +class AttachmentInformation +{ + #[ApiProperty(identifier: true)] + public int $attachmentId; + + #[LocalizedValue] + public array $names; + + #[LocalizedValue] + public array $descriptions; + + public string $fileName; + + public string $mimeType; + + public int $fileSize; + + public const API_RESOURCE_MAPPING = [ + '[localizedNames]' => '[names]', + '[localizedDescriptions]' => '[descriptions]', + ]; +} diff --git a/src/ApiPlatform/Resources/Attachment/FoundAttachment.php b/src/ApiPlatform/Resources/Attachment/FoundAttachment.php new file mode 100644 index 000000000..094e87fe5 --- /dev/null +++ b/src/ApiPlatform/Resources/Attachment/FoundAttachment.php @@ -0,0 +1,97 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attachment; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Exception\EmptySearchException; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Exception\EmptySearchInputException; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Query\SearchAttachment; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGetCollection( + uriTemplate: '/attachments/search', + CQRSQuery: SearchAttachment::class, + scopes: [ + 'attachment_read', + ], + CQRSQueryMapping: [ + '[phrase]' => '[searchPhrase]', + ], + ApiResourceMapping: self::API_RESOURCE_MAPPING, + parameters: new Parameters([ + new QueryParameter( + key: 'phrase', + required: true, + description: 'Search phrase to find attachments by name' + ), + ]), + openapiContext: [ + 'parameters' => [ + [ + 'name' => 'phrase', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'string', + ], + 'description' => 'Search phrase to find attachments by name', + ], + ], + ], + ), + ], + exceptionToStatus: [ + EmptySearchInputException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + EmptySearchException::class => Response::HTTP_NOT_FOUND, + ], + normalizationContext: ['skip_null_values' => false], +)] +class FoundAttachment +{ + #[ApiProperty(identifier: true)] + public int $attachmentId; + + #[LocalizedValue] + public array $names; + + #[LocalizedValue] + public array $descriptions; + + public string $fileName; + + public string $mimeType; + + public int $fileSize; + + public const API_RESOURCE_MAPPING = [ + '[localizedNames]' => '[names]', + '[localizedDescriptions]' => '[descriptions]', + ]; +} diff --git a/src/ApiPlatform/Resources/Attribute/Attribute.php b/src/ApiPlatform/Resources/Attribute/Attribute.php new file mode 100644 index 000000000..6cb61d89c --- /dev/null +++ b/src/ApiPlatform/Resources/Attribute/Attribute.php @@ -0,0 +1,127 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attribute; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Command\AddAttributeCommand; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Command\DeleteAttributeCommand; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Command\EditAttributeCommand; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Exception\AttributeConstraintException; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Exception\AttributeNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Query\GetAttributeForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/attributes/attributes/{attributeId}', + CQRSQuery: GetAttributeForEditing::class, + scopes: [ + 'attribute_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/attributes/attributes', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddAttributeCommand::class, + CQRSQuery: GetAttributeForEditing::class, + scopes: [ + 'attribute_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::CREATE_COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/attributes/attributes/{attributeId}', + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: EditAttributeCommand::class, + CQRSQuery: GetAttributeForEditing::class, + scopes: [ + 'attribute_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::UPDATE_COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/attributes/attributes/{attributeId}', + requirements: ['attributeId' => '\d+'], + CQRSCommand: DeleteAttributeCommand::class, + scopes: [ + 'attribute_write', + ], + ), + ], + exceptionToStatus: [ + AttributeConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + AttributeNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Attribute +{ + #[ApiProperty(identifier: true)] + public int $attributeId; + + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 1])] + public int $attributeGroupId; + + #[LocalizedValue] + #[Assert\NotBlank(groups: ['Create'])] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $names; + + public string $color; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public const QUERY_MAPPING = [ + '[localizedNames]' => '[names]', + '[name]' => '[names]', + '[associatedShopIds]' => '[shopIds]', + ]; + + public const CREATE_COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[shopIds]' => '[associatedShopIds]', + ]; + + public const UPDATE_COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[shopIds]' => '[associatedShopIds]', + ]; +} diff --git a/src/ApiPlatform/Resources/Attribute/AttributeGroup.php b/src/ApiPlatform/Resources/Attribute/AttributeGroup.php index efba65159..9da92c1ba 100644 --- a/src/ApiPlatform/Resources/Attribute/AttributeGroup.php +++ b/src/ApiPlatform/Resources/Attribute/AttributeGroup.php @@ -42,7 +42,7 @@ #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/attributes/group/{attributeGroupId}', + uriTemplate: '/attributes/groups/{attributeGroupId}', CQRSQuery: GetAttributeGroupForEditing::class, scopes: [ 'attribute_group_read', @@ -50,7 +50,7 @@ CQRSQueryMapping: self::QUERY_MAPPING, ), new CQRSCreate( - uriTemplate: '/attributes/group', + uriTemplate: '/attributes/groups', validationContext: ['groups' => ['Default', 'Create']], CQRSCommand: AddAttributeGroupCommand::class, CQRSQuery: GetAttributeGroupForEditing::class, @@ -61,7 +61,8 @@ CQRSCommandMapping: self::COMMAND_MAPPING, ), new CQRSPartialUpdate( - uriTemplate: '/attributes/group/{attributeGroupId}', + uriTemplate: '/attributes/groups/{attributeGroupId}', + requirements: ['attributeGroupId' => '\d+'], validationContext: ['groups' => ['Default', 'Update']], CQRSCommand: EditAttributeGroupCommand::class, CQRSQuery: GetAttributeGroupForEditing::class, @@ -72,7 +73,8 @@ CQRSCommandMapping: self::COMMAND_MAPPING, ), new CQRSDelete( - uriTemplate: '/attributes/group/{attributeGroupId}', + uriTemplate: '/attributes/groups/{attributeGroupId}', + requirements: ['attributeGroupId' => '\d+'], CQRSCommand: DeleteAttributeGroupCommand::class, scopes: [ 'attribute_group_write', diff --git a/src/ApiPlatform/Resources/Attribute/AttributeGroupPosition.php b/src/ApiPlatform/Resources/Attribute/AttributeGroupPosition.php new file mode 100644 index 000000000..058c966f4 --- /dev/null +++ b/src/ApiPlatform/Resources/Attribute/AttributeGroupPosition.php @@ -0,0 +1,42 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attribute; + +use ApiPlatform\Metadata\ApiResource; +use PrestaShopBundle\ApiPlatform\Metadata\PositionCollection; +use PrestaShopBundle\ApiPlatform\Metadata\UpdatePosition; + +#[ApiResource( + operations: [ + new UpdatePosition( + uriTemplate: '/attributes/groups/positions', + scopes: [ + 'attribute_group_write', + ], + positionDefinition: 'prestashop.core.grid.attribute_group.position_definition', + ), + ] +)] +class AttributeGroupPosition +{ + #[PositionCollection(rowIdField: 'attributeGroupId')] + public array $positions; +} diff --git a/src/ApiPlatform/Resources/Attribute/AttributeGroupWithAttributes.php b/src/ApiPlatform/Resources/Attribute/AttributeGroupWithAttributes.php new file mode 100644 index 000000000..524c55008 --- /dev/null +++ b/src/ApiPlatform/Resources/Attribute/AttributeGroupWithAttributes.php @@ -0,0 +1,110 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attribute; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Query\GetAttributeGroupList; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; + +#[ApiResource( + operations: [ + new CQRSGetCollection( + uriTemplate: '/attributes/groups-with-attributes', + CQRSQuery: GetAttributeGroupList::class, + scopes: ['attribute_group_read'], + CQRSQueryMapping: [ + '[_context][shopConstraint]' => '[shopConstraint]', + ], + ApiResourceMapping: [ + '[attributeGroupId]' => '[attributeGroupId]', + '[localizedNames]' => '[names]', + '[localizedPublicNames]' => '[publicNames]', + '[groupType]' => '[type]', + '[colorGroup]' => '[colorGroup]', + '[position]' => '[position]', + '[attributes]' => '[attributes]', + ], + ), + ], +)] +class AttributeGroupWithAttributes +{ + #[ApiProperty(identifier: true)] + public int $attributeGroupId; + + #[LocalizedValue] + public array $names; + + #[LocalizedValue] + public array $publicNames; + + /** + * Matches the `$type` field name used by AttributeGroup.php (single-entity resource) + * for the same semantic. The CQRS DTO exposes it as `groupType`; see ApiResourceMapping. + */ + public string $type; + + public bool $colorGroup; + + public int $position; + + /** + * Shop association is intentionally omitted: GetAttributeGroupList (see + * AttributeGroup QueryResult DTO in Core) does not return the associated shop ids. + * The single-entity resource AttributeGroup.php exposes `$shopIds` because it is + * backed by GetAttributeGroupForEditing, which does load them. + */ + + /** + * Per-group attribute list. Each item mirrors the Core DTO + * PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\QueryResult\Attribute: + * {attributeId, position, color, localizedNames{}, textureFilePath}. + * + * The underlying DTO getAttributes() returns null when attributes were not queried; + * this endpoint always queries them via GetAttributeGroupList, so the value is guaranteed + * to be an array here (possibly empty for a group with no attributes). + */ + #[ApiProperty(openapiContext: [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'attributeId' => ['type' => 'integer'], + 'position' => ['type' => 'integer'], + 'color' => ['type' => 'string'], + 'names' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string'], + 'example' => [ + 'en-US' => 'value', + 'fr-FR' => 'valeur', + ], + ], + 'textureFilePath' => ['type' => 'string', 'nullable' => true], + ], + ], + ])] + public array $attributes; +} diff --git a/src/ApiPlatform/Resources/Attribute/AttributeList.php b/src/ApiPlatform/Resources/Attribute/AttributeList.php new file mode 100644 index 000000000..2d41b97d6 --- /dev/null +++ b/src/ApiPlatform/Resources/Attribute/AttributeList.php @@ -0,0 +1,65 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attribute; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Link; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/attributes/groups/{attributeGroupId}/attributes', + scopes: [ + 'attribute_read', + ], + uriVariables: [ + 'attributeGroupId' => new Link( + identifiers: ['attributeGroupId'] + ), + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data.factory.attribute_decorator', + filtersMapping: [ + '[attributeId]' => '[id_attribute]', + ], + ), + ] +)] +class AttributeList +{ + #[ApiProperty(identifier: true)] + public int $attributeId; + + #[ApiProperty(readable: false, writable: false)] + public int $attributeGroupId; + + public string $name; + + public int $values; + + public int $position; + + public const MAPPING = [ + '[id_attribute]' => '[attributeId]', + ]; +} diff --git a/src/ApiPlatform/Resources/Attribute/AttributePosition.php b/src/ApiPlatform/Resources/Attribute/AttributePosition.php new file mode 100644 index 000000000..875db3594 --- /dev/null +++ b/src/ApiPlatform/Resources/Attribute/AttributePosition.php @@ -0,0 +1,46 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attribute; + +use ApiPlatform\Metadata\ApiResource; +use PrestaShopBundle\ApiPlatform\Metadata\PositionCollection; +use PrestaShopBundle\ApiPlatform\Metadata\UpdatePosition; + +#[ApiResource( + operations: [ + new UpdatePosition( + uriTemplate: '/attributes/groups/{attributeGroupId}/attributes/positions', + requirements: ['attributeGroupId' => '\d+'], + scopes: [ + 'attribute_write', + ], + positionDefinition: 'prestashop.core.grid.attribute.position_definition', + parentIdField: 'attributeGroupId', + ), + ], +)] +class AttributePosition +{ + public int $attributeGroupId; + + #[PositionCollection(rowIdField: 'attributeId')] + public array $positions; +} diff --git a/src/ApiPlatform/Resources/Attribute/BulkAttribute.php b/src/ApiPlatform/Resources/Attribute/BulkAttribute.php new file mode 100644 index 000000000..09eb4a53d --- /dev/null +++ b/src/ApiPlatform/Resources/Attribute/BulkAttribute.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attribute; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Command\BulkDeleteAttributeCommand; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Attribute\Exception\AttributeNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/attributes/attributes/bulk-delete', + CQRSCommand: BulkDeleteAttributeCommand::class, + scopes: [ + 'attribute_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + AttributeNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkAttribute +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $attributeIds; +} diff --git a/src/ApiPlatform/Resources/Attribute/BulkAttributeGroups.php b/src/ApiPlatform/Resources/Attribute/BulkAttributeGroups.php index 69980e604..42939b35c 100644 --- a/src/ApiPlatform/Resources/Attribute/BulkAttributeGroups.php +++ b/src/ApiPlatform/Resources/Attribute/BulkAttributeGroups.php @@ -24,20 +24,19 @@ use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Command\BulkDeleteAttributeGroupCommand; use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\Exception\AttributeGroupNotFoundException; -use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new CQRSUpdate( - uriTemplate: '/attributes/groups/delete', - // No output 204 code - output: false, + new CQRSDelete( + uriTemplate: '/attributes/groups/bulk-delete', CQRSCommand: BulkDeleteAttributeGroupCommand::class, scopes: [ 'attribute_group_write', ], + allowEmptyBody: false, ), ], exceptionToStatus: [ diff --git a/src/ApiPlatform/Resources/CartRule.php b/src/ApiPlatform/Resources/Cart/CartEmail.php similarity index 55% rename from src/ApiPlatform/Resources/CartRule.php rename to src/ApiPlatform/Resources/Cart/CartEmail.php index c4798aa20..963cea1fc 100644 --- a/src/ApiPlatform/Resources/CartRule.php +++ b/src/ApiPlatform/Resources/Cart/CartEmail.php @@ -20,57 +20,37 @@ declare(strict_types=1); -namespace PrestaShop\Module\APIResources\ApiPlatform\Resources; +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Cart; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; -use PrestaShop\PrestaShop\Core\Domain\CartRule\Command\EditCartRuleCommand; +use PrestaShop\PrestaShop\Core\Domain\Cart\Command\SendCartToCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Cart\Exception\CartException; +use PrestaShop\PrestaShop\Core\Domain\Cart\Exception\CartNotFoundException; use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; -use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; #[ApiResource( operations: [ new CQRSUpdate( - uriTemplate: '/cart-rule/{cartRuleId}', - CQRSCommand: EditCartRuleCommand::class, + uriTemplate: '/carts/{cartId}/emails', + requirements: ['cartId' => '\d+'], + read: false, + output: false, + allowEmptyBody: true, + CQRSCommand: SendCartToCustomerCommand::class, scopes: [ - 'cart_rule_write', + 'cart_write', ], - experimentalOperation: true, ), ], + exceptionToStatus: [ + CartNotFoundException::class => Response::HTTP_NOT_FOUND, + CartException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], )] -class CartRule +class CartEmail { #[ApiProperty(identifier: true)] - public int $cartRuleId; - - public string $description; - - public string $code; - - public array $minimumAmount; - - public bool $minimumAmountShippingIncluded; - - public int $customerId; - - #[LocalizedValue] - public array $localizedNames; - - public bool $highlightInCart; - - public bool $allowPartialUse; - - public int $priority; - - public bool $active; - - public array $validityDateRange; - - public int $totalQuantity; - - public int $quantityPerUser; - - public array $cartRuleAction; + public int $cartId; } diff --git a/src/ApiPlatform/Resources/CartRule/FoundCartRule.php b/src/ApiPlatform/Resources/CartRule/FoundCartRule.php new file mode 100644 index 000000000..8812a2c8e --- /dev/null +++ b/src/ApiPlatform/Resources/CartRule/FoundCartRule.php @@ -0,0 +1,76 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\CartRule; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use PrestaShop\PrestaShop\Core\Domain\CartRule\Query\SearchCartRules; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; + +#[ApiResource( + operations: [ + new CQRSGetCollection( + uriTemplate: '/cart-rules/search', + scopes: [ + 'cart_rule_read', + ], + CQRSQuery: SearchCartRules::class, + CQRSQueryMapping: self::QUERY_MAPPING, + parameters: new Parameters([ + new QueryParameter( + key: 'phrase', + required: true, + description: 'Search phrase to find cart rules by name or code' + ), + ]), + openapiContext: [ + 'parameters' => [ + [ + 'name' => 'phrase', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'string', + ], + 'description' => 'Search phrase to find cart rules by name or code', + ], + ], + ], + ), + ], +)] +class FoundCartRule +{ + #[ApiProperty(identifier: true)] + public int $cartRuleId; + + public string $name; + + public string $code; + + public const QUERY_MAPPING = [ + '[phrase]' => '[searchPhrase]', + ]; +} diff --git a/src/ApiPlatform/Resources/Category/BulkDeleteCategories.php b/src/ApiPlatform/Resources/Category/BulkDeleteCategories.php new file mode 100644 index 000000000..ee1341a7c --- /dev/null +++ b/src/ApiPlatform/Resources/Category/BulkDeleteCategories.php @@ -0,0 +1,74 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Category; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\BulkDeleteCategoriesCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Exception\CategoryNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/categories/bulk-delete/{deleteMode}', + CQRSCommand: BulkDeleteCategoriesCommand::class, + CQRSCommandMapping: [ + '[mode]' => '[deleteMode]', + ], + openapiContext: [ + 'summary' => 'Delete a categories using a specific mode', + 'parameters' => [ + [ + 'name' => 'mode', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string', + 'enum' => ['associate_and_disable', 'associate_only', 'remove_associated'], + ], + 'description' => 'Delete mode: "associate_and_disable" associate products with parent category and disable them, "associate_only" associate products with parent and do not change their status, "remove_associated" remove products that are associated only with category that is being deleted', + ], + ], + ], + scopes: [ + 'category_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + CategoryNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteCategories +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $categoryIds; + + public string $deleteMode; +} diff --git a/src/ApiPlatform/Resources/Category/BulkUpdateStatusCategories.php b/src/ApiPlatform/Resources/Category/BulkUpdateStatusCategories.php new file mode 100644 index 000000000..213ca351a --- /dev/null +++ b/src/ApiPlatform/Resources/Category/BulkUpdateStatusCategories.php @@ -0,0 +1,59 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Category; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\BulkUpdateCategoriesStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Exception\CategoryNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/categories/bulk-update-status', + output: false, + CQRSCommand: BulkUpdateCategoriesStatusCommand::class, + CQRSCommandMapping: [ + '[enabled]' => '[newStatus]', + ], + scopes: [ + 'category_write', + ], + ), + ], + exceptionToStatus: [ + CategoryNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkUpdateStatusCategories +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $categoryIds; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Category/Category.php b/src/ApiPlatform/Resources/Category/Category.php new file mode 100644 index 000000000..5aed8df69 --- /dev/null +++ b/src/ApiPlatform/Resources/Category/Category.php @@ -0,0 +1,210 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Category; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Module\APIResources\Validation\IframeValidationGroupsResolver; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\AddCategoryCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\DeleteCategoryCoverImageCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\DeleteCategoryThumbnailImageCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\EditCategoryCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\SetCategoryIsEnabledCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Exception\CategoryConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Category\Exception\CategoryNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Category\Query\GetCategoryForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/categories/{categoryId}', + CQRSQuery: GetCategoryForEditing::class, + scopes: [ + 'category_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/categories', + validationContext: [IframeValidationGroupsResolver::class, 'create'], + CQRSCommand: AddCategoryCommand::class, + CQRSQuery: GetCategoryForEditing::class, + scopes: [ + 'category_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/categories/{categoryId}', + validationContext: [IframeValidationGroupsResolver::class, 'update'], + CQRSCommand: EditCategoryCommand::class, + CQRSQuery: GetCategoryForEditing::class, + scopes: [ + 'category_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/categories/{categoryId}/status', + CQRSCommand: SetCategoryIsEnabledCommand::class, + CQRSQuery: GetCategoryForEditing::class, + scopes: [ + 'category_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: [ + '[enabled]' => '[isEnabled]', + ], + ), + new CQRSDelete( + uriTemplate: '/categories/{categoryId}/cover', + CQRSCommand: DeleteCategoryCoverImageCommand::class, + scopes: [ + 'category_write', + ], + ), + new CQRSDelete( + uriTemplate: '/categories/{categoryId}/thumbnail', + CQRSCommand: DeleteCategoryThumbnailImageCommand::class, + scopes: [ + 'category_write', + ], + ), + ], + exceptionToStatus: [ + CategoryConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CategoryNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Category +{ + #[ApiProperty(identifier: true)] + public int $categoryId; + + public bool $enabled; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $names; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'descriptions')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'descriptions', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_NO_IFRAME, + 'groups' => ['NoIframe'], + ]), + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_ALLOW_IFRAME, + 'groups' => ['AllowIframe'], + ]), + ])] + public array $descriptions; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'additionalDescriptions')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'additionalDescriptions', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_NO_IFRAME, + 'groups' => ['NoIframe'], + ]), + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_NO_IFRAME, + 'groups' => ['AllowIframe'], + ]), + ])] + public array $additionalDescriptions; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'linkRewrites')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'linkRewrites', allowNull: false)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_URL, + ]), + ])] + public array $linkRewrites; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'metaTitles')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'metaTitles', allowNull: true)] + public array $metaTitles; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'metaDescriptions')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'metaDescriptions', allowNull: true)] + public array $metaDescriptions; + + public int $position; + + public int $parentCategoryId; + + public string $redirectType; + + public ?int $redirectTarget = null; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public const QUERY_MAPPING = [ + '[id]' => '[categoryId]', + '[active]' => '[enabled]', + '[name]' => '[names]', + '[description]' => '[descriptions]', + '[additionalDescription]' => '[additionalDescriptions]', + '[associatedShopIds]' => '[shopIds]', + '[metaTitle]' => '[metaTitles]', + '[metaDescription]' => '[metaDescriptions]', + '[linkRewrite]' => '[linkRewrites]', + '[parentId]' => '[parentCategoryId]', + ]; + + public const COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[enabled]' => '[isEnabled]', + '[descriptions]' => '[localizedDescriptions]', + '[additionalDescriptions]' => '[localizedAdditionalDescriptions]', + '[shopIds]' => '[associatedShopIds]', + '[metaTitles]' => '[localizedMetaTitles]', + '[metaDescriptions]' => '[localizedMetaDescriptions]', + '[linkRewrites]' => '[localizedLinkRewrites]', + ]; +} diff --git a/src/ApiPlatform/Resources/Category/CategoryDelete.php b/src/ApiPlatform/Resources/Category/CategoryDelete.php new file mode 100644 index 000000000..acd3feed1 --- /dev/null +++ b/src/ApiPlatform/Resources/Category/CategoryDelete.php @@ -0,0 +1,75 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Category; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Category\Command\DeleteCategoryCommand; +use PrestaShop\PrestaShop\Core\Domain\Category\Exception\CategoryConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Category\Exception\CategoryNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/categories/{categoryId}/{mode}', + CQRSCommand: DeleteCategoryCommand::class, + scopes: ['category_write'], + openapiContext: [ + 'summary' => 'Delete a category using a specific mode', + 'parameters' => [ + [ + 'name' => 'categoryId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'integer'], + 'description' => 'Category ID to delete', + ], + [ + 'name' => 'mode', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string', + 'enum' => ['associate_and_disable', 'associate_only', 'remove_associated'], + ], + 'description' => 'Delete mode: "associate_and_disable" associate products with parent category and disable them, "associate_only" associate products with parent and do not change their status, "remove_associated" remove products that are associated only with category that is being deleted', + ], + ], + ], + CQRSCommandMapping: [ + '[deleteMode]' => '[mode]', + ], + ), + ], + exceptionToStatus: [ + CategoryConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CategoryNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CategoryDelete +{ + #[ApiProperty(identifier: true)] + public int $categoryId; + + public string $deleteMode; +} diff --git a/src/ApiPlatform/Resources/Category/CategoryList.php b/src/ApiPlatform/Resources/Category/CategoryList.php new file mode 100644 index 000000000..e1981a29d --- /dev/null +++ b/src/ApiPlatform/Resources/Category/CategoryList.php @@ -0,0 +1,66 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Category; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Category\Exception\CategoryNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopAssociationNotFound; +use PrestaShop\PrestaShop\Core\Search\Filters\CategoryFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/categories', + provider: QueryListProvider::class, + scopes: ['category_read'], + ApiResourceMapping: [ + '[id_category]' => '[categoryId]', + '[active]' => '[enabled]', + ], + gridDataFactory: 'prestashop.core.grid.data.factory.category_decorator', + filtersClass: CategoryFilters::class, + filtersMapping: [ + '[categoryId]' => '[id_category]', + '[enabled]' => '[active]', + ], + ), + ], + exceptionToStatus: [ + CategoryNotFoundException::class => Response::HTTP_NOT_FOUND, + ShopAssociationNotFound::class => Response::HTTP_NOT_FOUND, + ], +)] +class CategoryList +{ + #[ApiProperty(identifier: true)] + public int $categoryId; + + public bool $enabled; + + public string $name; +} diff --git a/src/ApiPlatform/Resources/Contact/Contact.php b/src/ApiPlatform/Resources/Contact/Contact.php new file mode 100644 index 000000000..1f92ae8f1 --- /dev/null +++ b/src/ApiPlatform/Resources/Contact/Contact.php @@ -0,0 +1,129 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Contact; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Contact\Command\AddContactCommand; +use PrestaShop\PrestaShop\Core\Domain\Contact\Command\EditContactCommand; +use PrestaShop\PrestaShop\Core\Domain\Contact\Exception\ContactConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Contact\Exception\ContactNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Contact\Query\GetContactForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/contacts/{contactId}', + CQRSQuery: GetContactForEditing::class, + scopes: [ + 'contact_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/contacts', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddContactCommand::class, + CQRSQuery: GetContactForEditing::class, + scopes: [ + 'contact_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::CREATE_COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/contacts/{contactId}', + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: EditContactCommand::class, + CQRSQuery: GetContactForEditing::class, + scopes: [ + 'contact_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::UPDATE_COMMAND_MAPPING, + ), + ], + exceptionToStatus: [ + ContactConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ContactNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Contact +{ + #[ApiProperty(identifier: true)] + public int $contactId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $names; + + #[Assert\Email(mode: Assert\Email::VALIDATION_MODE_STRICT)] + public string $email; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'descriptions', allowNull: true)] + #[DefaultLanguage(groups: ['Update'], fieldName: 'descriptions', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $descriptions; + + public bool $messagesSavingEnabled; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public const QUERY_MAPPING = [ + '[localisedTitles]' => '[names]', + '[localisedDescription]' => '[descriptions]', + '[messagesSavingEnabled]' => '[isMessagesSavingEnabled]', + '[shopAssociation]' => '[shopIds]', + ]; + + public const CREATE_COMMAND_MAPPING = [ + '[names]' => '[localisedTitles]', + '[descriptions]' => '[localisedDescription]', + '[messagesSavingEnabled]' => '[isMessageSavingEnabled]', + ]; + + public const UPDATE_COMMAND_MAPPING = [ + '[names]' => '[localisedTitles]', + '[descriptions]' => '[localisedDescription]', + '[messagesSavingEnabled]' => '[isMessagesSavingEnabled]', + ]; +} diff --git a/src/ApiPlatform/Resources/Contact/ContactList.php b/src/ApiPlatform/Resources/Contact/ContactList.php new file mode 100644 index 000000000..3cc972fd7 --- /dev/null +++ b/src/ApiPlatform/Resources/Contact/ContactList.php @@ -0,0 +1,58 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Contact; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Search\Filters\ContactFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/contacts', + scopes: [ + 'contact_read', + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data_provider.contacts', + filtersClass: ContactFilters::class, + filtersMapping: [ + '[contactId]' => '[id_contact]', + ], + ), + ] +)] +class ContactList +{ + #[ApiProperty(identifier: true)] + public int $contactId; + + public string $name; + + public string $email; + + public string $description; + + public const MAPPING = [ + '[id_contact]' => '[contactId]', + ]; +} diff --git a/src/ApiPlatform/Resources/Country/Country.php b/src/ApiPlatform/Resources/Country/Country.php new file mode 100644 index 000000000..91fe04ee2 --- /dev/null +++ b/src/ApiPlatform/Resources/Country/Country.php @@ -0,0 +1,138 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Country; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Module\APIResources\Validation\Constraints\ValidAddressFormat; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\Domain\Country\Command\AddCountryCommand; +use PrestaShop\PrestaShop\Core\Domain\Country\Command\DeleteCountryCommand; +use PrestaShop\PrestaShop\Core\Domain\Country\Command\EditCountryCommand; +use PrestaShop\PrestaShop\Core\Domain\Country\Exception\CountryNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Country\Query\GetCountryForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/countries', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddCountryCommand::class, + scopes: ['country_write'], + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSGet( + uriTemplate: '/countries/{countryId}', + requirements: ['countryId' => '\d+'], + CQRSQuery: GetCountryForEditing::class, + scopes: ['country_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/countries/{countryId}', + requirements: ['countryId' => '\d+'], + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: EditCountryCommand::class, + CQRSQuery: GetCountryForEditing::class, + scopes: ['country_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/countries/{countryId}', + requirements: ['countryId' => '\d+'], + CQRSCommand: DeleteCountryCommand::class, + scopes: ['country_write'], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + CountryNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Country +{ + #[ApiProperty(identifier: true)] + public int $countryId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + public array $names; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(max: 3)] + public string $isoCode; + + #[Assert\NotNull(groups: ['Create'])] + public int $callPrefix; + + #[Assert\NotNull(groups: ['Create'])] + public int $defaultCurrencyId; + + #[Assert\NotNull(groups: ['Create'])] + public int $zoneId; + + #[Assert\NotNull(groups: ['Create'])] + public bool $needZipCode; + + public ?string $zipCodeFormat; + + #[Assert\NotBlank(groups: ['Create'])] + #[ValidAddressFormat(groups: ['Create', 'Update'])] + public string $addressFormat; + + #[Assert\NotNull(groups: ['Create'])] + public bool $enabled; + + #[Assert\NotNull(groups: ['Create'])] + public bool $containsStates; + + #[Assert\NotNull(groups: ['Create'])] + public bool $needIdNumber; + + #[Assert\NotNull(groups: ['Create'])] + public bool $displayTaxLabel; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + public array $shopIds; + + public const COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[defaultCurrencyId]' => '[defaultCurrency]', + '[shopIds]' => '[shopAssociation]', + ]; + + public const QUERY_MAPPING = [ + '[localizedNames]' => '[names]', + '[defaultCurrency]' => '[defaultCurrencyId]', + '[zone]' => '[zoneId]', + '[shopAssociation]' => '[shopIds]', + ]; +} diff --git a/src/ApiPlatform/Resources/Country/CountryList.php b/src/ApiPlatform/Resources/Country/CountryList.php new file mode 100644 index 000000000..23063fbc9 --- /dev/null +++ b/src/ApiPlatform/Resources/Country/CountryList.php @@ -0,0 +1,86 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Country; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Country\Exception\CountryNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\CountryFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/countries', + provider: QueryListProvider::class, + scopes: ['country_read'], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data.factory.country', + filtersClass: CountryFilters::class, + filtersMapping: self::FILTERS_MAPPING, + ), + ], + exceptionToStatus: [ + CountryNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CountryList +{ + #[ApiProperty(identifier: true)] + public int $countryId; + + public string $name; + + public string $isoCode; + + public int $callPrefix; + + public string $zoneName; + + public bool $enabled; + + /** + * Maps the columns returned by CountryQueryBuilder to the API field names. + */ + public const MAPPING = [ + '[id_country]' => '[countryId]', + '[name]' => '[name]', + '[iso_code]' => '[isoCode]', + '[call_prefix]' => '[callPrefix]', + '[zone_name]' => '[zoneName]', + '[active]' => '[enabled]', + ]; + + /** + * Maps the API field names used in filters and orderBy back to the grid filter names. + */ + public const FILTERS_MAPPING = [ + '[countryId]' => '[id_country]', + '[isoCode]' => '[iso_code]', + '[callPrefix]' => '[call_prefix]', + '[zoneName]' => '[zone_name]', + '[enabled]' => '[active]', + ]; +} diff --git a/src/ApiPlatform/Resources/Customer/BulkCustomers.php b/src/ApiPlatform/Resources/Customer/BulkCustomers.php new file mode 100644 index 000000000..b5e52c841 --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/BulkCustomers.php @@ -0,0 +1,111 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\BulkDisableCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\BulkEnableCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/customers/bulk-disable', + // No output 204 code + output: false, + CQRSCommand: BulkDisableCustomerCommand::class, + scopes: [ + 'customer_write', + ], + openapiContext: [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['customerIds'], + 'properties' => [ + 'customerIds' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ], + ], + ], + 'example' => [ + 'customerIds' => [1, 2, 3], + ], + ], + ], + ], + ], + ), + new CQRSUpdate( + uriTemplate: '/customers/bulk-enable', + // No output 204 code + output: false, + CQRSCommand: BulkEnableCustomerCommand::class, + scopes: [ + 'customer_write', + ], + openapiContext: [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['customerIds'], + 'properties' => [ + 'customerIds' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ], + ], + ], + 'example' => [ + 'customerIds' => [1, 2, 3], + ], + ], + ], + ], + ], + ), + ], + exceptionToStatus: [ + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkCustomers +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 2, 3]])] + #[Assert\NotBlank()] + public array $customerIds; +} diff --git a/src/ApiPlatform/Resources/Customer/BulkDeleteCustomers.php b/src/ApiPlatform/Resources/Customer/BulkDeleteCustomers.php new file mode 100644 index 000000000..9117a6baa --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/BulkDeleteCustomers.php @@ -0,0 +1,105 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\BulkDeleteCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\ValueObject\CustomerDeleteMethod; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/customers/bulk-delete', + CQRSCommand: BulkDeleteCustomerCommand::class, + scopes: [ + 'customer_write', + ], + allowEmptyBody: false, + openapiContext: [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['customerIds'], + 'properties' => [ + 'customerIds' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ], + 'deleteMethod' => [ + 'type' => 'string', + 'enum' => [ + CustomerDeleteMethod::ALLOW_CUSTOMER_REGISTRATION, + CustomerDeleteMethod::DENY_CUSTOMER_REGISTRATION, + ], + 'description' => 'Method to use for customer deletion. Default: allow_registration_after', + ], + ], + ], + 'example' => [ + 'customerIds' => [1, 2, 3], + 'deleteMethod' => 'allow_registration_after', + ], + ], + ], + ], + ], + ), + ], + exceptionToStatus: [ + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteCustomers +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 2, 3]])] + #[Assert\NotBlank()] + public array $customerIds; + + #[ApiProperty(openapiContext: [ + 'type' => 'string', + 'enum' => [ + CustomerDeleteMethod::ALLOW_CUSTOMER_REGISTRATION, + CustomerDeleteMethod::DENY_CUSTOMER_REGISTRATION, + ], + 'example' => CustomerDeleteMethod::ALLOW_CUSTOMER_REGISTRATION, + ])] + #[Assert\Choice( + choices: [ + CustomerDeleteMethod::ALLOW_CUSTOMER_REGISTRATION, + CustomerDeleteMethod::DENY_CUSTOMER_REGISTRATION, + ], + message: 'The delete method must be either "allow_registration_after" or "deny_registration_after".', + )] + public string $deleteMethod = CustomerDeleteMethod::ALLOW_CUSTOMER_REGISTRATION; +} diff --git a/src/ApiPlatform/Resources/Customer/Customer.php b/src/ApiPlatform/Resources/Customer/Customer.php new file mode 100644 index 000000000..f8adbcb5a --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/Customer.php @@ -0,0 +1,197 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\AddCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\DeleteCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\EditCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\DuplicateCustomerEmailException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerForEditing; +use PrestaShop\PrestaShop\Core\Domain\Customer\ValueObject\CustomerDeleteMethod; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/customers', + CQRSCommand: AddCustomerCommand::class, + CQRSQuery: GetCustomerForEditing::class, + scopes: [ + 'customer_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + validationContext: ['groups' => ['Default', 'Create']], + ), + new CQRSGet( + uriTemplate: '/customers/{customerId}', + requirements: ['customerId' => '\d+'], + CQRSQuery: GetCustomerForEditing::class, + scopes: [ + 'customer_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/customers/{customerId}', + requirements: ['customerId' => '\d+'], + read: false, + CQRSCommand: EditCustomerCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + CQRSQuery: GetCustomerForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: [ + 'customer_write', + ], + ), + new CQRSDelete( + uriTemplate: '/customers/{customerId}', + CQRSCommand: DeleteCustomerCommand::class, + scopes: [ + 'customer_write', + ], + CQRSCommandMapping: self::DELETE_COMMAND_MAPPING, + openapiContext: [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['deleteMethod'], + 'properties' => [ + 'deleteMethod' => [ + 'type' => 'string', + 'enum' => [ + CustomerDeleteMethod::ALLOW_CUSTOMER_REGISTRATION, + CustomerDeleteMethod::DENY_CUSTOMER_REGISTRATION, + ], + 'description' => 'Method to use for customer deletion', + 'example' => CustomerDeleteMethod::ALLOW_CUSTOMER_REGISTRATION, + ], + ], + ], + 'example' => [ + 'deleteMethod' => 'allow_registration_after', + ], + ], + ], + 'description' => 'Request body specifying the deletion method', + ], + ], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + DuplicateCustomerEmailException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CustomerException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class Customer +{ + #[ApiProperty(identifier: true, openapiContext: ['type' => 'integer', 'example' => 1])] + public int $customerId; + + #[Assert\NotBlank(groups: ['Create'])] + public string $firstName; + + #[Assert\NotBlank(groups: ['Create'])] + public string $lastName; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Email(mode: Assert\Email::VALIDATION_MODE_STRICT)] + public string $email; + + #[Assert\NotBlank(groups: ['Create'])] + public string $password; + + #[Assert\NotBlank(groups: ['Create'])] + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 3])] + public int $defaultGroupId; + + #[Assert\NotBlank(groups: ['Create'])] + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + public array $groupIds; + + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 1])] + public ?int $genderId; + + #[ApiProperty(openapiContext: ['type' => 'boolean', 'example' => true])] + public bool $enabled; + + #[ApiProperty(openapiContext: ['type' => 'boolean', 'example' => false])] + public bool $newsletterSubscribed; + + #[ApiProperty(openapiContext: ['type' => 'boolean', 'example' => false])] + public bool $partnerOffersSubscribed; + + public ?string $birthday; + + public ?string $companyName; + + public ?string $siretCode; + + public ?string $apeCode; + + public ?string $website; + + #[ApiProperty(openapiContext: ['type' => 'string', 'example' => '1000.50'])] + public ?DecimalNumber $allowedOutstandingAmount; + + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 30])] + public ?int $maxPaymentDays; + + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 1])] + public ?int $riskId; + + #[ApiProperty(openapiContext: ['type' => 'boolean', 'example' => false])] + public bool $guest; + + public ?string $deleteMethod; + + public const QUERY_MAPPING = [ + '[id]' => '[customerId]', + ]; + + public const COMMAND_MAPPING = [ + '[_context][shopId]' => '[shopId]', + '[partnerOffersSubscribed]' => '[isPartnerOffersSubscribed]', + '[guest]' => '[isGuest]', + '[enabled]' => '[isEnabled]', + ]; + + public const DELETE_COMMAND_MAPPING = [ + '[deleteMethod]' => '[deleteMethod]', + ]; +} diff --git a/src/ApiPlatform/Resources/Customer/CustomerDetails.php b/src/ApiPlatform/Resources/Customer/CustomerDetails.php new file mode 100644 index 000000000..cbb5ae73d --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerDetails.php @@ -0,0 +1,76 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerForViewing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/customers/{customerId}/details', + requirements: ['customerId' => '\d+'], + CQRSQuery: GetCustomerForViewing::class, + scopes: [ + 'customer_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + ], + exceptionToStatus: [ + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CustomerDetails +{ + #[ApiProperty(identifier: true, openapiContext: ['type' => 'integer', 'example' => 1])] + public int $customerId; + + public array $personalInformation; + + public array $ordersInformation; + + public array $cartsInformation; + + public array $productsInformation; + + public array $messagesInformation; + + public array $discountsInformation; + + public array $sentEmailsInformation; + + public array $lastConnectionsInformation; + + public array $groupsInformation; + + public array $addressesInformation; + + public array $generalInformation; + + public const QUERY_MAPPING = [ + '[customerId]' => '[customerId]', + ]; +} diff --git a/src/ApiPlatform/Resources/Customer/CustomerGroup.php b/src/ApiPlatform/Resources/Customer/CustomerGroup.php index dcc219a96..6dd4df058 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerGroup.php +++ b/src/ApiPlatform/Resources/Customer/CustomerGroup.php @@ -40,7 +40,7 @@ #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/customers/group/{customerGroupId}', + uriTemplate: '/customers/groups/{customerGroupId}', CQRSQuery: GetCustomerGroupForEditing::class, scopes: [ 'customer_group_read', @@ -54,7 +54,7 @@ ], ), new CQRSCreate( - uriTemplate: '/customers/group', + uriTemplate: '/customers/groups', CQRSCommand: AddCustomerGroupCommand::class, CQRSQuery: GetCustomerGroupForEditing::class, scopes: [ @@ -72,7 +72,7 @@ ], ), new CQRSUpdate( - uriTemplate: '/customers/group/{customerGroupId}', + uriTemplate: '/customers/groups/{customerGroupId}', CQRSCommand: EditCustomerGroupCommand::class, CQRSQuery: GetCustomerGroupForEditing::class, scopes: [ @@ -85,7 +85,7 @@ ], ), new CQRSDelete( - uriTemplate: '/customers/group/{customerGroupId}', + uriTemplate: '/customers/groups/{customerGroupId}', CQRSCommand: DeleteCustomerGroupCommand::class, scopes: [ 'customer_group_write', diff --git a/src/ApiPlatform/Resources/Customer/CustomerRequiredFields.php b/src/ApiPlatform/Resources/Customer/CustomerRequiredFields.php new file mode 100644 index 000000000..212a3b1ad --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerRequiredFields.php @@ -0,0 +1,67 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\SetRequiredFieldsForCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CannotSetRequiredFieldsForCustomerException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\InvalidCustomerRequiredFieldsException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetRequiredFieldsForCustomer; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/customers/required-fields', + extraProperties: [ + 'minVersion' => '9.2.0', + ], + CQRSQuery: GetRequiredFieldsForCustomer::class, + scopes: ['customer_read'], + CQRSQueryMapping: ['[_queryResult]' => '[requiredFields]'], + ), + new CQRSUpdate( + uriTemplate: '/customers/required-fields', + output: false, + CQRSCommand: SetRequiredFieldsForCustomerCommand::class, + scopes: ['customer_write'], + ), + ], + exceptionToStatus: [ + InvalidCustomerRequiredFieldsException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CannotSetRequiredFieldsForCustomerException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class CustomerRequiredFields +{ + /** + * The customer fields that should be required (e.g. "optin", "newsletter"). + * + * @var string[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'string'], 'example' => ['optin', 'newsletter']])] + public array $requiredFields = []; +} diff --git a/src/ApiPlatform/Resources/Customer/FoundCustomer.php b/src/ApiPlatform/Resources/Customer/FoundCustomer.php new file mode 100644 index 000000000..7c8bdf9ca --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/FoundCustomer.php @@ -0,0 +1,109 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Query\SearchCustomers; +use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\InvalidShopConstraintException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGetCollection( + uriTemplate: '/customers/search', + scopes: [ + 'customer_read', + ], + CQRSQuery: SearchCustomers::class, + CQRSQueryMapping: self::QUERY_MAPPING, + ApiResourceMapping: self::API_RESOURCE_MAPPING, + parameters: new Parameters([ + new QueryParameter( + key: 'phrases', + required: true, + description: 'Array of search phrases to find customers (matches first name, last name, email, company name and id)' + ), + ]), + openapiContext: [ + 'parameters' => [ + [ + 'name' => 'phrases', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + 'description' => 'Array of search phrases to find customers (matches first name, last name, email, company name and id)', + 'style' => 'form', + 'explode' => true, + ], + ], + ], + ), + ], + exceptionToStatus: [ + CustomerException::class => Response::HTTP_BAD_REQUEST, + InvalidShopConstraintException::class => Response::HTTP_BAD_REQUEST, + ], +)] +class FoundCustomer +{ + #[ApiProperty(identifier: true, openapiContext: ['type' => 'integer', 'example' => 1])] + public int $idCustomer; + + public string $firstname; + + public string $lastname; + + public string $email; + + public string $fullnameAndEmail; + + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 1])] + public int $active; + + public ?string $company; + + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 3])] + public int $idDefaultGroup; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + public array $groups; + + public const QUERY_MAPPING = [ + '[phrases]' => '[phrases]', + '[_context][shopConstraint]' => '[shopConstraint]', + ]; + + public const API_RESOURCE_MAPPING = [ + '[id_customer]' => '[idCustomer]', + '[fullname_and_email]' => '[fullnameAndEmail]', + '[id_default_group]' => '[idDefaultGroup]', + ]; +} diff --git a/src/ApiPlatform/Resources/Discount/BulkDeleteDiscounts.php b/src/ApiPlatform/Resources/Discount/BulkDeleteDiscounts.php new file mode 100644 index 000000000..6a6220cde --- /dev/null +++ b/src/ApiPlatform/Resources/Discount/BulkDeleteDiscounts.php @@ -0,0 +1,79 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Discount; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Discount\Command\BulkDeleteDiscountsCommand; +use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\BulkDiscountException; +use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/discounts/bulk-delete', + CQRSCommand: BulkDeleteDiscountsCommand::class, + scopes: [ + 'discount_write', + ], + experimentalOperation: true, + allowEmptyBody: false, + openapiContext: [ + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'discountIds' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ], + ], + ], + 'example' => [ + 'discountIds' => [1, 3], + ], + ], + ], + ], + ], + ), + ], + exceptionToStatus: [ + DiscountNotFoundException::class => Response::HTTP_NOT_FOUND, + DiscountConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + BulkDiscountException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class BulkDeleteDiscounts +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $discountIds; +} diff --git a/src/ApiPlatform/Resources/Discount/BulkUpdateStatusDiscounts.php b/src/ApiPlatform/Resources/Discount/BulkUpdateStatusDiscounts.php new file mode 100644 index 000000000..c637680b0 --- /dev/null +++ b/src/ApiPlatform/Resources/Discount/BulkUpdateStatusDiscounts.php @@ -0,0 +1,62 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Discount; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Discount\Command\BulkUpdateDiscountsStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\BulkDiscountException; +use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSPartialUpdate( + uriTemplate: '/discounts/bulk-update-status', + output: false, + CQRSCommand: BulkUpdateDiscountsStatusCommand::class, + CQRSCommandMapping: [ + '[enabled]' => '[newStatus]', + ], + scopes: [ + 'discount_write', + ], + experimentalOperation: true, + ), + ], + exceptionToStatus: [ + DiscountNotFoundException::class => Response::HTTP_NOT_FOUND, + BulkDiscountException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class BulkUpdateStatusDiscounts +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $discountIds; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Discount/Discount.php b/src/ApiPlatform/Resources/Discount/Discount.php index 54bd99119..42cfc00f9 100644 --- a/src/ApiPlatform/Resources/Discount/Discount.php +++ b/src/ApiPlatform/Resources/Discount/Discount.php @@ -22,44 +22,77 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use PrestaShop\Decimal\DecimalNumber; use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddDiscountCommand; use PrestaShop\PrestaShop\Core\Domain\Discount\Command\DeleteDiscountCommand; +use PrestaShop\PrestaShop\Core\Domain\Discount\Command\DuplicateDiscountCommand; +use PrestaShop\PrestaShop\Core\Domain\Discount\Command\UpdateDiscountCommand; use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountConstraintException; use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Discount\Query\GetDiscountForEditing; use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/discount/{discountId}', + uriTemplate: '/discounts/{discountId}', requirements: ['discountId' => '\d+'], CQRSQuery: GetDiscountForEditing::class, scopes: ['discount_read'], CQRSQueryMapping: self::QUERY_MAPPING, + experimentalOperation: true, ), new CQRSCreate( - uriTemplate: '/discount', + uriTemplate: '/discounts', validationContext: ['groups' => ['Default', 'Create']], CQRSCommand: AddDiscountCommand::class, CQRSQuery: GetDiscountForEditing::class, scopes: ['discount_write'], CQRSQueryMapping: self::QUERY_MAPPING, CQRSCommandMapping: self::COMMAND_MAPPING, + experimentalOperation: true, + ), + new CQRSPartialUpdate( + uriTemplate: '/discounts/{discountId}', + requirements: ['discountId' => '\d+'], + CQRSCommand: UpdateDiscountCommand::class, + CQRSQuery: GetDiscountForEditing::class, + scopes: ['discount_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + experimentalOperation: true, ), new CQRSDelete( - uriTemplate: '/discount/{discountId}', + uriTemplate: '/discounts/{discountId}', CQRSCommand: DeleteDiscountCommand::class, scopes: [ 'discount_write', ], + experimentalOperation: true, + ), + new CQRSCreate( + uriTemplate: '/discounts/{discountId}/duplicate', + requirements: ['discountId' => '\d+'], + CQRSCommand: DuplicateDiscountCommand::class, + CQRSQuery: GetDiscountForEditing::class, + scopes: ['discount_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + allowEmptyBody: true, + experimentalOperation: true, + openapi: new OpenApiOperation( + summary: 'Duplicate a Discount resource.', + description: 'Creates a copy of an existing Discount resource.', + ) ), ], + normalizationContext: ['skip_null_values' => false], exceptionToStatus: [ DiscountNotFoundException::class => Response::HTTP_NOT_FOUND, DiscountConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, @@ -70,33 +103,177 @@ class Discount #[ApiProperty(identifier: true)] public int $discountId; #[Assert\NotBlank(groups: ['Create'])] + public string $type; + #[Assert\NotBlank(groups: ['Create'])] #[LocalizedValue] public array $names; - public int $priority; - public bool $active; - public \DateTimeImmutable $validFrom; - public \DateTimeImmutable $validTo; - public int $totalQuantity; - public int $quantityPerUser; public string $description; public string $code; - public int $customerId; + public bool $enabled; + public ?int $totalQuantity; + public ?int $quantityPerUser; + public ?DecimalNumber $reductionPercent; + #[ApiProperty( + openapiContext: [ + 'type' => 'object', + 'description' => 'Fixed reduction amount', + 'properties' => [ + 'amount' => [ + 'type' => 'number', + 'description' => 'Fixed reduction amount value', + ], + 'currencyId' => [ + 'type' => 'integer', + 'description' => 'Currency ID for reduction amount', + ], + 'taxIncluded' => [ + 'type' => 'boolean', + 'Whether reduction amount is tax included', + ], + ], + ] + )] + public ?array $reductionAmount; + public ?int $giftProductId; + public ?int $giftCombinationId; + + // Conditions/compatibility values + public bool $cheapestProduct; + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'Product conditions (rule groups)', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'quantity' => ['type' => 'integer'], + 'rules' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'enum' => [ + // We use hard-coded values because the ProductRuleType class is only available + // in 9.1 and using it breaks the parsing of API resources on 9.0 + 'categories', + 'products', + 'combinations', + 'manufacturers', + 'suppliers', + 'attributes', + 'features', + ], + ], + 'itemIds' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ], + 'required' => ['type', 'itemIds'], + ], + ], + ], + 'type' => [ + 'type' => 'string', + 'enum' => [ + // We use hard-coded values because the ProductRuleGroupType class is only available + // in 9.1 and using it breaks the parsing of API resources on 9.0 + 'all_product_rules', + 'at_least_one_product_rule', + ], + ], + 'required' => ['quantity', 'rules'], + ], + ], + ] + )] + public ?array $productConditions; + + #[ApiProperty( + openapiContext: [ + 'type' => 'integer', + 'description' => 'Minimum quantity of products required', + 'minimum' => 0, + ] + )] + public ?int $minimumProductQuantity; + + #[ApiProperty( + openapiContext: [ + 'type' => 'object', + 'description' => 'Minimum amount required', + 'properties' => [ + 'amount' => [ + 'type' => 'number', + 'description' => 'Minimum amount value', + ], + 'currencyId' => [ + 'type' => 'integer', + 'description' => 'Currency ID for minimum amount', + ], + 'taxIncluded' => [ + 'type' => 'boolean', + 'Whether minimum amount is tax included', + ], + 'shippingIncluded' => [ + 'type' => 'boolean', + 'description' => 'Whether minimum amount includes shipping', + ], + ], + ] + )] + public ?array $minimumAmount; + + public ?int $customerId; + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'Customer group IDs for which the discount is valid', + 'items' => ['type' => 'integer'], + ] + )] + public ?array $customerGroupIds; + + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'Carrier IDs for which the discount is valid', + 'items' => ['type' => 'integer'], + ] + )] + public ?array $carrierIds; + + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'Country IDs for which the discount is valid', + 'items' => ['type' => 'integer'], + ] + )] + public ?array $countryIds; + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'Discount Type IDs compatible with the discount', + 'items' => ['type' => 'integer'], + ] + )] + public ?array $compatibleDiscountTypeIds; + // End of conditions/compatibility values + public bool $highlightInCart; public bool $allowPartialUse; - #[Assert\NotBlank(groups: ['Create'])] - public string $type; - public ?DecimalNumber $percentDiscount; - public ?DecimalNumber $amountDiscount; - public int $currencyId; - public bool $isTaxIncluded; - public int $productId; - public array $combinations; - public int $reductionProduct; + public int $priority; + public \DateTimeImmutable $validFrom; + public ?\DateTimeImmutable $validTo; protected const QUERY_MAPPING = [ '[localizedNames]' => '[names]', + '[active]' => '[enabled]', ]; protected const COMMAND_MAPPING = [ '[names]' => '[localizedNames]', + '[enabled]' => '[active]', ]; } diff --git a/src/ApiPlatform/Resources/Discount/DiscountList.php b/src/ApiPlatform/Resources/Discount/DiscountList.php index ae2b351f3..f2f489b34 100644 --- a/src/ApiPlatform/Resources/Discount/DiscountList.php +++ b/src/ApiPlatform/Resources/Discount/DiscountList.php @@ -36,9 +36,12 @@ scopes: ['discount_read'], ApiResourceMapping: [ '[id_discount]' => '[discountId]', + '[active]' => '[enabled]', + '[discount_type]' => '[type]', ], gridDataFactory: 'prestashop.core.grid.data.factory.discount', filtersClass: DiscountFilters::class, + experimentalOperation: true, ), ], exceptionToStatus: [ @@ -51,6 +54,6 @@ class DiscountList public int $discountId; public string $type; public string $name; - public bool $active; + public bool $enabled; public string $code; } diff --git a/src/ApiPlatform/Resources/Discount/DiscountTypeList.php b/src/ApiPlatform/Resources/Discount/DiscountTypeList.php new file mode 100644 index 000000000..d1be7787a --- /dev/null +++ b/src/ApiPlatform/Resources/Discount/DiscountTypeList.php @@ -0,0 +1,64 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Discount; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Discount\Query\GetDiscountTypes; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; + +#[ApiResource( + operations: [ + new CQRSGetCollection( + uriTemplate: '/discounts/types', + CQRSQuery: GetDiscountTypes::class, + scopes: ['discount_read'], + CQRSQueryMapping: [], + ApiResourceMapping: [ + '[type]' => '[type]', + '[localizedNames]' => '[names]', + '[localizedDescriptions]' => '[descriptions]', + '[core]' => '[core]', + '[enabled]' => '[enabled]', + ], + experimentalOperation: true, + ), + ], + normalizationContext: ['skip_null_values' => false], +)] +class DiscountTypeList +{ + #[ApiProperty(identifier: true)] + public int $discountTypeId; + + public string $type; + + #[LocalizedValue] + public array $names; + + #[LocalizedValue] + public array $descriptions; + + public bool $core; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Feature/BulkFeatureValues.php b/src/ApiPlatform/Resources/Feature/BulkFeatureValues.php new file mode 100644 index 000000000..e1feb85cb --- /dev/null +++ b/src/ApiPlatform/Resources/Feature/BulkFeatureValues.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Feature; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\BulkDeleteFeatureValueCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Exception\FeatureValueNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/features/values/bulk-delete', + CQRSCommand: BulkDeleteFeatureValueCommand::class, + scopes: [ + 'feature_value_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + FeatureValueNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkFeatureValues +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $featureValueIds; +} diff --git a/src/ApiPlatform/Resources/Feature/BulkFeatures.php b/src/ApiPlatform/Resources/Feature/BulkFeatures.php new file mode 100644 index 000000000..1b3cc645e --- /dev/null +++ b/src/ApiPlatform/Resources/Feature/BulkFeatures.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Feature; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\BulkDeleteFeatureCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Exception\FeatureNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/features/bulk-delete', + CQRSCommand: BulkDeleteFeatureCommand::class, + scopes: [ + 'feature_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + FeatureNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkFeatures +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $featureIds; +} diff --git a/src/ApiPlatform/Resources/Feature/Feature.php b/src/ApiPlatform/Resources/Feature/Feature.php new file mode 100644 index 000000000..7f19aed34 --- /dev/null +++ b/src/ApiPlatform/Resources/Feature/Feature.php @@ -0,0 +1,117 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Feature; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\AddFeatureCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\DeleteFeatureCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\EditFeatureCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Exception\FeatureConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Feature\Exception\FeatureNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Feature\Query\GetFeatureForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/features/{featureId}', + CQRSQuery: GetFeatureForEditing::class, + scopes: [ + 'feature_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/features', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddFeatureCommand::class, + CQRSQuery: GetFeatureForEditing::class, + scopes: [ + 'feature_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/features/{featureId}', + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: EditFeatureCommand::class, + CQRSQuery: GetFeatureForEditing::class, + scopes: [ + 'feature_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/features/{featureId}', + requirements: ['featureId' => '\d+'], + CQRSCommand: DeleteFeatureCommand::class, + scopes: [ + 'feature_write', + ], + ), + ], + exceptionToStatus: [ + FeatureConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + FeatureNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Feature +{ + #[ApiProperty(identifier: true)] + public int $featureId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $names; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public int $position; + + public const QUERY_MAPPING = [ + '[name]' => '[names]', + '[shopAssociation]' => '[shopIds]', + ]; + + public const COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[shopIds]' => '[shopAssociation]', + ]; +} diff --git a/src/ApiPlatform/Resources/Feature/FeatureList.php b/src/ApiPlatform/Resources/Feature/FeatureList.php new file mode 100644 index 000000000..6b61d3d66 --- /dev/null +++ b/src/ApiPlatform/Resources/Feature/FeatureList.php @@ -0,0 +1,61 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Feature; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Search\Filters\FeatureFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/features', + scopes: [ + 'feature_read', + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data.factory.feature', + filtersClass: FeatureFilters::class, + filtersMapping: [ + '[featureId]' => '[id_feature]', + ], + ), + ] +)] +class FeatureList +{ + #[ApiProperty(identifier: true)] + public int $featureId; + + public string $name; + + public int $values; + + public int $position; + + public const MAPPING = [ + '[id_feature]' => '[featureId]', + '[name]' => '[name]', + '[values]' => '[values]', + '[position]' => '[position]', + ]; +} diff --git a/src/ApiPlatform/Resources/Feature/FeatureValue.php b/src/ApiPlatform/Resources/Feature/FeatureValue.php new file mode 100644 index 000000000..337928537 --- /dev/null +++ b/src/ApiPlatform/Resources/Feature/FeatureValue.php @@ -0,0 +1,114 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Feature; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\AddFeatureValueCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\DeleteFeatureValueCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Command\EditFeatureValueCommand; +use PrestaShop\PrestaShop\Core\Domain\Feature\Exception\FeatureValueConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Feature\Exception\FeatureValueNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Feature\Query\GetFeatureValueForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/features/values/{featureValueId}', + CQRSQuery: GetFeatureValueForEditing::class, + scopes: [ + 'feature_value_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/features/values', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddFeatureValueCommand::class, + CQRSQuery: GetFeatureValueForEditing::class, + scopes: [ + 'feature_value_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/features/values/{featureValueId}', + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: EditFeatureValueCommand::class, + CQRSQuery: GetFeatureValueForEditing::class, + scopes: [ + 'feature_value_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/features/values/{featureValueId}', + CQRSCommand: DeleteFeatureValueCommand::class, + scopes: [ + 'feature_value_write', + ], + ), + ], + exceptionToStatus: [ + FeatureValueConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + FeatureValueNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class FeatureValue +{ + #[ApiProperty(identifier: true)] + public int $featureValueId; + + #[ApiProperty(openapiContext: ['type' => 'integer', 'example' => 1])] + public int $featureId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'values')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'values', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $values; + + public int $position; + + public const QUERY_MAPPING = [ + '[value]' => '[values]', + '[localizedValues]' => '[values]', + ]; + + public const COMMAND_MAPPING = [ + '[values]' => '[localizedValues]', + ]; +} diff --git a/src/ApiPlatform/Resources/Feature/FeatureValueList.php b/src/ApiPlatform/Resources/Feature/FeatureValueList.php new file mode 100644 index 000000000..74ead6e07 --- /dev/null +++ b/src/ApiPlatform/Resources/Feature/FeatureValueList.php @@ -0,0 +1,67 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Feature; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Link; +use PrestaShop\PrestaShop\Core\Search\Filters\FeatureValueFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/features/{featureId}/values', + scopes: [ + 'feature_value_read', + ], + uriVariables: [ + 'featureId' => new Link( + identifiers: ['featureId'] + ), + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data.factory.feature_value', + filtersClass: FeatureValueFilters::class, + filtersMapping: [ + '[featureValueId]' => '[id_feature_value]', + ], + ), + ] +)] +class FeatureValueList +{ + #[ApiProperty(identifier: true)] + public int $featureValueId; + + #[ApiProperty(readable: false, writable: false)] + public int $featureId; + + public string $value; + + public int $position; + + public const MAPPING = [ + '[id_feature_value]' => '[featureValueId]', + '[value]' => '[value]', + '[position]' => '[position]', + ]; +} diff --git a/src/ApiPlatform/Resources/Hook.php b/src/ApiPlatform/Resources/Hook.php index 61ba8f622..95c69a7da 100644 --- a/src/ApiPlatform/Resources/Hook.php +++ b/src/ApiPlatform/Resources/Hook.php @@ -28,6 +28,7 @@ use PrestaShop\PrestaShop\Core\Domain\Hook\Command\UpdateHookStatusCommand; use PrestaShop\PrestaShop\Core\Domain\Hook\Exception\HookNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Hook\Query\GetHook; +use PrestaShop\PrestaShop\Core\Domain\Hook\Query\GetHookStatus; use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; @@ -36,26 +37,34 @@ #[ApiResource( operations: [ new CQRSUpdate( - uriTemplate: '/hook-status/{hookId}', + uriTemplate: '/hooks/{hookId}/status', CQRSCommand: UpdateHookStatusCommand::class, CQRSQuery: GetHook::class, scopes: ['hook_write'], - CQRSQueryMapping: self::MAPPING, - CQRSCommandMapping: self::MAPPING, + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, ), new CQRSGet( - uriTemplate: '/hook/{hookId}', + uriTemplate: '/hooks/{hookId}', requirements: ['hookId' => '\d+'], exceptionToStatus: [HookNotFoundException::class => 404], CQRSQuery: GetHook::class, scopes: ['hook_read'], - CQRSQueryMapping: self::MAPPING, + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSGet( + uriTemplate: '/hooks/{hookId}/status', + requirements: ['hookId' => '\d+'], + exceptionToStatus: [HookNotFoundException::class => 404], + CQRSQuery: GetHookStatus::class, + scopes: ['hook_read'], + CQRSQueryMapping: self::QUERY_MAPPING, ), new PaginatedList( uriTemplate: '/hooks', provider: QueryListProvider::class, scopes: ['hook_read'], - ApiResourceMapping: ['[id_hook]' => '[hookId]'], + ApiResourceMapping: self::LIST_MAPPING, gridDataFactory: 'prestashop.core.grid.data_factory.hook', filtersMapping: [ '[hookId]' => '[id_hook]', @@ -68,7 +77,7 @@ class Hook #[ApiProperty(identifier: true)] public int $hookId; - public bool $active; + public bool $enabled; public string $name; @@ -76,10 +85,22 @@ class Hook public string $description; - protected const MAPPING = [ + protected const QUERY_MAPPING = [ // Transforms the url hookId parameter into the $id parameter for GetHook '[hookId]' => '[id]', // Transforms the query result Hook::getId into the Api resource hookId '[id]' => '[hookId]', + '[active]' => '[enabled]', + ]; + + protected const LIST_MAPPING = [ + '[id_hook]' => '[hookId]', + '[active]' => '[enabled]', + ]; + + protected const COMMAND_MAPPING = [ + // Transforms the url hookId parameter into the $id parameter for UpdateHookStatusCommand + '[hookId]' => '[id]', + '[enabled]' => '[active]', ]; } diff --git a/src/ApiPlatform/Resources/Manufacturer/BulkManufacturers.php b/src/ApiPlatform/Resources/Manufacturer/BulkManufacturers.php new file mode 100644 index 000000000..8b93a3a78 --- /dev/null +++ b/src/ApiPlatform/Resources/Manufacturer/BulkManufacturers.php @@ -0,0 +1,62 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Manufacturer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Command\BulkDeleteManufacturerCommand; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\DeleteManufacturerException; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\ManufacturerException; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\ManufacturerNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/manufacturers/bulk-delete', + // No output 204 code + output: false, + CQRSCommand: BulkDeleteManufacturerCommand::class, + scopes: [ + 'manufacturer_write', + ], + ), + ], + exceptionToStatus: [ + ManufacturerNotFoundException::class => Response::HTTP_NOT_FOUND, + DeleteManufacturerException::class => Response::HTTP_INTERNAL_SERVER_ERROR, + ManufacturerException::class => Response::HTTP_INTERNAL_SERVER_ERROR, + ], +)] +class BulkManufacturers +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $manufacturerIds; +} diff --git a/src/ApiPlatform/Resources/Manufacturer/Manufacturer.php b/src/ApiPlatform/Resources/Manufacturer/Manufacturer.php new file mode 100644 index 000000000..a6860dc25 --- /dev/null +++ b/src/ApiPlatform/Resources/Manufacturer/Manufacturer.php @@ -0,0 +1,188 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Manufacturer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Module\APIResources\Validation\IframeValidationGroupsResolver; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Command\AddManufacturerCommand; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Command\DeleteManufacturerCommand; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Command\EditManufacturerCommand; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\ManufacturerConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\ManufacturerException; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\ManufacturerNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Query\GetManufacturerForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + // GET /manufacturer/{manufacturerId} + new CQRSGet( + uriTemplate: '/manufacturers/{manufacturerId}', + requirements: ['manufacturerId' => '\d+'], + CQRSQuery: GetManufacturerForEditing::class, + scopes: ['manufacturer_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + // POST /manufacturer + new CQRSCreate( + uriTemplate: '/manufacturers', + validationContext: [IframeValidationGroupsResolver::class, 'create'], + CQRSCommand: AddManufacturerCommand::class, + CQRSQuery: GetManufacturerForEditing::class, + scopes: ['manufacturer_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/manufacturers/{manufacturerId}', + validationContext: [IframeValidationGroupsResolver::class, 'create'], + requirements: ['manufacturerId' => '\d+'], + CQRSCommand: EditManufacturerCommand::class, + CQRSQuery: GetManufacturerForEditing::class, + scopes: ['manufacturer_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/manufacturers/{manufacturerId}', + requirements: ['manufacturerId' => '\d+'], + CQRSCommand: DeleteManufacturerCommand::class, + scopes: ['manufacturer_write'], + ), + ], + exceptionToStatus: [ + ManufacturerNotFoundException::class => Response::HTTP_NOT_FOUND, + ManufacturerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ManufacturerException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class Manufacturer +{ + #[ApiProperty(identifier: true)] + public int $manufacturerId; + + #[Assert\NotBlank(groups: ['Create'])] + #[TypedRegex(['type' => TypedRegex::TYPE_CATALOG_NAME])] + public string $name; + + #[ApiProperty( + required: false, + openapiContext: ['nullable' => true] + )] + public ?array $logoImage = null; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'shortDescriptions')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'shortDescriptions', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_NO_IFRAME, + 'groups' => ['NoIframe'], + ]), + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_ALLOW_IFRAME, + 'groups' => ['AllowIframe'], + ]), + ])] + public array $shortDescriptions; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'descriptions')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'descriptions', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_NO_IFRAME, + 'groups' => ['NoIframe'], + ]), + new TypedRegex([ + 'type' => TypedRegex::CLEAN_HTML_ALLOW_IFRAME, + 'groups' => ['AllowIframe'], + ]), + ])] + public array $descriptions; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'metaTitles')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'metaTitles', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_GENERIC_NAME, + ]), + ])] + public array $metaTitles; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'metaDescriptions')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'metaDescriptions', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_GENERIC_NAME, + ]), + ])] + public array $metaDescriptions; + + #[LocalizedValue] + public array $metaKeywords; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer']])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + #[Assert\Type(type: 'bool')] + public bool $enabled; + + public const QUERY_MAPPING = [ + '[manufacturerId]' => '[manufacturerId]', + '[name]' => '[name]', + '[logoImage]' => '[logoImage]', + '[localizedShortDescriptions]' => '[shortDescriptions]', + '[localizedMetaTitles]' => '[metaTitles]', + '[localizedDescriptions]' => '[descriptions]', + '[localizedMetaDescriptions]' => '[metaDescriptions]', + '[localizedMetaKeywords]' => '[metaKeywords]', + '[enabled]' => '[enabled]', + '[associatedShops]' => '[shopIds]', + ]; + + public const COMMAND_MAPPING = [ + '[manufacturerId]' => '[manufacturerId]', + '[name]' => '[name]', + '[logoImage]' => '[logoImage]', + '[shortDescriptions]' => '[localizedShortDescriptions]', + '[metaTitles]' => '[localizedMetaTitles]', + '[descriptions]' => '[localizedDescriptions]', + '[metaDescriptions]' => '[localizedMetaDescriptions]', + '[enabled]' => '[enabled]', + '[shopIds]' => '[shopAssociation]', + ]; +} diff --git a/src/ApiPlatform/Resources/Manufacturer/ManufacturerList.php b/src/ApiPlatform/Resources/Manufacturer/ManufacturerList.php new file mode 100644 index 000000000..da9000265 --- /dev/null +++ b/src/ApiPlatform/Resources/Manufacturer/ManufacturerList.php @@ -0,0 +1,76 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Manufacturer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Doctrine\DBAL\Exception\InvalidFieldNameException; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\ManufacturerConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Manufacturer\Exception\ManufacturerException; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/manufacturers', + scopes: [ + 'manufacturer_read', + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data.factory.manufacturer_decorator', + filtersMapping: [ + '[manufacturerId]' => '[id_manufacturer]', + ], + ), + ], + exceptionToStatus: [ + InvalidFieldNameException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ManufacturerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ManufacturerException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class ManufacturerList +{ + #[ApiProperty(identifier: true)] + public int $manufacturerId; + + public string $name; + + public ?string $logo; + + public int $productCount; + + public int|string $addressesCount; + + public bool $enabled; + + public const MAPPING = [ + '[id_manufacturer]' => '[manufacturerId]', + '[name]' => '[name]', + '[products_count]' => '[productsCount]', + '[addresses_count]' => '[addressesCount]', + '[active]' => '[enabled]', + ]; +} diff --git a/src/ApiPlatform/Resources/Module/BulkModules.php b/src/ApiPlatform/Resources/Module/BulkModules.php index d568918a0..30c3ecca3 100644 --- a/src/ApiPlatform/Resources/Module/BulkModules.php +++ b/src/ApiPlatform/Resources/Module/BulkModules.php @@ -31,7 +31,7 @@ #[ApiResource( operations: [ new CQRSUpdate( - uriTemplate: '/modules/toggle-status', + uriTemplate: '/modules/bulk-update-status', output: false, CQRSCommand: BulkToggleModuleStatusCommand::class, scopes: [ @@ -42,7 +42,7 @@ ], ), new CQRSUpdate( - uriTemplate: '/modules/uninstall', + uriTemplate: '/modules/bulk-uninstall', output: false, CQRSCommand: BulkUninstallModuleCommand::class, scopes: [ diff --git a/src/ApiPlatform/Resources/Module/Module.php b/src/ApiPlatform/Resources/Module/Module.php index 62298f56e..52d5086e5 100644 --- a/src/ApiPlatform/Resources/Module/Module.php +++ b/src/ApiPlatform/Resources/Module/Module.php @@ -36,14 +36,14 @@ #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/module/{technicalName}', + uriTemplate: '/modules/{technicalName}', CQRSQuery: GetModuleInfos::class, scopes: [ 'module_read', ], ), new CQRSUpdate( - uriTemplate: '/module/{technicalName}/status', + uriTemplate: '/modules/{technicalName}/status', CQRSCommand: UpdateModuleStatusCommand::class, CQRSQuery: GetModuleInfos::class, scopes: [ @@ -51,7 +51,7 @@ ], ), new CQRSUpdate( - uriTemplate: '/module/{technicalName}/install', + uriTemplate: '/modules/{technicalName}/install', CQRSCommand: InstallModuleCommand::class, CQRSQuery: GetModuleInfos::class, scopes: [ diff --git a/src/ApiPlatform/Resources/Module/ResetModule.php b/src/ApiPlatform/Resources/Module/ResetModule.php index 45b5fb13f..5119b88c5 100644 --- a/src/ApiPlatform/Resources/Module/ResetModule.php +++ b/src/ApiPlatform/Resources/Module/ResetModule.php @@ -30,7 +30,7 @@ #[ApiResource( operations: [ new CQRSPartialUpdate( - uriTemplate: '/module/{technicalName}/reset', + uriTemplate: '/modules/{technicalName}/reset', CQRSCommand: ResetModuleCommand::class, CQRSQuery: GetModuleInfos::class, scopes: [ diff --git a/src/ApiPlatform/Resources/Module/UninstallModule.php b/src/ApiPlatform/Resources/Module/UninstallModule.php index 2d25115e2..278b2661c 100644 --- a/src/ApiPlatform/Resources/Module/UninstallModule.php +++ b/src/ApiPlatform/Resources/Module/UninstallModule.php @@ -29,7 +29,7 @@ #[ApiResource( operations: [ new CQRSUpdate( - uriTemplate: '/module/{technicalName}/uninstall', + uriTemplate: '/modules/{technicalName}/uninstall', output: false, CQRSCommand: UninstallModuleCommand::class, scopes: [ diff --git a/src/ApiPlatform/Resources/Module/UpgradeModule.php b/src/ApiPlatform/Resources/Module/UpgradeModule.php index d862e1b8f..a5c576b49 100644 --- a/src/ApiPlatform/Resources/Module/UpgradeModule.php +++ b/src/ApiPlatform/Resources/Module/UpgradeModule.php @@ -30,7 +30,7 @@ #[ApiResource( operations: [ new CQRSUpdate( - uriTemplate: '/module/{technicalName}/upgrade', + uriTemplate: '/modules/{technicalName}/upgrade', CQRSCommand: UpgradeModuleCommand::class, CQRSQuery: GetModuleInfos::class, scopes: [ diff --git a/src/ApiPlatform/Resources/Module/UploadModule.php b/src/ApiPlatform/Resources/Module/UploadModule.php index 94fc3a689..ea24a056f 100644 --- a/src/ApiPlatform/Resources/Module/UploadModule.php +++ b/src/ApiPlatform/Resources/Module/UploadModule.php @@ -30,7 +30,7 @@ #[ApiResource( operations: [ new CQRSCreate( - uriTemplate: '/module/upload-source', + uriTemplate: '/modules/upload-source', CQRSCommand: UploadModuleCommand::class, CQRSQuery: GetModuleInfos::class, scopes: [ @@ -38,7 +38,7 @@ ], ), new CQRSCreate( - uriTemplate: '/module/upload-archive', + uriTemplate: '/modules/upload-archive', inputFormats: ['multipart' => ['multipart/form-data']], read: false, CQRSCommand: UploadModuleCommand::class, diff --git a/src/ApiPlatform/Resources/OrderMessage/BulkDeleteOrderMessages.php b/src/ApiPlatform/Resources/OrderMessage/BulkDeleteOrderMessages.php new file mode 100644 index 000000000..fa037296c --- /dev/null +++ b/src/ApiPlatform/Resources/OrderMessage/BulkDeleteOrderMessages.php @@ -0,0 +1,56 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\OrderMessage; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Command\BulkDeleteOrderMessageCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Exception\OrderMessageNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/order-messages/bulk-delete', + CQRSCommand: BulkDeleteOrderMessageCommand::class, + scopes: [ + 'order_message_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + OrderMessageNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteOrderMessages +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $orderMessageIds; +} diff --git a/src/ApiPlatform/Resources/OrderMessage/OrderMessage.php b/src/ApiPlatform/Resources/OrderMessage/OrderMessage.php new file mode 100644 index 000000000..531afb973 --- /dev/null +++ b/src/ApiPlatform/Resources/OrderMessage/OrderMessage.php @@ -0,0 +1,103 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\OrderMessage; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Command\AddOrderMessageCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Command\DeleteOrderMessageCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Command\EditOrderMessageCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Exception\OrderMessageConstraintException; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Exception\OrderMessageNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Query\GetOrderMessageForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/order-messages', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddOrderMessageCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + scopes: ['order_message_write'], + ), + new CQRSDelete( + uriTemplate: '/order-messages/{orderMessageId}', + requirements: ['orderMessageId' => '\d+'], + output: false, + CQRSCommand: DeleteOrderMessageCommand::class, + scopes: ['order_message_write'], + ), + new CQRSGet( + uriTemplate: '/order-messages/{orderMessageId}', + requirements: ['orderMessageId' => '\d+'], + CQRSQuery: GetOrderMessageForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: ['order_message_read'], + ), + new CQRSPartialUpdate( + uriTemplate: '/order-messages/{orderMessageId}', + requirements: ['orderMessageId' => '\d+'], + read: false, + CQRSCommand: EditOrderMessageCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + CQRSQuery: GetOrderMessageForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: ['order_message_write'], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + OrderMessageNotFoundException::class => Response::HTTP_NOT_FOUND, + OrderMessageConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class OrderMessage +{ + #[ApiProperty(identifier: true)] + public int $orderMessageId; + + #[LocalizedValue] + #[Assert\NotBlank(groups: ['Create'])] + public array $names; + + #[LocalizedValue] + #[Assert\NotBlank(groups: ['Create'])] + public array $messages; + + public const QUERY_MAPPING = [ + '[localizedName]' => '[names]', + '[localizedMessage]' => '[messages]', + ]; + + public const COMMAND_MAPPING = [ + '[names]' => '[localizedName]', + '[messages]' => '[localizedMessage]', + ]; +} diff --git a/src/ApiPlatform/Resources/OrderMessage/OrderMessageList.php b/src/ApiPlatform/Resources/OrderMessage/OrderMessageList.php new file mode 100644 index 000000000..23379d390 --- /dev/null +++ b/src/ApiPlatform/Resources/OrderMessage/OrderMessageList.php @@ -0,0 +1,58 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\OrderMessage; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\OrderMessage\Exception\OrderMessageNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\OrderMessageFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/order-messages', + provider: QueryListProvider::class, + scopes: ['order_message_read'], + ApiResourceMapping: [ + '[id_order_message]' => '[orderMessageId]', + ], + gridDataFactory: 'prestashop.core.grid.data.factory.order_message', + filtersClass: OrderMessageFilters::class, + ), + ], + exceptionToStatus: [ + OrderMessageNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class OrderMessageList +{ + #[ApiProperty(identifier: true)] + public int $orderMessageId; + + public string $name; + + public string $message; +} diff --git a/src/ApiPlatform/Resources/OrderReturn/OrderReturn.php b/src/ApiPlatform/Resources/OrderReturn/OrderReturn.php new file mode 100644 index 000000000..03cd2a336 --- /dev/null +++ b/src/ApiPlatform/Resources/OrderReturn/OrderReturn.php @@ -0,0 +1,80 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\OrderReturn; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\OrderReturn\Command\UpdateOrderReturnStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderReturn\Exception\OrderReturnConstraintException; +use PrestaShop\PrestaShop\Core\Domain\OrderReturn\Exception\OrderReturnNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\OrderReturn\Query\GetOrderReturnForEditing; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Exception\OrderReturnStateNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/order-returns/{orderReturnId}', + requirements: ['orderReturnId' => '\d+'], + CQRSQuery: GetOrderReturnForEditing::class, + scopes: ['order_return_read'], + ), + new CQRSPartialUpdate( + uriTemplate: '/order-returns/{orderReturnId}', + requirements: ['orderReturnId' => '\d+'], + read: false, + CQRSCommand: UpdateOrderReturnStateCommand::class, + CQRSQuery: GetOrderReturnForEditing::class, + scopes: ['order_return_write'], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + OrderReturnNotFoundException::class => Response::HTTP_NOT_FOUND, + OrderReturnConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + OrderReturnStateNotFoundException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class OrderReturn +{ + #[ApiProperty(identifier: true)] + public int $orderReturnId; + + public int $customerId; + + public string $customerFirstName; + + public string $customerLastName; + + public int $orderId; + + public string $orderDate; + + #[Assert\NotNull] + public int $orderReturnStateId; + + public string $question; +} diff --git a/src/ApiPlatform/Resources/OrderReturnState/BulkDeleteOrderReturnState.php b/src/ApiPlatform/Resources/OrderReturnState/BulkDeleteOrderReturnState.php new file mode 100644 index 000000000..759711401 --- /dev/null +++ b/src/ApiPlatform/Resources/OrderReturnState/BulkDeleteOrderReturnState.php @@ -0,0 +1,56 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\OrderReturnState; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Command\BulkDeleteOrderReturnStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Exception\OrderReturnStateException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/order-return-states/bulk-delete', + CQRSCommand: BulkDeleteOrderReturnStateCommand::class, + scopes: [ + 'order_return_state_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + OrderReturnStateException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteOrderReturnState +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $orderReturnStateIds; +} diff --git a/src/ApiPlatform/Resources/OrderReturnState/OrderReturnState.php b/src/ApiPlatform/Resources/OrderReturnState/OrderReturnState.php new file mode 100644 index 000000000..f5d9ef36a --- /dev/null +++ b/src/ApiPlatform/Resources/OrderReturnState/OrderReturnState.php @@ -0,0 +1,95 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\OrderReturnState; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Command\AddOrderReturnStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Command\DeleteOrderReturnStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Command\EditOrderReturnStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Exception\OrderReturnStateConstraintException; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Exception\OrderReturnStateNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\OrderReturnState\Query\GetOrderReturnStateForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/order-return-states', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddOrderReturnStateCommand::class, + scopes: ['order_return_state_write'], + ), + new CQRSGet( + uriTemplate: '/order-return-states/{orderReturnStateId}', + requirements: ['orderReturnStateId' => '\d+'], + CQRSQuery: GetOrderReturnStateForEditing::class, + scopes: ['order_return_state_read'], + ), + new CQRSPartialUpdate( + uriTemplate: '/order-return-states/{orderReturnStateId}', + requirements: ['orderReturnStateId' => '\d+'], + CQRSCommand: EditOrderReturnStateCommand::class, + CQRSQuery: GetOrderReturnStateForEditing::class, + scopes: ['order_return_state_write'], + CQRSCommandMapping: self::UPDATE_COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/order-return-states/{orderReturnStateId}', + requirements: ['orderReturnStateId' => '\d+'], + CQRSCommand: DeleteOrderReturnStateCommand::class, + scopes: ['order_return_state_write'], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + OrderReturnStateConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + OrderReturnStateNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class OrderReturnState +{ + #[ApiProperty(identifier: true)] + public int $orderReturnStateId; + + #[LocalizedValue] + #[Assert\NotBlank(groups: ['Create'])] + public array $localizedNames; + + #[Assert\NotBlank(groups: ['Create'])] + public string $color; + + /** + * The Edit command stores the localized names through setName(), so the + * normalized [localizedNames] field must be mapped to the command's [name] field. + */ + public const UPDATE_COMMAND_MAPPING = [ + '[localizedNames]' => '[name]', + ]; +} diff --git a/src/ApiPlatform/Resources/OrderReturnState/OrderReturnStateList.php b/src/ApiPlatform/Resources/OrderReturnState/OrderReturnStateList.php new file mode 100644 index 000000000..46d712152 --- /dev/null +++ b/src/ApiPlatform/Resources/OrderReturnState/OrderReturnStateList.php @@ -0,0 +1,53 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\OrderReturnState; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Search\Filters\OrderReturnStatesFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/order-return-states', + provider: QueryListProvider::class, + scopes: ['order_return_state_read'], + ApiResourceMapping: [ + '[id_order_return_state]' => '[orderReturnStateId]', + ], + gridDataFactory: 'prestashop.core.grid.data_provider.order_return_states', + filtersClass: OrderReturnStatesFilters::class, + ), + ], +)] +class OrderReturnStateList +{ + #[ApiProperty(identifier: true)] + public int $orderReturnStateId; + + public string $name; + + public string $color; +} diff --git a/src/ApiPlatform/Resources/Product/Combination.php b/src/ApiPlatform/Resources/Product/Combination.php new file mode 100644 index 000000000..07435b82c --- /dev/null +++ b/src/ApiPlatform/Resources/Product/Combination.php @@ -0,0 +1,101 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CombinationNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Query\GetCombinationForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/products/combinations/{combinationId}', + CQRSQuery: GetCombinationForEditing::class, + scopes: [ + 'product_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + ], + exceptionToStatus: [ + CombinationNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Combination +{ + public int $productId; + + #[ApiProperty(identifier: true)] + public int $combinationId; + public string $name; + public bool $default; + + public string $gtin; + public string $isbn; + public string $mpn; + public string $reference; + public string $upc; + + public string $coverThumbnailUrl; + #[ApiProperty(openapiContext: ['type' => 'array', 'description' => 'List of image IDs', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + public array $imageIds; + + public DecimalNumber $impactOnPriceTaxExcluded; + public DecimalNumber $impactOnPriceTaxIncluded; + public DecimalNumber $impactOnUnitPrice; + public DecimalNumber $impactOnUnitPriceTaxIncluded; + public DecimalNumber $ecotaxTaxExcluded; + public DecimalNumber $ecotaxTaxIncluded; + public DecimalNumber $impactOnWeight; + public DecimalNumber $wholesalePrice; + public DecimalNumber $productTaxRate; + public DecimalNumber $productPriceTaxExcluded; + public DecimalNumber $productEcotaxTaxExcluded; + + public int $quantity; + + public const QUERY_MAPPING = [ + '[_context][shopConstraint]' => '[shopConstraint]', + '[details][gtin]' => '[gtin]', + '[details][isbn]' => '[isbn]', + '[details][mpn]' => '[mpn]', + '[details][reference]' => '[reference]', + '[details][upc]' => '[upc]', + '[details][impactOnWeight]' => '[impactOnWeight]', + '[prices][impactOnPrice]' => '[impactOnPriceTaxExcluded]', + '[prices][impactOnPriceTaxIncluded]' => '[impactOnPriceTaxIncluded]', + '[prices][impactOnUnitPrice]' => '[impactOnUnitPriceTaxExcluded]', + '[prices][impactOnUnitPriceTaxIncluded]' => '[impactOnUnitPriceTaxIncluded]', + '[prices][ecotax]' => '[ecotaxTaxExcluded]', + '[prices][ecotaxTaxIncluded]' => '[ecotaxTaxIncluded]', + '[prices][wholesalePrice]' => '[wholesalePrice]', + '[prices][productTaxRate]' => '[productTaxRate]', + '[prices][productPrice]' => '[productPriceTaxExcluded]', + '[prices][productEcotax]' => '[productEcotaxTaxExcluded]', + '[stock][quantity]' => '[quantity]', + ]; +} diff --git a/src/ApiPlatform/Resources/Product/CombinationIdList.php b/src/ApiPlatform/Resources/Product/CombinationIdList.php new file mode 100644 index 000000000..b703f289a --- /dev/null +++ b/src/ApiPlatform/Resources/Product/CombinationIdList.php @@ -0,0 +1,55 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Query\GetCombinationIds; +use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/products/{productId}/combination-ids', + CQRSQuery: GetCombinationIds::class, + scopes: [ + 'product_read', + ], + CQRSQueryMapping: [ + '[_context][shopConstraint]' => '[shopConstraint]', + '[@index][combinationId]' => '[combinationIds][@index]', + ], + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CombinationIdList +{ + public int $productId; + #[ApiProperty(openapiContext: ['type' => 'array', 'description' => 'List of combination IDs', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + public array $combinationIds; +} diff --git a/src/ApiPlatform/Resources/Product/CombinationList.php b/src/ApiPlatform/Resources/Product/CombinationList.php new file mode 100644 index 000000000..8b3604f52 --- /dev/null +++ b/src/ApiPlatform/Resources/Product/CombinationList.php @@ -0,0 +1,88 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Query\GetEditableCombinationsList; +use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\ProductCombinationFilters; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPaginate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSPaginate( + uriTemplate: '/products/{productId}/combinations', + CQRSQuery: GetEditableCombinationsList::class, + scopes: [ + 'product_read', + ], + CQRSQueryMapping: [ + '[_context][langId]' => '[languageId]', + '[_context][shopConstraint]' => '[shopConstraint]', + ], + ApiResourceMapping: [ + '[combinationName]' => '[name]', + '[attributesInformation]' => '[attributes]', + '[impactOnPrice]' => '[impactOnPriceTaxExcluded]', + ], + filtersClass: ProductCombinationFilters::class, + filtersMapping: [ + '[_context][shopId]' => '[shopId]', + ], + itemsField: 'combinations', + countField: 'totalCombinationsCount', + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CombinationList +{ + public int $productId; + public int $combinationId; + public string $name; + public bool $default; + public string $reference; + public DecimalNumber $impactOnPriceTaxExcluded; + public DecimalNumber $ecoTax; + public int $quantity; + public string $imageUrl; + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'Combination attributes', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'attributeGroupId' => ['type' => 'integer'], + 'attributeGroupName' => ['type' => 'string'], + 'attributeId' => ['type' => 'integer'], + 'attributeName' => ['type' => 'string'], + ], + ], + ] + )] + public array $attributes; +} diff --git a/src/ApiPlatform/Resources/Product/FoundProduct.php b/src/ApiPlatform/Resources/Product/FoundProduct.php index bf6f4be97..bd4a94a64 100644 --- a/src/ApiPlatform/Resources/Product/FoundProduct.php +++ b/src/ApiPlatform/Resources/Product/FoundProduct.php @@ -24,50 +24,85 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use PrestaShop\Decimal\DecimalNumber; use PrestaShop\PrestaShop\Core\Domain\Product\Query\SearchProducts; use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; #[ApiResource( operations: [ new CQRSGetCollection( - uriTemplate: '/products/search/{phrase}/{resultsLimit}/{isoCode}', + uriTemplate: '/products/search', + scopes: [ + 'product_read', + ], + CQRSQuery: SearchProducts::class, + parameters: new Parameters([ + new QueryParameter( + key: 'phrase', + required: true, + description: 'Search phrase to find products' + ), + new QueryParameter( + key: 'resultsLimit', + schema: ['type' => 'integer', 'default' => '20'], + required: true, + description: 'Maximum number of results to return' + ), + new QueryParameter( + key: 'isoCode', + required: true, + description: 'Currency ISO code (e.g., EUR, USD)' + ), + new QueryParameter( + key: 'orderId', + schema: ['type' => 'integer'], + required: false, + description: 'Optional order ID for context-specific pricing' + ), + ]), openapiContext: [ 'parameters' => [ [ 'name' => 'phrase', - 'in' => 'path', + 'in' => 'query', 'required' => true, 'schema' => [ 'type' => 'string', ], + 'description' => 'Search phrase to find products', ], [ 'name' => 'resultsLimit', - 'in' => 'path', + 'in' => 'query', 'required' => true, 'schema' => [ - 'type' => 'int', + 'type' => 'integer', + 'default' => 20, ], + 'description' => 'Maximum number of results to return', ], [ 'name' => 'isoCode', - 'in' => 'path', + 'in' => 'query', 'required' => true, 'schema' => [ 'type' => 'string', ], + 'description' => 'Currency ISO code (e.g., EUR, USD)', ], [ 'name' => 'orderId', 'in' => 'query', 'required' => false, 'schema' => [ - 'type' => 'int', + 'type' => 'integer', ], + 'description' => 'Optional order ID for context-specific pricing', ], ], - ], - CQRSQuery: SearchProducts::class + ] ), ], )] @@ -80,13 +115,13 @@ class FoundProduct public string $name; - public float $taxRate; + public DecimalNumber $taxRate; public string $formattedPrice; - public float $priceTaxIncl; + public DecimalNumber $priceTaxIncl; - public float $priceTaxExcl; + public DecimalNumber $priceTaxExcl; public int $stock; diff --git a/src/ApiPlatform/Resources/Product/GenerateCombinations.php b/src/ApiPlatform/Resources/Product/GenerateCombinations.php new file mode 100644 index 000000000..0e9ac387b --- /dev/null +++ b/src/ApiPlatform/Resources/Product/GenerateCombinations.php @@ -0,0 +1,86 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Command\GenerateProductCombinationsCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/products/{productId}/generate-combinations', + CQRSCommand: GenerateProductCombinationsCommand::class, + scopes: [ + 'product_write', + ], + ApiResourceMapping: [ + // Used to denormalize the command result + '[@index][combinationId]' => '[newCombinationIds][@index]', + ], + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class GenerateCombinations +{ + public int $productId; + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'List of new generated combination IDs', + 'items' => [ + 'type' => 'integer', + 'description' => 'Combination ID', + ], + ] + )] + public array $newCombinationIds = []; + + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'description' => 'List of attributes grouped by their attribute group', + 'properties' => [ + 'attributeGroupId' => [ + 'type' => 'number', + 'description' => 'Attribute group ID', + ], + 'attributeIds' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'integer', + 'description' => 'Attribute ID', + ], + ], + ], + ], + ], + )] + public array $groupedAttributes; +} diff --git a/src/ApiPlatform/Resources/Product/NewProductImage.php b/src/ApiPlatform/Resources/Product/NewProductImage.php index 3b91a9337..fe2e4e960 100644 --- a/src/ApiPlatform/Resources/Product/NewProductImage.php +++ b/src/ApiPlatform/Resources/Product/NewProductImage.php @@ -34,8 +34,9 @@ #[ApiResource( operations: [ new CQRSCreate( - uriTemplate: '/product/{productId}/image', + uriTemplate: '/products/{productId}/images', inputFormats: ['multipart' => ['multipart/form-data']], + requirements: ['productId' => '\d+'], read: false, CQRSCommand: AddProductImageCommand::class, CQRSQuery: GetProductImage::class, @@ -56,7 +57,6 @@ class NewProductImage { public int $productId; - public int $imageId; public string $imageUrl; diff --git a/src/ApiPlatform/Resources/Product/Product.php b/src/ApiPlatform/Resources/Product/Product.php index 3be4b6b69..87d8a6a71 100644 --- a/src/ApiPlatform/Resources/Product/Product.php +++ b/src/ApiPlatform/Resources/Product/Product.php @@ -31,6 +31,7 @@ use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Product\Query\GetProductForEditing; use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopAssociationNotFound; +use PrestaShop\PrestaShop\Core\Util\DateTime\DateImmutable; use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; @@ -41,7 +42,7 @@ #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/product/{productId}', + uriTemplate: '/products/{productId}', CQRSQuery: GetProductForEditing::class, scopes: [ 'product_read', @@ -49,21 +50,17 @@ CQRSQueryMapping: Product::QUERY_MAPPING, ), new CQRSCreate( - uriTemplate: '/product', + uriTemplate: '/products', CQRSCommand: AddProductCommand::class, CQRSQuery: GetProductForEditing::class, scopes: [ 'product_write', ], CQRSQueryMapping: Product::QUERY_MAPPING, - CQRSCommandMapping: [ - '[_context][shopId]' => '[shopId]', - '[type]' => '[productType]', - '[names]' => '[localizedNames]', - ], + CQRSCommandMapping: self::CREATE_MAPPING, ), new CQRSPartialUpdate( - uriTemplate: '/product/{productId}', + uriTemplate: '/products/{productId}', CQRSCommand: UpdateProductCommand::class, CQRSQuery: GetProductForEditing::class, scopes: [ @@ -73,7 +70,7 @@ CQRSCommandMapping: Product::UPDATE_MAPPING, ), new CQRSDelete( - uriTemplate: '/product/{productId}', + uriTemplate: '/products/{productId}', CQRSCommand: DeleteProductCommand::class, scopes: [ 'product_write', @@ -95,7 +92,7 @@ class Product public string $type; - public bool $active; + public bool $enabled; #[LocalizedValue] public array $names; @@ -209,16 +206,39 @@ class Product #[LocalizedValue] public array $availableLaterLabels; - public ?\DateTimeImmutable $availableDate = null; + public ?DateImmutable $availableDate = null; public string $coverThumbnailUrl; #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] public array $shopIds; + #[ApiProperty(openapiContext: [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'categoryId' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'displayName' => ['type' => 'string'], + ], + ], + 'example' => [ + [ + 'categoryId' => 2, + 'name' => 'Home', + 'displayName' => 'Home', + ], + ]]) + ] + public array $categories; + + public int $defaultCategoryId; + public const QUERY_MAPPING = [ '[_context][shopConstraint]' => '[shopConstraint]', '[_context][langId]' => '[displayLanguageId]', + '[active]' => '[enabled]', '[basicInformation][localizedNames]' => '[names]', '[basicInformation][localizedDescriptions]' => '[descriptions]', '[basicInformation][localizedShortDescriptions]' => '[shortDescriptions]', @@ -270,11 +290,24 @@ class Product '[stockInformation][localizedAvailableLaterLabels]' => '[availableLaterLabels]', '[stockInformation][location]' => '[location]', '[stockInformation][availableDate]' => '[availableDate]', + // Transform each field one by one (instead of the whole array) to avoid having an extra id field in the target + '[categoriesInformation][categoriesInformation][@index][id]' => '[categories][@index][categoryId]', + '[categoriesInformation][categoriesInformation][@index][name]' => '[categories][@index][name]', + '[categoriesInformation][categoriesInformation][@index][displayName]' => '[categories][@index][displayName]', + '[categoriesInformation][defaultCategoryId]' => '[defaultCategoryId]', + ]; + + public const CREATE_MAPPING = [ + '[_context][shopId]' => '[shopId]', + '[type]' => '[productType]', + '[names]' => '[localizedNames]', + '[enabled]' => '[active]', ]; public const UPDATE_MAPPING = [ '[_context][shopConstraint]' => '[shopConstraint]', '[type]' => '[productType]', + '[enabled]' => '[active]', '[names]' => '[localizedNames]', '[descriptions]' => '[localizedDescriptions]', '[shortDescriptions]' => '[localizedShortDescriptions]', diff --git a/src/ApiPlatform/Resources/Product/ProductCategory.php b/src/ApiPlatform/Resources/Product/ProductCategory.php new file mode 100644 index 000000000..c6fbd0a30 --- /dev/null +++ b/src/ApiPlatform/Resources/Product/ProductCategory.php @@ -0,0 +1,84 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\AssignProductToCategoryCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\RemoveAllAssociatedProductCategoriesCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\SetAssociatedProductCategoriesCommand; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/products/{productId}/assign-to-categories', + requirements: ['productId' => '\d+'], + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AssignProductToCategoryCommand::class, + status: Response::HTTP_NO_CONTENT, + output: false, + scopes: [ + 'product_write', + ], + ), + new CQRSCreate( + uriTemplate: '/products/{productId}/categories', + requirements: ['productId' => '\d+'], + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: SetAssociatedProductCategoriesCommand::class, + CQRSCommandMapping: ProductCategory::COMMAND_MAPPING, + status: Response::HTTP_NO_CONTENT, + output: false, + scopes: [ + 'product_write', + ], + ), + new CQRSDelete( + uriTemplate: '/products/{productId}/categories', + requirements: ['productId' => '\d+'], + CQRSCommand: RemoveAllAssociatedProductCategoriesCommand::class, + CQRSCommandMapping: ProductCategory::COMMAND_MAPPING, + status: Response::HTTP_NO_CONTENT, + output: false, + scopes: [ + 'product_write', + ], + ), + ], +)] +class ProductCategory +{ + public int $categoryId; + + public int $productId; + + public ?int $defaultCategoryId; + + public ?array $categoryIds = null; + + public const COMMAND_MAPPING = [ + '[_context][shopConstraint]' => '[shopConstraint]', + ]; +} diff --git a/src/ApiPlatform/Resources/Product/ProductImage.php b/src/ApiPlatform/Resources/Product/ProductImage.php index bc0ce4843..9c425008c 100644 --- a/src/ApiPlatform/Resources/Product/ProductImage.php +++ b/src/ApiPlatform/Resources/Product/ProductImage.php @@ -32,13 +32,15 @@ use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/product/image/{imageId}', + uriTemplate: '/products/images/{imageId}', + requirements: ['imageId' => '\d+'], CQRSQuery: GetProductImage::class, scopes: [ 'product_read', @@ -48,7 +50,8 @@ new CQRSUpdate( // We have to force POST request, because we cannot use PUT with files AND data method: CQRSUpdate::METHOD_POST, - uriTemplate: '/product/image/{imageId}', + uriTemplate: '/products/images/{imageId}', + requirements: ['imageId' => '\d+'], inputFormats: ['multipart' => ['multipart/form-data']], status: Response::HTTP_OK, // Form data value are all string so we disable type enforcement @@ -59,15 +62,11 @@ 'product_write', ], CQRSQueryMapping: ProductImage::QUERY_MAPPING, - CQRSCommandMapping: [ - '[_context][shopConstraint]' => '[shopConstraint]', - '[image].pathName' => '[filePath]', - '[legends]' => '[localizedLegends]', - '[cover]' => '[isCover]', - ] + CQRSCommandMapping: ProductImage::COMMAND_MAPPING, ), new CQRSDelete( - uriTemplate: '/product/image/{imageId}', + uriTemplate: '/products/images/{imageId}', + requirements: ['imageId' => '\d+'], CQRSCommand: DeleteProductImageCommand::class, ), ], @@ -93,8 +92,17 @@ class ProductImage public array $shopIds; + public File $image; + public const QUERY_MAPPING = [ '[_context][shopConstraint]' => '[shopConstraint]', '[localizedLegends]' => '[legends]', ]; + + public const COMMAND_MAPPING = [ + '[_context][shopConstraint]' => '[shopConstraint]', + '[image].pathName' => '[filePath]', + '[legends]' => '[localizedLegends]', + '[cover]' => '[isCover]', + ]; } diff --git a/src/ApiPlatform/Resources/Product/ProductImageList.php b/src/ApiPlatform/Resources/Product/ProductImageList.php index 9f82ae919..33a9e0884 100644 --- a/src/ApiPlatform/Resources/Product/ProductImageList.php +++ b/src/ApiPlatform/Resources/Product/ProductImageList.php @@ -32,7 +32,7 @@ #[ApiResource( operations: [ new CQRSGetCollection( - uriTemplate: '/product/{productId}/images', + uriTemplate: '/products/{productId}/images', CQRSQuery: GetProductImages::class, scopes: [ 'product_read', diff --git a/src/ApiPlatform/Resources/Product/ProductList.php b/src/ApiPlatform/Resources/Product/ProductList.php index ce545856a..254ef0362 100644 --- a/src/ApiPlatform/Resources/Product/ProductList.php +++ b/src/ApiPlatform/Resources/Product/ProductList.php @@ -44,6 +44,7 @@ '[id_product]' => '[productId]', '[price_tax_excluded_decimal_value]' => '[priceTaxExcluded]', '[price_tax_included_decimal_value]' => '[priceTaxIncluded]', + '[active]' => '[enabled]', ], gridDataFactory: ProductGridDataFactoryDecorator::class, filtersClass: ProductFilters::class, @@ -51,6 +52,7 @@ '[productId]' => '[id_product]', '[priceTaxExcluded]' => '[final_price_tax_excluded]', '[priceTaxIncluded]' => '[final_price_tax_included]', + '[enabled]' => '[active]', ], ), ], @@ -66,7 +68,7 @@ class ProductList public string $type; - public bool $active; + public bool $enabled; public string $name; diff --git a/src/ApiPlatform/Resources/Product/ProductShops.php b/src/ApiPlatform/Resources/Product/ProductShops.php index 105d70830..b6623cc77 100644 --- a/src/ApiPlatform/Resources/Product/ProductShops.php +++ b/src/ApiPlatform/Resources/Product/ProductShops.php @@ -32,7 +32,7 @@ #[ApiResource( operations: [ new CQRSPartialUpdate( - uriTemplate: '/product/{productId}/shops', + uriTemplate: '/products/{productId}/shops', CQRSCommand: SetProductShopsCommand::class, CQRSQuery: GetProductForEditing::class, scopes: [ diff --git a/src/ApiPlatform/Resources/Profile/Profile.php b/src/ApiPlatform/Resources/Profile/Profile.php new file mode 100644 index 000000000..0caf74513 --- /dev/null +++ b/src/ApiPlatform/Resources/Profile/Profile.php @@ -0,0 +1,79 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Profile; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Profile\Command\AddProfileCommand; +use PrestaShop\PrestaShop\Core\Domain\Profile\Command\DeleteProfileCommand; +use PrestaShop\PrestaShop\Core\Domain\Profile\Exception\ProfileNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Profile\Query\GetProfileForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/profiles/{profileId}', + requirements: ['profileId' => '\d+'], + CQRSQuery: GetProfileForEditing::class, + scopes: ['profile_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/profiles', + CQRSCommand: AddProfileCommand::class, + CQRSQuery: GetProfileForEditing::class, + scopes: ['profile_write'], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/profiles/{profileId}', + CQRSCommand: DeleteProfileCommand::class, + scopes: ['profile_write'], + ), + ], + exceptionToStatus: [ + ProfileNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Profile +{ + #[ApiProperty(identifier: true)] + public int $profileId; + + #[LocalizedValue] + public array $names; + + protected const QUERY_MAPPING = [ + '[localizedNames]' => '[names]', + ]; + + protected const COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + ]; +} diff --git a/src/ApiPlatform/Resources/SearchAlias/BulkDeleteSearchAliases.php b/src/ApiPlatform/Resources/SearchAlias/BulkDeleteSearchAliases.php new file mode 100644 index 000000000..993f34960 --- /dev/null +++ b/src/ApiPlatform/Resources/SearchAlias/BulkDeleteSearchAliases.php @@ -0,0 +1,82 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\SearchAlias; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Alias\Command\BulkDeleteSearchTermsAliasesCommand; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + // Usually the identifier searchTerm would be in the URL, but since here it is a string this would + // conflict with the bulk url /search-aliases/bulk-delete (bulk-delete would be considered the identifier, + // vice versa) + // We will have only one endpoint for deletion, if there are multiple search terms to delete they will be + // passed in the JSON body, if there is only one search term to delete it will still need to be passed in + // the JSON body as an array with one element + // As a consequence, there is no single delete endpoint with the DeleteSearchTermAliasesCommand. + uriTemplate: '/search-aliases/bulk-delete', + CQRSCommand: BulkDeleteSearchTermsAliasesCommand::class, + scopes: ['search_alias_write'], + allowEmptyBody: false, + openapiContext: [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['searchTerms'], + 'properties' => [ + 'searchTerms' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], + ], + ], + 'example' => [ + 'searchTerms' => ['blouse', 't-shirt'], + ], + ], + ], + ], + ], + ), + ] +)] +class BulkDeleteSearchAliases +{ + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ] + )] + #[Assert\NotBlank] + #[Assert\Count(min: 1)] + public array $searchTerms = []; +} diff --git a/src/ApiPlatform/Resources/SearchAlias/SearchAlias.php b/src/ApiPlatform/Resources/SearchAlias/SearchAlias.php new file mode 100644 index 000000000..d489e2641 --- /dev/null +++ b/src/ApiPlatform/Resources/SearchAlias/SearchAlias.php @@ -0,0 +1,124 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\SearchAlias; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Alias\Command\AddSearchTermAliasesCommand; +use PrestaShop\PrestaShop\Core\Domain\Alias\Command\UpdateSearchTermAliasesCommand; +use PrestaShop\PrestaShop\Core\Domain\Alias\Exception\AliasConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Alias\Exception\AliasNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Alias\Query\GetAliasesBySearchTermForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/search-aliases/{searchTerm}', + CQRSQuery: GetAliasesBySearchTermForEditing::class, + scopes: ['search_alias_read'], + exceptionToStatus: [AliasNotFoundException::class => 404], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/search-aliases', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddSearchTermAliasesCommand::class, + CQRSQuery: GetAliasesBySearchTermForEditing::class, + scopes: ['search_alias_write'], + CQRSCommandMapping: self::CREATE_COMMAND_MAPPING, + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSUpdate( + uriTemplate: '/search-aliases/{searchTerm}', + CQRSCommand: UpdateSearchTermAliasesCommand::class, + CQRSQuery: GetAliasesBySearchTermForEditing::class, + scopes: ['search_alias_write'], + CQRSCommandMapping: self::UPDATE_COMMAND_MAPPING, + CQRSQueryMapping: self::QUERY_MAPPING, + ), + ], + exceptionToStatus: [ + AliasNotFoundException::class => Response::HTTP_NOT_FOUND, + AliasConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class SearchAlias +{ + #[ApiProperty(identifier: true)] + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: 255)] + public string $searchTerm = ''; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Count(min: 1, groups: ['Create'])] + #[Assert\All( + constraints: [ + new Assert\Collection( + fields: [ + 'alias' => new Assert\NotBlank(), + 'enabled' => new Assert\Type(type: 'bool'), + // Add active because after normalization both active and enabled are present + 'active' => new Assert\Type(type: 'bool'), + ], + ), + ], + )] + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'alias' => ['type' => 'string'], + 'enabled' => ['type' => 'boolean'], + ], + ], + ] + )] + public array $aliases = []; + + #[Assert\Length(min: 1, max: 255)] + public ?string $newSearchTerm = null; + + protected const QUERY_MAPPING = [ + '[aliases][@index][alias]' => '[aliases][@index][alias]', + '[aliases][@index][active]' => '[aliases][@index][enabled]', + ]; + + protected const CREATE_COMMAND_MAPPING = [ + '[aliases][@index][alias]' => '[aliases][@index][alias]', + '[aliases][@index][enabled]' => '[aliases][@index][active]', + ]; + + protected const UPDATE_COMMAND_MAPPING = [ + '[searchTerm]' => '[oldSearchTerm]', + '[aliases][@index][alias]' => '[aliases][@index][alias]', + '[aliases][@index][enabled]' => '[aliases][@index][active]', + ]; +} diff --git a/src/ApiPlatform/Resources/SearchAlias/SearchAliasList.php b/src/ApiPlatform/Resources/SearchAlias/SearchAliasList.php new file mode 100644 index 000000000..612bd5553 --- /dev/null +++ b/src/ApiPlatform/Resources/SearchAlias/SearchAliasList.php @@ -0,0 +1,61 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\SearchAlias; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/search-aliases', + provider: QueryListProvider::class, + scopes: ['search_alias_read'], + gridDataFactory: 'prestashop.core.grid.data_provider.alias_decorator', + experimentalOperation: true, + ), + ], +)] +class SearchAliasList +{ + #[ApiProperty(identifier: true)] + public string $search = ''; + + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id_alias' => ['type' => 'integer'], + 'alias' => ['type' => 'string'], + 'enabled' => ['type' => 'boolean'], + ], + ], + ] + )] + public array $aliases = []; +} diff --git a/src/ApiPlatform/Resources/SearchEngine/BulkDeleteSearchEngines.php b/src/ApiPlatform/Resources/SearchEngine/BulkDeleteSearchEngines.php new file mode 100644 index 000000000..a7fb2100a --- /dev/null +++ b/src/ApiPlatform/Resources/SearchEngine/BulkDeleteSearchEngines.php @@ -0,0 +1,56 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\SearchEngine; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Command\BulkDeleteSearchEngineCommand; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Exception\SearchEngineNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/search-engines/bulk-delete', + CQRSCommand: BulkDeleteSearchEngineCommand::class, + scopes: ['search_engine_write'], + ), + ], + exceptionToStatus: [ + SearchEngineNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteSearchEngines +{ + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'items' => ['type' => 'int'], + ] + )] + #[Assert\NotBlank] + #[Assert\Count(min: 1)] + public array $searchEngineIds = []; +} diff --git a/src/ApiPlatform/Resources/SearchEngine/DeleteSearchEngine.php b/src/ApiPlatform/Resources/SearchEngine/DeleteSearchEngine.php new file mode 100644 index 000000000..c6b01d90d --- /dev/null +++ b/src/ApiPlatform/Resources/SearchEngine/DeleteSearchEngine.php @@ -0,0 +1,53 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\SearchEngine; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Command\DeleteSearchEngineCommand; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Exception\SearchEngineException; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Exception\SearchEngineNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/search-engines/{searchEngineId}', + requirements: ['searchEngineId' => '\d+'], + CQRSCommand: DeleteSearchEngineCommand::class, + scopes: ['search_engine_write'], + ), + ], + exceptionToStatus: [ + SearchEngineNotFoundException::class => Response::HTTP_NOT_FOUND, + SearchEngineException::class => Response::HTTP_BAD_REQUEST, + ], +)] +class DeleteSearchEngine +{ + #[ApiProperty(identifier: true)] + #[Assert\NotBlank] + public int $searchEngineId; +} diff --git a/src/ApiPlatform/Resources/SearchEngine/SearchEngine.php b/src/ApiPlatform/Resources/SearchEngine/SearchEngine.php new file mode 100644 index 000000000..d4a85c0b5 --- /dev/null +++ b/src/ApiPlatform/Resources/SearchEngine/SearchEngine.php @@ -0,0 +1,87 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\SearchEngine; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Command\AddSearchEngineCommand; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Command\EditSearchEngineCommand; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Exception\SearchEngineException; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Exception\SearchEngineNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\SearchEngine\Query\GetSearchEngineForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/search-engines', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddSearchEngineCommand::class, + CQRSQuery: GetSearchEngineForEditing::class, + scopes: ['search_engine_write'], + ), + new CQRSGet( + uriTemplate: '/search-engines/{searchEngineId}', + requirements: ['searchEngineId' => '\d+'], + CQRSQuery: GetSearchEngineForEditing::class, + scopes: ['search_engine_read'], + ), + new CQRSPartialUpdate( + uriTemplate: '/search-engines/{searchEngineId}', + requirements: ['searchEngineId' => '\d+'], + validationContext: ['groups' => ['Default', 'Update']], + CQRSCommand: EditSearchEngineCommand::class, + CQRSQuery: GetSearchEngineForEditing::class, + scopes: ['search_engine_write'], + ), + ], + exceptionToStatus: [ + SearchEngineNotFoundException::class => Response::HTTP_NOT_FOUND, + SearchEngineException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class SearchEngine +{ + #[ApiProperty(identifier: true)] + public int $searchEngineId; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: 255, groups: ['Create'])] + #[Assert\AtLeastOneOf([ + new Assert\Blank(), + new Assert\Length(min: 1, max: 255), + ], groups: ['Update'])] + public string $server = ''; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: 255, groups: ['Create'])] + #[Assert\AtLeastOneOf([ + new Assert\Blank(), + new Assert\Length(min: 1, max: 255), + ], groups: ['Update'])] + public string $queryKey = ''; +} diff --git a/src/ApiPlatform/Resources/SearchEngine/SearchEngineList.php b/src/ApiPlatform/Resources/SearchEngine/SearchEngineList.php new file mode 100644 index 000000000..3d3a08f74 --- /dev/null +++ b/src/ApiPlatform/Resources/SearchEngine/SearchEngineList.php @@ -0,0 +1,55 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\SearchEngine; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Search\Filters\SearchEngineFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/search-engines', + provider: QueryListProvider::class, + scopes: ['search_engine_read'], + gridDataFactory: 'prestashop.core.grid.data_provider.search_engines', + ApiResourceMapping: [ + '[id_search_engine]' => '[searchEngineId]', + '[getvar]' => '[queryKey]', + ], + filtersClass: SearchEngineFilters::class, + filtersMapping: [ + '[searchEngineId]' => '[id_search_engine]', + ], + ), + ], +)] +class SearchEngineList +{ + #[ApiProperty(identifier: true)] + public int $searchEngineId; + public string $server; + public string $queryKey; +} diff --git a/src/ApiPlatform/Resources/ShowcaseCard/ShowcaseCard.php b/src/ApiPlatform/Resources/ShowcaseCard/ShowcaseCard.php new file mode 100644 index 000000000..ccd929dfa --- /dev/null +++ b/src/ApiPlatform/Resources/ShowcaseCard/ShowcaseCard.php @@ -0,0 +1,69 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\ShowcaseCard; + +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\ShowcaseCard\Command\CloseShowcaseCardCommand; +use PrestaShop\PrestaShop\Core\Domain\ShowcaseCard\Query\GetShowcaseCardIsClosed; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/showcase-cards/{showcaseCardName}/{employeeId}', + requirements: [ + 'showcaseCardName' => '[a-z_-]+', + 'employeeId' => '\d+', + ], + CQRSQuery: GetShowcaseCardIsClosed::class, + CQRSQueryMapping: [ + '[_queryResult]' => '[closed]', + ], + scopes: ['showcase_card_read'], + ), + new CQRSUpdate( + uriTemplate: '/showcase-cards/{showcaseCardName}/{employeeId}/close', + requirements: [ + 'showcaseCardName' => '[a-z_-]+', + 'employeeId' => '\d+', + ], + allowEmptyBody: true, + CQRSCommand: CloseShowcaseCardCommand::class, + CQRSQuery: GetShowcaseCardIsClosed::class, + CQRSQueryMapping: [ + '[_queryResult]' => '[closed]', + ], + scopes: ['showcase_card_write'], + ), + ], +)] +class ShowcaseCard +{ + public string $showcaseCardName; + + public int $employeeId; + + public bool $closed; +} diff --git a/src/ApiPlatform/Resources/Store/BulkDeleteStores.php b/src/ApiPlatform/Resources/Store/BulkDeleteStores.php new file mode 100644 index 000000000..521b904a3 --- /dev/null +++ b/src/ApiPlatform/Resources/Store/BulkDeleteStores.php @@ -0,0 +1,56 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Store; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Store\Command\BulkDeleteStoreCommand; +use PrestaShop\PrestaShop\Core\Domain\Store\Exception\StoreNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/stores/bulk-delete', + CQRSCommand: BulkDeleteStoreCommand::class, + scopes: [ + 'store_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + StoreNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteStores +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $storeIds; +} diff --git a/src/ApiPlatform/Resources/Store/BulkUpdateStatusStores.php b/src/ApiPlatform/Resources/Store/BulkUpdateStatusStores.php new file mode 100644 index 000000000..9b2e33e0b --- /dev/null +++ b/src/ApiPlatform/Resources/Store/BulkUpdateStatusStores.php @@ -0,0 +1,62 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Store; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Store\Command\BulkUpdateStoreStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\Store\Exception\StoreNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/stores/bulk-update-status', + // No output 204 code + output: false, + CQRSCommand: BulkUpdateStoreStatusCommand::class, + CQRSCommandMapping: [ + '[enabled]' => '[expectedStatus]', + ], + scopes: [ + 'store_write', + ], + ), + ], + exceptionToStatus: [ + StoreNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkUpdateStatusStores +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $storeIds; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Store/Store.php b/src/ApiPlatform/Resources/Store/Store.php new file mode 100644 index 000000000..20ed66ec1 --- /dev/null +++ b/src/ApiPlatform/Resources/Store/Store.php @@ -0,0 +1,82 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Store; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Store\Command\DeleteStoreCommand; +use PrestaShop\PrestaShop\Core\Domain\Store\Command\ToggleStoreStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\Store\Exception\StoreNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Store\Query\GetStoreForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/stores/{storeId}', + requirements: ['storeId' => '\d+'], + output: false, + CQRSCommand: DeleteStoreCommand::class, + scopes: ['store_write'] + ), + new CQRSGet( + uriTemplate: '/stores/{storeId}', + requirements: ['storeId' => '\d+'], + CQRSQuery: GetStoreForEditing::class, + scopes: ['store_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSUpdate( + uriTemplate: '/stores/{storeId}/toggle-status', + requirements: ['storeId' => '\d+'], + output: false, + allowEmptyBody: true, + CQRSCommand: ToggleStoreStatusCommand::class, + scopes: ['store_write'], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + StoreNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Store +{ + #[ApiProperty(identifier: true)] + public int $storeId; + + #[Assert\NotNull(groups: ['Create'])] + public bool $enabled; + + public const COMMAND_MAPPING = [ + '[enabled]' => '[active]', + ]; + + public const QUERY_MAPPING = [ + '[active]' => '[enabled]', + ]; +} diff --git a/src/ApiPlatform/Resources/Supplier/BulkSuppliers.php b/src/ApiPlatform/Resources/Supplier/BulkSuppliers.php new file mode 100644 index 000000000..2619ee876 --- /dev/null +++ b/src/ApiPlatform/Resources/Supplier/BulkSuppliers.php @@ -0,0 +1,75 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Supplier; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\BulkDeleteSupplierCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\BulkDisableSupplierCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\BulkEnableSupplierCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Exception\SupplierNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/suppliers/bulk-delete', + CQRSCommand: BulkDeleteSupplierCommand::class, + scopes: [ + 'supplier_write', + ], + allowEmptyBody: false, + ), + new CQRSUpdate( + uriTemplate: '/suppliers/bulk-disable', + // No output 204 code + output: false, + CQRSCommand: BulkDisableSupplierCommand::class, + scopes: [ + 'supplier_write', + ], + ), + new CQRSUpdate( + uriTemplate: '/suppliers/bulk-enable', + // No output 204 code + output: false, + CQRSCommand: BulkEnableSupplierCommand::class, + scopes: [ + 'supplier_write', + ], + ), + ], + exceptionToStatus: [ + SupplierNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkSuppliers +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $supplierIds; +} diff --git a/src/ApiPlatform/Resources/Supplier/Supplier.php b/src/ApiPlatform/Resources/Supplier/Supplier.php new file mode 100644 index 000000000..7aadbef7c --- /dev/null +++ b/src/ApiPlatform/Resources/Supplier/Supplier.php @@ -0,0 +1,155 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Supplier; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\AddSupplierCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\DeleteSupplierCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\DeleteSupplierLogoImageCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\EditSupplierCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Command\ToggleSupplierStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Exception\SupplierNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Query\GetSupplierForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/suppliers', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddSupplierCommand::class, + scopes: ['supplier_write'], + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/suppliers/{supplierId}', + requirements: ['supplierId' => '\d+'], + output: false, + CQRSCommand: DeleteSupplierCommand::class, + scopes: ['supplier_write'] + ), + new CQRSDelete( + uriTemplate: '/suppliers/{supplierId}/logo', + requirements: ['supplierId' => '\d+'], + output: false, + CQRSCommand: DeleteSupplierLogoImageCommand::class, + scopes: ['supplier_write'] + ), + new CQRSGet( + uriTemplate: '/suppliers/{supplierId}', + requirements: ['supplierId' => '\d+'], + CQRSQuery: GetSupplierForEditing::class, + scopes: ['supplier_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/suppliers/{supplierId}', + requirements: ['supplierId' => '\d+'], + read: false, + CQRSCommand: EditSupplierCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + CQRSQuery: GetSupplierForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: ['supplier_write'], + ), + new CQRSUpdate( + uriTemplate: '/suppliers/{supplierId}/toggle-status', + requirements: ['supplierId' => '\d+'], + output: false, + allowEmptyBody: true, + CQRSCommand: ToggleSupplierStatusCommand::class, + scopes: ['supplier_write'], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + SupplierNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Supplier +{ + #[ApiProperty(identifier: true)] + public int $supplierId; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: 64)] + public string $name; + + public string $address; + + public ?string $address2; + + public ?string $postCode; + + public string $city; + + public ?int $stateId; + + public int $countryId; + + public ?string $phone; + + public ?string $mobilePhone; + + public ?string $dni; + + #[Assert\NotNull(groups: ['Create'])] + public bool $enabled; + + #[LocalizedValue] + public array $descriptions; + + #[LocalizedValue] + public array $metaTitles; + + #[LocalizedValue] + public array $metaDescriptions; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public ?array $logoImage; + + public const COMMAND_MAPPING = [ + '[descriptions]' => '[localizedDescriptions]', + '[metaTitles]' => '[localizedMetaTitles]', + '[metaDescriptions]' => '[localizedMetaDescriptions]', + '[shopIds]' => '[shopAssociation]', + ]; + + public const QUERY_MAPPING = [ + '[localizedDescriptions]' => '[descriptions]', + '[localizedMetaTitles]' => '[metaTitles]', + '[localizedMetaDescriptions]' => '[metaDescriptions]', + '[associatedShops]' => '[shopIds]', + ]; +} diff --git a/src/ApiPlatform/Resources/Supplier/SupplierList.php b/src/ApiPlatform/Resources/Supplier/SupplierList.php new file mode 100644 index 000000000..64f0cf4c5 --- /dev/null +++ b/src/ApiPlatform/Resources/Supplier/SupplierList.php @@ -0,0 +1,64 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Supplier; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Supplier\Exception\SupplierNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\SupplierFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/suppliers', + provider: QueryListProvider::class, + scopes: ['supplier_read'], + ApiResourceMapping: [ + '[id_supplier]' => '[supplierId]', + '[active]' => '[enabled]', + '[products_count]' => '[productsCount]', + ], + gridDataFactory: 'prestashop.core.grid.factory.supplier_decorator', + filtersClass: SupplierFilters::class, + ), + ], + exceptionToStatus: [ + SupplierNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class SupplierList +{ + #[ApiProperty(identifier: true)] + public int $supplierId; + + public string $name; + + public bool $enabled; + + public int $productsCount; + + public ?string $logo; +} diff --git a/src/ApiPlatform/Resources/Tab/Tab.php b/src/ApiPlatform/Resources/Tab/Tab.php new file mode 100644 index 000000000..7801a0afb --- /dev/null +++ b/src/ApiPlatform/Resources/Tab/Tab.php @@ -0,0 +1,51 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Tab; + +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Tab\Command\UpdateTabStatusByClassNameCommand; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/tabs/{className}', + read: false, + CQRSCommand: UpdateTabStatusByClassNameCommand::class, + scopes: ['tab_write'], + CQRSCommandMapping: [ + '[enabled]' => '[status]', + ], + ), + ], + normalizationContext: ['skip_null_values' => false], +)] +class Tab +{ + #[Assert\NotBlank()] + public string $className; + + #[Assert\NotNull()] + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Tax/BulkTaxDelete.php b/src/ApiPlatform/Resources/Tax/BulkTaxDelete.php new file mode 100644 index 000000000..b2a9e893c --- /dev/null +++ b/src/ApiPlatform/Resources/Tax/BulkTaxDelete.php @@ -0,0 +1,50 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Tax; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Tax\Command\BulkDeleteTaxCommand; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/taxes/bulk-delete', + // No output 204 code + output: false, + CQRSCommand: BulkDeleteTaxCommand::class, + scopes: [ + 'tax_write', + ], + ), + ], +)] +class BulkTaxDelete +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $taxIds; +} diff --git a/src/ApiPlatform/Resources/Tax/BulkTaxToggleStatus.php b/src/ApiPlatform/Resources/Tax/BulkTaxToggleStatus.php new file mode 100644 index 000000000..813919009 --- /dev/null +++ b/src/ApiPlatform/Resources/Tax/BulkTaxToggleStatus.php @@ -0,0 +1,55 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Tax; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Tax\Command\BulkToggleTaxStatusCommand; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/taxes/bulk-set-status', + // No output 204 code + output: false, + CQRSCommand: BulkToggleTaxStatusCommand::class, + CQRSCommandMapping: [ + '[enabled]' => '[expectedStatus]', + ], + scopes: [ + 'tax_write', + ], + ), + ], +)] +class BulkTaxToggleStatus +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $taxIds; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Tax/Tax.php b/src/ApiPlatform/Resources/Tax/Tax.php new file mode 100644 index 000000000..665379527 --- /dev/null +++ b/src/ApiPlatform/Resources/Tax/Tax.php @@ -0,0 +1,106 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Tax; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\Domain\Tax\Command\AddTaxCommand; +use PrestaShop\PrestaShop\Core\Domain\Tax\Command\DeleteTaxCommand; +use PrestaShop\PrestaShop\Core\Domain\Tax\Command\EditTaxCommand; +use PrestaShop\PrestaShop\Core\Domain\Tax\Exception\TaxNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Tax\Query\GetTaxForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/taxes', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddTaxCommand::class, + scopes: ['tax_write'], + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/taxes/{taxId}', + requirements: ['taxId' => '\d+'], + output: false, + CQRSCommand: DeleteTaxCommand::class, + scopes: ['tax_write'] + ), + new CQRSGet( + uriTemplate: '/taxes/{taxId}', + requirements: ['taxId' => '\d+'], + CQRSQuery: GetTaxForEditing::class, + scopes: ['tax_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/taxes/{taxId}', + requirements: ['taxId' => '\d+'], + read: false, + CQRSCommand: EditTaxCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + CQRSQuery: GetTaxForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: ['tax_write'], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + TaxNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Tax +{ + #[ApiProperty(identifier: true)] + public int $taxId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + public array $names; + + #[Assert\NotNull(groups: ['Create'])] + public DecimalNumber $rate; + + #[Assert\NotNull(groups: ['Create'])] + public bool $enabled; + + public const COMMAND_MAPPING = [ + '[enabled]' => '[active]', + '[names]' => '[localizedNames]', + ]; + + public const QUERY_MAPPING = [ + '[active]' => '[enabled]', + '[localizedNames]' => '[names]', + ]; +} diff --git a/src/ApiPlatform/Resources/Tax/TaxList.php b/src/ApiPlatform/Resources/Tax/TaxList.php new file mode 100644 index 000000000..599421b50 --- /dev/null +++ b/src/ApiPlatform/Resources/Tax/TaxList.php @@ -0,0 +1,69 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Tax; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Search\Filters\TaxFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/taxes', + scopes: ['tax_read'], + ApiResourceMapping: [ + '[id_tax]' => '[taxId]', + '[active]' => '[enabled]', + ], + gridDataFactory: 'prestashop.core.grid.data_factory.tax', + provider: QueryListProvider::class, + filtersClass: TaxFilters::class, + filtersMapping: [ + '[taxId]' => '[id_tax]', + '[enabled]' => '[active]', + ], + ), + ], + normalizationContext: ['skip_null_values' => false], +)] +class TaxList +{ + #[ApiProperty(identifier: true)] + public int $taxId; + + public string $name; + + public DecimalNumber $rate; + + public bool $enabled; + + public function setRate(string $rate): self + { + $this->rate = new DecimalNumber($rate); + + return $this; + } +} diff --git a/src/ApiPlatform/Resources/TaxRule/TaxRule.php b/src/ApiPlatform/Resources/TaxRule/TaxRule.php new file mode 100644 index 000000000..ae21a1b5b --- /dev/null +++ b/src/ApiPlatform/Resources/TaxRule/TaxRule.php @@ -0,0 +1,97 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\TaxRule; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Exception\TaxRulesGroupNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\TaxRule\Command\AddTaxRuleCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\TaxRule\Command\DeleteTaxRuleCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\TaxRule\Exception\CannotAddTaxRuleException; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\TaxRule\Exception\CannotDeleteTaxRuleException; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\TaxRule\Exception\TaxRuleConstraintException; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\TaxRule\Exception\TaxRuleNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/tax-rules', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddTaxRuleCommand::class, + scopes: ['tax_rule_write'], + CQRSCommandMapping: self::COMMAND_MAPPING, + experimentalOperation: true, + ), + new CQRSDelete( + uriTemplate: '/tax-rules/{taxRuleId}', + requirements: ['taxRuleId' => '\d+'], + CQRSCommand: DeleteTaxRuleCommand::class, + scopes: ['tax_rule_write'], + experimentalOperation: true, + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + TaxRuleNotFoundException::class => Response::HTTP_NOT_FOUND, + TaxRulesGroupNotFoundException::class => Response::HTTP_NOT_FOUND, + TaxRuleConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CannotAddTaxRuleException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + CannotDeleteTaxRuleException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class TaxRule +{ + // Null when the country/state pair already had a unique tax rule and the Core silently created nothing + #[ApiProperty(identifier: true)] + public ?int $taxRuleId = null; + + #[Assert\GreaterThan(0, groups: ['Create'])] + public int $taxRulesGroupId; + + // The Core command also accepts 0 to mean "all active countries", but this endpoint always creates + // exactly one tax rule, so fan-out is rejected here rather than exposing a variable-size response + #[Assert\GreaterThan(0, groups: ['Create'])] + public int $countryId; + + #[Assert\Count(max: 1, groups: ['Create'])] + public array $stateIds = [0]; + + // 0 means no tax is associated to this rule (see testListTaxRuleWithoutTax) + #[Assert\GreaterThanOrEqual(0, groups: ['Create'])] + public int $taxId; + + #[Assert\Range(min: 0, max: 2, groups: ['Create'])] + public int $behavior = 0; + + public string $zipCode = ''; + + public string $description = ''; + + public const COMMAND_MAPPING = [ + '[taxRuleIds][0]' => '[taxRuleId]', + ]; +} diff --git a/src/ApiPlatform/Resources/TaxRule/TaxRuleList.php b/src/ApiPlatform/Resources/TaxRule/TaxRuleList.php new file mode 100644 index 000000000..84e765a1a --- /dev/null +++ b/src/ApiPlatform/Resources/TaxRule/TaxRuleList.php @@ -0,0 +1,96 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\TaxRule; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Search\Filters\TaxRuleFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/tax-rules', + provider: QueryListProvider::class, + scopes: ['tax_rule_read'], + ApiResourceMapping: [ + '[id_tax_rule]' => '[taxRuleId]', + '[id_tax_rules_group]' => '[taxRulesGroupId]', + '[country_id]' => '[countryId]', + '[country_name]' => '[countryName]', + '[state_id]' => '[stateId]', + '[state_name]' => '[stateName]', + '[tax_name]' => '[taxName]', + ], + gridDataFactory: 'prestashop.core.grid.data.factory.tax_rule', + filtersClass: TaxRuleFilters::class, + filtersMapping: [ + '[taxRuleId]' => '[id_tax_rule]', + '[taxRulesGroupId]' => '[id_tax_rules_group]', + '[countryId]' => '[country_id]', + '[countryName]' => '[country_name]', + '[stateId]' => '[state_id]', + '[stateName]' => '[state_name]', + '[taxName]' => '[tax_name]', + ], + ), + ], + normalizationContext: ['skip_null_values' => false], +)] +class TaxRuleList +{ + #[ApiProperty(identifier: true)] + public int $taxRuleId; + + // Null before PrestaShop 9.2 (column not selected by the grid query yet) + public ?int $taxRulesGroupId = null; + + // Null before PrestaShop 9.2 (column not selected by the grid query yet) + public ?int $countryId = null; + + public string $countryName; + + // 0 when scoped to a whole country, null before PrestaShop 9.2 + public ?int $stateId = null; + + public string $stateName; + + public string $zipcode; + + public int $behavior; + + public ?DecimalNumber $rate = null; + + public ?string $taxName = null; + + public string $description; + + public function setRate(?string $rate): self + { + $this->rate = $rate !== null ? new DecimalNumber($rate) : null; + + return $this; + } +} diff --git a/src/ApiPlatform/Resources/TaxRulesGroup/BulkDeleteTaxRulesGroup.php b/src/ApiPlatform/Resources/TaxRulesGroup/BulkDeleteTaxRulesGroup.php new file mode 100644 index 000000000..14333f8ea --- /dev/null +++ b/src/ApiPlatform/Resources/TaxRulesGroup/BulkDeleteTaxRulesGroup.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\TaxRulesGroup; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Command\BulkDeleteTaxRulesGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Exception\TaxRulesGroupException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/tax-rules-groups/bulk-delete', + CQRSCommand: BulkDeleteTaxRulesGroupCommand::class, + scopes: [ + 'tax_rules_group_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + TaxRulesGroupException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteTaxRulesGroup +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $taxRulesGroupIds; +} diff --git a/src/ApiPlatform/Resources/TaxRulesGroup/BulkUpdateStatusTaxRulesGroup.php b/src/ApiPlatform/Resources/TaxRulesGroup/BulkUpdateStatusTaxRulesGroup.php new file mode 100644 index 000000000..bc0a02044 --- /dev/null +++ b/src/ApiPlatform/Resources/TaxRulesGroup/BulkUpdateStatusTaxRulesGroup.php @@ -0,0 +1,60 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\TaxRulesGroup; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Command\BulkSetTaxRulesGroupStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Exception\TaxRulesGroupException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/tax-rules-groups/bulk-update-status', + // No output 204 code + output: false, + CQRSCommand: BulkSetTaxRulesGroupStatusCommand::class, + CQRSCommandMapping: [ + '[enabled]' => '[expectedStatus]', + ], + scopes: [ + 'tax_rules_group_write', + ], + ), + ], + exceptionToStatus: [ + TaxRulesGroupException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkUpdateStatusTaxRulesGroup +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $taxRulesGroupIds; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/TaxRulesGroup/TaxRuleList.php b/src/ApiPlatform/Resources/TaxRulesGroup/TaxRuleList.php new file mode 100644 index 000000000..e19ebb953 --- /dev/null +++ b/src/ApiPlatform/Resources/TaxRulesGroup/TaxRuleList.php @@ -0,0 +1,71 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\TaxRulesGroup; + +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Exception\TaxRulesGroupNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\TaxRule\Query\GetTaxRuleList; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPaginate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSPaginate( + uriTemplate: '/tax-rules-groups/{taxRulesGroupId}/tax-rules', + requirements: ['taxRulesGroupId' => '\d+'], + CQRSQuery: GetTaxRuleList::class, + scopes: [ + 'tax_rules_group_read', + ], + CQRSQueryMapping: [ + '[_context][langId]' => '[languageId]', + ], + itemsField: 'taxRules', + countField: 'totalCount', + experimentalOperation: true, + ), + ], + exceptionToStatus: [ + TaxRulesGroupNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class TaxRuleList +{ + public int $taxRulesGroupId; + + public int $taxRuleId; + + public string $countryName; + + public string $stateName; + + public string $zipcode; + + public int $behavior; + + public string $taxName; + + public string $taxRate; + + public string $description; +} diff --git a/src/ApiPlatform/Resources/TaxRulesGroup/TaxRulesGroup.php b/src/ApiPlatform/Resources/TaxRulesGroup/TaxRulesGroup.php new file mode 100644 index 000000000..693b1228e --- /dev/null +++ b/src/ApiPlatform/Resources/TaxRulesGroup/TaxRulesGroup.php @@ -0,0 +1,117 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\TaxRulesGroup; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Command\AddTaxRulesGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Command\DeleteTaxRulesGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Command\EditTaxRulesGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Command\SetTaxRulesGroupStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Exception\CannotAddTaxRulesGroupException; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Exception\TaxRulesGroupNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Query\GetTaxRulesGroupForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/tax-rules-groups', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddTaxRulesGroupCommand::class, + scopes: ['tax_rules_group_write'], + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/tax-rules-groups/{taxRulesGroupId}', + requirements: ['taxRulesGroupId' => '\d+'], + output: false, + CQRSCommand: DeleteTaxRulesGroupCommand::class, + scopes: ['tax_rules_group_write'] + ), + new CQRSGet( + uriTemplate: '/tax-rules-groups/{taxRulesGroupId}', + requirements: ['taxRulesGroupId' => '\d+'], + CQRSQuery: GetTaxRulesGroupForEditing::class, + scopes: ['tax_rules_group_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/tax-rules-groups/{taxRulesGroupId}', + requirements: ['taxRulesGroupId' => '\d+'], + read: false, + CQRSCommand: EditTaxRulesGroupCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + CQRSQuery: GetTaxRulesGroupForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: ['tax_rules_group_write'], + ), + new CQRSPartialUpdate( + uriTemplate: '/tax-rules-groups/{taxRulesGroupId}/set-status', + requirements: ['taxRulesGroupId' => '\d+'], + output: false, + read: false, + CQRSCommand: SetTaxRulesGroupStatusCommand::class, + scopes: ['tax_rules_group_write'], + // No output 204 code + CQRSCommandMapping: [ + '[enabled]' => '[expectedStatus]', + ], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + CannotAddTaxRulesGroupException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + TaxRulesGroupNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class TaxRulesGroup +{ + #[ApiProperty(identifier: true)] + public int $taxRulesGroupId; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: 64)] + public string $name; + + #[Assert\NotNull(groups: ['Create'])] + public bool $enabled; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public const COMMAND_MAPPING = [ + '[shopIds]' => '[shopAssociation]', + ]; + + public const QUERY_MAPPING = [ + '[active]' => '[enabled]', + '[shopAssociationIds]' => '[shopIds]', + ]; +} diff --git a/src/ApiPlatform/Resources/TaxRulesGroup/TaxRulesGroupList.php b/src/ApiPlatform/Resources/TaxRulesGroup/TaxRulesGroupList.php new file mode 100644 index 000000000..ab6a057db --- /dev/null +++ b/src/ApiPlatform/Resources/TaxRulesGroup/TaxRulesGroupList.php @@ -0,0 +1,59 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\TaxRulesGroup; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\Exception\TaxRulesGroupNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\TaxRulesGroupFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/tax-rules-groups', + provider: QueryListProvider::class, + scopes: ['tax_rules_group_read'], + ApiResourceMapping: [ + '[id_tax_rules_group]' => '[taxRulesGroupId]', + '[active]' => '[enabled]', + ], + gridDataFactory: 'prestashop.core.grid.data.factory.tax_rules_group', + filtersClass: TaxRulesGroupFilters::class, + ), + ], + exceptionToStatus: [ + TaxRulesGroupNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class TaxRulesGroupList +{ + #[ApiProperty(identifier: true)] + public int $taxRulesGroupId; + + public string $name; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Title/BulkDeleteTitles.php b/src/ApiPlatform/Resources/Title/BulkDeleteTitles.php new file mode 100644 index 000000000..a897e26d3 --- /dev/null +++ b/src/ApiPlatform/Resources/Title/BulkDeleteTitles.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Title; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Title\Command\BulkDeleteTitleCommand; +use PrestaShop\PrestaShop\Core\Domain\Title\Exception\TitleNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/titles/bulk-delete', + CQRSCommand: BulkDeleteTitleCommand::class, + scopes: [ + 'title_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + TitleNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteTitles +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $titleIds; +} diff --git a/src/ApiPlatform/Resources/Title/Title.php b/src/ApiPlatform/Resources/Title/Title.php new file mode 100644 index 000000000..c260ebeab --- /dev/null +++ b/src/ApiPlatform/Resources/Title/Title.php @@ -0,0 +1,118 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Title; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Title\Command\AddTitleCommand; +use PrestaShop\PrestaShop\Core\Domain\Title\Command\DeleteTitleCommand; +use PrestaShop\PrestaShop\Core\Domain\Title\Command\EditTitleCommand; +use PrestaShop\PrestaShop\Core\Domain\Title\Exception\TitleConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Title\Exception\TitleNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Title\Query\GetTitleForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/titles', + CQRSCommand: AddTitleCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + scopes: ['title_write'], + inputFormats: ['multipart' => ['multipart/form-data']], + // Form data value are all string so we disable type enforcement + denormalizationContext: [ObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true], + ), + new CQRSDelete( + uriTemplate: '/titles/{titleId}', + requirements: ['titleId' => '\d+'], + output: false, + CQRSCommand: DeleteTitleCommand::class, + scopes: ['title_write'] + ), + new CQRSGet( + uriTemplate: '/titles/{titleId}', + requirements: ['titleId' => '\d+'], + CQRSQuery: GetTitleForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: ['title_read'], + ), + new CQRSPartialUpdate( + uriTemplate: '/titles/{titleId}', + requirements: ['titleId' => '\d+'], + read: false, + CQRSCommand: EditTitleCommand::class, + CQRSCommandMapping: self::COMMAND_MAPPING, + CQRSQuery: GetTitleForEditing::class, + CQRSQueryMapping: self::QUERY_MAPPING, + scopes: ['title_write'] + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + TitleNotFoundException::class => Response::HTTP_NOT_FOUND, + TitleConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class Title +{ + #[ApiProperty(identifier: true)] + public int $titleId; + + #[LocalizedValue] + #[Assert\NotBlank(groups: ['Create'])] + public array $names; + + public int $gender; + + public ?File $imgFile; + + public ?int $width; + + public ?int $height; + + public const QUERY_MAPPING = [ + '[localizedNames]' => '[names]', + ]; + + public const COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[width]' => '[imgWidth]', + '[height]' => '[imgHeight]', + ]; + + public function setGender(string|int $gender): self + { + $this->gender = (int) $gender; + + return $this; + } +} diff --git a/src/ApiPlatform/Resources/Title/TitleList.php b/src/ApiPlatform/Resources/Title/TitleList.php new file mode 100644 index 000000000..dcb219e9d --- /dev/null +++ b/src/ApiPlatform/Resources/Title/TitleList.php @@ -0,0 +1,59 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Title; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Title\Exception\TitleNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\TitleFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/titles', + provider: QueryListProvider::class, + scopes: ['title_read'], + ApiResourceMapping: [ + '[id_gender]' => '[titleId]', + '[type]' => '[gender]', + ], + gridDataFactory: 'prestashop.core.grid.data.factory.title', + filtersClass: TitleFilters::class, + ), + ], + exceptionToStatus: [ + TitleNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class TitleList +{ + #[ApiProperty(identifier: true)] + public int $titleId; + + public string $name; + + public int $gender; +} diff --git a/src/ApiPlatform/Resources/WebserviceKey/WebserviceKey.php b/src/ApiPlatform/Resources/WebserviceKey/WebserviceKey.php new file mode 100644 index 000000000..17f956505 --- /dev/null +++ b/src/ApiPlatform/Resources/WebserviceKey/WebserviceKey.php @@ -0,0 +1,153 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\WebserviceKey; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Webservice\Command\AddWebserviceKeyCommand; +use PrestaShop\PrestaShop\Core\Domain\Webservice\Command\EditWebserviceKeyCommand; +use PrestaShop\PrestaShop\Core\Domain\Webservice\Exception\WebserviceConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Webservice\Exception\WebserviceKeyNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Webservice\Query\GetWebserviceKeyForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\Form\Admin\Type\FormattedTextareaType; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/webservice-keys', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddWebserviceKeyCommand::class, + scopes: ['webservice_key_write'], + CQRSCommandMapping: [ + '[shopIds]' => '[associatedShops]', + '[enabled]' => '[status]', + ], + ), + new CQRSGet( + uriTemplate: '/webservice-keys/{webserviceKeyId}', + requirements: ['webserviceKeyId' => '\d+'], + CQRSQuery: GetWebserviceKeyForEditing::class, + scopes: ['webservice_key_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/webservice-keys/{webserviceKeyId}', + read: false, + CQRSCommand: EditWebserviceKeyCommand::class, + CQRSQuery: GetWebserviceKeyForEditing::class, + scopes: ['webservice_key_write'], + CQRSCommandMapping: [ + '[shopIds]' => '[shopAssociation]', + '[enabled]' => '[status]', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + WebserviceKeyNotFoundException::class => Response::HTTP_NOT_FOUND, + WebserviceConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class WebserviceKey +{ + #[ApiProperty(identifier: true)] + public int $webserviceKeyId; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 32, max: 32)] + public string $key; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: FormattedTextareaType::LIMIT_MEDIUMTEXT_UTF8_MB4)] + public string $description; + + #[Assert\NotNull(groups: ['Create'])] + public bool $enabled; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'array']])] + #[Assert\Collection( + fields: [ + 'DELETE' => new Assert\All( + constraints: [ + new Assert\Type(type: 'string'), + ], + groups: ['Create'] + ), + 'GET' => new Assert\All( + constraints: [ + new Assert\Type(type: 'string'), + ], + groups: ['Create'] + ), + 'HEAD' => new Assert\All( + constraints: [ + new Assert\Type(type: 'string'), + ], + groups: ['Create'] + ), + 'PATCH' => new Assert\All( + constraints: [ + new Assert\Type(type: 'string'), + ], + groups: ['Create'] + ), + 'PUT' => new Assert\All( + constraints: [ + new Assert\Type(type: 'string'), + ], + groups: ['Create'] + ), + 'POST' => new Assert\All( + constraints: [ + new Assert\Type(type: 'string'), + ], + groups: ['Create'] + ), + ], + allowMissingFields: true, + groups: ['Create'])] + public array $permissions; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public const QUERY_MAPPING = [ + '[status]' => '[enabled]', + '[resourcePermissions]' => '[permissions]', + '[associatedShops]' => '[shopIds]', + ]; + + public function setEnabled(string|bool $enabled): self + { + $this->enabled = (bool) $enabled; + + return $this; + } +} diff --git a/src/ApiPlatform/Resources/WebserviceKey/WebserviceKeyList.php b/src/ApiPlatform/Resources/WebserviceKey/WebserviceKeyList.php new file mode 100644 index 000000000..333b70124 --- /dev/null +++ b/src/ApiPlatform/Resources/WebserviceKey/WebserviceKeyList.php @@ -0,0 +1,61 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\WebserviceKey; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Webservice\Exception\WebserviceKeyNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\WebserviceKeyFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/webservice-keys', + provider: QueryListProvider::class, + scopes: ['webservice_key_read'], + ApiResourceMapping: [ + '[id_webservice_account]' => '[webserviceKeyId]', + '[active]' => '[enabled]', + ], + gridDataFactory: 'prestashop.core.grid.data_factory.webservice_key', + filtersClass: WebserviceKeyFilters::class, + ), + ], + exceptionToStatus: [ + WebserviceKeyNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class WebserviceKeyList +{ + #[ApiProperty(identifier: true)] + public int $webserviceKeyId; + + public string $key; + + public string $description; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Zone/BulkDeleteZones.php b/src/ApiPlatform/Resources/Zone/BulkDeleteZones.php new file mode 100644 index 000000000..ede26687b --- /dev/null +++ b/src/ApiPlatform/Resources/Zone/BulkDeleteZones.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Zone; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Zone\Command\BulkDeleteZoneCommand; +use PrestaShop\PrestaShop\Core\Domain\Zone\Exception\ZoneNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/zones/bulk-delete', + CQRSCommand: BulkDeleteZoneCommand::class, + scopes: [ + 'zone_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + ZoneNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkDeleteZones +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $zoneIds; +} diff --git a/src/ApiPlatform/Resources/Zone/BulkUpdateStatusZone.php b/src/ApiPlatform/Resources/Zone/BulkUpdateStatusZone.php new file mode 100644 index 000000000..09ce18c70 --- /dev/null +++ b/src/ApiPlatform/Resources/Zone/BulkUpdateStatusZone.php @@ -0,0 +1,59 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Zone; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Zone\Command\BulkToggleZoneStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\Zone\Exception\ZoneNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/zones/bulk-update-status', + output: false, + CQRSCommand: BulkToggleZoneStatusCommand::class, + CQRSCommandMapping: [ + '[enabled]' => '[expectedStatus]', + ], + scopes: [ + 'zone_write', + ], + ), + ], + exceptionToStatus: [ + ZoneNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkUpdateStatusZone +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $zoneIds; + + public bool $enabled; +} diff --git a/src/ApiPlatform/Resources/Zone/Zone.php b/src/ApiPlatform/Resources/Zone/Zone.php new file mode 100644 index 000000000..8432134b0 --- /dev/null +++ b/src/ApiPlatform/Resources/Zone/Zone.php @@ -0,0 +1,110 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Zone; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Zone\Command\AddZoneCommand; +use PrestaShop\PrestaShop\Core\Domain\Zone\Command\DeleteZoneCommand; +use PrestaShop\PrestaShop\Core\Domain\Zone\Command\EditZoneCommand; +use PrestaShop\PrestaShop\Core\Domain\Zone\Command\ToggleZoneStatusCommand; +use PrestaShop\PrestaShop\Core\Domain\Zone\Exception\ZoneNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Zone\Query\GetZoneForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/zones', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddZoneCommand::class, + scopes: ['zone_write'], + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/zones/{zoneId}', + requirements: ['zoneId' => '\d+'], + output: false, + CQRSCommand: DeleteZoneCommand::class, + scopes: ['zone_write'] + ), + new CQRSGet( + uriTemplate: '/zones/{zoneId}', + requirements: ['zoneId' => '\d+'], + CQRSQuery: GetZoneForEditing::class, + scopes: ['zone_read'], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSUpdate( + uriTemplate: '/zones/{zoneId}/toggle-status', + requirements: ['zoneId' => '\d+'], + output: false, + allowEmptyBody: true, + CQRSCommand: ToggleZoneStatusCommand::class, + scopes: ['zone_write'], + ), + new CQRSUpdate( + uriTemplate: '/zones/{zoneId}', + requirements: ['zoneId' => '\d+'], + read: false, + CQRSCommand: EditZoneCommand::class, + CQRSQuery: GetZoneForEditing::class, + scopes: ['zone_write'], + CQRSCommandMapping: self::COMMAND_MAPPING, + CQRSQueryMapping: self::QUERY_MAPPING, + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + ZoneNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Zone +{ + #[ApiProperty(identifier: true)] + public int $zoneId; + + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: 64)] + public string $name; + + #[Assert\NotNull(groups: ['Create'])] + public bool $enabled; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank(allowNull: true)] + public array $shopIds; + + public const COMMAND_MAPPING = [ + '[shopIds]' => '[shopAssociation]', + ]; + + public const QUERY_MAPPING = [ + '[associatedShops]' => '[shopIds]', + ]; +} diff --git a/src/ApiPlatform/Resources/Zone/ZoneList.php b/src/ApiPlatform/Resources/Zone/ZoneList.php new file mode 100644 index 000000000..7f5294d01 --- /dev/null +++ b/src/ApiPlatform/Resources/Zone/ZoneList.php @@ -0,0 +1,59 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Zone; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Zone\Exception\ZoneNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\ZoneFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; +use PrestaShopBundle\ApiPlatform\Provider\QueryListProvider; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/zones', + provider: QueryListProvider::class, + scopes: ['zone_read'], + ApiResourceMapping: [ + '[id_zone]' => '[zoneId]', + '[active]' => '[enabled]', + ], + gridDataFactory: 'prestashop.core.grid.data.factory.zone_decorator', + filtersClass: ZoneFilters::class, + ), + ], + exceptionToStatus: [ + ZoneNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class ZoneList +{ + #[ApiProperty(identifier: true)] + public int $zoneId; + + public string $name; + + public bool $enabled; +} diff --git a/src/Command/GenerateApiTrackingTableCommand.php b/src/Command/GenerateApiTrackingTableCommand.php new file mode 100644 index 000000000..653e0a82b --- /dev/null +++ b/src/Command/GenerateApiTrackingTableCommand.php @@ -0,0 +1,727 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Finder; + +class GenerateApiTrackingTableCommand extends Command +{ + private const REASON_BACK_OFFICE_UI = 'Back office UI feature, out of API scope'; + private const USELESS_DUPLICATE = 'Duplicate of another endpoint, not relevant for the Admin API'; + + /** + * CQRS commands and queries that are intentionally NOT meant to be exposed as Admin API + * endpoints: admin-UI-only features, internal helpers used by back office forms, or flows + * handled outside of the API. They are filtered out of the tracking table and of every + * metric (totals, percentages, per-domain progress). + */ + private const EXCLUDED_CQRS_CLASSES = [ + // Already covered by other endpoints, not relevant for the Admin API + 'GetCustomerForAddressCreation' => self::USELESS_DUPLICATE, + 'ResetEmployeePasswordCommand' => self::USELESS_DUPLICATE, + 'GetEmployeeEmailById' => self::USELESS_DUPLICATE, + // Quick access links are a back office UI customization feature. + 'AddQuickAccessCommand' => self::REASON_BACK_OFFICE_UI, + 'EditQuickAccessCommand' => self::REASON_BACK_OFFICE_UI, + 'DeleteQuickAccessCommand' => self::REASON_BACK_OFFICE_UI, + 'BulkDeleteQuickAccessCommand' => self::REASON_BACK_OFFICE_UI, + 'ToggleQuickAccessNewWindowCommand' => self::REASON_BACK_OFFICE_UI, + 'GetQuickAccessForEditing' => self::REASON_BACK_OFFICE_UI, + ]; + + private array $cqrsEndpoints = []; + private array $cqrsLookup = []; + + protected function configure(): void + { + $this + ->setName('prestashop:api:generate-tracking-table') + ->setAliases(['prestashop:generate-api-tracking']) + ->setDescription('Generate API tracking table for Admin API endpoints') + ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Output file', 'api-endpoints-tracking.md') + ->addOption('github-token', 'g', InputOption::VALUE_OPTIONAL, 'GitHub API token for PR status detection') + ->addOption('skip-github', null, InputOption::VALUE_NONE, 'Skip GitHub PR analysis for faster execution') + ->addOption('include-excluded', null, InputOption::VALUE_NONE, 'Count the endpoints flagged as irrelevant for the Admin API in the table and metrics'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $outputFile = $input->getOption('output'); + + try { + $io->title('🔍 PrestaShop API Tracking Table Generator'); + + // Step 1: Get all CQRS endpoints from the core + $io->section('🔍 Scanning CQRS endpoints from the core...'); + $cqrsEndpoints = $this->getAllCqrsEndpoints(); + $io->info(sprintf('Found %d CQRS endpoints', count($cqrsEndpoints))); + + // Step 1bis: Discard the commands/queries that are not meant to become API endpoints + $excludedEndpoints = []; + if (!$input->getOption('include-excluded')) { + [$cqrsEndpoints, $excludedEndpoints] = $this->splitExcludedEndpoints($cqrsEndpoints); + $io->info(sprintf( + 'Excluded %d CQRS endpoints not relevant for the Admin API (%d remaining)', + count($excludedEndpoints), + count($cqrsEndpoints) + )); + } + + // Step 2: Scan API Platform resources directly + $io->section('📄 Scanning API Platform resources...'); + $apiEndpoints = $this->scanApiPlatformResources(); + $io->info(sprintf('Found %d API endpoints with CQRS mappings', count($apiEndpoints))); + + // Step 3: Analyze GitHub PRs for status detection (if enabled) + $prStatusMap = []; + if (!$input->getOption('skip-github')) { + $io->section('🐙 Analyzing GitHub PRs for status detection...'); + $githubToken = $input->getOption('github-token'); + $prStatusMap = $this->analyzeGitHubPullRequests($githubToken, $io); + $io->info(sprintf('Found status information for %d endpoints from GitHub PRs', count($prStatusMap))); + } + + // Step 4: Compare and match + $io->section('🔍 Comparing CQRS endpoints with API implementations...'); + $matchedEndpoints = $this->compareCqrsWithApi($cqrsEndpoints, $apiEndpoints, $prStatusMap); + + $apiCount = count(array_filter($matchedEndpoints, fn ($e) => $e['has_api'])); + $io->info(sprintf('Matched %d CQRS endpoints with API implementations', $apiCount)); + + // Step 4: Generate markdown table + $io->section('📝 Generating markdown table...'); + $domainGroups = $this->processDomainGroups($matchedEndpoints); + $this->generateMarkdownTable($domainGroups, $excludedEndpoints, $outputFile); + + $totalEndpoints = count($matchedEndpoints); + $implementedCount = $this->countImplementedEndpoints($domainGroups); + $inProgressCount = $this->countInProgressEndpoints($domainGroups); + $percentage = $totalEndpoints > 0 ? round(($implementedCount / $totalEndpoints) * 100, 1) : 0; + $projectedPercentage = $totalEndpoints > 0 ? round((($implementedCount + $inProgressCount) / $totalEndpoints) * 100, 1) : 0; + + $summary = [ + 'API tracking table generated successfully!', + sprintf('📁 Saved to: %s', $outputFile), + sprintf( + '📊 Summary: %d implemented, %d in progress, %d missing (%s%% complete)', + $implementedCount, + $inProgressCount, + $totalEndpoints - $implementedCount - $inProgressCount, + $percentage + ), + sprintf( + '🔮 Projected after merging open PRs: %s%% (+%s%%)', + $projectedPercentage, + round($projectedPercentage - $percentage, 1) + ), + ]; + + if (!empty($excludedEndpoints)) { + $summary[] = sprintf('🚫 %d endpoints excluded from the metrics (not relevant for the Admin API)', count($excludedEndpoints)); + } + + $io->success($summary); + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error('Failed to generate API tracking table: ' . $e->getMessage()); + + return Command::FAILURE; + } + } + + private function getCqrsEndpointsFromCoreCommand(): array + { + $application = $this->getApplication(); + $command = $application->find('prestashop:list:commands-and-queries'); + + $input = new ArrayInput([]); + $output = new BufferedOutput(); + $command->run($input, $output); + + return $this->parseCqrsCommandOutput($output->fetch()); + } + + private function parseCqrsCommandOutput(string $output): array + { + $endpoints = []; + $lines = explode("\n", trim($output)); + $currentEndpoint = null; + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) { + continue; + } + + if (preg_match('/^\d+\.$/', $line)) { + if ($currentEndpoint) { + $endpoints[] = $currentEndpoint; + } + $currentEndpoint = ['class' => '', 'type' => '', 'domain' => '', 'action' => '']; + continue; + } + + if (0 === strpos($line, 'Class: ')) { + $class = substr($line, 7); + $currentEndpoint['class'] = $class; + + if (preg_match('/PrestaShop\\\\PrestaShop\\\\Core\\\\Domain\\\\([^\\\\]+)\\\\(?:.*\\\\)?(Command|Query)\\\\(.+)/', $class, $matches)) { + $currentEndpoint['domain'] = $matches[1]; + $currentEndpoint['action'] = $matches[3]; + } + continue; + } + + if (0 === strpos($line, 'Type: ')) { + $currentEndpoint['type'] = substr($line, 6); + continue; + } + } + + if ($currentEndpoint) { + $endpoints[] = $currentEndpoint; + } + + return $endpoints; + } + + private function scanApiPlatformResources(): array + { + $endpoints = []; + $resourcesPath = _PS_MODULE_DIR_ . 'ps_apiresources/src/ApiPlatform/Resources'; + + if (!is_dir($resourcesPath)) { + return $endpoints; + } + + $finder = new Finder(); + $finder->files()->name('*.php')->in($resourcesPath); + + foreach ($finder as $file) { + $content = file_get_contents($file->getRealPath()); + $mappings = $this->extractCqrsMappingsFromResourceFile($content); + $endpoints = array_merge($endpoints, $mappings); + } + + return $endpoints; + } + + private function extractCqrsMappingsFromResourceFile(string $content): array + { + $mappings = []; + + // Map CQRS operation types to HTTP methods + $operationPatterns = [ + 'CQRSCreate' => 'POST', + 'CQRSUpdate' => 'PUT', + 'CQRSPartialUpdate' => 'PATCH', + 'CQRSDelete' => 'DELETE', + 'CQRSGet' => 'GET', + ]; + + foreach ($operationPatterns as $operationType => $httpMethod) { + preg_match_all('/new\s+' . $operationType . '\s*\((.*?)\)/s', $content, $matches); + + foreach ($matches[1] as $operationContent) { + $mapping = $this->parseCqrsOperationContent($operationContent, $httpMethod); + if ($mapping) { + $mappings[$mapping['cqrs_class']] = $mapping; + } + } + } + + return $mappings; + } + + private function parseCqrsOperationContent(string $operationContent, string $httpMethod): ?array + { + // Extract uriTemplate + if (!preg_match('/uriTemplate:\s*[\'"]([^\'"]+)[\'"]/', $operationContent, $uriMatch)) { + return null; + } + $uriTemplate = $uriMatch[1]; + + // Extract CQRSCommand or CQRSQuery class + $cqrsClass = null; + if (preg_match('/CQRSCommand:\s*([^:,\s]+)::class/', $operationContent, $commandMatch)) { + $cqrsClass = trim($commandMatch[1]); + } elseif (preg_match('/CQRSQuery:\s*([^:,\s]+)::class/', $operationContent, $queryMatch)) { + $cqrsClass = trim($queryMatch[1]); + } + + if (!$cqrsClass) { + return null; + } + + // Convert short class name to full class name + $fullCqrsClass = $this->findFullCqrsClassName($cqrsClass); + if (!$fullCqrsClass) { + return null; + } + + return [ + 'uri' => $uriTemplate, + 'method' => $httpMethod, + 'cqrs_class' => $fullCqrsClass, + 'operation' => strtolower($httpMethod) . '_' . str_replace(['/', '{', '}'], ['_', '', ''], $uriTemplate), + 'summary' => '', + ]; + } + + private function findFullCqrsClassName(string $shortClassName): ?string + { + if (empty($this->cqrsLookup)) { + foreach ($this->getAllCqrsEndpoints() as $endpoint) { + $shortName = basename(str_replace('\\', '/', $endpoint['class'])); + $this->cqrsLookup[$shortName] = $endpoint['class']; + } + } + + return $this->cqrsLookup[$shortClassName] ?? null; + } + + private function getAllCqrsEndpoints(): array + { + if (empty($this->cqrsEndpoints)) { + $this->cqrsEndpoints = $this->getCqrsEndpointsFromCoreCommand(); + } + + return $this->cqrsEndpoints; + } + + /** + * Split the CQRS endpoints into the ones that are trackable as Admin API endpoints and the + * ones flagged in self::EXCLUDED_CQRS_CLASSES. Excluded endpoints keep their exclusion reason + * so it can be reported in the generated markdown. + * + * @return array{0: array, 1: array} [$trackable, $excluded] + */ + private function splitExcludedEndpoints(array $cqrsEndpoints): array + { + $trackable = []; + $excluded = []; + + foreach ($cqrsEndpoints as $endpoint) { + $reason = $this->getExclusionReason($endpoint['class']); + + if (null === $reason) { + $trackable[] = $endpoint; + continue; + } + + $endpoint['exclusion_reason'] = $reason; + $excluded[] = $endpoint; + } + + return [$trackable, $excluded]; + } + + /** + * Return why a CQRS class is not relevant as an Admin API endpoint, or null when it is. + * Both the short class name and the fully qualified class name are accepted as keys. + */ + private function getExclusionReason(string $cqrsClass): ?string + { + $shortName = basename(str_replace('\\', '/', $cqrsClass)); + + return self::EXCLUDED_CQRS_CLASSES[$cqrsClass] ?? self::EXCLUDED_CQRS_CLASSES[$shortName] ?? null; + } + + private function compareCqrsWithApi(array $cqrsEndpoints, array $apiEndpoints, array $prStatusMap = []): array + { + $matched = []; + + foreach ($cqrsEndpoints as $cqrs) { + $hasApi = isset($apiEndpoints[$cqrs['class']]); + $apiInfo = $hasApi ? $apiEndpoints[$cqrs['class']] : null; + + // Determine status from PR analysis + $prStatus = $prStatusMap[$cqrs['class']] ?? null; + + $matched[] = [ + 'class' => $cqrs['class'], + 'type' => $cqrs['type'], + 'domain' => $cqrs['domain'], + 'action' => $cqrs['action'], + 'has_api' => $hasApi, + 'api' => $hasApi ? $apiInfo['method'] . ' ' . $apiInfo['uri'] : '', + 'api_info' => $apiInfo, + 'pr_status' => $prStatus, + ]; + } + + return $matched; + } + + private function analyzeGitHubPullRequests(?string $githubToken, SymfonyStyle $io): array + { + $statusMap = []; + $repoOwner = 'PrestaShop'; + $repoName = 'ps_apiresources'; + + try { + // Fetch open PRs only + $openPRs = $this->fetchGitHubPRs($repoOwner, $repoName, 'open', $githubToken); + + $io->text(sprintf(' Found %d open PRs', count($openPRs))); + + // Analyze open PRs for "In Progress" status + foreach ($openPRs as $pr) { + $changedEndpoints = $this->analyzePRChanges($pr, $githubToken); + foreach ($changedEndpoints as $endpoint) { + $statusMap[$endpoint] = [ + 'status' => '🚧 In Progress', + 'pr_url' => $pr['html_url'], + 'pr_title' => $pr['title'], + 'author' => $pr['user']['login'] ?? 'Unknown', + ]; + } + } + } catch (\Exception $e) { + $io->warning('GitHub API analysis failed: ' . $e->getMessage()); + $io->text('Continuing without PR status detection...'); + } + + return $statusMap; + } + + private function processDomainGroups(array $allEndpoints): array + { + $domainGroups = []; + foreach ($allEndpoints as $endpoint) { + $domain = $endpoint['domain'] ?: 'Unknown'; + + if (!isset($domainGroups[$domain])) { + $domainGroups[$domain] = []; + } + + $hasApi = $endpoint['has_api']; + $prStatus = $endpoint['pr_status'] ?? null; + + // Determine final status based on API implementation and PR status + $finalStatus = '❌ Missing'; + $authorInfo = ''; + + if ($hasApi) { + $finalStatus = '✅ Implemented'; + } elseif ($prStatus) { + $finalStatus = $prStatus['status']; + $authorInfo = $prStatus['author']; + } + + $domainGroups[$domain][] = [ + 'action' => $endpoint['action'] ?: basename(str_replace('\\', '/', $endpoint['class'])), + 'type' => $endpoint['type'], + 'hasApi' => $hasApi, + 'api' => $endpoint['api'], + 'status' => $finalStatus, + 'author' => $authorInfo, + 'pr_info' => $prStatus, + ]; + } + + ksort($domainGroups); + + foreach ($domainGroups as $domain => &$endpoints) { + usort($endpoints, function ($a, $b) { + if ($a['type'] !== $b['type']) { + return 'Command' === $a['type'] ? -1 : 1; + } + + return strcasecmp($a['action'], $b['action']); + }); + } + + return $domainGroups; + } + + private function countImplementedEndpoints(array $domainGroups): int + { + $count = 0; + foreach ($domainGroups as $endpoints) { + foreach ($endpoints as $endpoint) { + if ($endpoint['hasApi']) { + ++$count; + } + } + } + + return $count; + } + + private function countInProgressEndpoints(array $domainGroups): int + { + $count = 0; + foreach ($domainGroups as $endpoints) { + foreach ($endpoints as $endpoint) { + if (str_contains($endpoint['status'], '🚧 In Progress')) { + ++$count; + } + } + } + + return $count; + } + + private function statusEmoji(string $status): string + { + if (str_contains($status, 'Implemented')) { + return '✅'; + } + + if (str_contains($status, 'In Progress')) { + return '🚧'; + } + + return '❌'; + } + + private function generateMarkdownTable(array $domainGroups, array $excludedEndpoints, string $outputFile): void + { + $totalEndpoints = array_sum(array_map('count', $domainGroups)); + $implementedCount = $this->countImplementedEndpoints($domainGroups); + $inProgressCount = $this->countInProgressEndpoints($domainGroups); + $missingCount = $totalEndpoints - $implementedCount - $inProgressCount; + $percentage = $totalEndpoints > 0 ? round(($implementedCount / $totalEndpoints) * 100, 1) : 0; + $projectedPercentage = $totalEndpoints > 0 ? round((($implementedCount + $inProgressCount) / $totalEndpoints) * 100, 1) : 0; + + $markdown = "# PrestaShop API Endpoints - Tracking\n\n"; + $markdown .= "This table tracks the progress of API endpoint implementations for PrestaShop CQRS commands and queries.\n\n"; + $markdown .= "## 📊 Overall Progress\n\n"; + $markdown .= "- **Total Endpoints**: $totalEndpoints\n"; + $markdown .= "- **Implemented**: $implementedCount ✅\n"; + $markdown .= "- **In Progress**: $inProgressCount 🚧\n"; + $markdown .= "- **Missing**: $missingCount ❌\n"; + $markdown .= "- **Progress**: $percentage%\n"; + $markdown .= "- **Projected progress (if all open PRs merged)**: $projectedPercentage% 🔮\n"; + + if (!empty($excludedEndpoints)) { + $markdown .= '- **Excluded**: ' . count($excludedEndpoints) . " 🚫 *(not relevant for the Admin API, not counted above)*\n"; + } + + $markdown .= "\n---\n\n"; + + foreach ($domainGroups as $domain => $endpoints) { + $domainImplemented = count(array_filter($endpoints, fn ($e) => $e['hasApi'])); + $domainTotal = count($endpoints); + $domainPercentage = $domainTotal > 0 ? round(($domainImplemented / $domainTotal) * 100, 1) : 0; + + $markdown .= "## 🏷️ $domain\n\n"; + $markdown .= "$domainImplemented/$domainTotal ($domainPercentage%)\n\n"; + $markdown .= "| Action | Type | Status | API Endpoint | Author / PR |\n"; + $markdown .= "|--------|------|--------|--------------|-------------|\n"; + + foreach ($endpoints as $endpoint) { + $action = '`' . $endpoint['action'] . '`'; + $type = $endpoint['type']; + // Keep only the emoji in the table, the legend explains it. + $status = $this->statusEmoji($endpoint['status']); + $api = $endpoint['api']; + + // Build author/PR info + $authorInfo = ''; + if (!empty($endpoint['author'])) { + $authorInfo = $endpoint['author']; + if ($endpoint['pr_info'] && !empty($endpoint['pr_info']['pr_url'])) { + $authorInfo = "{$endpoint['author']} / [PR]({$endpoint['pr_info']['pr_url']})"; + } + } + + $markdown .= "| $action | $type | $status | $api | $authorInfo |\n"; + } + + $markdown .= "\n"; + } + + $markdown .= $this->renderExcludedSection($excludedEndpoints); + + $markdown .= "## 📋 Status Legend\n\n"; + $markdown .= "- ✅ **Implemented**: API endpoint is available and working\n"; + $markdown .= "- 🚧 **In Progress**: Someone is actively working on this endpoint (PR open)\n"; + $markdown .= "- ❌ **Missing**: API endpoint needs to be implemented\n"; + $markdown .= "- 🚫 **Excluded**: Command / query intentionally not exposed through the Admin API\n\n"; + $markdown .= '*Last updated: ' . date('Y-m-d H:i:s') . "*\n"; + + file_put_contents($outputFile, $markdown); + } + + /** + * Render the informative table of the commands/queries deliberately kept out of the Admin API. + * They are listed for transparency only and are not part of any metric above. + */ + private function renderExcludedSection(array $excludedEndpoints): string + { + if (empty($excludedEndpoints)) { + return ''; + } + + usort($excludedEndpoints, function ($a, $b) { + if ($a['domain'] !== $b['domain']) { + return strcasecmp($a['domain'], $b['domain']); + } + + return strcasecmp($a['action'], $b['action']); + }); + + $markdown = "## 🚫 Excluded from tracking\n\n"; + $markdown .= "These CQRS commands and queries are intentionally **not** exposed through the Admin API.\n"; + $markdown .= "(totals, percentages and per-domain progress).\n\n"; + $markdown .= "| Action | Type | Domain | Reason |\n"; + $markdown .= "|--------|------|--------|--------|\n"; + + foreach ($excludedEndpoints as $endpoint) { + $action = $endpoint['action'] ?: basename(str_replace('\\', '/', $endpoint['class'])); + $domain = $endpoint['domain'] ?: 'Unknown'; + + $markdown .= sprintf("| `%s` | %s | %s | %s |\n", $action, $endpoint['type'], $domain, $endpoint['exclusion_reason']); + } + + return $markdown . "\n"; + } + + private function fetchGitHubPRs(string $owner, string $repo, string $state, ?string $token, int $limit = 500): array + { + // GitHub caps per_page at 100, so we paginate to collect more than 100 PRs. + $perPage = 100; + $headers = [ + 'User-Agent: PrestaShop-API-Tracker', + 'Accept: application/vnd.github.v3+json', + ]; + + if ($token) { + $headers[] = "Authorization: token {$token}"; + } + + $context = stream_context_create([ + 'http' => [ + 'header' => implode("\r\n", $headers), + 'method' => 'GET', + ], + ]); + + $prs = []; + for ($page = 1; count($prs) < $limit; ++$page) { + $url = "https://api.github.com/repos/{$owner}/{$repo}/pulls?state={$state}&per_page={$perPage}&page={$page}"; + + $response = file_get_contents($url, false, $context); + if (false === $response) { + throw new \RuntimeException('Failed to fetch PRs from GitHub API'); + } + + $batch = json_decode($response, true); + if (!is_array($batch) || [] === $batch) { + break; + } + + $prs = array_merge($prs, $batch); + + // Last page reached when GitHub returns fewer than a full page. + if (count($batch) < $perPage) { + break; + } + } + + return array_slice($prs, 0, $limit); + } + + private function analyzePRChanges(array $pr, ?string $token): array + { + $endpoints = []; + + try { + // Fetch PR files + $url = $pr['url'] . '/files'; + $headers = [ + 'User-Agent: PrestaShop-API-Tracker', + 'Accept: application/vnd.github.v3+json', + ]; + + if ($token) { + $headers[] = "Authorization: token {$token}"; + } + + $context = stream_context_create([ + 'http' => [ + 'header' => implode("\r\n", $headers), + 'method' => 'GET', + ], + ]); + + $response = file_get_contents($url, false, $context); + if (false === $response) { + return $endpoints; + } + + $files = json_decode($response, true); + + foreach ($files as $file) { + $filename = $file['filename']; + + // Check if it's an API resource file + if (str_contains($filename, 'src/ApiPlatform/Resources/') && str_ends_with($filename, '.php')) { + // Extract CQRS references from the patch + $patch = $file['patch'] ?? ''; + $foundEndpoints = $this->extractCqrsFromPatch($patch); + $endpoints = array_merge($endpoints, $foundEndpoints); + } + } + } catch (\Exception $e) { + // Silently continue if we can't analyze PR changes + } + + return array_unique($endpoints); + } + + /** + * Extract CQRS command/query references from a git patch. + */ + private function extractCqrsFromPatch(string $patch): array + { + $endpoints = []; + + // Look for CQRS command/query references in added lines + preg_match_all('/\+.*CQRSCommand:\s*([A-Za-z]+)::class/', $patch, $commandMatches); + preg_match_all('/\+.*CQRSQuery:\s*([A-Za-z]+)::class/', $patch, $queryMatches); + + $allMatches = array_merge($commandMatches[1], $queryMatches[1]); + + foreach ($allMatches as $shortClassName) { + $fullClassName = $this->findFullCqrsClassName($shortClassName); + if ($fullClassName) { + $endpoints[] = $fullClassName; + } + } + + return $endpoints; + } +} diff --git a/src/Serializer/QueryParameterTypeCastSerializer.php b/src/Serializer/QueryParameterTypeCastSerializer.php new file mode 100644 index 000000000..ce3af5978 --- /dev/null +++ b/src/Serializer/QueryParameterTypeCastSerializer.php @@ -0,0 +1,83 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\Serializer; + +use PrestaShopBundle\ApiPlatform\Serializer\CQRSApiSerializer; + +/** + * Extends CQRSApiSerializer to add automatic type casting for query parameters. + */ +class QueryParameterTypeCastSerializer extends CQRSApiSerializer +{ + /** + * {@inheritdoc} + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if (is_array($data) && str_starts_with($type, 'PrestaShop\\PrestaShop\\Core\\Domain\\')) { + $data = $this->castQueryParametersToExpectedTypes($data, $type); + } + + return parent::denormalize($data, $type, $format, $context); + } + + /** + * Cast string query parameter values to their expected types based on the CQRS query constructor. + */ + private function castQueryParametersToExpectedTypes(array $data, string $queryClass): array + { + try { + $reflection = new \ReflectionClass($queryClass); + $constructor = $reflection->getConstructor(); + + if (!$constructor) { + return $data; + } + + foreach ($constructor->getParameters() as $parameter) { + $paramName = $parameter->getName(); + + if (!array_key_exists($paramName, $data) || !is_string($data[$paramName])) { + continue; + } + + $type = $parameter->getType(); + + if (!$type instanceof \ReflectionNamedType || !$type->isBuiltin()) { + continue; + } + + $data[$paramName] = match ($type->getName()) { + 'int' => (int) $data[$paramName], + 'float' => (float) $data[$paramName], + 'bool' => filter_var($data[$paramName], FILTER_VALIDATE_BOOLEAN), + default => $data[$paramName], + }; + } + } catch (\ReflectionException $e) { + return $data; + } + + return $data; + } +} diff --git a/src/Validation/Constraints/ValidAddressFormat.php b/src/Validation/Constraints/ValidAddressFormat.php new file mode 100644 index 000000000..7b343bd1a --- /dev/null +++ b/src/Validation/Constraints/ValidAddressFormat.php @@ -0,0 +1,42 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\Validation\Constraints; + +use Symfony\Component\Validator\Constraint; + +/** + * Module-local mirror of the core ValidAddressFormat constraint, kept here so + * the module remains autoloadable on PrestaShop versions that pre-date the + * core constraint (introduced in 9.2). The validator delegates to the core + * AddressFormatCheckerInterface when available and is a no-op otherwise. + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class ValidAddressFormat extends Constraint +{ + public string $message = 'Invalid address format: %errors%'; + + public function validatedBy(): string + { + return ValidAddressFormatValidator::class; + } +} diff --git a/src/Validation/Constraints/ValidAddressFormatValidator.php b/src/Validation/Constraints/ValidAddressFormatValidator.php new file mode 100644 index 000000000..ca0c215de --- /dev/null +++ b/src/Validation/Constraints/ValidAddressFormatValidator.php @@ -0,0 +1,69 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\Validation\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +class ValidAddressFormatValidator extends ConstraintValidator +{ + /** + * @param object|null $checker Optional core AddressFormatCheckerInterface instance. + * Type-hinted as object so this validator class is + * autoloadable on PrestaShop versions where the + * AddressFormatCheckerInterface symbol does not exist + * (pre-9.2). Service wiring uses '@?…' so the argument + * resolves to null when the alias is missing. + */ + public function __construct(private readonly ?object $checker = null) + { + } + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof ValidAddressFormat) { + throw new UnexpectedTypeException($constraint, ValidAddressFormat::class); + } + + // No core checker available (pre-9.2) — skip validation entirely so + // the field behaves as it did before the constraint was introduced. + if (null === $this->checker) { + return; + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + $errors = $this->checker->validate($value); + foreach ($errors as $error) { + $this->context->buildViolation($error)->addViolation(); + } + } +} diff --git a/src/Validation/IframeValidationGroupsResolver.php b/src/Validation/IframeValidationGroupsResolver.php new file mode 100644 index 000000000..b12fb184f --- /dev/null +++ b/src/Validation/IframeValidationGroupsResolver.php @@ -0,0 +1,47 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\Validation; + +use PrestaShop\PrestaShop\Adapter\Configuration; + +final class IframeValidationGroupsResolver +{ + public function __construct(private Configuration $config) + { + } + + public function create(): array + { + return ['groups' => array_filter(['Default', 'Create', $this->flagGroup()])]; + } + + public function update(): array + { + return ['groups' => array_filter(['Default', 'Update', $this->flagGroup()])]; + } + + private function flagGroup(): string + { + $allowIframe = (bool) ($this->config->get('PS_ALLOW_HTML_IFRAME') ?: false); + + return $allowIframe ? 'AllowIframe' : 'NoIframe'; + } +} diff --git a/tests/Integration/ApiPlatform/AddressEndpointTest.php b/tests/Integration/ApiPlatform/AddressEndpointTest.php new file mode 100644 index 000000000..87745b6aa --- /dev/null +++ b/tests/Integration/ApiPlatform/AddressEndpointTest.php @@ -0,0 +1,501 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class AddressEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + DatabaseDump::restoreTables(['address', 'customer', 'manufacturer']); + DatabaseDump::restoreMatchingTables('*cart*'); + DatabaseDump::restoreMatchingTables('*order*'); + self::createApiClient(['address_write', 'address_read']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + // Reset DB as it was before this test + DatabaseDump::restoreTables(['address', 'customer', 'manufacturer']); + DatabaseDump::restoreMatchingTables('*cart*'); + DatabaseDump::restoreMatchingTables('*order*'); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get customer address endpoint' => [ + 'GET', + '/addresses/customers/1', + ]; + + yield 'create customer address endpoint' => [ + 'POST', + '/addresses/customers', + ]; + + yield 'update customer address endpoint' => [ + 'PATCH', + '/addresses/customers/1', + ]; + + yield 'delete address endpoint' => [ + 'DELETE', + '/addresses/1', + ]; + + yield 'list addresses endpoint' => [ + 'GET', + '/addresses', + ]; + + yield 'bulk delete addresses endpoint' => [ + 'DELETE', + '/addresses/bulk-delete', + ]; + + yield 'get manufacturer address endpoint' => [ + 'GET', + '/addresses/manufacturers/1', + ]; + + yield 'create manufacturer address endpoint' => [ + 'POST', + '/addresses/manufacturers', + ]; + + yield 'update manufacturer address endpoint' => [ + 'PATCH', + '/addresses/manufacturers/1', + ]; + + yield 'update cart delivery address endpoint' => [ + 'PATCH', + '/addresses/carts/1', + ]; + + yield 'update order delivery address endpoint' => [ + 'PATCH', + '/addresses/orders/1', + ]; + } + + public function testAddManufacturerAddress(): int + { + $postData = [ + 'manufacturerId' => 1, + 'firstName' => 'Manu', + 'lastName' => 'Facturer', + 'address' => '11 Rue du Fabricant', + 'address2' => 'Bâtiment B', + 'city' => 'Paris', + 'postCode' => '75001', + 'countryId' => 8, + 'stateId' => 0, + 'homePhone' => '0123456789', + 'mobilePhone' => '0612345678', + 'other' => 'Entrée côté quai', + 'dni' => 'ABC123456', + ]; + + $manufacturerAddress = $this->createItem('/addresses/manufacturers', $postData, ['address_write']); + $this->assertArrayHasKey('addressId', $manufacturerAddress); + $addressId = $manufacturerAddress['addressId']; + + foreach ($postData as $key => $value) { + $this->assertEquals($value, $manufacturerAddress[$key], 'Manufacturer address data mismatch for key: ' . $key); + } + + return $addressId; + } + + /** + * @depends testAddManufacturerAddress + */ + public function testUpdateManufacturerAddress(int $addressId): int + { + $updatedData = [ + 'firstName' => 'Marie', + 'lastName' => 'Fab', + 'address' => '22 Avenue Industrie', + 'city' => 'Lyon', + 'postCode' => '69001', + 'homePhone' => '0199999999', + ]; + + $updated = $this->partialUpdateItem('/addresses/manufacturers/' . $addressId, $updatedData, ['address_write']); + $this->assertEquals($addressId, $updated['addressId']); + foreach ($updatedData as $key => $value) { + $this->assertEquals($value, $updated[$key], 'Manufacturer address update mismatch for key: ' . $key); + } + + return $addressId; + } + + /** + * @depends testUpdateManufacturerAddress + */ + public function testGetManufacturerAddress(int $addressId): void + { + $expected = $this->getItem('/addresses/manufacturers/' . $addressId, ['address_read']); + $this->assertEquals($addressId, $expected['addressId']); + $this->assertEquals('Marie', $expected['firstName']); + $this->assertEquals('Fab', $expected['lastName']); + $this->assertEquals('22 Avenue Industrie', $expected['address']); + $this->assertEquals('Lyon', $expected['city']); + $this->assertEquals('69001', $expected['postCode']); + } + + public function testUpdateCartAddress(): void + { + $expectedData = [ + 'cartId' => 1, + 'customerId' => 1, + 'addressAlias' => 'My address', + 'firstName' => 'John', + 'lastName' => 'DOE', + 'address' => '16, Main street', + 'address2' => '2nd floor', + 'city' => 'Miami', + 'postCode' => '33133', + 'countryId' => 21, + 'stateId' => 12, + 'homePhone' => '0102030405', + 'mobilePhone' => '', + 'company' => 'My Company', + 'vatNumber' => '', + 'other' => '', + 'dni' => '', + ]; + $addressTypes = [ + 'invoice_address', + 'delivery_address', + ]; + + foreach ($addressTypes as $addressType) { + // Use cart ID 1 from test database + $updatedCart = $this->partialUpdateItem('/addresses/carts/1', [ + 'addressType' => $addressType, + ], ['address_write']); + // The addressId may have been updated if the address was already used and duplicated + $expectedData['addressId'] = $updatedCart['addressId']; + // addressType is not in the result anyway + $this->assertEquals($expectedData, $updatedCart); + } + } + + public function testUpdateCartAddressNotFound(): void + { + $updatedData = [ + 'addressType' => 'delivery_address', + 'firstName' => 'Cart', + 'lastName' => 'User', + 'address' => '10 Rue du Panier', + 'city' => 'Marseille', + 'postCode' => '13001', + ]; + + // Expect 404 for non existing cart id + $this->partialUpdateItem('/addresses/carts/999999', $updatedData, ['address_write'], Response::HTTP_NOT_FOUND); + } + + public function testUpdateOrderAddress(): void + { + // First, create a customer address that we'll use for the order + $addressData = [ + 'customerId' => 1, + 'addressAlias' => 'Order Test Address', + 'firstName' => 'Order', + 'lastName' => 'Test', + 'address' => '1 street Example', + 'city' => 'Orleans', + 'postCode' => '45000', + 'countryId' => 8, + 'stateId' => 0, + ]; + $customerAddress = $this->createItem('/addresses/customers', $addressData, ['address_write']); + $addressId = $customerAddress['addressId']; + $expectedAddress = [ + 'addressId' => $addressId, + 'address2' => '', + 'dni' => '', + 'company' => '', + 'vatNumber' => '', + 'homePhone' => '', + 'mobilePhone' => '', + 'other' => '', + ] + $addressData; + $this->assertEquals($expectedAddress, $customerAddress); + + // Create a minimal order with this address + $order = new \Order(); + $order->id_customer = 1; + $order->id_address_invoice = $addressId; + $order->id_address_delivery = $addressId; + $order->id_cart = 1; + $order->id_currency = 1; + $order->id_carrier = 1; + $order->id_shop = 1; + $order->id_shop_group = 1; + $order->payment = 'Payment by check'; + $order->module = 'ps_checkpayment'; + $order->total_paid = $order->total_paid_real = $order->total_paid_tax_incl = $order->total_paid_tax_excl = 42; + $order->total_products = $order->total_products_wt = 42; + $order->conversion_rate = 1.0; + $order->save(); + $orderId = $order->id; + + $addressTypes = [ + 'invoice_address', + 'delivery_address', + ]; + + foreach ($addressTypes as $addressType) { + $updatedAddress = $this->partialUpdateItem('/addresses/orders/' . $orderId, [ + 'addressType' => $addressType, + ], ['address_write']); + // The addressId may have been updated if the address was already used and duplicated + $expectedAddress['addressId'] = $updatedAddress['addressId']; + // The returned JSON contains the address + the orderId from the URL + $expectedAddress['orderId'] = $orderId; + // addressType is not in the result anyway + $this->assertEquals($expectedAddress, $updatedAddress); + } + + // Cleanup: delete the order + $order->delete(); + } + + public function testUpdateOrderAddressNotFound(): void + { + $updatedData = [ + 'addressType' => 'delivery_address', + 'firstName' => 'Order', + 'lastName' => 'User', + 'address' => '11 Rue de la Paix', + 'city' => 'Paris', + 'postCode' => '75002', + ]; + + // Expect 422 because underlying address resolution fails when order is invalid + $this->partialUpdateItem('/addresses/orders/999999', $updatedData, ['address_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testAddCustomerAddress(): int + { + $postData = [ + 'customerId' => 1, + 'addressAlias' => 'Test Address', + 'firstName' => 'John', + 'lastName' => 'Doe', + 'address' => '123 Test Street', + 'address2' => 'Building 1', + 'city' => 'Test City', + 'postCode' => '12345', + 'countryId' => 8, // France + 'dni' => '1234567890', + 'company' => 'Test Company', + 'vatNumber' => 'FR12345678901', + 'stateId' => 0, + 'homePhone' => '0123456789', + 'mobilePhone' => '0612345678', + 'other' => 'Test Other', + ]; + + $customerAddress = $this->createItem('/addresses/customers', $postData, ['address_write']); + $this->assertArrayHasKey('addressId', $customerAddress); + $addressId = $customerAddress['addressId']; + + // Check that all data was saved correctly + foreach ($postData as $key => $value) { + $this->assertEquals($value, $customerAddress[$key], 'Address data mismatch for key: ' . $key); + } + + return $addressId; + } + + /** + * @depends testAddCustomerAddress + * + * @param int $addressId + * + * @return int + */ + public function testListAddresses(int $addressId): int + { + $addresses = $this->listItems('/addresses?orderBy=addressId&sortOrder=desc', ['address_read']); + $this->assertGreaterThan(0, $addresses['totalItems']); + + // Check the details to make sure filters mapping is correct + $this->assertEquals('addressId', $addresses['orderBy']); + + // Test address should be the first returned in the list + $testAddress = $addresses['items'][0]; + $expectedAddress = [ + 'addressId' => $addressId, + 'address1' => '123 Test Street', + 'city' => 'Test City', + ]; + foreach ($expectedAddress as $key => $value) { + $this->assertEquals($value, $testAddress[$key], 'Address data mismatch for key: ' . $key); + } + + return $addressId; + } + + /** + * @depends testAddCustomerAddress + * + * @param int $addressId + * + * @return int + */ + public function testUpdateCustomerAddress(int $addressId): int + { + $updatedData = [ + 'addressAlias' => 'Updated Test Address', + 'firstName' => 'Jane', + 'lastName' => 'Smith', + 'address' => '456 Updated Street', + 'city' => 'Updated City', + 'postCode' => '54321', + 'company' => 'Updated Company', + ]; + + $updatedAddress = $this->partialUpdateItem('/addresses/customers/' . $addressId, $updatedData, ['address_write']); + + // Check that the data was updated correctly + $this->assertArrayHasKey('addressId', $updatedAddress); + $this->assertEquals($addressId, $updatedAddress['addressId'], 'Address ID mismatch after update'); + foreach ($updatedData as $key => $value) { + $this->assertEquals($value, $updatedAddress[$key], 'Address data mismatch for key: ' . $key); + } + + return $addressId; + } + + /** + * @depends testUpdateCustomerAddress + * + * @param int $addressId + * + * @return int + */ + public function testGetCustomerAddress(int $addressId): int + { + $expectedData = [ + 'addressAlias' => 'Updated Test Address', + 'firstName' => 'Jane', + 'lastName' => 'Smith', + 'address' => '456 Updated Street', + 'city' => 'Updated City', + 'postCode' => '54321', + 'company' => 'Updated Company', + ]; + + $customerAddress = $this->getItem('/addresses/customers/' . $addressId, ['address_read']); + + $this->assertEquals($addressId, $customerAddress['addressId']); + foreach ($expectedData as $key => $value) { + $this->assertEquals($value, $customerAddress[$key], 'Address data mismatch for key: ' . $key); + } + + return $addressId; + } + + /** + * @depends testGetCustomerAddress + */ + public function testBulkDeleteAddresses(int $addressId): void + { + // Create a second address for bulk delete test + $postData = [ + 'customerId' => 1, + 'addressAlias' => 'Second Test Address', + 'firstName' => 'Bob', + 'lastName' => 'Wilson', + 'address' => '789 Second Street', + 'city' => 'Second City', + 'postCode' => '67890', + 'countryId' => 8, + 'stateId' => 0, + ]; + $secondAddress = $this->createItem('/addresses/customers', $postData, ['address_write']); + $secondAddressId = $secondAddress['addressId']; + + // Test bulk delete + $bulkDeleteData = [ + 'addressIds' => [$addressId, $secondAddressId], + ]; + $this->bulkDeleteItems('/addresses/bulk-delete', $bulkDeleteData, ['address_write']); + + // Verify addresses were deleted + $this->getItem('/addresses/customers/' . $addressId, ['address_read'], Response::HTTP_NOT_FOUND); + $this->getItem('/addresses/customers/' . $secondAddressId, ['address_read'], Response::HTTP_NOT_FOUND); + } + + public function testDeleteSingleAddress(): void + { + // Create an address to delete + $postData = [ + 'customerId' => 1, + 'addressAlias' => 'Address to Delete', + 'firstName' => 'Delete', + 'lastName' => 'Test', + 'address' => '999 Delete Street', + 'city' => 'Delete City', + 'postCode' => '99999', + 'countryId' => 8, + 'stateId' => 0, + ]; + $address = $this->createItem('/addresses/customers', $postData, ['address_write']); + $addressId = $address['addressId']; + + // Delete and check + $this->deleteItem('/addresses/' . $addressId, ['address_write']); + $this->getItem('/addresses/customers/' . $addressId, ['address_read'], Response::HTTP_NOT_FOUND); + } + + public function testAddressValidation(): void + { + // Test with invalid data to check validation + $invalidData = [ + 'customerId' => 1, + 'addressAlias' => 'Test', + 'firstName' => 'John', + 'lastName' => 'Doe', + 'address' => '', // Empty address should fail validation + 'city' => 'Test City', + 'postCode' => '12345', + 'countryId' => 8, + 'stateId' => 0, + ]; + + // Use the createItem method but expect it to fail with validation error + $this->createItem('/addresses/customers', $invalidData, ['address_write'], 422); + } +} diff --git a/tests/Integration/ApiPlatform/ApiClientEndpointTest.php b/tests/Integration/ApiPlatform/ApiClientEndpointTest.php index 99745db5c..8e081d1ec 100644 --- a/tests/Integration/ApiPlatform/ApiClientEndpointTest.php +++ b/tests/Integration/ApiPlatform/ApiClientEndpointTest.php @@ -35,26 +35,26 @@ public static function setUpBeforeClass(): void self::createApiClient(['api_client_write', 'api_client_read']); } - public function getProtectedEndpoints(): iterable + public static function getProtectedEndpoints(): iterable { yield 'get endpoint' => [ 'GET', - '/api-client/1', + '/api-clients/1', ]; yield 'create endpoint' => [ 'POST', - '/api-client', + '/api-clients', ]; yield 'update endpoint' => [ 'PATCH', - '/api-client/1', + '/api-clients/1', ]; yield 'delete endpoint' => [ 'DELETE', - '/api-client/1', + '/api-clients/1', ]; yield 'list endpoint' => [ @@ -67,7 +67,7 @@ public function testAddApiClient(): int { $itemsCount = $this->countItems('/api-clients', ['api_client_read']); - $apiClient = $this->createItem('/api-client', [ + $apiClient = $this->createItem('/api-clients', [ 'clientId' => 'client_id_test', 'clientName' => 'Client name test', 'description' => 'Client description test', @@ -106,7 +106,7 @@ public function testAddApiClient(): int */ public function testGetApiClient(int $apiClientId): int { - $apiClient = $this->getItem('/api-client/' . $apiClientId, ['api_client_read']); + $apiClient = $this->getItem('/api-clients/' . $apiClientId, ['api_client_read']); $this->assertEquals( [ 'apiClientId' => $apiClientId, @@ -137,7 +137,7 @@ public function testGetApiClient(int $apiClientId): int public function testUpdateApiClient(int $apiClientId): int { // Update API client - $updatedApiClient = $this->partialUpdateItem('/api-client/' . $apiClientId, [ + $updatedApiClient = $this->partialUpdateItem('/api-clients/' . $apiClientId, [ 'clientId' => 'client_id_test_updated', 'clientName' => 'Client name test updated', 'description' => 'Client description test updated', @@ -166,7 +166,7 @@ public function testUpdateApiClient(int $apiClientId): int ); // Update partially API client - $updatedApiClient = $this->partialUpdateItem('/api-client/' . $apiClientId, [ + $updatedApiClient = $this->partialUpdateItem('/api-clients/' . $apiClientId, [ 'description' => 'Client description test partially updated', 'lifetime' => 900, // Even if we try to modify the external issuer it is an immutable/internal field anyway @@ -200,7 +200,7 @@ public function testUpdateApiClient(int $apiClientId): int */ public function testGetUpdatedApiClient(int $apiClientId): int { - $apiClient = $this->getItem('/api-client/' . $apiClientId, ['api_client_read']); + $apiClient = $this->getItem('/api-clients/' . $apiClientId, ['api_client_read']); $this->assertEquals( [ 'apiClientId' => $apiClientId, @@ -266,32 +266,32 @@ public function testListApiClients(int $apiClientId): int public function testDeleteApiClient(int $apiClientId): void { // Delete API client without token is unauthorized (401) - $errorMessage = $this->deleteItem('/api-client/' . $apiClientId, [], Response::HTTP_UNAUTHORIZED); + $errorMessage = $this->deleteItem('/api-clients/' . $apiClientId, [], Response::HTTP_UNAUTHORIZED); $this->assertEquals('No Authorization header provided', $errorMessage); // Delete API client with invalid token - static::createClient()->request('DELETE', '/api-client/' . $apiClientId, [ + static::createClient()->request('DELETE', '/api-clients/' . $apiClientId, [ 'auth_bearer' => 'toto', ]); self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); // Try to delete with a token with only read scope is forbidden (403) - $this->deleteItem('/api-client/' . $apiClientId, ['api_client_read'], Response::HTTP_FORBIDDEN); + $this->deleteItem('/api-clients/' . $apiClientId, ['api_client_read'], Response::HTTP_FORBIDDEN); // Check that API client was not deleted - $this->assertNotNull($this->getItem('/api-client/' . $apiClientId, ['api_client_read'])); + $this->assertNotNull($this->getItem('/api-clients/' . $apiClientId, ['api_client_read'])); // Delete API client with valid token - $this->deleteItem('/api-client/' . $apiClientId, ['api_client_write']); + $this->deleteItem('/api-clients/' . $apiClientId, ['api_client_write']); - $errorResponse = $this->getItem('/api-client/' . $apiClientId, ['api_client_read'], Response::HTTP_NOT_FOUND); + $errorResponse = $this->getItem('/api-clients/' . $apiClientId, ['api_client_read'], Response::HTTP_NOT_FOUND); $this->assertEquals('Could not find Api client ' . $apiClientId, $errorResponse['detail']); } public function testCreateInvalidApiClient(): void { // Creating with invalid data should return a response with invalid constraint messages and use an http code 422 - $validationErrorsResponse = $this->createItem('/api-client', [ + $validationErrorsResponse = $this->createItem('/api-clients', [ 'clientId' => '', 'clientName' => '', 'description' => RandomString::generate(ApiClientSettings::MAX_DESCRIPTION_LENGTH + 1), diff --git a/tests/Integration/ApiPlatform/ApiTestCase.php b/tests/Integration/ApiPlatform/ApiTestCase.php index 72a255c95..fb68452f6 100644 --- a/tests/Integration/ApiPlatform/ApiTestCase.php +++ b/tests/Integration/ApiPlatform/ApiTestCase.php @@ -28,11 +28,13 @@ use PrestaShop\PrestaShop\Core\Domain\Configuration\ShopConfigurationInterface; use PrestaShop\PrestaShop\Core\Domain\Language\Command\AddLanguageCommand; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; +use PrestaShop\PrestaShop\Core\Version; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\KernelInterface; use Tests\Resources\Resetter\ApiClientResetter; +use Tests\Resources\Resetter\LanguageResetter; abstract class ApiTestCase extends SymfonyApiTestCase { @@ -47,11 +49,15 @@ public static function setUpBeforeClass(): void parent::setUpBeforeClass(); self::updateConfiguration('PS_ADMIN_API_FORCE_DEBUG_SECURED', 0); ApiClientResetter::resetApiClient(); + // Always reset and install a new language, this way all endpoints must test their multi lang fields accurately + LanguageResetter::resetLanguages(); + self::addLanguageByLocale('fr-FR'); } public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); + LanguageResetter::resetLanguages(); ApiClientResetter::resetApiClient(); self::updateConfiguration('PS_ADMIN_API_FORCE_DEBUG_SECURED', 1); self::$apiClients = null; @@ -110,7 +116,7 @@ public function testProtectedEndpoints(string $method, string $uri, string $cont * * yield 'get endpoint' => [ * 'GET', - * '/product/1', + * '/products/1', * ]; * * Since all Api Platform resources should likely have some protected endpoints this provider @@ -120,7 +126,7 @@ public function testProtectedEndpoints(string $method, string $uri, string $cont * * @return iterable */ - abstract public function getProtectedEndpoints(): iterable; + abstract public static function getProtectedEndpoints(): iterable; protected static function createClient(array $kernelOptions = [], array $defaultOptions = []): Client { @@ -135,6 +141,18 @@ protected static function createClient(array $kernelOptions = [], array $default return parent::createClient($kernelOptions, $defaultOptions); } + /** + * Skips the test when the PrestaShop core version is lower than the required minimum version. Useful + * for endpoints relying on the minVersion extra property: on older cores they are filtered out (404), + * so their tests can only run on core versions that actually expose them. + */ + protected function markTestSkippedByMinVersion(string $minVersion): void + { + if (version_compare(Version::VERSION, $minVersion, '<')) { + static::markTestSkipped(sprintf('This test requires PrestaShop >= %s (current core version: %s)', $minVersion, Version::VERSION)); + } + } + /** * Get bearer token with the requested scopes, if not ApiClient exists that can use these scopes * it is automatically created. @@ -277,6 +295,33 @@ protected function deleteItem(string $endPointUrl, array $scopes = [], ?int $exp return $this->requestApi(Request::METHOD_DELETE, $endPointUrl, null, $scopes, $expectedHttpCode, $requestOptions); } + /** + * Performs a DELETE request to delete multiple items, which requires passing some data + */ + protected function bulkDeleteItems(string $endPointUrl, ?array $data, array $scopes = [], ?int $expectedHttpCode = null, ?array $requestOptions = null): array|string|null + { + return $this->requestApi(Request::METHOD_DELETE, $endPointUrl, $data, $scopes, $expectedHttpCode, $requestOptions); + } + + protected function bulkCommandItemsWithExpectedErrors(string $httpMethod, string $endPointUrl, ?array $data, ?array $expectedErrors, array $scopes = [], ?array $requestOptions = null): array|string|null + { + // In 9.1, we added a new normalizer to handle bulk command exceptions and return a 207 code with details about each error. + // If the normalizer is not present, it means we are on an older version and the API will return a 422 code with a generic error message, + // so we adapt our assertions based on the normalizer presence. + $newNormalizer = class_exists("PrestaShopBundle\ApiPlatform\Normalizer\BulkCommandExceptionNormalizer"); + $expectedHttpCode = $newNormalizer ? Response::HTTP_MULTI_STATUS : Response::HTTP_UNPROCESSABLE_ENTITY; + + $responseData = $this->requestApi($httpMethod, $endPointUrl, $data, $scopes, $expectedHttpCode, $requestOptions); + + if ($newNormalizer && null !== $expectedErrors) { + $this->assertArrayHasKey('errors', $responseData); + $this->assertIsArray($responseData['errors']); + $this->assertEquals($expectedErrors, $responseData['errors']); + } + + return $responseData; + } + /** * Performs a GET request to list some items, returned data is paginated */ diff --git a/tests/Integration/ApiPlatform/AttachmentReadEndpointTest.php b/tests/Integration/ApiPlatform/AttachmentReadEndpointTest.php new file mode 100644 index 000000000..a566c6f6f --- /dev/null +++ b/tests/Integration/ApiPlatform/AttachmentReadEndpointTest.php @@ -0,0 +1,199 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use PrestaShop\PrestaShop\Core\Context\LanguageContextBuilder; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Command\AddAttachmentCommand; +use PrestaShop\PrestaShop\Core\Domain\Attachment\ValueObject\AttachmentId; +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; +use Tests\Resources\DummyFileUploader; + +/** + * Covers the read-only Attachment endpoints wiring the SearchAttachment, + * GetAttachment and GetAttachmentInformation CQRS queries. The CRUD endpoints + * (GET/POST/PATCH/DELETE) are handled by a separate resource (see PR #295) and + * are intentionally not tested here. + */ +class AttachmentReadEndpointTest extends ApiTestCase +{ + private const SEARCHABLE_NAME = 'ApiResourceAttachment'; + + private static int $attachmentId; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + self::createApiClient(['attachment_read']); + + self::$attachmentId = self::createAttachment(self::SEARCHABLE_NAME, 'test_text_file.txt'); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'attachment', + 'attachment_lang', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'search endpoint' => [ + 'GET', + '/attachments/search?phrase=test', + ]; + + yield 'get file endpoint' => [ + 'GET', + '/attachments/1/files', + ]; + + yield 'get information endpoint' => [ + 'GET', + '/attachments/1/information', + ]; + } + + public function testSearchAttachments(): void + { + $defaultLocale = self::getDefaultLocale(); + + $searchResults = $this->getItem('/attachments/search?phrase=' . self::SEARCHABLE_NAME, ['attachment_read']); + + $this->assertIsArray($searchResults); + $this->assertNotEmpty($searchResults); + + $found = false; + foreach ($searchResults as $result) { + $this->assertArrayHasKey('attachmentId', $result); + $this->assertArrayHasKey('names', $result); + $this->assertArrayHasKey('fileName', $result); + if ((int) $result['attachmentId'] === self::$attachmentId) { + $found = true; + $this->assertSame(self::SEARCHABLE_NAME, $result['names'][$defaultLocale]); + $this->assertSame('test_text_file.txt', $result['fileName']); + } + } + + $this->assertTrue($found, 'The seeded attachment should be returned by the search.'); + } + + public function testSearchAttachmentsWithNoResults(): void + { + $this->getItem('/attachments/search?phrase=nonexistentattachment999', ['attachment_read'], Response::HTTP_NOT_FOUND); + } + + public function testSearchAttachmentsWithEmptyPhrase(): void + { + $this->getItem('/attachments/search?phrase=', ['attachment_read'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testGetAttachmentFile(): void + { + $attachmentFile = $this->getItem('/attachments/' . self::$attachmentId . '/files', ['attachment_read']); + + $this->assertEquals(self::$attachmentId, $attachmentFile['attachmentId']); + $this->assertSame('test_text_file.txt', $attachmentFile['name']); + $this->assertNotEmpty($attachmentFile['path']); + $this->assertFileExists($attachmentFile['path']); + } + + public function testGetNotFoundAttachmentFile(): void + { + $this->getItem('/attachments/999999/files', ['attachment_read'], Response::HTTP_NOT_FOUND); + } + + public function testGetAttachmentInformation(): void + { + $defaultLocale = self::getDefaultLocale(); + + $information = $this->getItem('/attachments/' . self::$attachmentId . '/information', ['attachment_read']); + + $this->assertEquals(self::$attachmentId, $information['attachmentId']); + $this->assertSame(self::SEARCHABLE_NAME, $information['names'][$defaultLocale]); + $this->assertArrayHasKey('descriptions', $information); + $this->assertSame('test_text_file.txt', $information['fileName']); + $this->assertSame('text/plain', $information['mimeType']); + $this->assertGreaterThan(0, $information['fileSize']); + } + + public function testGetNotFoundAttachmentInformation(): void + { + $this->getItem('/attachments/999999/information', ['attachment_read'], Response::HTTP_NOT_FOUND); + } + + private static function createAttachment(string $name, string $dummyFileName): int + { + $defaultLangId = (int) \Configuration::get('PS_LANG_DEFAULT'); + + $container = static::createClient()->getContainer(); + + // The AddAttachmentCommand validates the localized names against the default language, + // which requires the language context to be built. Outside of an HTTP request it must + // be defined explicitly. + /** @var LanguageContextBuilder $languageContextBuilder */ + $languageContextBuilder = $container->get('test_language_context_builder'); + $languageContextBuilder->setLanguageId($defaultLangId); + $languageContextBuilder->setDefaultLanguageId($defaultLangId); + + $sourceFile = DummyFileUploader::upload($dummyFileName); + + $command = new AddAttachmentCommand( + [$defaultLangId => $name], + [$defaultLangId => 'Description of ' . $name] + ); + $command->setFileInformation( + $sourceFile, + (int) filesize($sourceFile), + (string) mime_content_type($sourceFile), + $dummyFileName + ); + + /** @var AttachmentId $attachmentId */ + $attachmentId = $container->get('prestashop.core.command_bus')->handle($command); + $attachmentId = $attachmentId->getValue(); + + // The core file uploader relies on move_uploaded_file(), which is a no-op outside a + // real HTTP upload, so the physical file is never stored. Copy it to the location + // GetAttachment reads from so the /files endpoint can be exercised. + $attachment = new \Attachment($attachmentId); + copy(DummyFileUploader::getDummyFilePath($dummyFileName), _PS_DOWNLOAD_DIR_ . $attachment->file); + + return $attachmentId; + } + + private static function getDefaultLocale(): string + { + $defaultLangId = (int) \Configuration::get('PS_LANG_DEFAULT'); + + return (new \Language($defaultLangId))->getLocale(); + } +} diff --git a/tests/Integration/ApiPlatform/AttributeEndpointTest.php b/tests/Integration/ApiPlatform/AttributeEndpointTest.php new file mode 100644 index 000000000..5c7618f4f --- /dev/null +++ b/tests/Integration/ApiPlatform/AttributeEndpointTest.php @@ -0,0 +1,509 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class AttributeEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + // Pre-create the API Client with the needed scopes, this way we reduce the number of created API Clients + self::createApiClient(['attribute_write', 'attribute_read']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'attribute', + 'attribute_lang', + 'attribute_shop', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/attributes/attributes/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/attributes/attributes', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/attributes/attributes/1', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/attributes/attributes/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/attributes/groups/1/attributes', + ]; + + yield 'update positions endpoint' => [ + 'PATCH', + '/attributes/groups/1/attributes/positions', + ]; + + yield 'bulk delete endpoint' => [ + 'DELETE', + '/attributes/attributes/bulk-delete', + ]; + } + + public function testAddAttribute(): int + { + $itemsCount = $this->countItems('/attributes/groups/1/attributes', ['attribute_read']); + + $postData = [ + 'names' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'attributeGroupId' => 1, + 'color' => '', + 'shopIds' => [1], + ]; + + // Create an attribute, the POST endpoint returns the created item as JSON + $attribute = $this->createItem('/attributes/attributes', $postData, ['attribute_write']); + $this->assertArrayHasKey('attributeId', $attribute); + $attributeId = $attribute['attributeId']; + + // We assert the returned data matches what was posted (plus the ID) + $this->assertEquals( + ['attributeId' => $attributeId] + $postData, + $attribute + ); + + $newItemsCount = $this->countItems('/attributes/groups/1/attributes', ['attribute_read']); + $this->assertEquals($itemsCount + 1, $newItemsCount); + + return $attributeId; + } + + /** + * @depends testAddAttribute + * + * @param int $attributeId + * + * @return int + */ + public function testGetAttribute(int $attributeId): int + { + $attribute = $this->getItem('/attributes/attributes/' . $attributeId, ['attribute_read']); + $this->assertEquals([ + 'attributeId' => $attributeId, + 'names' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'shopIds' => [1], + 'attributeGroupId' => 1, + 'color' => '', + ], $attribute); + + return $attributeId; + } + + /** + * @depends testGetAttribute + * + * @param int $attributeId + * + * @return int + */ + public function testPartialUpdateAttribute(int $attributeId): int + { + $patchData = [ + 'names' => [ + 'en-US' => 'updated name en', + 'fr-FR' => 'updated name fr', + ], + 'shopIds' => [1], + 'attributeGroupId' => 1, + 'color' => '', + ]; + + $updatedAttribute = $this->partialUpdateItem('/attributes/attributes/' . $attributeId, $patchData, ['attribute_write']); + $this->assertEquals(['attributeId' => $attributeId] + $patchData, $updatedAttribute); + + // We check that when we GET the item it is updated as expected + $attribute = $this->getItem('/attributes/attributes/' . $attributeId, ['attribute_read']); + $this->assertEquals(['attributeId' => $attributeId] + $patchData, $attribute); + + // Test partial update + $partialUpdateData = [ + 'names' => [ + 'fr-FR' => 'updated nom fr', + ], + ]; + $expectedUpdatedData = [ + 'attributeId' => $attributeId, + 'names' => [ + 'en-US' => 'updated name en', + 'fr-FR' => 'updated nom fr', + ], + 'attributeGroupId' => 1, + 'shopIds' => [1], + 'color' => '', + ]; + $updatedAttribute = $this->partialUpdateItem('/attributes/attributes/' . $attributeId, $partialUpdateData, ['attribute_write']); + $this->assertEquals($expectedUpdatedData, $updatedAttribute); + + return $attributeId; + } + + /** + * @depends testPartialUpdateAttribute + * + * @param int $attributeId + * + * @return int + */ + public function testListAttributes(int $attributeId): int + { + // List by attributeId in descending order so the created one comes first (and test ordering at the same time) + $paginatedAttributes = $this->listItems('/attributes/groups/1/attributes?orderBy=attributeId&sortOrder=desc', ['attribute_read']); + $this->assertGreaterThanOrEqual(1, $paginatedAttributes['totalItems']); + + // Check the details to make sure filters mapping is correct + $this->assertEquals('attributeId', $paginatedAttributes['orderBy']); + + // Test attribute should be the first returned in the list + $testAttribute = $paginatedAttributes['items'][0]; + + // Position should be at least 3 since there are three attribute in the default fixtures data + $this->assertGreaterThanOrEqual(3, $testAttribute['position']); + $position = $testAttribute['position']; + $expectedAttribute = [ + 'attributeId' => $attributeId, + 'name' => 'updated name en', + 'position' => $position, + ]; + $this->assertEquals($expectedAttribute, $testAttribute); + + $filteredAttributes = $this->listItems('/attributes/groups/1/attributes', ['attribute_read'], [ + 'attributeId' => $attributeId, + ]); + $this->assertEquals(1, $filteredAttributes['totalItems']); + + $testAttribute = $filteredAttributes['items'][0]; + $this->assertEquals($expectedAttribute, $testAttribute); + + // Check the filters details + $this->assertEquals([ + 'attributeId' => $attributeId, + ], $filteredAttributes['filters']); + + return $attributeId; + } + + /** + * @depends testListAttributes + * + * @param int $attributeId + */ + public function testRemoveAttribute(int $attributeId): void + { + // Delete the item + $return = $this->deleteItem('/attributes/attributes/' . $attributeId, ['attribute_write']); + // This endpoint return empty response and 204 HTTP code + $this->assertNull($return); + + // Getting the item should result in a 404 now + $this->getItem('/attributes/attributes/' . $attributeId, ['attribute_read'], Response::HTTP_NOT_FOUND); + } + + /** + * @depends testRemoveAttribute + */ + public function testBulkRemoveAttributes(): void + { + $attributes = $this->listItems('/attributes/groups/1/attributes', ['attribute_read']); + + // There are four attributes in group with id 1 + $this->assertEquals(4, $attributes['totalItems']); + + // We remove the first two attributes + $removeAttributeIds = [ + $attributes['items'][0]['attributeId'], + $attributes['items'][2]['attributeId'], + ]; + + $this->bulkDeleteItems('/attributes/attributes/bulk-delete', [ + 'attributeIds' => $removeAttributeIds, + ], ['attribute_write']); + + // Assert the provided attributes have been removed + foreach ($removeAttributeIds as $attributeId) { + $this->getItem('/attributes/attributes/' . $attributeId, ['attribute_read'], Response::HTTP_NOT_FOUND); + } + + // Only two attribute remain + $this->assertEquals(2, $this->countItems('/attributes/groups/1/attributes', ['attribute_read'])); + } + + public function testAttributePositions(): void + { + // List default fixture Color + $list = $this->listItems('/attributes/groups/2/attributes?limit=5&orderBy=position', ['attribute_read']); + $expectedAttributes = [ + [ + 'attributeId' => 5, + 'name' => 'Gray', + 'position' => 0, + ], + [ + 'attributeId' => 6, + 'name' => 'Taupe', + 'position' => 1, + ], + [ + 'attributeId' => 7, + 'name' => 'Beige', + 'position' => 2, + ], + [ + 'attributeId' => 8, + 'name' => 'White', + 'position' => 3, + ], + [ + 'attributeId' => 9, + 'name' => 'Off White', + 'position' => 4, + ], + ]; + $this->assertEquals($expectedAttributes, $list['items']); + + // Increase Gray position + $this->partialUpdateItem('/attributes/groups/2/attributes/positions', [ + 'positions' => [ + [ + 'attributeId' => 5, + 'newPosition' => 3, + ], + ], + ], ['attribute_write'], Response::HTTP_NO_CONTENT); + + // Check updated order + $list = $this->listItems('/attributes/groups/2/attributes?limit=5&orderBy=position', ['attribute_read']); + $expectedAttributes = [ + [ + 'attributeId' => 6, + 'name' => 'Taupe', + 'position' => 0, + ], + [ + 'attributeId' => 7, + 'name' => 'Beige', + 'position' => 1, + ], + [ + 'attributeId' => 8, + 'name' => 'White', + 'position' => 2, + ], + // Gray replaces white, since it came from lower White is pushed below + [ + 'attributeId' => 5, + 'name' => 'Gray', + 'position' => 3, + ], + [ + 'attributeId' => 9, + 'name' => 'Off White', + 'position' => 4, + ], + ]; + $this->assertEquals($expectedAttributes, $list['items']); + + // Decrease Gray position, so one new item and another disappears from the subset + $this->partialUpdateItem('/attributes/groups/2/attributes/positions', [ + 'positions' => [ + [ + 'attributeId' => 10, + 'newPosition' => 2, + ], + ], + ], ['attribute_write'], Response::HTTP_NO_CONTENT); + + // Check updated order + $list = $this->listItems('/attributes/groups/2/attributes?limit=5&orderBy=position', ['attribute_read']); + $expectedAttributes = [ + [ + 'attributeId' => 6, + 'name' => 'Taupe', + 'position' => 0, + ], + [ + 'attributeId' => 7, + 'name' => 'Beige', + 'position' => 1, + ], + // Red replaces White, since it came from upper white is pushed up along with all the following + [ + 'attributeId' => 10, + 'name' => 'Red', + 'position' => 2, + ], + [ + 'attributeId' => 8, + 'name' => 'White', + 'position' => 3, + ], + [ + 'attributeId' => 5, + 'name' => 'Gray', + 'position' => 4, + ], + ]; + $this->assertEquals($expectedAttributes, $list['items']); + } + + public function testInvalidAttribute(): void + { + $attributeInvalidData = [ + 'names' => [ + // en-US (default language) value is missing + // < character is forbidden + 'fr-FR' => 'name fr<', + ], + 'attributeGroupId' => 1, + 'color' => '', + // ShopId must not be empty + 'shopIds' => [], + ]; + + // Creating with invalid data should return a response with invalid constraint messages and use an http code 422 + $validationErrorsResponse = $this->createItem('/attributes/attributes', $attributeInvalidData, ['attribute_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + $this->assertValidationErrors([ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + [ + 'propertyPath' => 'names[fr-FR]', + 'message' => '"name fr<" is invalid', + ], + [ + 'propertyPath' => 'shopIds', + 'message' => 'This value should not be blank.', + ], + ], $validationErrorsResponse); + + // Now create a valid attribute to test the validation on PATCH request + $validAttribute = $this->createItem('/attributes/attributes', [ + 'names' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'publicNames' => [ + 'en-US' => 'name en', + 'fr-FR' => 'name fr', + ], + 'attributeGroupId' => 1, + 'color' => '', + 'shopIds' => [1], + ], ['attribute_write']); + + $attributeId = $validAttribute['attributeId']; + $invalidUpdateData = [ + // Only the provided data is validated (we only get one invalid error) + [ + 'data' => [ + 'names' => [ + 'en-US' => 'name en<', + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'names[en-US]', + 'message' => '"name en<" is invalid', + ], + ], + ], + // We can partially update only one language, the DefaultLanguage constraint doesn't block because en-US is not specified + [ + 'data' => [ + 'names' => [ + 'fr-FR' => 'name fr<', + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'names[fr-FR]', + 'message' => '"name fr<" is invalid', + ], + ], + ], + // However trying to force empty value is forbidden + [ + 'data' => [ + 'names' => [ + 'en-US' => '', + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + ], + ], + [ + 'data' => [ + 'shopIds' => [ + ], + ], + 'expectedErrors' => [ + [ + 'propertyPath' => 'shopIds', + 'message' => 'This value should not be blank.', + ], + ], + ], + ]; + foreach ($invalidUpdateData as $updateData) { + $validationErrorsResponse = $this->partialUpdateItem('/attributes/attributes/' . $attributeId, $updateData['data'], ['attribute_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertValidationErrors($updateData['expectedErrors'], $validationErrorsResponse); + } + } +} diff --git a/tests/Integration/ApiPlatform/AttributeGroupEndpointTest.php b/tests/Integration/ApiPlatform/AttributeGroupEndpointTest.php index d094d6603..6357ebb1f 100644 --- a/tests/Integration/ApiPlatform/AttributeGroupEndpointTest.php +++ b/tests/Integration/ApiPlatform/AttributeGroupEndpointTest.php @@ -22,16 +22,12 @@ use Symfony\Component\HttpFoundation\Response; use Tests\Resources\DatabaseDump; -use Tests\Resources\Resetter\LanguageResetter; class AttributeGroupEndpointTest extends ApiTestCase { public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); - // Add the fr-FR language to test multi lang values accurately - LanguageResetter::resetLanguages(); - self::addLanguageByLocale('fr-FR'); self::resetTables(); // Pre-create the API Client with the needed scopes, this way we reduce the number of created API Clients self::createApiClient(['attribute_group_write', 'attribute_group_read']); @@ -40,8 +36,6 @@ public static function setUpBeforeClass(): void public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); - // Reset DB as it was before this test - LanguageResetter::resetLanguages(); self::resetTables(); } @@ -51,29 +45,32 @@ protected static function resetTables(): void 'attribute_group', 'attribute_group_lang', 'attribute_group_shop', + 'attribute', + 'attribute_lang', + 'attribute_shop', ]); } - public function getProtectedEndpoints(): iterable + public static function getProtectedEndpoints(): iterable { yield 'get endpoint' => [ 'GET', - '/attributes/group/1', + '/attributes/groups/1', ]; yield 'create endpoint' => [ 'POST', - '/attributes/group', + '/attributes/groups', ]; yield 'patch endpoint' => [ 'PATCH', - '/attributes/group/1', + '/attributes/groups/1', ]; yield 'delete endpoint' => [ 'DELETE', - '/attributes/group/1', + '/attributes/groups/1', ]; yield 'list endpoint' => [ @@ -81,9 +78,14 @@ public function getProtectedEndpoints(): iterable '/attributes/groups', ]; + yield 'update positions endpoint' => [ + 'PATCH', + '/attributes/groups/positions', + ]; + yield 'bulk delete endpoint' => [ - 'PUT', - '/attributes/groups/delete', + 'DELETE', + '/attributes/groups/bulk-delete', ]; } @@ -105,7 +107,7 @@ public function testAddAttributeGroup(): int ]; // Create an attribute group, the POST endpoint returns the created item as JSON - $attributeGroup = $this->createItem('/attributes/group', $postData, ['attribute_group_write']); + $attributeGroup = $this->createItem('/attributes/groups', $postData, ['attribute_group_write']); $this->assertArrayHasKey('attributeGroupId', $attributeGroup); $attributeGroupId = $attributeGroup['attributeGroupId']; @@ -130,7 +132,7 @@ public function testAddAttributeGroup(): int */ public function testGetAttributeGroup(int $attributeGroupId): int { - $attributeGroup = $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read']); + $attributeGroup = $this->getItem('/attributes/groups/' . $attributeGroupId, ['attribute_group_read']); $this->assertEquals([ 'attributeGroupId' => $attributeGroupId, 'names' => [ @@ -170,11 +172,11 @@ public function testPartialUpdateAttributeGroup(int $attributeGroupId): int 'shopIds' => [1], ]; - $updatedAttributeGroup = $this->partialUpdateItem('/attributes/group/' . $attributeGroupId, $patchData, ['attribute_group_write']); + $updatedAttributeGroup = $this->partialUpdateItem('/attributes/groups/' . $attributeGroupId, $patchData, ['attribute_group_write']); $this->assertEquals(['attributeGroupId' => $attributeGroupId] + $patchData, $updatedAttributeGroup); // We check that when we GET the item it is updated as expected - $attributeGroup = $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read']); + $attributeGroup = $this->getItem('/attributes/groups/' . $attributeGroupId, ['attribute_group_read']); $this->assertEquals(['attributeGroupId' => $attributeGroupId] + $patchData, $attributeGroup); // Test partial update @@ -199,7 +201,7 @@ public function testPartialUpdateAttributeGroup(int $attributeGroupId): int 'type' => 'radio', 'shopIds' => [1], ]; - $updatedAttributeGroup = $this->partialUpdateItem('/attributes/group/' . $attributeGroupId, $partialUpdateData, ['attribute_group_write']); + $updatedAttributeGroup = $this->partialUpdateItem('/attributes/groups/' . $attributeGroupId, $partialUpdateData, ['attribute_group_write']); $this->assertEquals($expectedUpdatedData, $updatedAttributeGroup); return $attributeGroupId; @@ -259,12 +261,125 @@ public function testListAttributeGroups(int $attributeGroupId): int public function testRemoveAttributeGroup(int $attributeGroupId): void { // Delete the item - $return = $this->deleteItem('/attributes/group/' . $attributeGroupId, ['attribute_group_write']); + $return = $this->deleteItem('/attributes/groups/' . $attributeGroupId, ['attribute_group_write']); // This endpoint return empty response and 204 HTTP code $this->assertNull($return); // Getting the item should result in a 404 now - $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read'], Response::HTTP_NOT_FOUND); + $this->getItem('/attributes/groups/' . $attributeGroupId, ['attribute_group_read'], Response::HTTP_NOT_FOUND); + } + + public function testAttributeGroupPositions(): void + { + // List default fixture Color + $list = $this->listItems('/attributes/groups?orderBy=position', ['attribute_group_read']); + $expectedAttributeGroups = [ + [ + 'attributeGroupId' => 1, + 'name' => 'Size', + 'position' => 0, + 'values' => 4, + ], + [ + 'attributeGroupId' => 2, + 'name' => 'Color', + 'position' => 1, + 'values' => 14, + ], + [ + 'attributeGroupId' => 3, + 'name' => 'Dimension', + 'position' => 2, + 'values' => 3, + ], + [ + 'attributeGroupId' => 4, + 'name' => 'Paper Type', + 'position' => 3, + 'values' => 4, + ], + ]; + $this->assertEquals($expectedAttributeGroups, $list['items']); + + // Increase Size position + $this->partialUpdateItem('/attributes/groups/positions', [ + 'positions' => [ + [ + 'attributeGroupId' => 1, + 'newPosition' => 2, + ], + ], + ], ['attribute_group_write'], Response::HTTP_NO_CONTENT); + + // Check updated order + $list = $this->listItems('/attributes/groups?orderBy=position', ['attribute_group_read']); + $expectedAttributeGroups = [ + [ + 'attributeGroupId' => 2, + 'name' => 'Color', + 'position' => 0, + 'values' => 14, + ], + [ + 'attributeGroupId' => 3, + 'name' => 'Dimension', + 'position' => 1, + 'values' => 3, + ], + [ + 'attributeGroupId' => 1, + 'name' => 'Size', + 'position' => 2, + 'values' => 4, + ], + [ + 'attributeGroupId' => 4, + 'name' => 'Paper Type', + 'position' => 3, + 'values' => 4, + ], + ]; + $this->assertEquals($expectedAttributeGroups, $list['items']); + + // Decrease Paper Type position + $this->partialUpdateItem('/attributes/groups/positions', [ + 'positions' => [ + [ + 'attributeGroupId' => 4, + 'newPosition' => 1, + ], + ], + ], ['attribute_group_write'], Response::HTTP_NO_CONTENT); + + // Check updated order + $list = $this->listItems('/attributes/groups?orderBy=position', ['attribute_group_read']); + $expectedAttributeGroups = [ + [ + 'attributeGroupId' => 2, + 'name' => 'Color', + 'position' => 0, + 'values' => 14, + ], + [ + 'attributeGroupId' => 4, + 'name' => 'Paper Type', + 'position' => 1, + 'values' => 4, + ], + [ + 'attributeGroupId' => 3, + 'name' => 'Dimension', + 'position' => 2, + 'values' => 3, + ], + [ + 'attributeGroupId' => 1, + 'name' => 'Size', + 'position' => 3, + 'values' => 4, + ], + ]; + $this->assertEquals($expectedAttributeGroups, $list['items']); } public function testBulkRemoveAttributeGroups(): void @@ -280,13 +395,13 @@ public function testBulkRemoveAttributeGroups(): void $attributeGroups['items'][2]['attributeGroupId'], ]; - $this->updateItem('/attributes/groups/delete', [ + $this->bulkDeleteItems('/attributes/groups/bulk-delete', [ 'attributeGroupIds' => $removeAttributeGroupIds, - ], ['attribute_group_write'], Response::HTTP_NO_CONTENT); + ], ['attribute_group_write']); // Assert the provided attribute groups have been removed foreach ($removeAttributeGroupIds as $attributeGroupId) { - $this->getItem('/attributes/group/' . $attributeGroupId, ['attribute_group_read'], Response::HTTP_NOT_FOUND); + $this->getItem('/attributes/groups/' . $attributeGroupId, ['attribute_group_read'], Response::HTTP_NOT_FOUND); } // Only two attribute group remain @@ -313,7 +428,7 @@ public function testInvalidAttributeGroup(): void ]; // Creating with invalid data should return a response with invalid constraint messages and use an http code 422 - $validationErrorsResponse = $this->createItem('/attributes/group', $attributeGroupInvalidData, ['attribute_group_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $validationErrorsResponse = $this->createItem('/attributes/groups', $attributeGroupInvalidData, ['attribute_group_write'], Response::HTTP_UNPROCESSABLE_ENTITY); $this->assertIsArray($validationErrorsResponse); $this->assertValidationErrors([ [ @@ -343,7 +458,7 @@ public function testInvalidAttributeGroup(): void ], $validationErrorsResponse); // Now create a valid attribute group to test the validation on PATCH request - $validAttributeGroup = $this->createItem('/attributes/group', [ + $validAttributeGroup = $this->createItem('/attributes/groups', [ 'names' => [ 'en-US' => 'name en', 'fr-FR' => 'name fr', @@ -439,7 +554,7 @@ public function testInvalidAttributeGroup(): void ], ]; foreach ($invalidUpdateData as $updateData) { - $validationErrorsResponse = $this->partialUpdateItem('/attributes/group/' . $attributeGroupId, $updateData['data'], ['attribute_group_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $validationErrorsResponse = $this->partialUpdateItem('/attributes/groups/' . $attributeGroupId, $updateData['data'], ['attribute_group_write'], Response::HTTP_UNPROCESSABLE_ENTITY); $this->assertValidationErrors($updateData['expectedErrors'], $validationErrorsResponse); } } diff --git a/tests/Integration/ApiPlatform/AttributeGroupWithAttributesEndpointTest.php b/tests/Integration/ApiPlatform/AttributeGroupWithAttributesEndpointTest.php new file mode 100644 index 000000000..2aefe9ba3 --- /dev/null +++ b/tests/Integration/ApiPlatform/AttributeGroupWithAttributesEndpointTest.php @@ -0,0 +1,181 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Tests\Resources\DatabaseDump; +use Tests\Resources\Resetter\LanguageResetter; + +class AttributeGroupWithAttributesEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + LanguageResetter::resetLanguages(); + self::addLanguageByLocale('fr-FR'); + self::resetTables(); + self::createApiClient(['attribute_group_read', 'attribute_group_write', 'attribute_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + LanguageResetter::resetLanguages(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'attribute_group', + 'attribute_group_lang', + 'attribute_group_shop', + 'attribute', + 'attribute_lang', + 'attribute_shop', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'list attribute groups with attributes' => ['GET', '/attributes/groups-with-attributes']; + } + + /** + * Creates a controlled attribute group with two attributes (both localized + * in en-US and fr-FR), then asserts the full structure of that group as + * returned by /attributes/groups-with-attributes. + * + * The endpoint is validated on data we create in-test, not on the demo + * fixtures: attribute IDs, colors and even group types drift between core + * versions (9.0.3 / 9.1.x / 9.2.x / develop), so a snapshot of the demo + * dataset cannot match every CI matrix cell. + * + * The whole aggregate row is compared with assertEquals so that any new + * field on the group row or on a nested attribute row surfaces here, + * per @jolelievre's review on PR #390. In particular every localized + * value — including the nested `attributes[].localizedNames` — must be + * indexed by locale, never by id_lang, and renamed into names (localized + * prefix is dropped). + */ + public function testListAttributeGroupsWithAttributes(): void + { + $groupsBefore = $this->getItem('/attributes/groups-with-attributes', ['attribute_group_read']); + $expectedPosition = count($groupsBefore); + + $createdGroup = $this->createItem( + '/attributes/groups', + [ + 'names' => [ + 'en-US' => 'Test WA group en', + 'fr-FR' => 'Test WA group fr', + ], + 'publicNames' => [ + 'en-US' => 'Test WA public en', + 'fr-FR' => 'Test WA public fr', + ], + 'type' => 'color', + 'shopIds' => [1], + ], + ['attribute_group_write'] + ); + $groupId = $createdGroup['attributeGroupId']; + + $firstAttribute = $this->createItem( + '/attributes/attributes', + [ + 'names' => [ + 'en-US' => 'WA attr 1 en', + 'fr-FR' => 'WA attr 1 fr', + ], + 'attributeGroupId' => $groupId, + 'color' => '#123456', + 'shopIds' => [1], + ], + ['attribute_write'] + ); + $secondAttribute = $this->createItem( + '/attributes/attributes', + [ + 'names' => [ + 'en-US' => 'WA attr 2 en', + 'fr-FR' => 'WA attr 2 fr', + ], + 'attributeGroupId' => $groupId, + 'color' => '#654321', + 'shopIds' => [1], + ], + ['attribute_write'] + ); + + $result = $this->getItem('/attributes/groups-with-attributes', ['attribute_group_read']); + + $ourGroup = null; + foreach ($result as $row) { + if (isset($row['attributeGroupId']) && $row['attributeGroupId'] === $groupId) { + $ourGroup = $row; + break; + } + } + $this->assertNotNull($ourGroup, sprintf('Expected group %d in the response.', $groupId)); + + $this->assertEquals( + [ + 'attributeGroupId' => $groupId, + 'names' => [ + 'en-US' => 'Test WA group en', + 'fr-FR' => 'Test WA group fr', + ], + 'publicNames' => [ + 'en-US' => 'Test WA public en', + 'fr-FR' => 'Test WA public fr', + ], + 'type' => 'color', + 'colorGroup' => true, + 'position' => $expectedPosition, + 'attributes' => [ + [ + 'attributeId' => $firstAttribute['attributeId'], + 'position' => 0, + 'color' => '#123456', + 'names' => [ + 'en-US' => 'WA attr 1 en', + 'fr-FR' => 'WA attr 1 fr', + ], + 'textureFilePath' => null, + ], + [ + 'attributeId' => $secondAttribute['attributeId'], + 'position' => 1, + 'color' => '#654321', + 'names' => [ + 'en-US' => 'WA attr 2 en', + 'fr-FR' => 'WA attr 2 fr', + ], + 'textureFilePath' => null, + ], + ], + ], + $ourGroup + ); + } +} diff --git a/tests/Integration/ApiPlatform/CartEmailEndpointTest.php b/tests/Integration/ApiPlatform/CartEmailEndpointTest.php new file mode 100644 index 000000000..64863dc76 --- /dev/null +++ b/tests/Integration/ApiPlatform/CartEmailEndpointTest.php @@ -0,0 +1,118 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class CartEmailEndpointTest extends ApiTestCase +{ + private static int $originalMailMethod; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::createApiClient(['cart_write']); + + DatabaseDump::restoreTables(['cart', 'cart_product']); + + // Disable real email sending so Mail::send() succeeds without an SMTP server. + self::$originalMailMethod = (int) \Configuration::get('PS_MAIL_METHOD'); + \Configuration::updateValue('PS_MAIL_METHOD', \Mail::METHOD_DISABLE); + } + + public static function tearDownAfterClass(): void + { + \Configuration::updateValue('PS_MAIL_METHOD', self::$originalMailMethod); + + DatabaseDump::restoreTables(['cart', 'cart_product']); + + parent::tearDownAfterClass(); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'send cart to customer endpoint' => ['PUT', '/carts/1/emails']; + } + + public function testSendCartToCustomer(): void + { + $cartId = $this->createCustomerCart(); + + $this->requestApi( + 'PUT', + '/carts/' . $cartId . '/emails', + null, + ['cart_write'], + Response::HTTP_NO_CONTENT + ); + } + + // Note: the CartException => 422 mapping is not exercised here. The only way the handler + // raises a generic CartException is a mail-send failure or a cart whose customer no longer + // exists — states that cannot be reliably set up through this endpoint in an integration test. + public function testSendUnknownCartReturnsNotFound(): void + { + $unknownCartId = 1 + (int) \Db::getInstance()->getValue( + 'SELECT MAX(`id_cart`) FROM `' . _DB_PREFIX_ . 'cart`' + ); + + $this->requestApi( + 'PUT', + '/carts/' . $unknownCartId . '/emails', + null, + ['cart_write'], + Response::HTTP_NOT_FOUND + ); + } + + private function createCustomerCart(): int + { + $customerId = (int) \Db::getInstance()->getValue( + 'SELECT `id_customer` FROM `' . _DB_PREFIX_ . 'customer` WHERE `active` = 1 ORDER BY `id_customer` ASC' + ); + + $cart = new \Cart(); + $cart->id_customer = $customerId; + $cart->id_currency = (int) \Configuration::get('PS_CURRENCY_DEFAULT'); + $cart->id_lang = (int) \Configuration::get('PS_LANG_DEFAULT'); + $cart->id_shop = 1; + $cart->add(); + + // A cart with a product is more representative of a real "send to customer" case. + $cart->updateQty(1, $this->getSimpleInStockProductId()); + + return (int) $cart->id; + } + + private function getSimpleInStockProductId(): int + { + return (int) \Db::getInstance()->getValue( + 'SELECT sa.`id_product` FROM `' . _DB_PREFIX_ . 'stock_available` sa + INNER JOIN `' . _DB_PREFIX_ . 'product` p ON p.`id_product` = sa.`id_product` + WHERE sa.`quantity` > 0 AND sa.`id_product_attribute` = 0 AND p.`active` = 1 + AND sa.`id_product` NOT IN (SELECT `id_product` FROM `' . _DB_PREFIX_ . 'product_attribute`) + ORDER BY sa.`id_product` ASC' + ); + } +} diff --git a/tests/Integration/ApiPlatform/CartRuleSearchEndpointTest.php b/tests/Integration/ApiPlatform/CartRuleSearchEndpointTest.php new file mode 100644 index 000000000..9c99782bd --- /dev/null +++ b/tests/Integration/ApiPlatform/CartRuleSearchEndpointTest.php @@ -0,0 +1,85 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class CartRuleSearchEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + self::createApiClient(['cart_rule_read']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'cart_rule', + 'cart_rule_lang', + 'cart_rule_shop', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'search cart rules endpoint' => [ + 'GET', + '/cart-rules/search?phrase=test', + ]; + } + + public function testSearchCartRules(): void + { + $searchResults = $this->getItem('/cart-rules/search?phrase=test', ['cart_rule_read']); + + $this->assertIsArray($searchResults); + + foreach ($searchResults as $result) { + $this->assertArrayHasKey('cartRuleId', $result); + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('code', $result); + } + } + + public function testSearchCartRulesWithNoResults(): void + { + $searchResults = $this->getItem('/cart-rules/search?phrase=nonexistentcartrule999', ['cart_rule_read']); + + $this->assertIsArray($searchResults); + $this->assertEmpty($searchResults); + } + + public function testSearchCartRulesMissingPhrase(): void + { + $this->requestApi('GET', '/cart-rules/search', null, ['cart_rule_read'], Response::HTTP_UNPROCESSABLE_ENTITY); + } +} diff --git a/tests/Integration/ApiPlatform/CategoryEndpointTest.php b/tests/Integration/ApiPlatform/CategoryEndpointTest.php new file mode 100644 index 000000000..5d6826173 --- /dev/null +++ b/tests/Integration/ApiPlatform/CategoryEndpointTest.php @@ -0,0 +1,317 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class CategoryEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + // Pre-create API client with needed scopes + self::createApiClient(['category_read', 'category_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'category', + 'category_lang', + 'category_group', + 'category_shop', + 'category_product', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/categories/3', + ]; + + yield 'create endpoint' => [ + 'POST', + '/categories', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/categories/10', + ]; + + yield 'bulk toggle endpoint' => [ + 'PUT', + '/categories/bulk-update-status', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/categories/10/associate_and_disable', + ]; + + yield 'update status endpoint' => [ + 'PATCH', + '/categories/3/status', + ]; + + yield 'delete thumbnail endpoint' => [ + 'DELETE', + '/categories/3/thumbnail', + ]; + + yield 'delete cover endpoint' => [ + 'DELETE', + '/categories/4/thumbnail', + ]; + + yield 'bulk delete endpoint' => [ + 'DELETE', + '/categories/bulk-delete/associate_and_disable', + ]; + } + + public function testAddCategory(): int + { + $postData = [ + 'names' => [ + 'en-US' => 'Category EN', + 'fr-FR' => 'Catégorie FR', + ], + 'linkRewrites' => [ + 'en-US' => 'category-en', + 'fr-FR' => 'categorie-fr', + ], + 'isActive' => true, + 'parentCategoryId' => 2, + 'shopIds' => [1], + ]; + + $category = $this->createItem('/categories', $postData, ['category_write']); + $categoryId = $category['categoryId']; + + $this->assertArrayHasKey('categoryId', $category); + + $this->assertSame($postData['names'], $category['names']); + $this->assertSame($postData['linkRewrites'], $category['linkRewrites']); + $this->assertSame($postData['parentCategoryId'], $category['parentCategoryId']); + + return $categoryId; + } + + /** + * @depends testAddCategory + */ + public function testGetCategory(int $categoryId): int + { + $category = $this->getItem('/categories/' . $categoryId, ['category_read']); + + $this->assertSame( + $category['names'], + [ + 'en-US' => 'Category EN', + 'fr-FR' => 'Catégorie FR', + ] + ); + + $this->assertSame( + $category['linkRewrites'], + [ + 'en-US' => 'category-en', + 'fr-FR' => 'categorie-fr', + ] + ); + + $this->assertSame(2, $category['parentCategoryId']); + + return $categoryId; + } + + /** + * @depends testGetCategory + */ + public function testListCategories(int $categoryId): int + { + $paginated = $this->listItems('/categories?orderBy=categoryId&sortOrder=desc', ['category_read']); + + $this->assertGreaterThanOrEqual(10, $paginated['totalItems']); + + // First item should be our test category + $first = $paginated['items'][0]; + + $this->assertArrayHasKey('categoryId', $first); + $this->assertArrayHasKey('name', $first); + $this->assertArrayHasKey('enabled', $first); + $this->assertEquals($categoryId, $first['categoryId']); + + return $categoryId; + } + + /** + * @depends testListCategories + */ + public function testDeleteCategory(int $categoryId): void + { + // Delete the item + $this->deleteItem( + '/categories/' . $categoryId . '/associate_and_disable', + ['category_write'] + ); + + // Fetching the item returns a 404 indicatjng it no longer exists + $this->getItem('/categories/' . $categoryId, ['category_read'], Response::HTTP_NOT_FOUND); + } + + public function testUpdateCategoryStatus(): void + { + // Disable the category and assert the change is effective + $this->requestApi( + Request::METHOD_PATCH, + '/categories/3/status', + ['enabled' => false], + ['category_write'], + Response::HTTP_OK + ); + + $category = $this->getItem('/categories/3', ['category_read']); + + $this->assertFalse($category['enabled']); + + // Re-enable the category to avoid leaving side effects for other tests + $this->requestApi( + Request::METHOD_PATCH, + '/categories/3/status', + ['enabled' => true], + ['category_write'], + Response::HTTP_OK + ); + + $category = $this->getItem('/categories/3', ['category_read']); + $this->assertTrue($category['enabled']); + } + + public function testDeleteCategoryThumbnail(): void + { + // This test checks the happy path of the "delete thumbnail" endpoint. + // We use a predefined category (ID 3) that is known + // to have a cover image, so the DELETE request must succeed and return 204 (No Content). + $this->requestApi( + Request::METHOD_DELETE, + '/categories/3/thumbnail', + [], + ['category_write'], + Response::HTTP_NO_CONTENT + ); + } + + public function testDeleteCategoryCover(): void + { + // This test checks the happy path of the "delete cover" endpoint. + // We use a predefined category (ID 4) that is known + // to have a cover image, so the DELETE request must succeed and return 204 (No Content). + $this->requestApi( + Request::METHOD_DELETE, + '/categories/4/cover', + [], + ['category_write'], + Response::HTTP_NO_CONTENT + ); + } + + public function testBulkUpdateStatus(): array + { + $bulkCategories = $this->createTemporaryCategories(); + + // Perform bulk disable on the selected categories + $this->updateItem('/categories/bulk-update-status', [ + 'categoryIds' => $bulkCategories, + 'enabled' => false, + ], ['category_write'], Response::HTTP_NO_CONTENT); + + // Assert that the selected categories have been successfully disabled + foreach ($bulkCategories as $categoryId) { + $category = $this->getItem('/categories/' . $categoryId, ['category_read']); + $this->assertEquals(false, $category['enabled']); + } + + // Return IDs so they can be reused by testBulkDelete + return $bulkCategories; + } + + /** + * @depends testBulkUpdateStatus + */ + public function testBulkDelete(array $bulkCategories): void + { + // Bulk delete with deleteMode + $this->bulkDeleteItems('/categories/bulk-delete/associate_and_disable', [ + 'categoryIds' => $bulkCategories, + ], ['category_write']); + + foreach ($bulkCategories as $categoryId) { + $this->getItem('/categories/' . $categoryId, ['category_read'], Response::HTTP_NOT_FOUND); + } + } + + /** + * Create two temporary categories for bulk operation tests. + * + * @return int[] The IDs of the created categories + */ + private function createTemporaryCategories(): array + { + $cat1 = $this->createItem('/categories', [ + 'names' => ['en-US' => 'TempCat 1'], + 'linkRewrites' => [ + 'en-US' => 'temp-cat-2', + 'fr-FR' => 'temp-cat-2', + ], + 'isActive' => true, + 'parentCategoryId' => 2, + 'shopIds' => [1], + ], ['category_write']); + + $cat2 = $this->createItem('/categories', [ + 'names' => ['en-US' => 'TempCat 2'], + 'linkRewrites' => [ + 'en-US' => 'temp-cat-2', + 'fr-FR' => 'temp-cat-2', + ], + 'isActive' => true, + 'parentCategoryId' => 2, + 'shopIds' => [1], + ], ['category_write']); + + return [$cat1['categoryId'], $cat2['categoryId']]; + } +} diff --git a/tests/Integration/ApiPlatform/ContactEndpointTest.php b/tests/Integration/ApiPlatform/ContactEndpointTest.php new file mode 100644 index 000000000..e6c1dc55f --- /dev/null +++ b/tests/Integration/ApiPlatform/ContactEndpointTest.php @@ -0,0 +1,189 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class ContactEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + self::createApiClient(['contact_read', 'contact_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'contact', + 'contact_lang', + 'contact_shop', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/contacts/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/contacts', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/contacts/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/contacts', + ]; + } + + public function testAddContact(): int + { + $postData = [ + 'names' => [ + 'en-US' => 'Contact EN', + 'fr-FR' => 'Contact FR', + ], + 'descriptions' => [ + 'en-US' => 'Description EN', + 'fr-FR' => 'Description FR', + ], + 'email' => 'test@test.com', + 'messagesSavingEnabled' => true, + 'shopIds' => [1], + ]; + + $contact = $this->createItem('/contacts', $postData, ['contact_write']); + $this->assertArrayHasKey('contactId', $contact); + $contactId = $contact['contactId']; + + $this->assertSame($postData['names'], $contact['names']); + + return $contactId; + } + + /** + * @depends testAddContact + */ + public function testGetContact(int $contactId): int + { + $contact = $this->getItem('/contacts/' . $contactId, ['contact_read']); + $this->assertEquals($contactId, $contact['contactId']); + $this->assertArrayHasKey('names', $contact); + + return $contactId; + } + + /** + * @depends testGetContact + */ + public function testPartialUpdateContact(int $contactId): int + { + $patchData = [ + 'names' => [ + 'en-US' => 'Updated Contact EN', + 'fr-FR' => 'Updated Contact FR', + ], + 'email' => 'updated@test.com', + 'messagesSavingEnabled' => false, + 'shopIds' => [1], + ]; + + $updatedContact = $this->partialUpdateItem('/contacts/' . $contactId, $patchData, ['contact_write']); + $this->assertSame($patchData['names'], $updatedContact['names']); + $this->assertSame($patchData['email'], $updatedContact['email']); + + // We check that when we GET the item it is updated as expected + $contact = $this->getItem('/contacts/' . $contactId, ['contact_read']); + $this->assertSame($patchData['names'], $contact['names']); + $this->assertSame($patchData['email'], $contact['email']); + $this->assertArrayHasKey('messagesSavingEnabled', $contact); + $this->assertSame((bool) $patchData['messagesSavingEnabled'], (bool) $contact['messagesSavingEnabled']); + + return $contactId; + } + + /** + * @depends testPartialUpdateContact + */ + public function testListContacts(int $contactId): int + { + $paginatedContacts = $this->listItems('/contacts?orderBy=contactId&sortOrder=desc', ['contact_read']); + $this->assertGreaterThanOrEqual(1, $paginatedContacts['totalItems']); + $this->assertEquals('contactId', $paginatedContacts['orderBy']); + + $firstContact = $paginatedContacts['items'][0]; + $this->assertEquals($contactId, $firstContact['contactId']); + + return $contactId; + } + + public function testInvalidContact(): void + { + $invalidData = [ + 'names' => [ + 'fr-FR' => 'Invalid<', + ], + 'email' => 'invalidemail@', + 'messagesSavingEnabled' => true, + 'shopIds' => [], + ]; + + $validationErrorsResponse = $this->createItem('/contacts', $invalidData, ['contact_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + + $this->assertValidationErrors([ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + [ + 'propertyPath' => 'names[fr-FR]', + 'message' => '"Invalid<" is invalid', + ], + [ + 'propertyPath' => 'email', + 'message' => 'This value is not a valid email address.', + ], + [ + 'propertyPath' => 'shopIds', + 'message' => 'This value should not be blank.', + ], + ], $validationErrorsResponse); + } +} diff --git a/tests/Integration/ApiPlatform/CoreVersionCompatibilityDecoratorTest.php b/tests/Integration/ApiPlatform/CoreVersionCompatibilityDecoratorTest.php new file mode 100644 index 000000000..a641cbc46 --- /dev/null +++ b/tests/Integration/ApiPlatform/CoreVersionCompatibilityDecoratorTest.php @@ -0,0 +1,154 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use PHPUnit\Framework\TestCase; +use PrestaShop\Module\APIResources\ApiPlatform\Metadata\Resource\Factory\CoreVersionCompatibilityMetadataCollectionFactoryDecorator; +use PrestaShop\PrestaShop\Core\FeatureFlag\DisabledFeatureFlagStateChecker; +use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagStateCheckerInterface; +use PrestaShop\PrestaShop\Core\Version; + +/** + * Logic-level test for the (deprecated) module twin of the core CoreVersionCompatibilityMetadataCollectionFactoryDecorator. + * The versioned operations use extreme min/max versions (1.0.0 and 99.99.99) plus the running Version::VERSION for the + * inclusive bound cases, so the expectations hold on every core version of the CI matrix. + */ +class CoreVersionCompatibilityDecoratorTest extends TestCase +{ + // All the operations in their declaration order, expected when the filtering is bypassed + private const ALL_OPERATIONS = [ + 'no_version', + 'min_below', + 'min_equal', + 'min_above', + 'max_above', + 'max_equal', + 'max_below', + 'range_in', + 'range_out', + ]; + + private const EXPECTED_KEPT_OPERATIONS = [ + 'no_version', + 'min_below', + 'min_equal', + 'max_above', + 'max_equal', + 'range_in', + ]; + + public function testIncompatibleOperationsAreFiltered(): void + { + if ($this->coreDecoratorClassExists()) { + $this->markTestSkipped('On PrestaShop >= 9.2 the module decorator is a pure pass-through, filtering is covered by testPassThroughWhenCoreDecoratorClassExists'); + } + + $decorator = new CoreVersionCompatibilityMetadataCollectionFactoryDecorator( + $this->buildDecoratedFactory(), + new DisabledFeatureFlagStateChecker(), + ); + + $this->assertEquals(self::EXPECTED_KEPT_OPERATIONS, $this->getOperationNames($decorator->create('resourceClass'))); + } + + public function testPassThroughWhenCoreDecoratorClassExists(): void + { + // When the core twin decorator class exists (PrestaShop >= 9.2) the module decorator filters nothing, + // the core is responsible for the filtering. The detection relies on class_exists, so this case can + // only be exercised when the CI matrix runs against a core version that ships the class. + if (!$this->coreDecoratorClassExists()) { + $this->markTestSkipped('The core twin decorator class only exists on PrestaShop >= 9.2, filtering is covered by testIncompatibleOperationsAreFiltered'); + } + + $decorator = new CoreVersionCompatibilityMetadataCollectionFactoryDecorator( + $this->buildDecoratedFactory(), + new DisabledFeatureFlagStateChecker(), + ); + + $this->assertEquals(self::ALL_OPERATIONS, $this->getOperationNames($decorator->create('resourceClass'))); + } + + public function testEnabledExperimentalEndpointsFeatureFlagFiltersNothing(): void + { + $featureFlagStateChecker = $this->createMock(FeatureFlagStateCheckerInterface::class); + $featureFlagStateChecker->method('isEnabled')->willReturn(true); + + $decorator = new CoreVersionCompatibilityMetadataCollectionFactoryDecorator( + $this->buildDecoratedFactory(), + $featureFlagStateChecker, + ); + + $this->assertEquals(self::ALL_OPERATIONS, $this->getOperationNames($decorator->create('resourceClass'))); + } + + private function coreDecoratorClassExists(): bool + { + return class_exists(\PrestaShopBundle\ApiPlatform\Metadata\Resource\Factory\CoreVersionCompatibilityMetadataCollectionFactoryDecorator::class); + } + + private function buildDecoratedFactory(): ResourceMetadataCollectionFactoryInterface + { + // The operations are built with the generic Get operation and raw extra properties on purpose: + // the minVersion/maxVersion named constructor arguments only exist on PrestaShop >= 9.2 while this + // test must run against every supported core version + $collection = new ResourceMetadataCollection('resourceClass', [ + (new ApiResource())->withOperations(new Operations([ + 'no_version' => new Get(uriTemplate: '/no-version'), + 'min_below' => new Get(uriTemplate: '/min-below', extraProperties: ['minVersion' => '1.0.0']), + 'min_equal' => new Get(uriTemplate: '/min-equal', extraProperties: ['minVersion' => Version::VERSION]), + 'min_above' => new Get(uriTemplate: '/min-above', extraProperties: ['minVersion' => '99.99.99']), + 'max_above' => new Get(uriTemplate: '/max-above', extraProperties: ['maxVersion' => '99.99.99']), + 'max_equal' => new Get(uriTemplate: '/max-equal', extraProperties: ['maxVersion' => Version::VERSION]), + 'max_below' => new Get(uriTemplate: '/max-below', extraProperties: ['maxVersion' => '1.0.0']), + 'range_in' => new Get(uriTemplate: '/range-in', extraProperties: ['minVersion' => '1.0.0', 'maxVersion' => '99.99.99']), + 'range_out' => new Get(uriTemplate: '/range-out', extraProperties: ['minVersion' => '98.0.0', 'maxVersion' => '99.99.99']), + ])), + ]); + + $decoratedFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decoratedFactory->method('create')->willReturn($collection); + + return $decoratedFactory; + } + + /** + * @return string[] + */ + private function getOperationNames(ResourceMetadataCollection $resourceMetadataCollection): array + { + $operationNames = []; + /** @var ApiResource $resourceMetadata */ + foreach ($resourceMetadataCollection as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operationName => $operation) { + $operationNames[] = $operationName; + } + } + + return $operationNames; + } +} diff --git a/tests/Integration/ApiPlatform/CountryEndpointTest.php b/tests/Integration/ApiPlatform/CountryEndpointTest.php new file mode 100644 index 000000000..d8125aaa8 --- /dev/null +++ b/tests/Integration/ApiPlatform/CountryEndpointTest.php @@ -0,0 +1,419 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class CountryEndpointTest extends ApiTestCase +{ + // Default PS address format from install-dev/data/xml/address_format.xml. + // Must list every static-required Address field — firstname, lastname, address1, + // city, Country:name — or AddressFormatChecker rejects it. + private const DEFAULT_ADDRESS_FORMAT = "firstname lastname\ncompany\nvat_number\naddress1\naddress2\npostcode city\nCountry:name\nphone"; + + // Variant used by testEditCountry — same required tokens, different optional ones. + private const UPDATED_ADDRESS_FORMAT = "firstname lastname\naddress1\npostcode city\nCountry:name"; + + // Tests that depend on PS 9.2+ behavior (Country API returning the stored + // address format, ValidAddressFormat constraint firing on add/edit) gate + // themselves on the existence of this interface — introduced in 9.2. + private const CORE_ADDRESS_FORMAT_CHECKER = 'PrestaShop\\PrestaShop\\Core\\Domain\\Country\\AddressFormat\\AddressFormatCheckerInterface'; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::createApiClient(['country_read', 'country_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + DatabaseDump::restoreTables(['country', 'country_lang', 'country_shop', 'address_format']); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'create endpoint' => ['POST', '/countries']; + yield 'get endpoint' => ['GET', '/countries/1']; + yield 'update endpoint' => ['PATCH', '/countries/1']; + yield 'delete endpoint' => ['DELETE', '/countries/1']; + yield 'list endpoint' => ['GET', '/countries']; + } + + public function testAddCountry(): int + { + $country = $this->createItem('/countries', $this->getCreatePayload(), ['country_write']); + + $this->assertArrayHasKey('countryId', $country); + $this->assertEquals(['countryId' => $country['countryId']], $country); + + return $country['countryId']; + } + + /** + * @depends testAddCountry + */ + public function testGetCountry(int $countryId): int + { + $expected = ['countryId' => $countryId] + $this->getCreatePayload(); + // On PS 9.0/9.1 the AddCountryHandler ignores addressFormat from the + // command and GetCountryForEditing returns an empty string regardless + // of what was sent — the field only round-trips on 9.2+. + $expected['addressFormat'] = $this->expectedAddressFormat($expected['addressFormat']); + + $country = $this->getItem('/countries/' . $countryId, ['country_read']); + $this->assertEquals($expected, $country); + + return $countryId; + } + + /** + * @depends testGetCountry + */ + public function testEditCountry(int $countryId): int + { + $patchData = [ + 'names' => [ + 'en-US' => 'Updated Country EN', + 'fr-FR' => 'Updated Country FR', + ], + 'enabled' => false, + 'addressFormat' => self::UPDATED_ADDRESS_FORMAT, + ]; + + $expected = [ + 'countryId' => $countryId, + 'names' => [ + 'en-US' => 'Updated Country EN', + 'fr-FR' => 'Updated Country FR', + ], + 'isoCode' => 'ZZ', + 'callPrefix' => 999, + 'defaultCurrencyId' => 0, + 'zoneId' => 1, + 'needZipCode' => false, + 'zipCodeFormat' => null, + // Same caveat as testGetCountry — addressFormat only round-trips on 9.2+. + 'addressFormat' => $this->expectedAddressFormat(self::UPDATED_ADDRESS_FORMAT), + 'enabled' => false, + 'containsStates' => false, + 'needIdNumber' => false, + 'displayTaxLabel' => true, + 'shopIds' => [1], + ]; + + $updated = $this->partialUpdateItem('/countries/' . $countryId, $patchData, ['country_write']); + $this->assertEquals($expected, $updated); + + // Round-trip via GET to confirm the persisted state matches the PATCH response. + $fetched = $this->getItem('/countries/' . $countryId, ['country_read']); + $this->assertEquals($expected, $fetched); + + return $countryId; + } + + /** + * @depends testEditCountry + */ + public function testListCountries(int $countryId): int + { + // Plain listing returns a paginated, structured collection. + $countries = $this->listItems('/countries', ['country_read']); + $this->assertGreaterThanOrEqual(1, $countries['totalItems']); + $this->assertNotEmpty($countries['items']); + + // Filtering by isoCode isolates the country created earlier in this test class. + $filtered = $this->listItems('/countries', ['country_read'], ['isoCode' => 'ZZ']); + $this->assertEquals(1, $filtered['totalItems']); + $this->assertEquals(['isoCode' => 'ZZ'], $filtered['filters']); + + $testCountry = $filtered['items'][0]; + // testEditCountry left the record with these values (default lang name = en-US). + $this->assertEquals($countryId, $testCountry['countryId']); + $this->assertEquals('Updated Country EN', $testCountry['name']); + $this->assertEquals('ZZ', $testCountry['isoCode']); + $this->assertEquals(999, $testCountry['callPrefix']); + $this->assertFalse($testCountry['enabled']); + // zoneName comes from the zone grid join (z.name); zone 1 always has a name. + $this->assertIsString($testCountry['zoneName']); + $this->assertNotEmpty($testCountry['zoneName']); + + // Filtering by the boolean status (combined with isoCode to avoid relying + // on the default pagination window) matches the disabled created country. + $disabled = $this->listItems('/countries', ['country_read'], ['isoCode' => 'ZZ', 'enabled' => false]); + $this->assertNotEmpty($disabled['items']); + $disabledIds = array_column($disabled['items'], 'countryId'); + $this->assertContains($countryId, $disabledIds); + foreach ($disabled['items'] as $disabledCountry) { + $this->assertFalse($disabledCountry['enabled']); + } + + // Sorting parameters are honoured and echoed back in the response. + $sorted = $this->listItems('/countries?orderBy=countryId&sortOrder=desc', ['country_read']); + $this->assertEquals('countryId', $sorted['orderBy']); + $this->assertEquals('desc', $sorted['sortOrder']); + + // Pagination: a single-item first page, then the second page returns a different country. + $firstPage = $this->listItems('/countries?limit=1&offset=0&orderBy=countryId&sortOrder=asc', ['country_read']); + $this->assertCount(1, $firstPage['items']); + $this->assertEquals(1, $firstPage['limit']); + $this->assertEquals($countries['totalItems'], $firstPage['totalItems']); + + $secondPage = $this->listItems('/countries?limit=1&offset=1&orderBy=countryId&sortOrder=asc', ['country_read']); + $this->assertCount(1, $secondPage['items']); + $this->assertNotEquals( + $firstPage['items'][0]['countryId'], + $secondPage['items'][0]['countryId'] + ); + + return $countryId; + } + + /** + * Order-critical: must run after every other test that needs the created + * country alive. The validation tests below also chain off testGetCountry + * and PATCH the same record, so listing them here forces PHPUnit to + * schedule the delete last. + * + * @depends testEditCountry + * @depends testListCountries + * @depends testAddCountryWithInvalidAddressFormat + * @depends testEditCountryWithInvalidAddressFormat + */ + public function testRemoveCountry(int $countryId): void + { + $return = $this->deleteItem('/countries/' . $countryId, ['country_write']); + // This endpoint returns empty response and 204 HTTP code + $this->assertNull($return); + + // Getting the item should result in a 404 now + $this->getItem('/countries/' . $countryId, ['country_read'], Response::HTTP_NOT_FOUND); + } + + public function testGetNonExistentCountry(): void + { + $this->getItem('/countries/999999', ['country_read'], Response::HTTP_NOT_FOUND); + } + + public function testAddCountryWithInvalidData(): void + { + $validationErrorsResponse = $this->createItem('/countries', [ + 'isoCode' => '', + ], ['country_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + $this->assertValidationErrors([ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + [ + 'propertyPath' => 'isoCode', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'callPrefix', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'defaultCurrencyId', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'zoneId', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'needZipCode', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'addressFormat', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'enabled', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'containsStates', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'needIdNumber', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'displayTaxLabel', + 'message' => 'This value should not be null.', + ], + ], $validationErrorsResponse); + } + + /** + * @dataProvider invalidAddressFormatProvider + * + * @param array $expectedErrors + */ + public function testAddCountryWithInvalidAddressFormat(string $invalidFormat, array $expectedErrors): void + { + $this->skipIfAddressFormatCheckerMissing(); + + $payload = $this->getCreatePayload(); + $payload['addressFormat'] = $invalidFormat; + + $response = $this->createItem('/countries', $payload, ['country_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->assertIsArray($response); + $this->assertValidationErrors($expectedErrors, $response); + } + + /** + * @depends testGetCountry + * + * @dataProvider invalidAddressFormatProvider + * + * @param array $expectedErrors + */ + public function testEditCountryWithInvalidAddressFormat(string $invalidFormat, array $expectedErrors, int $countryId): void + { + $this->skipIfAddressFormatCheckerMissing(); + + // The validator returns early on empty strings, so on PATCH an empty + // addressFormat slips past the constraint and reaches the handler. We + // skip that data-provider row here — it would 500, not 422. + if ('' === $invalidFormat) { + $this->markTestSkipped('Empty addressFormat on PATCH is not handled by the constraint (validator early-returns on empty).'); + } + + $response = $this->partialUpdateItem( + '/countries/' . $countryId, + ['addressFormat' => $invalidFormat], + ['country_write'], + Response::HTTP_UNPROCESSABLE_ENTITY + ); + + $this->assertIsArray($response); + $this->assertValidationErrors($expectedErrors, $response); + } + + /** + * @return iterable}> + */ + public static function invalidAddressFormatProvider(): iterable + { + yield 'empty format (Create only — NotBlank fires)' => [ + '', + [ + ['propertyPath' => 'addressFormat', 'message' => 'This value should not be blank.'], + ], + ]; + + yield 'unknown Address field' => [ + "firstname lastname\naddress1\npostcode city\nCountry:name\nnot_a_real_field", + [ + ['propertyPath' => 'addressFormat', 'message' => 'This field is not a valid address property: not_a_real_field.'], + ], + ]; + + yield 'unknown picker class' => [ + "firstname lastname\naddress1\npostcode city\nCountry:name\nUnknownObject:name", + [ + ['propertyPath' => 'addressFormat', 'message' => 'This object is not allowed: UnknownObject.'], + ], + ]; + + yield 'unknown field on known class' => [ + "firstname lastname\naddress1\npostcode city\nCountry:name\nCountry:not_a_field", + [ + ['propertyPath' => 'addressFormat', 'message' => 'The field not_a_field does not exist on Country.'], + ], + ]; + + yield 'duplicate token' => [ + "firstname lastname\naddress1\npostcode city\nCountry:name\nfirstname", + [ + ['propertyPath' => 'addressFormat', 'message' => 'This key has already been used: firstname.'], + ], + ]; + + yield 'missing required field (no Country:name)' => [ + "firstname lastname\naddress1\npostcode city", + [ + ['propertyPath' => 'addressFormat', 'message' => 'The Country:name field is required.'], + ], + ]; + } + + /** + * Validation tests that exercise the ValidAddressFormat constraint call + * this helper to opt out cleanly on PS 9.0/9.1, where the constraint + * validator no-ops (no AddressFormatCheckerInterface in core). + * + * Round-trip tests (testGetCountry, testEditCountry) do NOT skip — they + * run on every version and use {@see self::expectedAddressFormat()} to + * branch the assertion instead. + */ + private function skipIfAddressFormatCheckerMissing(): void + { + if (!interface_exists(self::CORE_ADDRESS_FORMAT_CHECKER)) { + $this->markTestSkipped('Requires PrestaShop 9.2+ (AddressFormatCheckerInterface).'); + } + } + + /** + * On PS 9.2+ the Country API persists and returns the addressFormat string + * supplied on add/edit. On 9.0/9.1 the AddCountryHandler/EditCountryHandler + * ignore that field and GetCountryForEditing returns an empty string — + * regardless of what the request sent. + */ + private function expectedAddressFormat(string $sentFormat): string + { + return interface_exists(self::CORE_ADDRESS_FORMAT_CHECKER) ? $sentFormat : ''; + } + + /** + * @return array + */ + private function getCreatePayload(): array + { + return [ + 'names' => [ + 'en-US' => 'My Country EN', + 'fr-FR' => 'My Country FR', + ], + 'isoCode' => 'ZZ', + 'callPrefix' => 999, + 'defaultCurrencyId' => 0, + 'zoneId' => 1, + 'needZipCode' => false, + 'zipCodeFormat' => null, + 'addressFormat' => self::DEFAULT_ADDRESS_FORMAT, + 'enabled' => true, + 'containsStates' => false, + 'needIdNumber' => false, + 'displayTaxLabel' => true, + 'shopIds' => [1], + ]; + } +} diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php new file mode 100644 index 000000000..fe855e019 --- /dev/null +++ b/tests/Integration/ApiPlatform/CustomerEndpointTest.php @@ -0,0 +1,1023 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class CustomerEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + DatabaseDump::restoreTables(['customer', 'customer_group']); + self::createApiClient(['customer_write', 'customer_read']); + } + + public static function tearDownBeforeClass(): void + { + parent::tearDownBeforeClass(); + // Reset DB as it was before this test + DatabaseDump::restoreTables(['customer', 'customer_group']); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'create customer endpoint' => [ + 'POST', + '/customers', + ]; + + yield 'get customer endpoint' => [ + 'GET', + '/customers/1', + ]; + + yield 'get customer details endpoint' => [ + 'GET', + '/customers/1/details', + ]; + + yield 'update customer endpoint' => [ + 'PATCH', + '/customers/1', + ]; + + yield 'delete customer endpoint' => [ + 'DELETE', + '/customers/1', + ]; + + yield 'bulk delete customers endpoint' => [ + 'DELETE', + '/customers/bulk-delete', + ]; + + yield 'bulk disable customers endpoint' => [ + 'PUT', + '/customers/bulk-disable', + ]; + + yield 'bulk enable customers endpoint' => [ + 'PUT', + '/customers/bulk-enable', + ]; + + yield 'search customers endpoint' => [ + 'GET', + '/customers/search?phrases[]=test', + ]; + } + + public function testAddCustomer(): int + { + $postData = [ + 'firstName' => 'Jane', + 'lastName' => 'Doe', + 'email' => 'jane.doe@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'genderId' => 1, + 'enabled' => true, + 'partnerOffersSubscribed' => true, + 'birthday' => '1990-01-15', + 'guest' => false, + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $this->assertArrayHasKey('customerId', $customer); + $customerId = $customer['customerId']; + + // Check that all data was saved correctly + $expectedData = [ + 'customerId' => $customerId, + 'firstName' => 'Jane', + 'lastName' => 'Doe', + 'email' => 'jane.doe@example.com', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'genderId' => 1, + 'enabled' => true, + 'partnerOffersSubscribed' => true, + 'birthday' => '1990-01-15', + 'guest' => false, + 'newsletterSubscribed' => false, + 'companyName' => '', + 'siretCode' => '', + 'apeCode' => '', + 'website' => '', + 'allowedOutstandingAmount' => 0.0, + 'maxPaymentDays' => 0, + 'riskId' => 0, + ]; + $this->assertEquals($expectedData, $customer); + + return $customerId; + } + + public function testAddCustomerWithB2bFields(): int + { + $postData = [ + 'firstName' => 'Jane', + 'lastName' => 'Smith', + 'email' => 'jane.smith@example.com', + 'password' => 'TestPassword321!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'genderId' => 2, + 'enabled' => true, + 'partnerOffersSubscribed' => true, + 'birthday' => '1985-05-20', + 'guest' => false, + 'companyName' => 'Test Company', + 'siretCode' => '12345678901234', + 'apeCode' => '1234Z', + 'website' => 'https://www.example.com', + 'allowedOutstandingAmount' => 1000.50, + 'maxPaymentDays' => 30, + 'riskId' => 1, + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $this->assertArrayHasKey('customerId', $customer); + $customerId = $customer['customerId']; + + // Check that all data was saved correctly + $expectedData = [ + 'customerId' => $customerId, + 'firstName' => 'Jane', + 'lastName' => 'Smith', + 'email' => 'jane.smith@example.com', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'genderId' => 2, + 'enabled' => true, + 'partnerOffersSubscribed' => true, + 'birthday' => '1985-05-20', + 'guest' => false, + 'newsletterSubscribed' => false, + 'companyName' => 'Test Company', + 'siretCode' => '12345678901234', + 'apeCode' => '1234Z', + 'website' => 'https://www.example.com', + 'allowedOutstandingAmount' => 1000.50, + 'maxPaymentDays' => 30, + 'riskId' => 1, + ]; + $this->assertEquals($expectedData, $customer); + + return $customerId; + } + + public function testAddCustomerWithMinimalData(): int + { + $postData = [ + 'firstName' => 'Minimal', + 'lastName' => 'Customer', + 'email' => 'minimal.customer@example.com', + 'password' => 'TestPassword456!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $this->assertArrayHasKey('customerId', $customer); + $customerId = $customer['customerId']; + + // Check that all required data was saved correctly + $expectedData = [ + 'customerId' => $customerId, + 'firstName' => 'Minimal', + 'lastName' => 'Customer', + 'email' => 'minimal.customer@example.com', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'genderId' => 0, + 'enabled' => true, + 'partnerOffersSubscribed' => false, + 'birthday' => '0000-00-00', + 'guest' => false, + 'newsletterSubscribed' => false, + 'companyName' => '', + 'siretCode' => '', + 'apeCode' => '', + 'website' => '', + 'allowedOutstandingAmount' => 0.0, + 'maxPaymentDays' => 0, + 'riskId' => 0, + ]; + $this->assertEquals($expectedData, $customer); + + return $customerId; + } + + public function testAddGuestCustomer(): int + { + $postData = [ + 'firstName' => 'Jane', + 'lastName' => 'GUEST', + 'email' => 'jane.guest@example.com', + 'password' => 'INVALID', + 'genderId' => 2, + 'guest' => true, + 'defaultGroupId' => 1, + 'groupIds' => [1], + 'enabled' => false, + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $this->assertArrayHasKey('customerId', $customer); + $customerId = $customer['customerId']; + + // Check that all data was saved correctly + $expectedData = [ + 'customerId' => $customerId, + 'firstName' => 'Jane', + 'lastName' => 'GUEST', + 'email' => 'jane.guest@example.com', + 'defaultGroupId' => 1, + 'groupIds' => [1], + 'genderId' => 2, + 'enabled' => false, + 'partnerOffersSubscribed' => false, + 'birthday' => '0000-00-00', + 'guest' => true, + 'newsletterSubscribed' => false, + 'companyName' => '', + 'siretCode' => '', + 'apeCode' => '', + 'website' => '', + 'allowedOutstandingAmount' => 0.0, + 'maxPaymentDays' => 0, + 'riskId' => 0, + ]; + $this->assertEquals($expectedData, $customer); + + return $customerId; + } + + public function testAddCustomerValidationErrors(): void + { + // Test with missing required fields + $invalidData = [ + 'firstName' => '', + 'lastName' => 'Doe', + 'email' => 'invalid-email', + 'password' => '', + 'defaultGroupId' => 3, + 'groupIds' => [], + ]; + + $validationErrorsResponse = $this->createItem('/customers', $invalidData, ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + + // Check that we have validation errors + $this->assertIsArray($validationErrorsResponse); + $this->assertNotEmpty($validationErrorsResponse); + + // Check for specific validation errors (messages are lang-dependant. We check the key) + $propertyPaths = array_column($validationErrorsResponse, 'propertyPath'); + $this->assertContains('firstName', $propertyPaths); + $this->assertContains('email', $propertyPaths); + $this->assertContains('password', $propertyPaths); + $this->assertContains('groupIds', $propertyPaths); + } + + public function testAddCustomerDuplicateEmail(): void + { + // First, create a customer + $postData = [ + 'firstName' => 'First', + 'lastName' => 'Customer', + 'email' => 'duplicate@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + + $firstCustomer = $this->createItem('/customers', $postData, ['customer_write']); + $this->assertArrayHasKey('customerId', $firstCustomer); + + // Try to create another customer with the same email (should fail) + $duplicateData = [ + 'firstName' => 'Second', + 'lastName' => 'Customer', + 'email' => 'duplicate@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + + $this->createItem('/customers', $duplicateData, ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + /** + * @depends testPartialUpdateCustomer + */ + public function testPartialUpdateCustomerMultipleFields(int $customerId): int + { + // Update multiple fields with unique email to avoid conflicts + $uniqueEmail = 'jane.smith.' . time() . '@example.com'; + $updatedCustomer = $this->partialUpdateItem('/customers/' . $customerId, [ + 'lastName' => 'Smith', + 'email' => $uniqueEmail, + 'enabled' => false, + 'newsletterSubscribed' => true, + 'partnerOffersSubscribed' => true, + ], ['customer_write']); + + $this->assertEquals('Jane', $updatedCustomer['firstName']); + $this->assertEquals('Smith', $updatedCustomer['lastName']); + $this->assertEquals($uniqueEmail, $updatedCustomer['email']); + $this->assertFalse($updatedCustomer['enabled']); + $this->assertTrue($updatedCustomer['newsletterSubscribed']); + $this->assertTrue($updatedCustomer['partnerOffersSubscribed']); + + return $customerId; + } + + /** + * @depends testPartialUpdateCustomerMultipleFields + */ + public function testPartialUpdateCustomerWithB2bFields(int $customerId): int + { + // Update with B2B fields + $updatedCustomer = $this->partialUpdateItem('/customers/' . $customerId, [ + 'companyName' => 'Test Company', + 'siretCode' => '12345678901234', + 'apeCode' => '1234Z', + 'website' => 'https://www.example.com', + 'allowedOutstandingAmount' => 1000.50, + 'maxPaymentDays' => 30, + 'riskId' => 1, + ], ['customer_write']); + + $this->assertEquals('Test Company', $updatedCustomer['companyName']); + $this->assertEquals('12345678901234', $updatedCustomer['siretCode']); + $this->assertEquals('1234Z', $updatedCustomer['apeCode']); + $this->assertEquals('https://www.example.com', $updatedCustomer['website']); + $this->assertEquals(1000.50, $updatedCustomer['allowedOutstandingAmount']); + $this->assertEquals(30, $updatedCustomer['maxPaymentDays']); + $this->assertEquals(1, $updatedCustomer['riskId']); + + return $customerId; + } + + public function testPartialUpdateCustomerNotFound(): void + { + // Try to update a non-existent customer + $this->partialUpdateItem('/customers/99999', [ + 'firstName' => 'Test', + ], ['customer_write'], Response::HTTP_NOT_FOUND); + } + + public function testPartialUpdateCustomerValidationErrors(): void + { + // First, create a customer + $postData = [ + 'firstName' => 'Test', + 'lastName' => 'Customer', + 'email' => 'test.validation@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customerId = $customer['customerId']; + + // Try to update with invalid email + $validationErrorsResponse = $this->partialUpdateItem('/customers/' . $customerId, [ + 'email' => 'invalid-email', + ], ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->assertIsArray($validationErrorsResponse); + $this->assertNotEmpty($validationErrorsResponse); + + // Check for email validation error + $propertyPaths = array_column($validationErrorsResponse, 'propertyPath'); + $this->assertContains('email', $propertyPaths); + } + + public function testPartialUpdateCustomerDuplicateEmail(): void + { + // Create first customer + $this->createItem('/customers', [ + 'firstName' => 'First', + 'lastName' => 'Customer', + 'email' => 'first.customer@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ], ['customer_write']); + + // Create second customer + $secondCustomer = $this->createItem('/customers', [ + 'firstName' => 'Second', + 'lastName' => 'Customer', + 'email' => 'second.customer@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ], ['customer_write']); + + // Try to update second customer with first customer's email (should fail) + $this->partialUpdateItem('/customers/' . $secondCustomer['customerId'], [ + 'email' => 'first.customer@example.com', + ], ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testPartialUpdateCustomerPassword(): int + { + // Create a customer + $customer = $this->createItem('/customers', [ + 'firstName' => 'Password', + 'lastName' => 'Test', + 'email' => 'password.test@example.com', + 'password' => 'OldPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ], ['customer_write']); + + $customerId = $customer['customerId']; + + // Update password + $updatedCustomer = $this->partialUpdateItem('/customers/' . $customerId, [ + 'password' => 'NewPassword123!', + ], ['customer_write']); + + $this->assertEquals($customerId, $updatedCustomer['customerId']); + // Password should not be returned in response, but customer should be updated + $this->assertArrayNotHasKey('password', $updatedCustomer); + + return $customerId; + } + + public function testPartialUpdateCustomerGroups(): int + { + // Create a customer + $customer = $this->createItem('/customers', [ + 'firstName' => 'Group', + 'lastName' => 'Test', + 'email' => 'group.test@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ], ['customer_write']); + + $customerId = $customer['customerId']; + + // Update groups - use group 3 which should exist + $updatedCustomer = $this->partialUpdateItem('/customers/' . $customerId, [ + 'defaultGroupId' => 3, + 'groupIds' => [3], + ], ['customer_write']); + + $this->assertEquals(3, $updatedCustomer['defaultGroupId']); + $this->assertContains(3, $updatedCustomer['groupIds']); + + return $customerId; + } + + /** + * @depends testAddCustomer + */ + public function testGetCustomer(int $customerId): void + { + $customer = $this->getItem('/customers/' . $customerId, ['customer_read']); + + // Verify basic structure of EditableCustomer response (same as POST/PATCH) + $this->assertArrayHasKey('customerId', $customer); + $this->assertEquals($customerId, $customer['customerId']); + $this->assertArrayHasKey('firstName', $customer); + $this->assertArrayHasKey('lastName', $customer); + $this->assertArrayHasKey('email', $customer); + $this->assertArrayHasKey('defaultGroupId', $customer); + $this->assertArrayHasKey('groupIds', $customer); + } + + /** + * @depends testAddCustomer + */ + public function testGetCustomerDetails(int $customerId): void + { + $customer = $this->getItem('/customers/' . $customerId . '/details', ['customer_read']); + + // Verify basic structure of ViewableCustomer response + $this->assertArrayHasKey('customerId', $customer); + $this->assertEquals($customerId, $customer['customerId']); + + // ViewableCustomer should have personalInformation, ordersInformation, etc. + $this->assertArrayHasKey('personalInformation', $customer); + $this->assertArrayHasKey('ordersInformation', $customer); + $this->assertArrayHasKey('groupsInformation', $customer); + $this->assertArrayHasKey('generalInformation', $customer); + + // Verify personalInformation structure + $personalInfo = $customer['personalInformation']; + $this->assertIsArray($personalInfo); + $this->assertArrayHasKey('firstName', $personalInfo); + $this->assertArrayHasKey('lastName', $personalInfo); + $this->assertArrayHasKey('email', $personalInfo); + } + + public function testGetCustomerNotFound(): void + { + // Try to get a non-existent customer + $this->requestApi('GET', '/customers/99999', null, ['customer_read'], Response::HTTP_NOT_FOUND); + } + + public function testGetCustomerDetailsNotFound(): void + { + // Try to get a non-existent customer details + $this->requestApi('GET', '/customers/99999/details', null, ['customer_read'], Response::HTTP_NOT_FOUND); + } + + public function testDeleteCustomer(): void + { + // First, create a customer to delete + $postData = [ + 'firstName' => 'ToDelete', + 'lastName' => 'Customer', + 'email' => 'todelete@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customerId = $customer['customerId']; + + // Delete with allow_registration_after method + $deleteData = [ + 'deleteMethod' => 'allow_registration_after', + ]; + $this->requestApi('DELETE', '/customers/' . $customerId, $deleteData, ['customer_write'], Response::HTTP_NO_CONTENT); + + // Verify that the customer is deleted from the database (GET should return 404) + $this->requestApi('GET', '/customers/' . $customerId, null, ['customer_read'], Response::HTTP_NOT_FOUND); + } + + public function testDeleteCustomerWithDenyRegistration(): void + { + // First, create a customer to delete + $postData = [ + 'firstName' => 'ToDeleteDeny', + 'lastName' => 'Customer', + 'email' => 'todeletedeny@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customerId = $customer['customerId']; + + // Delete with deny_registration_after method + $deleteData = [ + 'deleteMethod' => 'deny_registration_after', + ]; + $this->requestApi('DELETE', '/customers/' . $customerId, $deleteData, ['customer_write'], Response::HTTP_NO_CONTENT); + + // After deletion, verify that the customer still exists in the database but with deleted = 1 + $db = $this->getContainer()->get('doctrine.dbal.default_connection'); + $deletedValue = $db->fetchOne('SELECT deleted FROM ps_customer WHERE id_customer = ?', [$customerId]); + $this->assertEquals('1', (string) $deletedValue, 'Customer should be soft deleted (deleted = 1)'); + } + + public function testDeleteCustomerNotFound(): void + { + // Try to delete a non-existent customer + $deleteData = [ + 'deleteMethod' => 'allow_registration_after', + ]; + $this->requestApi('DELETE', '/customers/99999', $deleteData, ['customer_write'], Response::HTTP_NOT_FOUND); + } + + public function testDeleteCustomerInvalidMethod(): void + { + // First, create a customer + $postData = [ + 'firstName' => 'ToDeleteInvalid', + 'lastName' => 'Customer', + 'email' => 'todeleteinvalid@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customerId = $customer['customerId']; + + // Try to delete with invalid method + $deleteData = [ + 'deleteMethod' => 'invalid_method', + ]; + $this->requestApi('DELETE', '/customers/' . $customerId, $deleteData, ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testBulkDeleteCustomers(): void + { + // Create multiple customers for bulk delete + $firstNames = ['BulkDeleteOne', 'BulkDeleteTwo', 'BulkDeleteThree']; + $customers = []; + foreach ($firstNames as $index => $firstName) { + $postData = [ + 'firstName' => $firstName, + 'lastName' => 'Customer', + 'email' => 'bulkdelete' . ($index + 1) . '@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customers[] = $customer['customerId']; + } + + // Bulk delete with allow_registration_after method + $bulkDeleteData = [ + 'customerIds' => $customers, + 'deleteMethod' => 'allow_registration_after', + ]; + $this->bulkDeleteItems('/customers/bulk-delete', $bulkDeleteData, ['customer_write']); + + // Verify customers were deleted (check in database) + $db = $this->getContainer()->get('doctrine.dbal.default_connection'); + foreach ($customers as $customerId) { + $customerExists = $db->fetchOne('SELECT COUNT(*) FROM ps_customer WHERE id_customer = ?', [$customerId]); + $this->assertEquals('0', (string) $customerExists, 'Customer should be deleted from database'); + } + } + + public function testBulkDeleteCustomersWithDenyRegistration(): void + { + // Create multiple customers for bulk delete + $firstNames = ['BulkDeleteDenyOne', 'BulkDeleteDenyTwo']; + $customers = []; + foreach ($firstNames as $index => $firstName) { + $postData = [ + 'firstName' => $firstName, + 'lastName' => 'Customer', + 'email' => 'bulkdeletedeny' . ($index + 1) . '@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customers[] = $customer['customerId']; + } + + // Bulk delete with deny_registration_after method + $bulkDeleteData = [ + 'customerIds' => $customers, + 'deleteMethod' => 'deny_registration_after', + ]; + $this->bulkDeleteItems('/customers/bulk-delete', $bulkDeleteData, ['customer_write']); + + // Verify customers are soft deleted (deleted = 1) + $db = $this->getContainer()->get('doctrine.dbal.default_connection'); + foreach ($customers as $customerId) { + $deletedValue = $db->fetchOne('SELECT deleted FROM ps_customer WHERE id_customer = ?', [$customerId]); + $this->assertEquals('1', (string) $deletedValue, 'Customer should be soft deleted (deleted = 1)'); + } + } + + /** + * @todo: previously the default value was forced by the Processor that built the command with default value + * This processor was removed because overkill, the BulkDeleteCustomers has a default value but it is not + * used during the serialization of the command, we need to find a solution to re-implement this behaviour, + * ideally from the module side to avoid an update on the core (which implies a major version of the module + * only compatible with 9.1), but if it's not possible only from module then the core will need to evolve + */ + /*public function testBulkDeleteCustomersWithDefaultMethod(): void + { + // Create multiple customers for bulk delete + $firstNames = ['BulkDeleteDefaultOne', 'BulkDeleteDefaultTwo']; + $customers = []; + foreach ($firstNames as $index => $firstName) { + $postData = [ + 'firstName' => $firstName, + 'lastName' => 'Customer', + 'email' => 'bulkdeletedefault' . ($index + 1) . '@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + ]; + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customers[] = $customer['customerId']; + } + + // Bulk delete without deleteMethod (should use default allow_registration_after) + $bulkDeleteData = [ + 'customerIds' => $customers, + ]; + $this->bulkDeleteItems('/customers/bulk-delete', $bulkDeleteData, ['customer_write']); + + // Verify customers were deleted (check in database) + $db = $this->getContainer()->get('doctrine.dbal.default_connection'); + foreach ($customers as $customerId) { + $customerExists = $db->fetchOne('SELECT COUNT(*) FROM ps_customer WHERE id_customer = ?', [$customerId]); + $this->assertEquals('0', (string) $customerExists, 'Customer should be deleted from database'); + } + }*/ + + public function testBulkDeleteCustomersValidationErrors(): void + { + // Test with empty customerIds - should return 422 validation error + $bulkDeleteData = [ + 'customerIds' => [], + ]; + $this->bulkDeleteItems('/customers/bulk-delete', $bulkDeleteData, ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + + // Test with invalid deleteMethod - should return 422 validation error + $bulkDeleteData = [ + 'customerIds' => [1, 2], + 'deleteMethod' => 'invalid_method', + ]; + $this->bulkDeleteItems('/customers/bulk-delete', $bulkDeleteData, ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testBulkDeleteCustomersNotFound(): void + { + // Try to bulk delete non-existent customers + $bulkDeleteData = [ + 'customerIds' => [99999, 99998], + 'deleteMethod' => 'allow_registration_after', + ]; + $this->bulkDeleteItems('/customers/bulk-delete', $bulkDeleteData, ['customer_write'], Response::HTTP_NOT_FOUND); + } + + public function testBulkDisableCustomers(): void + { + // Create multiple customers for bulk disable + $firstNames = ['BulkDisableOne', 'BulkDisableTwo', 'BulkDisableThree']; + $customers = []; + foreach ($firstNames as $index => $firstName) { + $postData = [ + 'firstName' => $firstName, + 'lastName' => 'Customer', + 'email' => 'bulkdisable' . ($index + 1) . '@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'enabled' => true, + ]; + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customers[] = $customer['customerId']; + } + + // Bulk disable + $bulkDisableData = [ + 'customerIds' => $customers, + ]; + $this->updateItem('/customers/bulk-disable', $bulkDisableData, ['customer_write'], Response::HTTP_NO_CONTENT); + + // Verify customers were disabled (check in database) + $db = $this->getContainer()->get('doctrine.dbal.default_connection'); + foreach ($customers as $customerId) { + $activeValue = $db->fetchOne('SELECT active FROM ps_customer WHERE id_customer = ?', [$customerId]); + $this->assertEquals('0', (string) $activeValue, 'Customer should be disabled (active = 0)'); + } + } + + public function testBulkEnableCustomers(): void + { + // Create multiple customers for bulk enable + $firstNames = ['BulkEnableOne', 'BulkEnableTwo', 'BulkEnableThree']; + $customers = []; + foreach ($firstNames as $index => $firstName) { + $postData = [ + 'firstName' => $firstName, + 'lastName' => 'Customer', + 'email' => 'bulkenable' . ($index + 1) . '@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'enabled' => false, + ]; + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customers[] = $customer['customerId']; + } + + // Bulk enable + $bulkEnableData = [ + 'customerIds' => $customers, + ]; + $this->updateItem('/customers/bulk-enable', $bulkEnableData, ['customer_write'], Response::HTTP_NO_CONTENT); + + // Verify customers were enabled (check in database) + $db = $this->getContainer()->get('doctrine.dbal.default_connection'); + foreach ($customers as $customerId) { + $activeValue = $db->fetchOne('SELECT active FROM ps_customer WHERE id_customer = ?', [$customerId]); + $this->assertEquals('1', (string) $activeValue, 'Customer should be enabled (active = 1)'); + } + } + + public function testBulkDisableCustomersValidationErrors(): void + { + // Test with empty customerIds - should return 422 validation error + $bulkDisableData = [ + 'customerIds' => [], + ]; + $this->updateItem('/customers/bulk-disable', $bulkDisableData, ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testBulkEnableCustomersValidationErrors(): void + { + // Test with empty customerIds - should return 422 validation error + $bulkEnableData = [ + 'customerIds' => [], + ]; + $this->updateItem('/customers/bulk-enable', $bulkEnableData, ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testBulkDisableCustomersNotFound(): void + { + // Try to bulk disable non-existent customers + $bulkDisableData = [ + 'customerIds' => [99999, 99998], + ]; + $this->updateItem('/customers/bulk-disable', $bulkDisableData, ['customer_write'], Response::HTTP_NOT_FOUND); + } + + public function testBulkEnableCustomersNotFound(): void + { + // Try to bulk enable non-existent customers + $bulkEnableData = [ + 'customerIds' => [99999, 99998], + ]; + $this->updateItem('/customers/bulk-enable', $bulkEnableData, ['customer_write'], Response::HTTP_NOT_FOUND); + } + + public function testSearchCustomers(): void + { + // First, create a customer to search for + $postData = [ + 'firstName' => 'Search', + 'lastName' => 'Test', + 'email' => 'search.test@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'enabled' => true, + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customerId = $customer['customerId']; + + // Search for the customer by first name + $searchResults = $this->getItem('/customers/search?phrases[]=Search', ['customer_read']); + + $this->assertIsArray($searchResults); + $this->assertNotEmpty($searchResults); + + // Find our customer in the results + $foundCustomer = null; + foreach ($searchResults as $result) { + if ($result['idCustomer'] === $customerId) { + $foundCustomer = $result; + break; + } + } + + $this->assertNotNull($foundCustomer, 'Created customer should be found in search results'); + $this->assertEquals('Search', $foundCustomer['firstname']); + $this->assertEquals('Test', $foundCustomer['lastname']); + $this->assertEquals('search.test@example.com', $foundCustomer['email']); + $this->assertArrayHasKey('fullnameAndEmail', $foundCustomer); + $this->assertArrayHasKey('groups', $foundCustomer); + } + + public function testSearchCustomersByEmail(): void + { + // Create a customer + $postData = [ + 'firstName' => 'Email', + 'lastName' => 'Search', + 'email' => 'email.search@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'enabled' => true, + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customerId = $customer['customerId']; + + // Search by email + $searchResults = $this->getItem('/customers/search?phrases[]=email.search@example.com', ['customer_read']); + + $this->assertIsArray($searchResults); + $this->assertNotEmpty($searchResults); + + $foundCustomer = null; + foreach ($searchResults as $result) { + if ($result['idCustomer'] === $customerId) { + $foundCustomer = $result; + break; + } + } + + $this->assertNotNull($foundCustomer, 'Customer should be found by email'); + $this->assertEquals('email.search@example.com', $foundCustomer['email']); + } + + public function testSearchCustomersEmptyPhrases(): void + { + // Try to search with empty phrases (should return 422 validation error) + $this->requestApi('GET', '/customers/search', null, ['customer_read'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testSearchCustomersMultiplePhrases(): void + { + // Create two customers + $postData1 = [ + 'firstName' => 'Multi', + 'lastName' => 'One', + 'email' => 'multi.one@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'enabled' => true, + ]; + + $postData2 = [ + 'firstName' => 'Multi', + 'lastName' => 'Two', + 'email' => 'multi.two@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'enabled' => true, + ]; + + $customer1 = $this->createItem('/customers', $postData1, ['customer_write']); + $customer2 = $this->createItem('/customers', $postData2, ['customer_write']); + + // Search with multiple phrases + $searchResults = $this->getItem('/customers/search?phrases[]=Multi&phrases[]=One', ['customer_read']); + + $this->assertIsArray($searchResults); + $this->assertNotEmpty($searchResults); + + // Both customers should be found (they both match "Multi") + $foundIds = array_column($searchResults, 'idCustomer'); + $this->assertContains($customer1['customerId'], $foundIds); + $this->assertContains($customer2['customerId'], $foundIds); + } + + public function testPartialUpdateCustomer(): int + { + // First, create a customer to update + $postData = [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'email' => 'john.doe@example.com', + 'password' => 'TestPassword123!', + 'defaultGroupId' => 3, + 'groupIds' => [3], + 'genderId' => 1, + 'enabled' => true, + 'partnerOffersSubscribed' => false, + 'birthday' => '1990-01-15', + 'guest' => false, + ]; + + $customer = $this->createItem('/customers', $postData, ['customer_write']); + $customerId = $customer['customerId']; + + // Update only firstName + $updatedCustomer = $this->partialUpdateItem('/customers/' . $customerId, [ + 'firstName' => 'Jane', + ], ['customer_write']); + + $this->assertEquals('Jane', $updatedCustomer['firstName']); + $this->assertEquals('Doe', $updatedCustomer['lastName']); + $this->assertEquals('john.doe@example.com', $updatedCustomer['email']); + $this->assertEquals($customerId, $updatedCustomer['customerId']); + + return $customerId; + } +} diff --git a/tests/Integration/ApiPlatform/CustomerGroupEndpointTest.php b/tests/Integration/ApiPlatform/CustomerGroupEndpointTest.php index a632637c9..e22d2cc71 100644 --- a/tests/Integration/ApiPlatform/CustomerGroupEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerGroupEndpointTest.php @@ -44,26 +44,26 @@ public static function tearDownAfterClass(): void DatabaseDump::restoreTables(['group', 'group_lang', 'group_reduction', 'group_shop', 'category_group']); } - public function getProtectedEndpoints(): iterable + public static function getProtectedEndpoints(): iterable { yield 'get endpoint' => [ 'GET', - '/customers/group/1', + '/customers/groups/1', ]; yield 'create endpoint' => [ 'POST', - '/customers/group', + '/customers/groups', ]; yield 'update endpoint' => [ 'PUT', - '/customers/group/1', + '/customers/groups/1', ]; yield 'delete endpoint' => [ 'DELETE', - '/customers/group/1', + '/customers/groups/1', ]; } @@ -72,12 +72,13 @@ public function testAddCustomerGroupWithoutShopIds(): void $postData = [ 'localizedNames' => [ 'en-US' => 'test1', + 'fr-FR' => 'test1 FR', ], 'reductionPercent' => 10.3, 'displayPriceTaxExcluded' => true, 'showPrice' => true, ]; - $customerGroup = $this->createItem('/customers/group', $postData, ['customer_group_write']); + $customerGroup = $this->createItem('/customers/groups', $postData, ['customer_group_write']); $this->assertArrayHasKey('customerGroupId', $customerGroup); $customerGroupId = $customerGroup['customerGroupId']; $this->assertEquals(['customerGroupId' => $customerGroupId, 'shopIds' => [1]] + $postData, $customerGroup); @@ -90,13 +91,14 @@ public function testAddCustomerGroup(): int $postData = [ 'localizedNames' => [ 'en-US' => 'test1', + 'fr-FR' => 'test1 FR', ], 'reductionPercent' => 10.3, 'displayPriceTaxExcluded' => true, 'showPrice' => true, 'shopIds' => [1], ]; - $customerGroup = $this->createItem('/customers/group', $postData, ['customer_group_write']); + $customerGroup = $this->createItem('/customers/groups', $postData, ['customer_group_write']); $this->assertArrayHasKey('customerGroupId', $customerGroup); $customerGroupId = $customerGroup['customerGroupId']; $this->assertEquals(['customerGroupId' => $customerGroupId] + $postData, $customerGroup); @@ -118,9 +120,10 @@ public function testUpdateCustomerGroup(int $customerGroupId): int { $itemsCount = $this->countItems('/customers/groups', ['customer_group_read']); - $updatedCustomerGroup = $this->updateItem('/customers/group/' . $customerGroupId, [ + $updatedCustomerGroup = $this->updateItem('/customers/groups/' . $customerGroupId, [ 'localizedNames' => [ 'en-US' => 'new_test1', + 'fr-FR' => 'new_test1 FR', ], 'displayPriceTaxExcluded' => false, 'shopIds' => [1], @@ -132,6 +135,7 @@ public function testUpdateCustomerGroup(int $customerGroupId): int 'customerGroupId' => $customerGroupId, 'localizedNames' => [ 'en-US' => 'new_test1', + 'fr-FR' => 'new_test1 FR', ], 'reductionPercent' => 10.3, 'displayPriceTaxExcluded' => false, @@ -157,13 +161,14 @@ public function testUpdateCustomerGroup(int $customerGroupId): int */ public function testGetCustomerGroup(int $customerGroupId): int { - $customerGroup = $this->getItem('/customers/group/' . $customerGroupId, ['customer_group_read']); + $customerGroup = $this->getItem('/customers/groups/' . $customerGroupId, ['customer_group_read']); // Fetching the updated item returns the same data as the previous response content $this->assertEquals( [ 'customerGroupId' => $customerGroupId, 'localizedNames' => [ 'en-US' => 'new_test1', + 'fr-FR' => 'new_test1 FR', ], 'reductionPercent' => 10.3, 'displayPriceTaxExcluded' => false, @@ -214,8 +219,8 @@ public function testListCustomerGroups(int $customerGroupId): int public function testDeleteCustomerGroup(int $customerGroupId): void { // Delete the item - $this->deleteItem('/customers/group/' . $customerGroupId, ['customer_group_write']); + $this->deleteItem('/customers/groups/' . $customerGroupId, ['customer_group_write']); // Fetching the item returns a 404 indicatjng it no longer exists - $this->getItem('/customers/group/' . $customerGroupId, ['customer_group_read'], Response::HTTP_NOT_FOUND); + $this->getItem('/customers/groups/' . $customerGroupId, ['customer_group_read'], Response::HTTP_NOT_FOUND); } } diff --git a/tests/Integration/ApiPlatform/CustomerRequiredFieldsEndpointTest.php b/tests/Integration/ApiPlatform/CustomerRequiredFieldsEndpointTest.php new file mode 100644 index 000000000..59de3bd7f --- /dev/null +++ b/tests/Integration/ApiPlatform/CustomerRequiredFieldsEndpointTest.php @@ -0,0 +1,102 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use PrestaShop\PrestaShop\Core\Version; +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class CustomerRequiredFieldsEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + DatabaseDump::restoreTables(['required_field']); + self::createApiClient(['customer_read', 'customer_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + DatabaseDump::restoreTables(['required_field']); + } + + public static function getProtectedEndpoints(): iterable + { + // GET endpoint only works for 9.2+ + if (version_compare(Version::VERSION, '9.2.0', '>=')) { + yield 'get required fields endpoint' => [ + 'GET', + '/customers/required-fields', + ]; + } + + yield 'set required fields endpoint' => [ + 'PUT', + '/customers/required-fields', + ]; + } + + public function testGetCustomerRequiredFields(): void + { + $this->markTestSkippedByMinVersion('9.2.0'); + $requiredFields = $this->getItem('/customers/required-fields', ['customer_read']); + // By default, there is no required filed + $this->assertEquals([], $requiredFields['requiredFields']); + } + + public function testSetCustomerRequiredFields(): void + { + $this->updateItem( + '/customers/required-fields', + ['requiredFields' => ['newsletter']], + ['customer_write'], + Response::HTTP_NO_CONTENT + ); + + // GET endpoint only works for 9.2+ + if (version_compare(Version::VERSION, '9.2.0', '>=')) { + $requiredFields = $this->getItem('/customers/required-fields', ['customer_read']); + $this->assertEquals(['newsletter'], $requiredFields['requiredFields']); + } else { + // Test manually from the DB + $storedFields = \Db::getInstance()->executeS( + 'SELECT `field_name` FROM `' . _DB_PREFIX_ . "required_field` WHERE `object_name` = 'Customer'" + ); + $fieldNames = array_column($storedFields ?: [], 'field_name'); + + $this->assertContains('newsletter', $fieldNames); + } + } + + public function testSetInvalidCustomerRequiredFieldIsRejected(): void + { + $this->updateItem( + '/customers/required-fields', + ['requiredFields' => ['not_a_valid_field']], + ['customer_write'], + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } +} diff --git a/tests/Integration/ApiPlatform/DiscountEndpointTest.php b/tests/Integration/ApiPlatform/DiscountEndpointTest.php index d588838ab..bf4f1558f 100644 --- a/tests/Integration/ApiPlatform/DiscountEndpointTest.php +++ b/tests/Integration/ApiPlatform/DiscountEndpointTest.php @@ -23,6 +23,11 @@ namespace PsApiResourcesTest\Integration\ApiPlatform; use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddDiscountCommand; +use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Discount\ProductRuleGroupType; +use PrestaShop\PrestaShop\Core\Domain\Discount\ProductRuleType; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Tests\Resources\DatabaseDump; use Tests\Resources\Resetter\LanguageResetter; @@ -38,24 +43,22 @@ public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); - LanguageResetter::resetLanguages(); - DatabaseDump::restoreTables([ - 'cart_cart_rule', - 'cart_rule', - 'cart_rule_carrier', - 'cart_rule_combination', - 'cart_rule_country', - 'cart_rule_group', - 'cart_rule_lang', - 'cart_rule_product_rule', - 'cart_rule_product_rule_group', - 'cart_rule_product_rule_value', - 'cart_rule_shop', - ]); - - self::addLanguageByLocale('fr-FR'); - // Check if the command exists, if it doesn't the scopes are not usable + // Check if the command exists, if it doesn't the tests won't run so nothing to init if (class_exists(AddDiscountCommand::class)) { + DatabaseDump::restoreTables([ + 'cart_cart_rule', + 'cart_rule', + 'cart_rule_carrier', + 'cart_rule_combination', + 'cart_rule_compatible_types', + 'cart_rule_country', + 'cart_rule_group', + 'cart_rule_lang', + 'cart_rule_product_rule', + 'cart_rule_product_rule_group', + 'cart_rule_product_rule_value', + 'cart_rule_shop', + ]); self::createApiClient(['discount_write', 'discount_read']); } } @@ -64,7 +67,9 @@ public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); - LanguageResetter::resetLanguages(); + if (class_exists(AddDiscountCommand::class)) { + LanguageResetter::resetLanguages(); + } } protected function setUp(): void @@ -76,6 +81,44 @@ protected function setUp(): void } } + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/discounts/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/discounts', + ]; + + yield 'update endpoint' => [ + 'PATCH', + '/discounts/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/discounts', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/discounts/1', + ]; + + yield 'bulk toggle status endpoint' => [ + 'PATCH', + '/discounts/bulk-update-status', + ]; + + yield 'bulk delete endpoint' => [ + 'DELETE', + '/discounts/bulk-delete', + ]; + } + /** * @dataProvider discountTypesDataProvider * @@ -86,15 +129,15 @@ protected function setUp(): void */ public function testAddDiscountAndGet(string $type, array $names, ?array $data): int { - $json = [ + $creationData = [ 'type' => $type, 'names' => $names, ]; if ($data !== null) { - $json = array_merge($json, $data); + $creationData = array_merge($creationData, $data); } - $discount = $this->createItem('/discount', $json, ['discount_write']); + $discount = $this->createItem('/discounts', $creationData, ['discount_write']); $this->assertArrayHasKey('discountId', $discount); $discountId = $discount['discountId']; @@ -102,17 +145,27 @@ public function testAddDiscountAndGet(string $type, array $names, ?array $data): 'discountId' => $discountId, 'type' => $type, 'names' => $names, - 'priority' => 1, - 'active' => false, - 'totalQuantity' => 1, - 'quantityPerUser' => 1, 'description' => '', 'code' => '', - 'customerId' => 0, + 'enabled' => false, + 'totalQuantity' => null, + 'quantityPerUser' => null, + 'reductionPercent' => null, + 'reductionAmount' => null, + 'giftProductId' => null, + 'giftCombinationId' => null, + 'cheapestProduct' => false, + 'productConditions' => [], + 'minimumProductQuantity' => 0, + 'minimumAmount' => null, + 'customerId' => null, + 'customerGroupIds' => [], + 'carrierIds' => [], + 'countryIds' => [], + 'compatibleDiscountTypeIds' => [], 'highlightInCart' => false, 'allowPartialUse' => true, - 'currencyId' => 1, - 'reductionProduct' => 0, + 'priority' => 1, // These two values are dynamic, we can't hard-code the expected value 'validFrom' => $discount['validFrom'], 'validTo' => $discount['validTo'], @@ -123,60 +176,61 @@ public function testAddDiscountAndGet(string $type, array $names, ?array $data): $this->assertEquals($expectedDiscount, $discount); // Now test that the GET request returns the same expected result - $this->assertEquals($expectedDiscount, $this->getItem('/discount/' . $discountId, ['discount_read'])); + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); return $discountId; } - public function discountTypesDataProvider(): array + public static function discountTypesDataProvider(): iterable { - return [ + yield 'cart level discount' => [ + self::CART_LEVEL, [ - self::CART_LEVEL, - [ - 'en-US' => 'new cart level discount', - 'fr-FR' => 'nouveau discount panier', - ], - null, + 'en-US' => 'new cart level discount', + 'fr-FR' => 'nouveau discount panier', ], + null, + ]; + + yield 'product level discount' => [ + self::PRODUCT_LEVEL, [ - self::PRODUCT_LEVEL, - [ - 'en-US' => 'new product level discount', - 'fr-FR' => 'nouveau discount produit', - ], - [ - 'reductionProduct' => -1, - 'percentDiscount' => 20.0, - ], + 'en-US' => 'new product level discount', + 'fr-FR' => 'nouveau discount produit', ], - // todo: This one must be improved, the naming productId is not correct, it should be giftProductId - /*[ - self::FREE_GIFT, - [ - 'en-US' => 'new free gift discount', - 'fr-FR' => 'nouveau discount produit offert', - ], - [ - 'productId' => 1, - ], - ],*/ [ - self::FREE_SHIPPING, - [ - 'en-US' => 'new free shipping discount', - 'fr-FR' => 'nouveau discount frais de port offert', - ], - null, + 'cheapestProduct' => true, + 'reductionPercent' => 10.0, ], + ]; + + yield 'free gift discount' => [ + self::FREE_GIFT, [ - self::ORDER_LEVEL, - [ - 'en-US' => 'new order level discount', - 'fr-FR' => 'nouveau discount commande', - ], - null, + 'en-US' => 'new free gift discount', + 'fr-FR' => 'nouveau discount produit offert', + ], + [ + 'giftProductId' => 1, + ], + ]; + + yield 'free shipping discount' => [ + self::FREE_SHIPPING, + [ + 'en-US' => 'new free shipping discount', + 'fr-FR' => 'nouveau discount frais de port offert', ], + null, + ]; + + yield 'order level discount' => [ + self::ORDER_LEVEL, + [ + 'en-US' => 'new order level discount', + 'fr-FR' => 'nouveau discount commande', + ], + null, ]; } @@ -186,7 +240,12 @@ public function discountTypesDataProvider(): array public function testListDiscount(): void { $paginatedDiscounts = $this->listItems('/discounts', ['discount_read']); - $createdDiscountData = $this->discountTypesDataProvider(); + $createdDiscountData = []; + $index = 0; + foreach ($this->discountTypesDataProvider() as $discountData) { + $createdDiscountData[$index] = $discountData; + ++$index; + } $this->assertEquals(count($createdDiscountData), $paginatedDiscounts['totalItems']); foreach ($paginatedDiscounts['items'] as $key => $discount) { @@ -195,42 +254,455 @@ public function testListDiscount(): void 'discountId' => $discount['discountId'], 'type' => $creationData[0], 'name' => $creationData[1]['en-US'], - 'active' => false, + 'enabled' => false, 'code' => '', ]; $this->assertEquals($expectedDiscount, $discount); } } + /** + * @depends testListDiscount + */ public function testDeleteDiscount(): void { - $bearerToken = $this->getBearerToken(['discount_write']); - static::createClient()->request('DELETE', '/discount/1', [ - 'auth_bearer' => $bearerToken, - ]); - self::assertResponseStatusCodeSame(204); + $this->deleteItem('/discounts/1', ['discount_write']); + // Check that the discount no longer exists + $this->getItem('/discounts/1', ['discount_read'], Response::HTTP_NOT_FOUND); + } + + /** + * Create a discount specifically for update tests + * + * @return array + */ + public function testCreateDiscountForUpdateTests(): array + { + $discount = $this->createItem('/discounts', [ + 'type' => self::CART_LEVEL, + 'names' => [ + 'en-US' => 'Discount for update tests', + 'fr-FR' => 'Discount pour tests de mise à jour', + ], + ], ['discount_write']); + $this->assertArrayHasKey('discountId', $discount); + + return $discount; } - public function getProtectedEndpoints(): iterable + /** + * @depends testCreateDiscountForUpdateTests + * + * @param array $discount + * + * @return array + */ + public function testPartialUpdateDiscount(array $discount): array { - yield 'get endpoint' => [ - 'GET', - '/discount/1', + $discountId = $discount['discountId']; + $expectedDiscount = [ + 'discountId' => $discountId, + 'type' => self::CART_LEVEL, + 'names' => [ + 'en-US' => 'Discount for update tests', + 'fr-FR' => 'Discount pour tests de mise à jour', + ], + 'description' => '', + 'code' => '', + 'enabled' => false, + 'totalQuantity' => null, + 'quantityPerUser' => null, + 'reductionPercent' => null, + 'reductionAmount' => null, + 'giftProductId' => null, + 'giftCombinationId' => null, + 'cheapestProduct' => false, + 'productConditions' => [], + 'minimumProductQuantity' => 0, + 'minimumAmount' => null, + 'customerId' => null, + 'customerGroupIds' => [], + 'carrierIds' => [], + 'countryIds' => [], + 'compatibleDiscountTypeIds' => [], + 'highlightInCart' => false, + 'allowPartialUse' => true, + 'priority' => 1, + // These two values are dynamic, we can't hard-code the expected value + 'validFrom' => $discount['validFrom'], + 'validTo' => $discount['validTo'], ]; - yield 'create endpoint' => [ - 'POST', - '/discount', + $updatedFields = [ + 'names' => [ + 'en-US' => 'Updated EN name', + 'fr-FR' => 'Updated FR name', + ], + 'description' => 'Updated description', + 'code' => 'NEWCODE123', + 'enabled' => true, + 'totalQuantity' => 100, + 'quantityPerUser' => 5, + 'highlightInCart' => true, + 'allowPartialUse' => false, + 'priority' => 10, ]; - yield 'list endpoint' => [ - 'GET', - '/discounts', + // Update fields one by one and check that the content is updated accordingly + foreach ($updatedFields as $key => $value) { + $expectedDiscount[$key] = $value; + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + $key => $value, + ], ['discount_write']); + $this->assertEquals($expectedDiscount, $updatedDiscount); + // Also check that the discount is updated when we read it + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); + } + + return $expectedDiscount; + } + + /** + * @depends testPartialUpdateDiscount + * + * @param array $expectedDiscount + * + * @return array + */ + public function testGetUpdatedDiscount(array $expectedDiscount): array + { + $discountId = $expectedDiscount['discountId']; + $discount = $this->getItem('/discounts/' . $discountId, ['discount_read']); + $this->assertEquals($expectedDiscount, $discount); + + return $expectedDiscount; + } + + /** + * @depends testGetUpdatedDiscount + * + * @param array $expectedDiscount + * + * @return int + */ + public function testUpdateDiscountConditions(array $expectedDiscount): array + { + $discountId = $expectedDiscount['discountId']; + $conditionFields = [ + 'minimumProductQuantity' => 5, + 'carrierIds' => [1, 2], + 'countryIds' => [1, 2, 3], + 'customerId' => 42, + 'customerGroupIds' => [1, 2], + // Hummingbird t-shirt + 'giftProductId' => 1, + 'giftCombinationId' => 1, + 'compatibleDiscountTypeIds' => [1, 2], + 'reductionPercent' => 0.5, + 'minimumAmount' => [ + 'amount' => '50.00', + 'currencyId' => 1, + 'taxIncluded' => true, + 'shippingIncluded' => true, + ], ]; - yield 'delete endpoint' => [ - 'DELETE', - '/discount/1', + // Update fields one by one and check that the content is updated accordingly + foreach ($conditionFields as $key => $value) { + $expectedDiscount[$key] = $value; + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + $key => $value, + ], ['discount_write']); + $this->assertEquals($expectedDiscount, $updatedDiscount, sprintf('Unexpected updated data for %s', $key)); + // Also check that the discount is updated when we read it + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read']), sprintf('Unexpected get data for %s', $key)); + } + + // Update reduction amount automatically removes reduction percent + $reductionAmount = [ + 'amount' => '42.99', + 'currencyId' => 2, + 'taxIncluded' => false, ]; + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + 'reductionAmount' => $reductionAmount, + ], ['discount_write']); + $expectedDiscount['reductionAmount'] = $reductionAmount; + $expectedDiscount['reductionPercent'] = null; + $this->assertEquals($expectedDiscount, $updatedDiscount); + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); + + // And when percent is used the amount is null + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + 'reductionPercent' => 0.43, + ], ['discount_write']); + $expectedDiscount['reductionAmount'] = null; + $expectedDiscount['reductionPercent'] = 0.43; + $this->assertEquals($expectedDiscount, $updatedDiscount); + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); + + // Unset the minimum amount + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + 'minimumAmount' => null, + ], ['discount_write']); + $expectedDiscount['minimumAmount'] = null; + $this->assertEquals($expectedDiscount, $updatedDiscount); + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); + + return $expectedDiscount; + } + + /** + * @depends testUpdateDiscountConditions + * + * @param array $expectedDiscount + * + * @return int + */ + public function testUpdateDiscountProductConditions(array $expectedDiscount): int + { + $discountId = $expectedDiscount['discountId']; + + $simpleProductSelection = [ + [ + 'quantity' => 5, + 'rules' => [ + [ + 'type' => ProductRuleType::PRODUCTS->value, + 'itemIds' => [1, 2, 3], + ], + ], + // This would be the default value even if left empty + 'type' => ProductRuleGroupType::AT_LEAST_ONE_PRODUCT_RULE->value, + ], + ]; + $productConditionsToTest = [ + 'product selection' => $simpleProductSelection, + 'product segment' => [ + [ + 'quantity' => 5, + 'rules' => [ + [ + 'type' => ProductRuleType::CATEGORIES->value, + 'itemIds' => [1, 2, 3], + ], + ], + // This would be the default value even if left empty + 'type' => ProductRuleGroupType::ALL_PRODUCT_RULES->value, + ], + ], + // Remove product conditions + 'no conditions' => [], + ]; + + foreach ($productConditionsToTest as $productConditionsUseCase => $productConditions) { + $expectedDiscount['productConditions'] = $productConditions; + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + 'productConditions' => $productConditions, + ], ['discount_write']); + $this->assertEquals($expectedDiscount, $updatedDiscount, 'Unexpected value for use case ' . $productConditionsUseCase); + // Also check that the discount is updated when we read it + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); + } + + // Special case: switch to the cheapest product, the product conditions is removed + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + 'cheapestProduct' => true, + ], ['discount_write']); + $expectedDiscount['productConditions'] = []; + $expectedDiscount['cheapestProduct'] = true; + $this->assertEquals($expectedDiscount, $updatedDiscount, 'Unexpected value for use case ' . $productConditionsUseCase); + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); + + // And when we set a new product segment cheapestProduct is false + $updatedDiscount = $this->partialUpdateItem('/discounts/' . $discountId, [ + 'productConditions' => $simpleProductSelection, + ], ['discount_write']); + $expectedDiscount['productConditions'] = $simpleProductSelection; + $expectedDiscount['cheapestProduct'] = false; + $this->assertEquals($expectedDiscount, $updatedDiscount, 'Unexpected value for use case ' . $productConditionsUseCase); + $this->assertEquals($expectedDiscount, $this->getItem('/discounts/' . $discountId, ['discount_read'])); + + return $discountId; + } + + /** + * Test bulk enable discounts + */ + public function testBulkEnableDiscounts(): array + { + // Create multiple discounts for bulk testing + $discountIds = []; + for ($i = 0; $i < 3; ++$i) { + $discount = $this->createItem('/discounts', [ + 'type' => self::CART_LEVEL, + 'names' => [ + 'en-US' => 'Bulk test discount ' . $i, + 'fr-FR' => 'Discount test bulk ' . $i, + ], + ], ['discount_write']); + $discountIds[] = $discount['discountId']; + } + + // Bulk enable all discounts + $this->partialUpdateItem('/discounts/bulk-update-status', [ + 'discountIds' => $discountIds, + 'enabled' => true, + ], ['discount_write'], Response::HTTP_NO_CONTENT); + + // Verify all discounts are enabled + foreach ($discountIds as $discountId) { + $discount = $this->getItem('/discounts/' . $discountId, ['discount_read']); + $this->assertTrue($discount['enabled'], "Discount {$discountId} should be enabled"); + } + + return $discountIds; + } + + /** + * @depends testBulkEnableDiscounts + * + * Test bulk disable discounts + */ + public function testBulkDisableDiscounts(array $discountIds): array + { + // Bulk disable all discounts + $this->partialUpdateItem('/discounts/bulk-update-status', [ + 'discountIds' => $discountIds, + 'enabled' => false, + ], ['discount_write'], Response::HTTP_NO_CONTENT); + + // Verify all discounts are disabled + foreach ($discountIds as $discountId) { + $discount = $this->getItem('/discounts/' . $discountId, ['discount_read']); + $this->assertFalse($discount['enabled'], "Discount {$discountId} should be disabled"); + } + + return $discountIds; + } + + /** + * @depends testBulkDisableDiscounts + * + * Test bulk delete discounts + */ + public function testBulkDeleteDiscounts(array $discountIds): void + { + // Bulk delete all discounts + $this->bulkDeleteItems('/discounts/bulk-delete', [ + 'discountIds' => $discountIds, + ], ['discount_write'], Response::HTTP_NO_CONTENT); + + // Verify all discounts are deleted + foreach ($discountIds as $discountId) { + $this->getItem('/discounts/' . $discountId, ['discount_read'], 404); + } + } + + /** + * Test bulk enable with mixed valid and invalid IDs + */ + public function testBulkEnableWithInvalidIds(): void + { + // Create one valid discount + $discount = $this->createItem('/discounts', [ + 'type' => self::CART_LEVEL, + 'names' => [ + 'en-US' => 'Valid discount', + 'fr-FR' => 'Discount valide', + ], + ], ['discount_write']); + $validId = $discount['discountId']; + + // Try to bulk enable with mixed valid and invalid IDs + $this->bulkCommandItemsWithExpectedErrors( + Request::METHOD_DELETE, + '/discounts/bulk-delete', + ['discountIds' => [$validId, 999999]], + [ + [ + 'type' => DiscountNotFoundException::class, + 'message' => 'CartRule #999999 was not found', + 'status' => Response::HTTP_NOT_FOUND, + ], + ], + ['discount_write'] + ); + } + + /** + * Test bulk delete with empty array + */ + public function testBulkDeleteWithEmptyArray(): void + { + $this->bulkDeleteItems('/discounts/bulk-delete', [ + 'discountIds' => [], + ], ['discount_write'], 422); + } + + /** + * Test bulk toggle status with empty array + */ + public function testBulkToggleStatusWithEmptyArray(): void + { + $this->partialUpdateItem('/discounts/bulk-update-status', [ + 'discountIds' => [], + 'enabled' => true, + ], ['discount_write'], 422); + } + + /** + * Test bulk delete without providing discountIds parameter + */ + public function testBulkDeleteWithMissingParameter(): void + { + $bearerToken = $this->getBearerToken(['discount_write']); + static::createClient()->request('DELETE', '/discounts/bulk-delete', [ + 'auth_bearer' => $bearerToken, + 'json' => [], + ]); + + self::assertResponseStatusCodeSame(422); + } + + /** + * Test duplicate discount + * + * @depends testCreateDiscountForUpdateTests + * + * @param array $createdDiscount + * + * @return void + */ + public function testDuplicateDiscount(array $createdDiscount): void + { + $discountId = $createdDiscount['discountId']; + $originalDiscount = $this->getItem( + '/discounts/' . $discountId, + ['discount_read'] + ); + $newDiscount = $this->requestApi( + httpMethod: Request::METHOD_POST, + endPointUrl: '/discounts/' . $discountId . '/duplicate', + scopes: ['discount_write'] + ); + + $expectedDiscount = [ + 'discountId' => $newDiscount['discountId'], + 'names' => [ + 'en-US' => 'copy of ' . $originalDiscount['names']['en-US'], + 'fr-FR' => 'copie de ' . $originalDiscount['names']['fr-FR'], + ], + // New code has been generated + 'code' => $newDiscount['code'], + // Status is forced to disabled + 'enabled' => false, + ] + $originalDiscount; + + $this->assertEquals($expectedDiscount, $newDiscount); + // New ID has been created + $this->assertNotEquals($originalDiscount['discountId'], $newDiscount['discountId']); + // New code has been created + $this->assertNotEquals($originalDiscount['code'], $newDiscount['code']); } } diff --git a/tests/Integration/ApiPlatform/DiscountTypeEndpointTest.php b/tests/Integration/ApiPlatform/DiscountTypeEndpointTest.php new file mode 100644 index 000000000..164ff1da8 --- /dev/null +++ b/tests/Integration/ApiPlatform/DiscountTypeEndpointTest.php @@ -0,0 +1,85 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddDiscountCommand; + +class DiscountTypeEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + // Check if the command exists, if it doesn't the tests won't run so nothing to init + if (class_exists(AddDiscountCommand::class)) { + self::createApiClient(); + } + } + + protected function setUp(): void + { + parent::setUp(); + // If the discount domain does not exist we can skip all the tests here + if (!class_exists(AddDiscountCommand::class)) { + $this->markTestSkipped('AddDiscountCommand class does not exist'); + } + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/discounts/types', + ]; + } + + public function testGetDiscountTypes(): void + { + $discountTypes = $this->getItem('/discounts/types', ['discount_read']); + $this->assertIsArray($discountTypes); + $this->assertGreaterThanOrEqual(1, count($discountTypes)); + + $discountType = null; + foreach ($discountTypes as $type) { + if ($type['type'] === 'cart_level') { + $discountType = $type; + break; + } + } + $this->assertNotNull($discountType, 'Expected at least a discount type with cart_level type'); + + $expectedCartLevelType = [ + 'discountTypeId' => 2, + 'type' => 'cart_level', + 'names' => [ + 'en-US' => 'On cart amount', + 'fr-FR' => 'On cart amount', + ], + 'descriptions' => [ + 'en-US' => 'Discount applied to cart', + 'fr-FR' => 'Discount applied to cart', + ], + 'core' => true, + 'enabled' => true, + ]; + + $this->assertEquals($expectedCartLevelType, $discountType); + } +} diff --git a/tests/Integration/ApiPlatform/FeatureEndpointTest.php b/tests/Integration/ApiPlatform/FeatureEndpointTest.php new file mode 100644 index 000000000..2a7b8cccf --- /dev/null +++ b/tests/Integration/ApiPlatform/FeatureEndpointTest.php @@ -0,0 +1,205 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class FeatureEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + self::createApiClient(['feature_read', 'feature_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'feature', + 'feature_lang', + 'feature_shop', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/features/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/features', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/features/1', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/features/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/features', + ]; + + yield 'bulk delete endpoint' => [ + 'DELETE', + '/features/bulk-delete', + ]; + } + + public function testAddFeature(): int + { + $postData = [ + 'names' => [ + 'en-US' => 'Feature EN', + 'fr-FR' => 'Feature FR', + ], + 'shopIds' => [1], + ]; + + $feature = $this->createItem('/features', $postData, ['feature_write']); + $this->assertArrayHasKey('featureId', $feature); + $featureId = $feature['featureId']; + + $this->assertSame($postData['names'], $feature['names']); + + return $featureId; + } + + /** + * @depends testAddFeature + */ + public function testGetFeature(int $featureId): int + { + $feature = $this->getItem('/features/' . $featureId, ['feature_read']); + $this->assertEquals($featureId, $feature['featureId']); + $this->assertArrayHasKey('names', $feature); + + return $featureId; + } + + /** + * @depends testGetFeature + */ + public function testPartialUpdateFeature(int $featureId): int + { + $patchData = [ + 'names' => [ + 'en-US' => 'Updated Feature EN', + 'fr-FR' => 'Updated Feature FR', + ], + 'shopIds' => [1], + ]; + + $updatedFeature = $this->partialUpdateItem('/features/' . $featureId, $patchData, ['feature_write']); + $this->assertSame($patchData['names'], $updatedFeature['names']); + + return $featureId; + } + + /** + * @depends testPartialUpdateFeature + */ + public function testListFeatures(int $featureId): int + { + $paginatedFeatures = $this->listItems('/features?orderBy=featureId&sortOrder=desc', ['feature_read']); + $this->assertGreaterThanOrEqual(1, $paginatedFeatures['totalItems']); + $this->assertEquals('featureId', $paginatedFeatures['orderBy']); + + $firstFeature = $paginatedFeatures['items'][0]; + $this->assertEquals($featureId, $firstFeature['featureId']); + + return $featureId; + } + + /** + * @depends testListFeatures + */ + public function testRemoveFeature(int $featureId): void + { + $this->deleteItem('/features/' . $featureId, ['feature_write']); + $this->getItem('/features/' . $featureId, ['feature_read'], Response::HTTP_NOT_FOUND); + } + + public function testBulkDeleteFeatures(): void + { + $features = $this->listItems('/features', ['feature_read']); + + $this->assertGreaterThanOrEqual(2, $features['totalItems']); + + $removeFeatureIds = [ + $features['items'][0]['featureId'], + ]; + + $this->bulkDeleteItems('/features/bulk-delete', [ + 'featureIds' => $removeFeatureIds, + ], ['feature_write']); + + foreach ($removeFeatureIds as $featureId) { + $this->getItem('/features/' . $featureId, ['feature_read'], Response::HTTP_NOT_FOUND); + } + } + + public function testInvalidFeature(): void + { + $invalidData = [ + 'names' => [ + 'fr-FR' => 'Invalid<', + ], + 'shopIds' => [], + ]; + + $validationErrorsResponse = $this->createItem('/features', $invalidData, ['feature_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + + $this->assertValidationErrors([ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + [ + 'propertyPath' => 'names[fr-FR]', + 'message' => '"Invalid<" is invalid', + ], + [ + 'propertyPath' => 'shopIds', + 'message' => 'This value should not be blank.', + ], + ], $validationErrorsResponse); + } +} diff --git a/tests/Integration/ApiPlatform/FeatureValueEndpointTest.php b/tests/Integration/ApiPlatform/FeatureValueEndpointTest.php new file mode 100644 index 000000000..7cd98894d --- /dev/null +++ b/tests/Integration/ApiPlatform/FeatureValueEndpointTest.php @@ -0,0 +1,199 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class FeatureValueEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + self::createApiClient(['feature_value_read', 'feature_value_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'feature_value', + 'feature_value_lang', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/features/values/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/features/values', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/features/values/1', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/features/values/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/features/1/values', + ]; + + yield 'bulk delete endpoint' => [ + 'DELETE', + '/features/values/batch', + ]; + } + + public function testAddFeatureValue(): int + { + $postData = [ + 'values' => [ + 'en-US' => 'Feature value EN', + 'fr-FR' => 'Feature value FR', + ], + 'featureId' => 1, + ]; + + $featureValue = $this->createItem('/features/values', $postData, ['feature_value_write']); + $this->assertArrayHasKey('featureValueId', $featureValue); + + $featureValueId = $featureValue['featureValueId']; + $this->assertSame($postData['values'], $featureValue['values']); + + return $featureValueId; + } + + /** + * @depends testAddFeatureValue + */ + public function testGetFeatureValue(int $featureValueId): int + { + $feature = $this->getItem('/features/values/' . $featureValueId, ['feature_value_read']); + $this->assertEquals($featureValueId, $feature['featureValueId']); + $this->assertArrayHasKey('values', $feature); + + return $featureValueId; + } + + /** + * @depends testGetFeatureValue + */ + public function testPartialUpdateFeatureValue(int $featureValueId): int + { + $patchData = [ + 'values' => [ + 'en-US' => 'Updated Feature value EN', + 'fr-FR' => 'Updated Feature value FR', + ], + ]; + + $updatedFeature = $this->partialUpdateItem('/features/values/' . $featureValueId, $patchData, ['feature_value_write']); + $this->assertSame($patchData['values'], $updatedFeature['values']); + + return $featureValueId; + } + + /** + * @depends testPartialUpdateFeatureValue + */ + public function testListFeatureValues(int $featureValueId): int + { + $paginatedFeatures = $this->listItems('/features/1/values?orderBy=featureValueId&sortOrder=desc', ['feature_value_read']); + $this->assertGreaterThanOrEqual(1, $paginatedFeatures['totalItems']); + $this->assertEquals('featureValueId', $paginatedFeatures['orderBy']); + + $firstFeature = $paginatedFeatures['items'][0]; + + $this->assertEquals($featureValueId, $firstFeature['featureValueId']); + + return $featureValueId; + } + + /** + * @depends testListFeatureValues + */ + public function testRemoveFeatureValue(int $featureValueId): void + { + $this->deleteItem('/features/values/' . $featureValueId, ['feature_value_write']); + $this->getItem('/features/values/' . $featureValueId, ['feature_value_read'], Response::HTTP_NOT_FOUND); + } + + public function testBulkRemoveFeatureValues(): void + { + $features = $this->listItems('/features/1/values', ['feature_value_read']); + + $this->assertGreaterThanOrEqual(2, $features['totalItems']); + + $removeFeatureIds = [ + $features['items'][0]['featureValueId'], + ]; + + $this->bulkDeleteItems('/features/values/bulk-delete', [ + 'featureValueIds' => $removeFeatureIds, + ], ['feature_value_write']); + + foreach ($removeFeatureIds as $featureValueId) { + $this->getItem('/features/values/' . $featureValueId, ['feature_value_read'], Response::HTTP_NOT_FOUND); + } + } + + public function testInvalidFeatureValue(): void + { + $invalidData = [ + 'values' => [ + 'fr-FR' => 'Invalid<', + ], + ]; + + $validationErrorsResponse = $this->createItem('/features/values', $invalidData, ['feature_value_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + + $this->assertValidationErrors([ + [ + 'propertyPath' => 'values', + 'message' => 'The field values is required at least in your default language.', + ], + [ + 'propertyPath' => 'values[fr-FR]', + 'message' => '"Invalid<" is invalid', + ], + ], $validationErrorsResponse); + } +} diff --git a/tests/Integration/ApiPlatform/HookEndpointTest.php b/tests/Integration/ApiPlatform/HookEndpointTest.php index 3ec2de26c..f1d82c89f 100644 --- a/tests/Integration/ApiPlatform/HookEndpointTest.php +++ b/tests/Integration/ApiPlatform/HookEndpointTest.php @@ -43,22 +43,27 @@ public static function tearDownAfterClass(): void DatabaseDump::restoreTables(['hook']); } - public function getProtectedEndpoints(): iterable + public static function getProtectedEndpoints(): iterable { yield 'get endpoint' => [ 'GET', - '/hook/1', + '/hooks/1', ]; yield 'put endpoint' => [ 'PUT', - '/hook-status/1', + '/hooks/1/status', ]; yield 'list endpoint' => [ 'GET', '/hooks', ]; + + yield 'get status endpoint' => [ + 'GET', + '/hooks/1/status', + ]; } public function testGetHook(): int @@ -72,13 +77,13 @@ public function testGetHook(): int $testHook->add(); $hookId = (int) $testHook->id; - $hook = $this->getItem('/hook/' . $hookId, ['hook_read']); + $hook = $this->getItem('/hooks/' . $hookId, ['hook_read']); $this->assertEquals([ 'hookId' => $hookId, 'name' => 'testHook', 'title' => 'Test Hook', 'description' => 'Hook created only for API test', - 'active' => true, + 'enabled' => true, ], $hook); return $hookId; @@ -89,27 +94,40 @@ public function testGetHook(): int */ public function testUpdateHookStatus(int $hookId): int { - $updatedHook = $this->updateItem('/hook-status/' . $hookId, ['active' => false], ['hook_write']); + $updatedHook = $this->updateItem('/hooks/' . $hookId . '/status', ['enabled' => false], ['hook_write']); $expectedHook = [ 'hookId' => $hookId, 'name' => 'testHook', 'title' => 'Test Hook', 'description' => 'Hook created only for API test', - 'active' => false, + 'enabled' => false, ]; $this->assertEquals($expectedHook, $updatedHook); - $this->assertEquals($expectedHook, $this->getItem('/hook/' . $hookId, ['hook_read'])); + $this->assertEquals($expectedHook, $this->getItem('/hooks/' . $hookId, ['hook_read'])); - $updatedHook = $this->updateItem('/hook-status/' . $hookId, ['active' => true], ['hook_write']); - $expectedHook['active'] = true; + $updatedHook = $this->updateItem('/hooks/' . $hookId . '/status', ['enabled' => true], ['hook_write']); + $expectedHook['enabled'] = true; $this->assertEquals($expectedHook, $updatedHook); - $this->assertEquals($expectedHook, $this->getItem('/hook/' . $hookId, ['hook_read'])); + $this->assertEquals($expectedHook, $this->getItem('/hooks/' . $hookId, ['hook_read'])); + + return $hookId; + } + + /** + * @depends testGetHook + */ + public function testGetHookStatus(int $hookId): int + { + $hookStatus = $this->getItem('/hooks/' . $hookId . '/status', ['hook_read']); + + $this->assertArrayHasKey('enabled', $hookStatus); + $this->assertTrue($hookStatus['enabled'], 'Expected hook to be enabled'); return $hookId; } /** - * @depends testUpdateHookStatus + * @depends testGetHookStatus */ public function testListHooks(int $hookId): void { @@ -129,7 +147,7 @@ public function testListHooks(int $hookId): void 'name' => 'testHook', 'title' => 'Test Hook', 'description' => 'Hook created only for API test', - 'active' => true, + 'enabled' => true, ], $testHook); } } diff --git a/tests/Integration/ApiPlatform/ManufacturerEndpointTest.php b/tests/Integration/ApiPlatform/ManufacturerEndpointTest.php new file mode 100644 index 000000000..927149181 --- /dev/null +++ b/tests/Integration/ApiPlatform/ManufacturerEndpointTest.php @@ -0,0 +1,408 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; +use Tests\Resources\Resetter\LanguageResetter; + +class ManufacturerEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + // Add the fr-FR language to test multi lang values accurately + LanguageResetter::resetLanguages(); + self::addLanguageByLocale('fr-FR'); + self::resetTables(); + // Pre-create the API Client with the needed scopes, this way we reduce the number of created API Clients + self::createApiClient(['manufacturer_write', 'manufacturer_read']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + // Reset DB as it was before this test + LanguageResetter::resetLanguages(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'manufacturer', + 'manufacturer_lang', + 'manufacturer_shop', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/manufacturers/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/manufacturers', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/manufacturers/1', + ]; + + yield 'bulk delete endpoint' => [ + 'PUT', + '/manufacturers/bulk-delete', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/manufacturers/1', + ]; + } + + public function testAddManufacturer(): int + { + $itemsCount = $this->countItems('/manufacturers', ['manufacturer_read']); + $postData = [ + 'name' => 'manufacturer name', + 'shortDescriptions' => [ + 'en-US' => 'short description en', + 'fr-FR' => 'short description fr', + ], + 'descriptions' => [ + 'en-US' => 'description en', + 'fr-FR' => 'description fr', + ], + 'metaTitles' => [ + 'en-US' => 'meta title en', + 'fr-FR' => 'meta title fr', + ], + 'metaDescriptions' => [ + 'en-US' => 'meta description en', + 'fr-FR' => 'meta description fr', + ], + 'shopIds' => [1], + 'enabled' => true, + ]; + // Create an manufacturer, the POST endpoint returns the created item as JSON + $manufacturer = $this->createItem('/manufacturers', $postData, ['manufacturer_write']); + $this->assertArrayHasKey('manufacturerId', $manufacturer); + $manufacturerId = $manufacturer['manufacturerId']; + + // We assert the returned data matches what was posted (plus the ID) + $this->assertEquals( + ['manufacturerId' => $manufacturerId] + $postData, + $manufacturer + ); + + $newItemsCount = $this->countItems('/manufacturers', ['manufacturer_read']); + $this->assertEquals($itemsCount + 1, $newItemsCount); + + return $manufacturerId; + } + + /** + * @depends testAddManufacturer + * + * @param int $manufacturerId + * + * @return int + */ + public function testGetManufacturer(int $manufacturerId): int + { + $manufacturer = $this->getItem('/manufacturers/' . $manufacturerId, ['manufacturer_read']); + $this->assertEquals([ + 'manufacturerId' => $manufacturerId, + 'name' => 'manufacturer name', + 'shortDescriptions' => [ + 'en-US' => 'short description en', + 'fr-FR' => 'short description fr', + ], + 'descriptions' => [ + 'en-US' => 'description en', + 'fr-FR' => 'description fr', + ], + 'metaTitles' => [ + 'en-US' => 'meta title en', + 'fr-FR' => 'meta title fr', + ], + 'metaDescriptions' => [ + 'en-US' => 'meta description en', + 'fr-FR' => 'meta description fr', + ], + 'shopIds' => [1], + 'enabled' => true, + ], $manufacturer); + + return $manufacturerId; + } + + /** + * @depends testGetManufacturer + * + * @param int $manufacturerId + * + * @return int + */ + public function testUpdatePartialManufacturer(int $manufacturerId): int + { + $patchData = [ + 'name' => 'updated manufacturer', + 'enabled' => true, + 'shortDescriptions' => [ + 'en-US' => 'updated short desc en', + 'fr-FR' => 'updated short desc fr', + ], + 'descriptions' => [ + 'en-US' => 'updated description en', + 'fr-FR' => 'updated description fr', + ], + 'metaTitles' => [ + 'en-US' => 'updated meta title en', + 'fr-FR' => 'updated meta title en', + ], + 'metaDescriptions' => [ + 'en-US' => 'updated meta description en', + 'fr-FR' => 'updated meta description fr', + ], + 'shopIds' => [1], + ]; + + $updatedManufacturer = $this->partialUpdateItem('/manufacturers/' . $manufacturerId, $patchData, ['manufacturer_write']); + $this->assertEquals(['manufacturerId' => $manufacturerId] + $patchData, $updatedManufacturer); + + // We check that when we GET the item it is updated as expected + $manufacturer = $this->getItem('/manufacturers/' . $manufacturerId, ['manufacturer_read']); + $this->assertEquals(['manufacturerId' => $manufacturerId] + $patchData, $manufacturer); + + // Test partial update + $partialUpdateData = [ + 'name' => 'updated manufacturer name', + 'enabled' => false, + 'shortDescriptions' => [ + 'en-US' => 'updated short description en', + 'fr-FR' => 'updated short description fr', + ], + ]; + $expectedUpdatedData = [ + 'manufacturerId' => $manufacturerId, + 'name' => 'updated manufacturer name', + 'enabled' => false, + 'shortDescriptions' => [ + 'en-US' => 'updated short description en', + 'fr-FR' => 'updated short description fr', + ], + 'descriptions' => [ + 'en-US' => 'updated description en', + 'fr-FR' => 'updated description fr', + ], + 'metaTitles' => [ + 'en-US' => 'updated meta title en', + 'fr-FR' => 'updated meta title en', + ], + 'metaDescriptions' => [ + 'en-US' => 'updated meta description en', + 'fr-FR' => 'updated meta description fr', + ], + 'shopIds' => [1], + ]; + + $updatedManufacturer = $this->partialUpdateItem('/manufacturers/' . $manufacturerId, $partialUpdateData, ['manufacturer_write']); + $this->assertEquals($expectedUpdatedData, $updatedManufacturer); + + return $manufacturerId; + } + + /** + * @depends testUpdatePartialManufacturer + * + * @param int $manufacturerId + * + * @return int + */ + public function testListManufacturers(int $manufacturerId): int + { + // List by manufacturerId in descending order so the created one comes first (and test ordering at the same time) + foreach (['manufacturerId', 'active', 'products_count', 'addresses_count', 'name'] as $orderBy) { + $paginatedManufacturers = $this->listItems('/manufacturers?orderBy=' . $orderBy . '&sortOrder=desc', ['manufacturer_read']); + $this->assertGreaterThanOrEqual(1, $paginatedManufacturers['totalItems']); + + // Check the details to make sure filters mapping is correct + $this->assertEquals($orderBy, $paginatedManufacturers['orderBy']); + } + + // Test manufacturer should be the first returned in the list + $testManufacturer = $paginatedManufacturers['items'][0]; + + $expectedManufacturer = [ + 'manufacturerId' => $manufacturerId, + 'name' => 'updated manufacturer name', + 'addressesCount' => '--', + 'enabled' => false, + ]; + $this->assertEquals($expectedManufacturer, $testManufacturer); + + $filteredManufacturers = $this->listItems('/manufacturers', ['manufacturer_read'], [ + 'manufacturerId' => $manufacturerId, + ]); + $this->assertEquals(1, $filteredManufacturers['totalItems']); + + $testManufacturer = $filteredManufacturers['items'][0]; + $this->assertEquals($expectedManufacturer, $testManufacturer); + + // Check the filters details + $this->assertEquals([ + 'manufacturerId' => $manufacturerId, + ], $filteredManufacturers['filters']); + + return $manufacturerId; + } + + /** + * @depends testListManufacturers + * + * @param int $manufacturerId + */ + public function testRemoveManufacturer(int $manufacturerId): void + { + // Delete the item + $return = $this->deleteItem('/manufacturers/' . $manufacturerId, ['manufacturer_write']); + // This endpoint return empty response and 204 HTTP code + $this->assertNull($return); + + // Getting the item should result in a 404 now + $this->getItem('/manufacturers/' . $manufacturerId, ['manufacturer_read'], Response::HTTP_NOT_FOUND); + } + + /** + * @depends testListManufacturers + * + * @param int $manufacturerId + */ + public function testBulkRemoveManufacturers(): void + { + // We create another manufacturer + $postData = [ + 'name' => 'manufacturer name', + 'shortDescriptions' => [ + 'en-US' => 'short description en', + 'fr-FR' => 'short description fr', + ], + 'descriptions' => [ + 'en-US' => 'description en', + 'fr-FR' => 'description fr', + ], + 'metaTitles' => [ + 'en-US' => 'meta title en', + 'fr-FR' => 'meta title fr', + ], + 'metaDescriptions' => [ + 'en-US' => 'meta description en', + 'fr-FR' => 'meta description fr', + ], + 'shopIds' => [1], + 'enabled' => true, + ]; + // Create an manufacturer, the POST endpoint returns the created item as JSON + $manufacturer = $this->createItem('/manufacturers', $postData, ['manufacturer_write']); + + $manufacturers = $this->listItems('/manufacturers', ['manufacturer_read']); + + // There are currently tree manufacturers + $this->assertEquals(3, $manufacturers['totalItems']); + + // We remove two manufacturers + $removeManufacturerIds = [ + $manufacturers['items'][0]['manufacturerId'], + $manufacturers['items'][2]['manufacturerId'], + ]; + + $this->updateItem('/manufacturers/bulk-delete', [ + 'manufacturerIds' => $removeManufacturerIds, + ], ['manufacturer_write'], Response::HTTP_NO_CONTENT); + + // Assert the provided manufacturers have been removed + foreach ($removeManufacturerIds as $manufacturerId) { + $this->getItem('/manufacturers/' . $manufacturerId, ['manufacturer_read'], Response::HTTP_NOT_FOUND); + } + + // Only two manufacturer remain + $this->assertEquals(1, $this->countItems('/manufacturers', ['manufacturer_read'])); + } + + public function testInvalidFilterManufacturers() + { + $orderByInvalid = 'INVALID_FILTER'; + + $this->requestApi('GET', '/manufacturers?orderBy=' . $orderByInvalid . '&sortOrder=desc', null, ['manufacturer_read'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testInvalidManufacturer(): void + { + $manufacturer = $this->getItem('/manufacturers/9999', ['manufacturer_read'], Response::HTTP_NOT_FOUND); + + $manufacturerInvalidData = [ + 'name' => 'updated manufacturer name', + 'enabled' => false, + 'shortDescriptions' => [ + //