From 2367064613a1d32a0083666e372df9080132fd5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Wed, 19 Aug 2026 17:34:46 +0200 Subject: [PATCH] API: the puzzle library - wishlist, unsolved, lend/borrow, sell/swap lists and a library summary for /me and /players/{id} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 5 of docs/features/api/v1-expansion-plan.md: the website's puzzle library page as the API, read-only. Ten GET endpoints under one tag "Puzzle Library", scope collections:read ("read the puzzle library"; PAT for the owner's own): /me/library, /players/{id}/library - the page's summary: collections with item_count (system "default" + custom), and count + visibility of unsolved, wishlist, lend_borrow (lent/borrowed), sell_swap, solved /me/wishlist, /players/{id}/wishlist /me/unsolved-puzzles, /players/{id}/unsolved-puzzles (borrowed unsolved first) /me/lend-borrow, /players/{id}/lend-borrow (direction lent|borrowed, counterparty id + display name) /me/sell-swap, /players/{id}/sell-swap (public offer fields, reserved-for never exposed) Built from the very queries the web pages use (GetWishListItems, GetUnsolvedPuzzles + GetBorrowedPuzzles::unsolvedByHolderId, GetLentPuzzles + GetBorrowedPuzzles, GetSellSwapListItems, the PuzzleLibraryController count queries). Visibility is the website's rule in PuzzleLibraryVisibility: the player behind the token always sees their own (also via /players/{their id}); a private profile hides everything (zeroed, never 403); otherwise the section's own setting - a hidden list is {count: 0, items: []} with no batch query, a hidden summary section count 0 + "private". Items carry the four insight objects of a collection item through one PuzzleResponseFactory::insightsFor() batch per list: prediction = the token owner's own forecast, solves = the list owner's (PR 3b semantics), difficulty for a member token owner, statistics always. hide_image_until => image null on every list. Budgets asserted per endpoint and at two list sizes. Docs: README "Puzzle Library Endpoints", for-developers rows, OpenAPI from the attributes, plan §12 ticked with the measured budgets. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015uH2n4Y6gPLiYASEJwNr3H --- config/packages/security.php | 7 + docs/features/api/README.md | 58 ++- docs/features/api/v1-expansion-plan.md | 2 +- src/Api/V1/LendBorrowResponse.php | 69 +++ src/Api/V1/LentPuzzleCounterpartyResponse.php | 21 + src/Api/V1/LentPuzzleResponse.php | 36 ++ src/Api/V1/LibraryCollectionResponse.php | 24 + .../V1/LibraryLendBorrowSectionResponse.php | 21 + src/Api/V1/LibraryResponse.php | 74 +++ src/Api/V1/LibrarySectionResponse.php | 20 + src/Api/V1/LibrarySellSwapSectionResponse.php | 18 + src/Api/V1/MyLendBorrowResponseProvider.php | 42 ++ src/Api/V1/MyLibraryResponseProvider.php | 35 ++ src/Api/V1/MySellSwapResponseProvider.php | 41 ++ .../V1/MyUnsolvedPuzzlesResponseProvider.php | 41 ++ src/Api/V1/MyWishlistResponseProvider.php | 40 ++ .../V1/PlayerLendBorrowResponseProvider.php | 49 ++ src/Api/V1/PlayerLibraryResponseProvider.php | 40 ++ src/Api/V1/PlayerSellSwapResponseProvider.php | 50 +++ .../PlayerUnsolvedPuzzlesResponseProvider.php | 49 ++ src/Api/V1/PlayerWishlistResponseProvider.php | 49 ++ src/Api/V1/SellSwapItemResponse.php | 40 ++ src/Api/V1/SellSwapResponse.php | 68 +++ src/Api/V1/UnsolvedPuzzleResponse.php | 30 ++ src/Api/V1/UnsolvedPuzzlesResponse.php | 70 +++ src/Api/V1/WishlistItemResponse.php | 33 ++ src/Api/V1/WishlistResponse.php | 64 +++ .../Api/PuzzleLibraryItemsFactory.php | 241 ++++++++++ .../Api/PuzzleLibrarySummaryFactory.php | 130 ++++++ src/Services/Api/PuzzleLibraryVisibility.php | 62 +++ templates/for-developers.html.twig | 61 +++ .../Api/V1/LendBorrowEndpointTest.php | 389 ++++++++++++++++ .../Controller/Api/V1/LibraryEndpointTest.php | 314 +++++++++++++ .../V1/PuzzleLibraryEndpointTestHelpers.php | 333 ++++++++++++++ .../Api/V1/SellSwapEndpointTest.php | 413 +++++++++++++++++ .../Api/V1/UnsolvedPuzzlesEndpointTest.php | 391 ++++++++++++++++ .../Api/V1/WishlistEndpointTest.php | 422 ++++++++++++++++++ translations/messages.en.yml | 1 + 38 files changed, 3846 insertions(+), 2 deletions(-) create mode 100644 src/Api/V1/LendBorrowResponse.php create mode 100644 src/Api/V1/LentPuzzleCounterpartyResponse.php create mode 100644 src/Api/V1/LentPuzzleResponse.php create mode 100644 src/Api/V1/LibraryCollectionResponse.php create mode 100644 src/Api/V1/LibraryLendBorrowSectionResponse.php create mode 100644 src/Api/V1/LibraryResponse.php create mode 100644 src/Api/V1/LibrarySectionResponse.php create mode 100644 src/Api/V1/LibrarySellSwapSectionResponse.php create mode 100644 src/Api/V1/MyLendBorrowResponseProvider.php create mode 100644 src/Api/V1/MyLibraryResponseProvider.php create mode 100644 src/Api/V1/MySellSwapResponseProvider.php create mode 100644 src/Api/V1/MyUnsolvedPuzzlesResponseProvider.php create mode 100644 src/Api/V1/MyWishlistResponseProvider.php create mode 100644 src/Api/V1/PlayerLendBorrowResponseProvider.php create mode 100644 src/Api/V1/PlayerLibraryResponseProvider.php create mode 100644 src/Api/V1/PlayerSellSwapResponseProvider.php create mode 100644 src/Api/V1/PlayerUnsolvedPuzzlesResponseProvider.php create mode 100644 src/Api/V1/PlayerWishlistResponseProvider.php create mode 100644 src/Api/V1/SellSwapItemResponse.php create mode 100644 src/Api/V1/SellSwapResponse.php create mode 100644 src/Api/V1/UnsolvedPuzzleResponse.php create mode 100644 src/Api/V1/UnsolvedPuzzlesResponse.php create mode 100644 src/Api/V1/WishlistItemResponse.php create mode 100644 src/Api/V1/WishlistResponse.php create mode 100644 src/Services/Api/PuzzleLibraryItemsFactory.php create mode 100644 src/Services/Api/PuzzleLibrarySummaryFactory.php create mode 100644 src/Services/Api/PuzzleLibraryVisibility.php create mode 100644 tests/Controller/Api/V1/LendBorrowEndpointTest.php create mode 100644 tests/Controller/Api/V1/LibraryEndpointTest.php create mode 100644 tests/Controller/Api/V1/PuzzleLibraryEndpointTestHelpers.php create mode 100644 tests/Controller/Api/V1/SellSwapEndpointTest.php create mode 100644 tests/Controller/Api/V1/UnsolvedPuzzlesEndpointTest.php create mode 100644 tests/Controller/Api/V1/WishlistEndpointTest.php diff --git a/config/packages/security.php b/config/packages/security.php index e18d7e7f..51d29c2f 100644 --- a/config/packages/security.php +++ b/config/packages/security.php @@ -234,6 +234,13 @@ 'path' => '^/api/v1/players/.*/collections', 'roles' => [OAuth2Scope::CollectionsRead->role()], ], + // The puzzle library (summary, wishlist, unsolved puzzles, lend/borrow, + // sell/swap) is collections:read - "read the puzzle library"; the owner's + // visibility settings are applied inside the providers, never as 403. + [ + 'path' => '^/api/v1/players/.*/(library|wishlist|unsolved-puzzles|lend-borrow|sell-swap)', + 'roles' => [OAuth2Scope::CollectionsRead->role()], + ], [ 'path' => '^/api/v1/competitions', 'roles' => [AuthenticatedVoter::IS_AUTHENTICATED_FULLY], diff --git a/docs/features/api/README.md b/docs/features/api/README.md index 499799e4..8c286eb7 100644 --- a/docs/features/api/README.md +++ b/docs/features/api/README.md @@ -40,7 +40,7 @@ Built on `league/oauth2-server-bundle`. Supports two flows: | `email:read` | View user email address | Yes | Yes | | `results:read` | View puzzle solving results | Yes | Yes | | `statistics:read` | View solving statistics | Yes | Yes | -| `collections:read` | View puzzle collections | Yes | Yes | +| `collections:read` | Read the puzzle library - collections, wishlist, unsolved puzzles, lend/borrow list, sell/swap list, library summary | Yes | Yes | | `solving-times:write` | Create and edit solving times | Yes | No | | `collections:write` | Create, edit, delete collections and items | Yes | No | @@ -87,6 +87,11 @@ hand-typed `SOLVING_TIMES` variant silently matched nothing until 2026-08 (PR #1 | DELETE | `/api/v1/me/collections/{id}` | PAT or `collections:write` (members only) | | POST | `/api/v1/me/collections/{id}/items` | PAT or `collections:write` (the created item is returned in the same shape as a `GET …/items` item, the four objects included) | | DELETE | `/api/v1/me/collections/{id}/items/{itemId}` | PAT or `collections:write` | +| GET | `/api/v1/me/library` | PAT or `collections:read` - the puzzle library summary: collections with item counts, and the count + visibility of the unsolved / wishlist / lend-borrow / sell-swap / solved sections - see Puzzle Library Endpoints below | +| GET | `/api/v1/me/wishlist` | PAT or `collections:read`. Items carry `statistics`, `difficulty`, `prediction`, `solves` with the collection-item gates | +| GET | `/api/v1/me/unsolved-puzzles` | PAT or `collections:read` (puzzles of your collections you have not solved + borrowed unsolved ones) | +| GET | `/api/v1/me/lend-borrow` | PAT or `collections:read` (lent out + borrowed, `direction` per item) | +| GET | `/api/v1/me/sell-swap` | PAT or `collections:read` | ### Player Endpoints (OAuth2 only) @@ -97,6 +102,11 @@ hand-typed `SOLVING_TIMES` variant silently matched nothing until 2026-08 (PR #1 | GET | `/api/v1/players/{id}/statistics` | `statistics:read` | | GET | `/api/v1/players/{id}/collections` | `collections:read` (public only) | | GET | `/api/v1/players/{id}/collections/{cid}/items` | `collections:read`. Visibility as on the website: a private profile, a private custom collection and a private system collection (`default` - the player's puzzle-collection setting) are zeroed for everyone but the player behind the token. Each item also carries `statistics` (public), `difficulty` (**token owner** member), `solves` (the **collection owner's** history, only with `results:read` on the token) and `prediction` (the **token owner's own** forecast - what the website shows a visitor next to each item of somebody else's collection; member + not opted out + `results:read`) | +| GET | `/api/v1/players/{id}/library` | `collections:read` (client_credentials allowed) - the library summary as the website shows it to a visitor: public collections, private sections as `count: 0` + `"private"`, a private profile zeroed - see Puzzle Library Endpoints below | +| GET | `/api/v1/players/{id}/wishlist` | `collections:read` - when the player made the wishlist public (else zeroed). Items: `difficulty` (**token owner** member), `prediction` = the **token owner's own** forecast (member, `results:read`), `solves` = the **list owner's** (`results:read`) | +| GET | `/api/v1/players/{id}/unsolved-puzzles` | `collections:read` - when the player made the list public (else zeroed); same item gates | +| GET | `/api/v1/players/{id}/lend-borrow` | `collections:read` - when the player made the list public (else zeroed); same item gates | +| GET | `/api/v1/players/{id}/sell-swap` | `collections:read` - always public on the website, only a private profile is zeroed; same item gates | ### Competition Endpoints (any authenticated token) @@ -205,6 +215,48 @@ Private profiles keep today's zeroed `/players/{id}/…` response (`count: 0`, n **Fixed query cost** (asserted at two collection sizes by `MyCollectionItemsEndpointTest`, `PlayerCollectionItemsEndpointTest`, `MyResultsInsightsEndpointTest`, `PlayerResultsInsightsEndpointTest`): every provider collects the puzzle ids once and calls `PuzzleResponseFactory::insightsFor($puzzleIds, $solvesOfPlayerId, $includePrediction)` - the same batch method the puzzle cards use - which runs one query per object the token is entitled to (`GetPuzzleStatistics::forPuzzleList`, `GetPuzzleDifficulty::forPuzzleList`, `GetPlayerPredictions::forPuzzles` (≤ 4), `GetPlayerPuzzleSolves::forPuzzles`) and hands back a `PuzzleInsightsBatch` the provider maps onto its items. Measured 2026-08-19 (request only): collection items - `client_credentials` 4-5, non-member 5-6 (PAT) / 7-8 (OAuth2), member 9-11 (PAT) / 13 (OAuth2) on `/me`, 9 on `/players`; result lists - today + 2 (non-member: statistics, owner profile) or + 3 (member: + difficulty), `client_credentials` + 1. +### Puzzle Library Endpoints + +The website's puzzle library (`PuzzleLibraryController` and the list pages behind its cards) as the API, read-only: a **library summary** and the four lists that are not collections - **wishlist**, **unsolved puzzles**, **lend/borrow list**, **sell/swap list** (the solved puzzles are `/results`, the collections `/collections`). The scope is `collections:read` - "read the puzzle library"; a PAT reads the owner's own. Write endpoints (add / remove / return) are a follow-up. Plan: `docs/features/api/v1-expansion-plan.md` §7b. + +| Endpoint | Source (the page's own query) | Visibility | +|---|---|---| +| `GET /me/library`, `GET /players/{id}/library` | the count queries of `PuzzleLibraryController` (`GetPlayerCollectionsWithCounts`, `GetUnsolvedPuzzles::countByPlayerId` + `GetBorrowedPuzzles::countUnsolvedByHolderId`, `GetWishListItems::countByPlayerId`, `GetLentPuzzles::countByOwnerId` + `GetBorrowedPuzzles::countByHolderId`, `GetSellSwapListItems::countByPlayerId`, `GetPlayerSolvedPuzzles::countByPlayerId`) | per section, below | +| `GET /me/wishlist`, `GET /players/{id}/wishlist` | `GetWishListItems::byPlayerId` (newest first) | the owner's wish-list setting | +| `GET /me/unsolved-puzzles`, `GET /players/{id}/unsolved-puzzles` | `GetBorrowedPuzzles::unsolvedByHolderId` (borrowed, first) + `GetUnsolvedPuzzles::byPlayerId` (one entry per puzzle of any of the player's collections, newest first) | the owner's unsolved-puzzles setting | +| `GET /me/lend-borrow`, `GET /players/{id}/lend-borrow` | `GetLentPuzzles::byOwnerId` (direction `lent`, first) + `GetBorrowedPuzzles::byHolderId` (`borrowed`), each newest first | the owner's lend/borrow setting | +| `GET /me/sell-swap`, `GET /players/{id}/sell-swap` | `GetSellSwapListItems::byPlayerId` (newest first) | always public | + +**Visibility** (`PuzzleLibraryVisibility`, `src/Services/Api/`) is the website's rule, in order: the player behind the token always sees their own - also through `/players/{their id}` (exactly as under `/me`); a **private profile hides everything** from anyone else (zeroed, never 403, like every `/players/{id}` endpoint - the website masks only the profile header on these pages, the API keeps its privacy rule); otherwise the section's own setting decides (`public` / `private`; the system collection follows the puzzle-collection setting; the sell/swap list has none and is public). A list the token may not see is `{ player_id, count: 0, items: [] }` and runs no batch query; the summary reports such a section as `count: 0` with its visibility (`"private"` throughout for a private profile - the setting would otherwise promise a public list the token cannot see). A machine token (`client_credentials`) is a stranger. + +**Summary** (`GET /me/library`, `GET /players/{id}/library`): + +```json +{ "player_id": "018d…", + "collections": [ { "collection_id": "default", "name": "Default Collection", "description": null, "visibility": "public", "item_count": 5 }, + { "collection_id": "018d0008-…", "name": "My Trefl Collection", "description": "All my Trefl puzzles", "visibility": "public", "item_count": 3 } ], + "unsolved": { "count": 7, "visibility": "private" }, + "wishlist": { "count": 3, "visibility": "private" }, + "lend_borrow": { "lent_count": 4, "borrowed_count": 2, "visibility": "private" }, + "sell_swap": { "count": 7 }, + "solved": { "count": 10, "visibility": "private" } } +``` + +`collections` are the cards of the library page: the system collection (`default`, the owner's puzzle-collection visibility, listed when public or own) and the custom collections the token may see (all of them for the owner, public ones for others), newest first, each with its `item_count` - the same four fields as `GET /me/collections` plus the count (`LibraryCollectionResponse`). `unsolved` = puzzles of the player's collections not solved yet + borrowed unsolved ones; `solved` = distinct puzzles with a solving time (what `/results` lists). + +**List items** follow the collection item (flat puzzle fields `puzzle_id, puzzle_name, manufacturer_name, pieces_count, image`, then the four insight objects `statistics, difficulty, prediction, solves` - shapes under Puzzles, gates under Insights on lists) plus the list's own fields: + +| List | Item fields before the insight objects | +|---|---| +| wishlist | `wishlist_item_id`, puzzle fields, `added_at` | +| unsolved-puzzles | puzzle fields, `added_at` (when it first entered a collection / was lent to the player), `is_borrowed` | +| lend-borrow | `lent_puzzle_id`, `direction` (`lent` / `borrowed`), puzzle fields, `counterparty { player_id: ?string, name }` (the holder of a lent puzzle, the owner of a borrowed one - a registered player's id + display name, or the free-text name; `name: ""` and no id for a returned puzzle), `lent_at`, `notes` | +| sell-swap | `item_id`, puzzle fields, `listing_type` (`sell` / `swap` / `both` / `free`), `price` (number or null), `currency` (the seller's list-wide currency - custom name or ISO code - `null` when not set or the listing has no price), `condition` (`new` / `like_new` / `normal` / `not_so_good` / `missing_pieces`), `comment`, `is_reserved`, `is_published_on_marketplace`, `added_at`. **Who an offer is reserved for is not exposed** (the website shows it to the seller only) | + +The four objects are gated exactly as on `/players/{id}/collections/{cid}/items`: `statistics` always; `difficulty` when the **token owner** is a member; `prediction` is always the **token owner's own** forecast (member, not opted out, PAT or `results:read`) - on another player's list as well, because the website shows the visitor their own predicted time next to each puzzle, never the owner's (plan §0 N1); `solves` are the **list owner's** own history (PAT or `results:read` - on `/players` that is the same data `/players/{id}/results` exposes). Puzzle images honour `hide_image_until` (`image: null` until the embargo ends; every list query applies it). `hide_until` is not filtered on these lists - like collection items and results, it is the player's own library and a puzzle gets there only through the player's own action. + +**Fixed query cost** (asserted at two list sizes by `WishlistEndpointTest`, `UnsolvedPuzzlesEndpointTest`, `LendBorrowEndpointTest`, `SellSwapEndpointTest`; the summary by `LibraryEndpointTest`): `PuzzleLibraryItemsFactory` builds every list from the page's query and one `PuzzleResponseFactory::insightsFor()` batch; `PuzzleLibrarySummaryFactory` runs one count query per visible section. Measured 2026-08-19 (request only; authentication 1 PAT / 3 OAuth2 / 1-2 `client_credentials`): wishlist `/me` 5 (non-member PAT) · 10 (member PAT) · 12 (member OAuth2 with `results:read`), `/players` 4-5 (`client_credentials`) · 8 (non-member) · 13 (member); unsolved-puzzles and lend-borrow (two item queries each) one more: 6 · 11 · 13 / 5-6 · 9 · 14; sell-swap 3 (empty list, no batch) · 10 · 12 / 4-5 · 8 · 13; library summary 11 (PAT) · 13 (OAuth2) own, 11 (`client_credentials`) · 14 (OAuth2) for a complete public library, 8 for a stranger with the default (private) settings; a private profile 5 (OAuth2) / 2 (`client_credentials`) on every path. + ### GET `/api/v1/me/puzzles/{puzzleId}/predicted-time` The API twin of the Puzzle Insights block on the puzzle detail page (`PuzzleDetailController`), same gates. The flat shape predates the insight objects of `GET /api/v1/puzzles` and `GET /api/v1/puzzles/{puzzleId}` and stays as it is (no BC breaks); since PR 2 it is a projection of the very same objects - `MyPredictedTimeResponseProvider` gates through `ApiTokenOwner` and flattens `TimePredictionResponse` / `PuzzleDifficultyResponse` via `PredictedTimeResponse::fromInsights()` (a puzzle without a difficulty row is still `null` here, not `"insufficient"`): @@ -272,6 +324,7 @@ Response (`SolvingTimeResponse`, shared with `PUT …/solving-times/{timeId}`): - `/api/v1/me/*` always returns full data for the token owner - `/api/v1/players/{id}/*` returns empty/zeroed data for private profiles (not 403); `/api/v1/players/{id}` itself returns the masked shape (`is_private: true`, `id` + `code` + `has_active_membership`, everything else `null` / `[]`) +- the puzzle-library lists also follow the owner's per-list visibility settings (zeroed, not 403), the library summary reports a hidden section as `count: 0` + its visibility - Hidden players are never returned in service-to-service queries ### Error Handling @@ -343,6 +396,7 @@ Access control: - `^/api/v1/players/.*/results` → `ROLE_OAUTH2_RESULTS:READ` - `^/api/v1/players/.*/statistics` → `ROLE_OAUTH2_STATISTICS:READ` - `^/api/v1/players/.*/collections` → `ROLE_OAUTH2_COLLECTIONS:READ` +- `^/api/v1/players/.*/(library|wishlist|unsolved-puzzles|lend-borrow|sell-swap)` → `ROLE_OAUTH2_COLLECTIONS:READ` (the puzzle library) - `^/api/v1/competitions` → `IS_AUTHENTICATED_FULLY` (PAT or any OAuth2 token, no specific scope) - `^/api/v1/puzzles` → `IS_AUTHENTICATED_FULLY` (PAT or any OAuth2 token, no specific scope; members-only parts of the response are gated per token owner inside the providers) @@ -413,6 +467,8 @@ Stub endpoints for in-app purchase verification (not implemented). | `src/Api/V1/` | All API Platform resources, providers, and processors | | `src/Api/V1/PuzzleListResponse.php` | `GET /api/v1/puzzles` resource: the single declaration of its query parameters (validation + OpenAPI) | | `src/Api/V1/PuzzleDetailResponse.php` | `GET /api/v1/puzzles/{puzzleId}` resource - the card of one puzzle, built only via `fromCard()` (provider `PuzzleDetailResponseProvider`) | +| `src/Api/V1/LibraryResponse.php`, `WishlistResponse.php`, `UnsolvedPuzzlesResponse.php`, `LendBorrowResponse.php`, `SellSwapResponse.php` | The puzzle-library resources, each with its `/me/…` and `/players/{playerId}/…` operation (providers `My*ResponseProvider` / `Player*ResponseProvider`) | +| `src/Services/Api/PuzzleLibraryVisibility.php`, `PuzzleLibraryItemsFactory.php`, `PuzzleLibrarySummaryFactory.php` | The website's library visibility rule; the list items (one insights batch per list); the summary counts | | `src/Api/V1/PuzzleResponse.php` | The puzzle card (+ `PuzzleStatisticsResponse`, `PuzzleDifficultyResponse`, `TimePredictionResponse`, `PlayerSolvesResponse`) | | `src/Services/Api/ApiTokenOwner.php` | The single membership / scope gate behind every provider | | `src/Services/Api/PuzzleResponseFactory.php` | Builds puzzle cards for the calling token at a fixed query cost (one batch call per object); `insightsFor()` + `PuzzleInsightsBatch` serve the collection-item and result lists with the same batch | diff --git a/docs/features/api/v1-expansion-plan.md b/docs/features/api/v1-expansion-plan.md index 710620fe..1d07fe6b 100644 --- a/docs/features/api/v1-expansion-plan.md +++ b/docs/features/api/v1-expansion-plan.md @@ -233,7 +233,7 @@ Waves (dependencies): **wave 1** PR 0 ∥ PR 1 (independent) → **wave 2** PR 2 - [x] PR 3 insights & solves on lists (2026-08-19; `PuzzleResponseFactory::insightsFor()` + `PuzzleInsightsBatch` shared by cards, collection items and result lists; the `POST /me/collections/{id}/items` item answers in the same shape; measured: collection items cc 4-5 / non-member 5-6 PAT, 7-8 OAuth2 / member 9-11 PAT, 13 OAuth2 on `/me`, 9 on `/players`; result lists today + ≤ 3; `solves` on `/me/*` needs PAT or `results:read` like everywhere else - §6 table corrected from "always") - [x] PR 3b `/players/{id}/collections/{cid}/items`: collection visibility as on the web (private profile / private custom collection / private system collection ⇒ zeroed for everyone but the owner; +1 query for custom collections) and `prediction` = the token owner's own forecast there, like the website's collection page (2026-08-19) - [x] PR 4 `prediction` on `POST /me/solving-times` (2026-08-19; `time_seconds` filled from `SolvingTime::fromUserInput` - the handler's parser; gate = solo + time + `ApiTokenOwner::isMember()` + not opted out **+ PAT / `results:read`** (§2's rule - the write scope alone does not read insights, so an auth-code token with only `solving-times:write` gets `null`); measured request-only counts, PAT: the create's own write path is 28-35 (solo, data-dependent: the `PuzzleSolved` event runs the statistics/intelligence recalculations, wishlist removal and notifications synchronously) / 21 (duo), the feature adds profile 1 + prediction 4 (personal) or 2 (statistical) - ceilings pinned per scenario as write path + 1 + 5 / + 1 / + 0 in `CreateSolvingTimePredictionEndpointTest`; one plan wrinkle: the synchronous recalculation rewrites the posted puzzle's `puzzle_difficulty` row inside the request, so a seeded difficulty does not survive the POST - the statistical-prediction test seeds four other players' first attempts instead so the puzzle is scored from real data) -- [ ] PR 5 puzzle library lists (wishlist, unsolved, lend/borrow, sell/swap, library summary) +- [x] PR 5 puzzle library lists (2026-08-19; `GET /me|players/{id}/library`, `/wishlist`, `/unsolved-puzzles`, `/lend-borrow`, `/sell-swap` - one resource per list with both operations, `PuzzleLibraryVisibility` (owner always / private profile hides all / the section's setting) + `PuzzleLibraryItemsFactory` (one `insightsFor()` batch per list, prediction = token owner's own, solves = list owner's, as PR 3b) + `PuzzleLibrarySummaryFactory` (one count query per visible section); the summary's `collections[]` carry `item_count` (the library card's number) as `LibraryCollectionResponse` - the four `/me/collections` fields + the count, the system collection "default" with its real visibility; `sell_swap` is `{count}` only; measured (request only): wishlist `/me` 5 non-member PAT / 10 member PAT / 12 member OAuth2, `/players` 4-5 cc / 8 non-member / 13 member; unsolved and lend-borrow +1 (two item queries): 6 / 11 / 13 and 5-6 / 9 / 14; sell-swap 3 (empty) / 10 / 12 and 4-5 / 8 / 13; library summary 11 PAT / 13 OAuth2 own, 11 cc / 14 OAuth2 for a complete public library, 8 for a stranger with default settings; private profile 5 / 2 everywhere. Deviation from §7b: the website's library pages do not hide a private profile's lists (only the header is masked) - the API keeps the `/players/{id}` privacy rule and zeroes them) - [x] PR 5a medians in `puzzle_statistics` — code (2026-08-19: `median_time(_solo/_duo/_team)` columns, computed with `percentile_cont(0.5)` over the same per-player-best population as the averages, `median_seconds` on every `statistics` group); **backfill on the box still to do after deploy: `php bin/console myspeedpuzzling:recalculate-puzzle-statistics` once — until then `median_seconds` is `null`** - [x] PR 6 profile insights on `/me` + `GET /players/{id}` (2026-08-19; measured budgets: `/me` member 7 OAuth2 / 5 PAT - the §8 "+3" on top of auth 3 + profile; `/players/{id}` member viewer 8, non-member 7, cc 4, masked 5 (auth-code) / 2 (cc). The owner's own private profile on `/players/{id}` and on `/me` returns the rating (plan §8, "the full one"), while the web's own-private-profile view replaces both blocks with an explanation - the API follows the plan) diff --git a/src/Api/V1/LendBorrowResponse.php b/src/Api/V1/LendBorrowResponse.php new file mode 100644 index 00000000..9fa515d5 --- /dev/null +++ b/src/Api/V1/LendBorrowResponse.php @@ -0,0 +1,69 @@ + */ + public array $items; + + /** + * @param array $items + */ + public function __construct( + public string $player_id, + public int $count, + array $items, + ) { + $this->items = $items; + } +} diff --git a/src/Api/V1/LentPuzzleCounterpartyResponse.php b/src/Api/V1/LentPuzzleCounterpartyResponse.php new file mode 100644 index 00000000..7ed9799e --- /dev/null +++ b/src/Api/V1/LentPuzzleCounterpartyResponse.php @@ -0,0 +1,21 @@ + */ + public array $collections; + + /** + * @param array $collections + */ + public function __construct( + public string $player_id, + array $collections, + public LibrarySectionResponse $unsolved, + public LibrarySectionResponse $wishlist, + public LibraryLendBorrowSectionResponse $lend_borrow, + public LibrarySellSwapSectionResponse $sell_swap, + public LibrarySectionResponse $solved, + ) { + $this->collections = $collections; + } +} diff --git a/src/Api/V1/LibrarySectionResponse.php b/src/Api/V1/LibrarySectionResponse.php new file mode 100644 index 00000000..7eeac10a --- /dev/null +++ b/src/Api/V1/LibrarySectionResponse.php @@ -0,0 +1,20 @@ + + */ +final readonly class MyLendBorrowResponseProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): LendBorrowResponse + { + $user = $this->security->getUser(); + assert($user instanceof ApiUser); + + $playerId = $user->getPlayer()->id->toString(); + $items = $this->itemsFactory->lendBorrow($playerId); + + return new LendBorrowResponse( + player_id: $playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/MyLibraryResponseProvider.php b/src/Api/V1/MyLibraryResponseProvider.php new file mode 100644 index 00000000..189020d4 --- /dev/null +++ b/src/Api/V1/MyLibraryResponseProvider.php @@ -0,0 +1,35 @@ + + */ +final readonly class MyLibraryResponseProvider implements ProviderInterface +{ + public function __construct( + private ApiTokenOwner $tokenOwner, + private PuzzleLibrarySummaryFactory $summaryFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): LibraryResponse + { + // access_control admits only tokens with a player behind them (PAT, auth-code), + // so the owner is always there; the profile is loaded once and memoised. + $profile = $this->tokenOwner->profile() ?? throw new PlayerNotFound(); + + return $this->summaryFactory->summary($profile); + } +} diff --git a/src/Api/V1/MySellSwapResponseProvider.php b/src/Api/V1/MySellSwapResponseProvider.php new file mode 100644 index 00000000..e376fcc6 --- /dev/null +++ b/src/Api/V1/MySellSwapResponseProvider.php @@ -0,0 +1,41 @@ + + */ +final readonly class MySellSwapResponseProvider implements ProviderInterface +{ + public function __construct( + private ApiTokenOwner $tokenOwner, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): SellSwapResponse + { + // access_control admits only tokens with a player behind them (PAT, auth-code), + // so the owner is always there; the profile (it carries the list-wide + // currency) is loaded once and memoised - the insights batch reuses it. + $profile = $this->tokenOwner->profile() ?? throw new PlayerNotFound(); + + $items = $this->itemsFactory->sellSwap($profile); + + return new SellSwapResponse( + player_id: $profile->playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/MyUnsolvedPuzzlesResponseProvider.php b/src/Api/V1/MyUnsolvedPuzzlesResponseProvider.php new file mode 100644 index 00000000..425cafde --- /dev/null +++ b/src/Api/V1/MyUnsolvedPuzzlesResponseProvider.php @@ -0,0 +1,41 @@ + + */ +final readonly class MyUnsolvedPuzzlesResponseProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): UnsolvedPuzzlesResponse + { + $user = $this->security->getUser(); + assert($user instanceof ApiUser); + + $playerId = $user->getPlayer()->id->toString(); + $items = $this->itemsFactory->unsolvedPuzzles($playerId); + + return new UnsolvedPuzzlesResponse( + player_id: $playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/MyWishlistResponseProvider.php b/src/Api/V1/MyWishlistResponseProvider.php new file mode 100644 index 00000000..057db81a --- /dev/null +++ b/src/Api/V1/MyWishlistResponseProvider.php @@ -0,0 +1,40 @@ + + */ +final readonly class MyWishlistResponseProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): WishlistResponse + { + $user = $this->security->getUser(); + assert($user instanceof ApiUser); + + $playerId = $user->getPlayer()->id->toString(); + $items = $this->itemsFactory->wishlist($playerId); + + return new WishlistResponse( + player_id: $playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/PlayerLendBorrowResponseProvider.php b/src/Api/V1/PlayerLendBorrowResponseProvider.php new file mode 100644 index 00000000..72e4b9db --- /dev/null +++ b/src/Api/V1/PlayerLendBorrowResponseProvider.php @@ -0,0 +1,49 @@ + + */ +final readonly class PlayerLendBorrowResponseProvider implements ProviderInterface +{ + public function __construct( + private GetPlayerProfile $getPlayerProfile, + private PuzzleLibraryVisibility $visibility, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): LendBorrowResponse + { + /** @var string $playerId */ + $playerId = $uriVariables['playerId']; + + // validates the id, 404 for an unknown player + $profile = $this->getPlayerProfile->byId($playerId); + + $items = $this->visibility->isVisibleToTokenOwner($profile, $profile->lendBorrowListVisibility) + ? $this->itemsFactory->lendBorrow($playerId) + : []; + + return new LendBorrowResponse( + player_id: $playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/PlayerLibraryResponseProvider.php b/src/Api/V1/PlayerLibraryResponseProvider.php new file mode 100644 index 00000000..6d58524d --- /dev/null +++ b/src/Api/V1/PlayerLibraryResponseProvider.php @@ -0,0 +1,40 @@ + + */ +final readonly class PlayerLibraryResponseProvider implements ProviderInterface +{ + public function __construct( + private GetPlayerProfile $getPlayerProfile, + private PuzzleLibrarySummaryFactory $summaryFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): LibraryResponse + { + /** @var string $playerId */ + $playerId = $uriVariables['playerId']; + + // validates the id, 404 for an unknown player + $profile = $this->getPlayerProfile->byId($playerId); + + return $this->summaryFactory->summary($profile); + } +} diff --git a/src/Api/V1/PlayerSellSwapResponseProvider.php b/src/Api/V1/PlayerSellSwapResponseProvider.php new file mode 100644 index 00000000..b47ccfd0 --- /dev/null +++ b/src/Api/V1/PlayerSellSwapResponseProvider.php @@ -0,0 +1,50 @@ + + */ +final readonly class PlayerSellSwapResponseProvider implements ProviderInterface +{ + public function __construct( + private GetPlayerProfile $getPlayerProfile, + private PuzzleLibraryVisibility $visibility, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): SellSwapResponse + { + /** @var string $playerId */ + $playerId = $uriVariables['playerId']; + + // validates the id, 404 for an unknown player + $profile = $this->getPlayerProfile->byId($playerId); + + $items = $this->visibility->isVisibleToTokenOwner($profile, CollectionVisibility::Public) + ? $this->itemsFactory->sellSwap($profile) + : []; + + return new SellSwapResponse( + player_id: $playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/PlayerUnsolvedPuzzlesResponseProvider.php b/src/Api/V1/PlayerUnsolvedPuzzlesResponseProvider.php new file mode 100644 index 00000000..5eaba36b --- /dev/null +++ b/src/Api/V1/PlayerUnsolvedPuzzlesResponseProvider.php @@ -0,0 +1,49 @@ + + */ +final readonly class PlayerUnsolvedPuzzlesResponseProvider implements ProviderInterface +{ + public function __construct( + private GetPlayerProfile $getPlayerProfile, + private PuzzleLibraryVisibility $visibility, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): UnsolvedPuzzlesResponse + { + /** @var string $playerId */ + $playerId = $uriVariables['playerId']; + + // validates the id, 404 for an unknown player + $profile = $this->getPlayerProfile->byId($playerId); + + $items = $this->visibility->isVisibleToTokenOwner($profile, $profile->unsolvedPuzzlesVisibility) + ? $this->itemsFactory->unsolvedPuzzles($playerId) + : []; + + return new UnsolvedPuzzlesResponse( + player_id: $playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/PlayerWishlistResponseProvider.php b/src/Api/V1/PlayerWishlistResponseProvider.php new file mode 100644 index 00000000..f0be9a87 --- /dev/null +++ b/src/Api/V1/PlayerWishlistResponseProvider.php @@ -0,0 +1,49 @@ + + */ +final readonly class PlayerWishlistResponseProvider implements ProviderInterface +{ + public function __construct( + private GetPlayerProfile $getPlayerProfile, + private PuzzleLibraryVisibility $visibility, + private PuzzleLibraryItemsFactory $itemsFactory, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): WishlistResponse + { + /** @var string $playerId */ + $playerId = $uriVariables['playerId']; + + // validates the id, 404 for an unknown player + $profile = $this->getPlayerProfile->byId($playerId); + + $items = $this->visibility->isVisibleToTokenOwner($profile, $profile->wishListVisibility) + ? $this->itemsFactory->wishlist($playerId) + : []; + + return new WishlistResponse( + player_id: $playerId, + count: count($items), + items: $items, + ); + } +} diff --git a/src/Api/V1/SellSwapItemResponse.php b/src/Api/V1/SellSwapItemResponse.php new file mode 100644 index 00000000..d0a7edf3 --- /dev/null +++ b/src/Api/V1/SellSwapItemResponse.php @@ -0,0 +1,40 @@ + */ + public array $items; + + /** + * @param array $items + */ + public function __construct( + public string $player_id, + public int $count, + array $items, + ) { + $this->items = $items; + } +} diff --git a/src/Api/V1/UnsolvedPuzzleResponse.php b/src/Api/V1/UnsolvedPuzzleResponse.php new file mode 100644 index 00000000..5d5111b8 --- /dev/null +++ b/src/Api/V1/UnsolvedPuzzleResponse.php @@ -0,0 +1,30 @@ + */ + public array $items; + + /** + * @param array $items + */ + public function __construct( + public string $player_id, + public int $count, + array $items, + ) { + $this->items = $items; + } +} diff --git a/src/Api/V1/WishlistItemResponse.php b/src/Api/V1/WishlistItemResponse.php new file mode 100644 index 00000000..d3aebed1 --- /dev/null +++ b/src/Api/V1/WishlistItemResponse.php @@ -0,0 +1,33 @@ + */ + public array $items; + + /** + * @param array $items + */ + public function __construct( + public string $player_id, + public int $count, + array $items, + ) { + $this->items = $items; + } +} diff --git a/src/Services/Api/PuzzleLibraryItemsFactory.php b/src/Services/Api/PuzzleLibraryItemsFactory.php new file mode 100644 index 00000000..8ed6b77c --- /dev/null +++ b/src/Services/Api/PuzzleLibraryItemsFactory.php @@ -0,0 +1,241 @@ + newest first + */ + public function wishlist(string $ownerPlayerId): array + { + $items = $this->getWishListItems->byPlayerId($ownerPlayerId); + + $insights = $this->insightsFor( + array_map(static fn (WishListItemOverview $item): string => $item->puzzleId, $items), + $ownerPlayerId, + ); + + return array_values(array_map( + static fn (WishListItemOverview $item): WishlistItemResponse => new WishlistItemResponse( + wishlist_item_id: $item->wishListItemId, + puzzle_id: $item->puzzleId, + puzzle_name: $item->puzzleName, + manufacturer_name: $item->manufacturerName, + pieces_count: $item->piecesCount, + image: $item->image, + added_at: $item->addedAt->format('c'), + statistics: $insights->statistics($item->puzzleId), + difficulty: $insights->difficulty($item->puzzleId), + prediction: $insights->prediction($item->puzzleId), + solves: $insights->solves($item->puzzleId), + ), + $items, + )); + } + + /** + * Borrowed unsolved puzzles first, then the unsolved puzzles of the + * player's collections - the order of the website's page. + * + * @return list + */ + public function unsolvedPuzzles(string $ownerPlayerId): array + { + $items = [ + ...$this->getBorrowedPuzzles->unsolvedByHolderId($ownerPlayerId), + ...$this->getUnsolvedPuzzles->byPlayerId($ownerPlayerId), + ]; + + $insights = $this->insightsFor( + array_map(static fn (UnsolvedPuzzleItem $item): string => $item->puzzleId, $items), + $ownerPlayerId, + ); + + return array_values(array_map( + static fn (UnsolvedPuzzleItem $item): UnsolvedPuzzleResponse => new UnsolvedPuzzleResponse( + puzzle_id: $item->puzzleId, + puzzle_name: $item->puzzleName, + manufacturer_name: $item->manufacturerName, + pieces_count: $item->piecesCount, + image: $item->image, + added_at: $item->addedAt->format('c'), + is_borrowed: $item->isBorrowed, + statistics: $insights->statistics($item->puzzleId), + difficulty: $insights->difficulty($item->puzzleId), + prediction: $insights->prediction($item->puzzleId), + solves: $insights->solves($item->puzzleId), + ), + $items, + )); + } + + /** + * The puzzles the player lent out, then the ones they are borrowing - the + * two tabs of the website's page, each newest first. + * + * @return list + */ + public function lendBorrow(string $ownerPlayerId): array + { + $lent = $this->getLentPuzzles->byOwnerId($ownerPlayerId); + $borrowed = $this->getBorrowedPuzzles->byHolderId($ownerPlayerId); + + $insights = $this->insightsFor( + [ + ...array_map(static fn (LentPuzzleOverview $item): string => $item->puzzleId, $lent), + ...array_map(static fn (BorrowedPuzzleOverview $item): string => $item->puzzleId, $borrowed), + ], + $ownerPlayerId, + ); + + $items = []; + + foreach ($lent as $item) { + $items[] = new LentPuzzleResponse( + lent_puzzle_id: $item->lentPuzzleId, + direction: LentPuzzleResponse::DIRECTION_LENT, + puzzle_id: $item->puzzleId, + puzzle_name: $item->puzzleName, + manufacturer_name: $item->manufacturerName, + pieces_count: $item->piecesCount, + image: $item->image, + counterparty: new LentPuzzleCounterpartyResponse( + player_id: $item->currentHolderId, + name: $item->currentHolderName, + ), + lent_at: $item->lentAt->format('c'), + notes: $item->notes, + statistics: $insights->statistics($item->puzzleId), + difficulty: $insights->difficulty($item->puzzleId), + prediction: $insights->prediction($item->puzzleId), + solves: $insights->solves($item->puzzleId), + ); + } + + foreach ($borrowed as $item) { + $items[] = new LentPuzzleResponse( + lent_puzzle_id: $item->lentPuzzleId, + direction: LentPuzzleResponse::DIRECTION_BORROWED, + puzzle_id: $item->puzzleId, + puzzle_name: $item->puzzleName, + manufacturer_name: $item->manufacturerName, + pieces_count: $item->piecesCount, + image: $item->image, + counterparty: new LentPuzzleCounterpartyResponse( + player_id: $item->ownerId, + name: $item->ownerName, + ), + lent_at: $item->lentAt->format('c'), + notes: $item->notes, + statistics: $insights->statistics($item->puzzleId), + difficulty: $insights->difficulty($item->puzzleId), + prediction: $insights->prediction($item->puzzleId), + solves: $insights->solves($item->puzzleId), + ); + } + + return $items; + } + + /** + * @return list newest first + */ + public function sellSwap(PlayerProfile $owner): array + { + $items = $this->getSellSwapListItems->byPlayerId($owner->playerId); + + $insights = $this->insightsFor( + array_map(static fn (SellSwapListItemOverview $item): string => $item->puzzleId, $items), + $owner->playerId, + ); + + // The seller's list-wide currency, as the website prints it next to every + // price: the custom one, or the chosen ISO code unless that says "custom" + $settings = $owner->sellSwapListSettings; + $currency = $settings === null + ? null + : ($settings->customCurrency ?? ($settings->currency !== 'custom' ? $settings->currency : null)); + + return array_values(array_map( + static fn (SellSwapListItemOverview $item): SellSwapItemResponse => new SellSwapItemResponse( + item_id: $item->sellSwapListItemId, + puzzle_id: $item->puzzleId, + puzzle_name: $item->puzzleName, + manufacturer_name: $item->manufacturerName, + pieces_count: $item->piecesCount, + image: $item->image, + listing_type: $item->listingType->value, + price: $item->price, + currency: $item->price !== null ? $currency : null, + condition: $item->condition->value, + comment: $item->comment, + is_reserved: $item->reserved, + is_published_on_marketplace: $item->publishedOnMarketplace, + added_at: $item->addedAt->format('c'), + statistics: $insights->statistics($item->puzzleId), + difficulty: $insights->difficulty($item->puzzleId), + prediction: $insights->prediction($item->puzzleId), + solves: $insights->solves($item->puzzleId), + ), + $items, + )); + } + + /** + * One batch per list, whatever its size (an empty list costs nothing). + * + * @param array $puzzleIds + */ + private function insightsFor(array $puzzleIds, string $ownerPlayerId): PuzzleInsightsBatch + { + return $this->puzzleResponseFactory->insightsFor( + $puzzleIds, + solvesOfPlayerId: $ownerPlayerId, + includePrediction: true, + ); + } +} diff --git a/src/Services/Api/PuzzleLibrarySummaryFactory.php b/src/Services/Api/PuzzleLibrarySummaryFactory.php new file mode 100644 index 00000000..c822d67d --- /dev/null +++ b/src/Services/Api/PuzzleLibrarySummaryFactory.php @@ -0,0 +1,130 @@ +playerId; + + $lentCount = 0; + $borrowedCount = 0; + + if ($this->visibility->isVisibleToTokenOwner($owner, $owner->lendBorrowListVisibility)) { + $lentCount = $this->getLentPuzzles->countByOwnerId($playerId); + $borrowedCount = $this->getBorrowedPuzzles->countByHolderId($playerId); + } + + return new LibraryResponse( + player_id: $playerId, + collections: $this->collections($owner), + unsolved: new LibrarySectionResponse( + count: $this->visibility->isVisibleToTokenOwner($owner, $owner->unsolvedPuzzlesVisibility) + ? $this->getUnsolvedPuzzles->countByPlayerId($playerId) + $this->getBorrowedPuzzles->countUnsolvedByHolderId($playerId) + : 0, + visibility: $this->visibility->reportedVisibility($owner, $owner->unsolvedPuzzlesVisibility), + ), + wishlist: new LibrarySectionResponse( + count: $this->visibility->isVisibleToTokenOwner($owner, $owner->wishListVisibility) + ? $this->getWishListItems->countByPlayerId($playerId) + : 0, + visibility: $this->visibility->reportedVisibility($owner, $owner->wishListVisibility), + ), + lend_borrow: new LibraryLendBorrowSectionResponse( + lent_count: $lentCount, + borrowed_count: $borrowedCount, + visibility: $this->visibility->reportedVisibility($owner, $owner->lendBorrowListVisibility), + ), + // always public on the website - only a private profile hides it + sell_swap: new LibrarySellSwapSectionResponse( + count: $this->visibility->isVisibleToTokenOwner($owner, CollectionVisibility::Public) + ? $this->getSellSwapListItems->countByPlayerId($playerId) + : 0, + ), + solved: new LibrarySectionResponse( + count: $this->visibility->isVisibleToTokenOwner($owner, $owner->solvedPuzzlesVisibility) + ? $this->getPlayerSolvedPuzzles->countByPlayerId($playerId) + : 0, + visibility: $this->visibility->reportedVisibility($owner, $owner->solvedPuzzlesVisibility), + ), + ); + } + + /** + * @return list + */ + private function collections(PlayerProfile $owner): array + { + // a private profile shows no collections at all to anybody but the owner + if ($this->visibility->isVisibleToTokenOwner($owner, CollectionVisibility::Public) === false) { + return []; + } + + $isOwner = $this->visibility->isOwnedByTokenOwner($owner); + $collections = []; + + if ($this->visibility->isVisibleToTokenOwner($owner, $owner->puzzleCollectionVisibility)) { + $collections[] = new LibraryCollectionResponse( + collection_id: 'default', + name: self::SYSTEM_COLLECTION_NAME, + description: null, + visibility: $owner->puzzleCollectionVisibility->value, + item_count: $this->getPlayerCollectionsWithCounts->countSystemCollection($owner->playerId), + ); + } + + foreach ($this->getPlayerCollectionsWithCounts->byPlayerId($owner->playerId, includePrivate: $isOwner) as $collection) { + $collections[] = new LibraryCollectionResponse( + collection_id: $collection->collectionId ?? 'default', + name: $collection->name, + description: $collection->description, + visibility: $collection->visibility->value, + item_count: $collection->itemCount, + ); + } + + return $collections; + } +} diff --git a/src/Services/Api/PuzzleLibraryVisibility.php b/src/Services/Api/PuzzleLibraryVisibility.php new file mode 100644 index 00000000..e283bd12 --- /dev/null +++ b/src/Services/Api/PuzzleLibraryVisibility.php @@ -0,0 +1,62 @@ +tokenOwner->profile()?->playerId === $owner->playerId; + } + + public function isVisibleToTokenOwner(PlayerProfile $owner, CollectionVisibility $sectionVisibility): bool + { + if ($this->isOwnedByTokenOwner($owner)) { + return true; + } + + if ($owner->isPrivate) { + return false; + } + + return $sectionVisibility === CollectionVisibility::Public; + } + + /** + * The visibility the API reports for a section: the owner's setting, except + * that a private profile is "private" throughout for everybody but the owner + * - the setting would otherwise promise a public list the token cannot see. + */ + public function reportedVisibility(PlayerProfile $owner, CollectionVisibility $sectionVisibility): string + { + if ($owner->isPrivate && $this->isOwnedByTokenOwner($owner) === false) { + return CollectionVisibility::Private->value; + } + + return $sectionVisibility->value; + } +} diff --git a/templates/for-developers.html.twig b/templates/for-developers.html.twig index 162c3f80..96e408f3 100644 --- a/templates/for-developers.html.twig +++ b/templates/for-developers.html.twig @@ -351,6 +351,67 @@ HTTP/1.1 200 OK + {{ 'for_developers.table_section_library'|trans }} + + GET /api/v1/me/library + + + — + + + GET /api/v1/me/wishlist + + + — + + + GET /api/v1/me/unsolved-puzzles + + + — + + + GET /api/v1/me/lend-borrow + + + — + + + GET /api/v1/me/sell-swap + + + — + + + GET /api/v1/players/{id}/library + — + + + + + GET /api/v1/players/{id}/wishlist + — + + + + + GET /api/v1/players/{id}/unsolved-puzzles + — + + + + + GET /api/v1/players/{id}/lend-borrow + — + + + + + GET /api/v1/players/{id}/sell-swap + — + + + {{ 'for_developers.table_section_puzzles'|trans }} GET /api/v1/puzzles diff --git a/tests/Controller/Api/V1/LendBorrowEndpointTest.php b/tests/Controller/Api/V1/LendBorrowEndpointTest.php new file mode 100644 index 00000000..b120001f --- /dev/null +++ b/tests/Controller/Api/V1/LendBorrowEndpointTest.php @@ -0,0 +1,389 @@ + */ + private const array ITEM_KEYS = ['lent_puzzle_id', 'direction', 'puzzle_id', 'puzzle_name', 'manufacturer_name', 'pieces_count', 'image', 'counterparty', 'lent_at', 'notes', ...self::INSIGHT_KEYS]; + + public function testAuthentication(): void + { + $browser = self::createClient(); + + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['results:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath('undefined')); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testOwnListIsLentThenBorrowedWithTheCounterpartyTheWebsiteShows(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6); + + $this->assertSame( + [LentPuzzleFixture::LENT_04, LentPuzzleFixture::LENT_02, LentPuzzleFixture::LENT_01, LentPuzzleFixture::LENT_03, LentPuzzleFixture::LENT_06, LentPuzzleFixture::LENT_05], + $this->column($items, 'lent_puzzle_id'), + ); + $this->assertSame(['lent', 'lent', 'lent', 'lent', 'borrowed', 'borrowed'], $this->column($items, 'direction')); + + foreach ($items as $item) { + $this->assertSame(self::ITEM_KEYS, array_keys($item)); + $this->assertSame(['player_id', 'name'], $this->keys($item['counterparty'])); + $this->assertIsString($item['lent_at']); + $this->assertNotFalse(DateTimeImmutable::createFromFormat(DATE_ATOM, $item['lent_at'])); + } + + // lent to a registered player: id + name; notes as on the list + $this->assertSame(PuzzleFixture::PUZZLE_500_03, $items[0]['puzzle_id']); + $this->assertSame('Puzzle 3', $items[0]['puzzle_name']); + $this->assertSame('Ravensburger', $items[0]['manufacturer_name']); + $this->assertSame(500, $items[0]['pieces_count']); + $this->assertNull($items[0]['image']); + $this->assertSame(['player_id' => PlayerFixture::PLAYER_WITH_FAVORITES, 'name' => 'Michael Johnson'], $items[0]['counterparty']); + $this->assertSame('For testing purposes', $items[0]['notes']); + + // lent to somebody entered by name only + $this->assertSame(['player_id' => null, 'name' => 'Jane Doe'], $items[1]['counterparty']); + $this->assertNull($items[1]['notes']); + + // returned: nobody holds it (the website lists it with an empty holder) + $this->assertSame(['player_id' => null, 'name' => ''], $items[3]['counterparty']); + $this->assertSame('Returned in good condition', $items[3]['notes']); + + // borrowed from a registered player + $this->assertSame(PuzzleFixture::PUZZLE_3000, $items[4]['puzzle_id']); + $this->assertSame(['player_id' => PlayerFixture::PLAYER_REGULAR, 'name' => 'John Doe'], $items[4]['counterparty']); + + // the non-member sees their own list too + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_REGULAR, 5); + $this->assertSame(['lent', 'lent', 'lent', 'lent', 'borrowed'], $this->column($items, 'direction')); + $this->assertSame(LentPuzzleFixture::LENT_01, $items[4]['lent_puzzle_id']); + $this->assertSame(['player_id' => PlayerFixture::PLAYER_WITH_STRIPE, 'name' => 'Sarah Williams'], $items[4]['counterparty']); + } + + public function testInsightGatesOnTheOwnList(): void + { + $browser = self::createClient(); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_2000, 1.18, MetricConfidence::Medium); + + // non-member PAT: PLAYER_REGULAR borrowed PUZZLE_2000 and solved it + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_REGULAR, 5), PuzzleFixture::PUZZLE_2000); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + $this->assertGreaterThan(0, $this->solvesOf($item)['solo']['count']); + + // member PAT: PLAYER_WITH_STRIPE lent PUZZLE_2000 out and solved it too + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $browser->request('GET', $this->myPath()); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6); + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_2000); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame('challenging', $this->difficultyOf($item)['level']); + $this->assertTrue($this->predictionOf($item)['is_personalized']); + $this->assertGreaterThan(0, $this->solvesOf($item)['solo']['count']); + // the borrowed, unsolved PUZZLE_3000: statistical prediction, zero solves, synthesised difficulty + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_3000); + $this->assertSame('insufficient', $this->difficultyOf($item)['confidence']); + $this->assertFalse($this->predictionOf($item)['is_personalized']); + $this->assertSame(0, $this->solvesOf($item)['solo']['count']); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6), PuzzleFixture::PUZZLE_2000); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: false); + + $this->optOutOfTimePredictions($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6), PuzzleFixture::PUZZLE_2000); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: true); + } + + public function testAnotherPlayersListFollowsItsVisibility(): void + { + $browser = self::createClient(); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 0)); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 0); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6); + + $this->setLendBorrowListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6); + $this->assertSame(self::ITEM_KEYS, array_keys($items[0])); + // the counterparty is what the website prints on the public list - id and display name, nothing more + $this->assertSame(['player_id' => PlayerFixture::PLAYER_WITH_FAVORITES, 'name' => 'Michael Johnson'], $items[0]['counterparty']); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6); + } + + public function testInsightsOnAnotherPlayersList(): void + { + $browser = self::createClient(); + $this->setLendBorrowListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_1000_01, 1.18, MetricConfidence::Medium); + + // PLAYER_ADMIN (member) solved PUZZLE_1000_01 (LENT_03) - personal prediction, the owner's solves + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6), PuzzleFixture::PUZZLE_1000_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertTrue($this->predictionOf($item)['is_personalized']); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6), PuzzleFixture::PUZZLE_1000_01); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: false); + + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6), PuzzleFixture::PUZZLE_1000_01); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + } + + public function testPrivateProfileIsZeroedWithoutBatchQueries(): void + { + $browser = self::createClient(); + $this->setLendBorrowListVisibility($browser, PlayerFixture::PLAYER_PRIVATE, CollectionVisibility::Public); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_PRIVATE, 0)); + $this->assertQueryCountAtMost($browser, 5, 'private profile short-circuit'); + } + + public function testEmbargoedImageIsNull(): void + { + $browser = self::createClient(); + // one lent, one borrowed - both queries apply the embargo + $this->setImage($browser, PuzzleFixture::PUZZLE_500_03, 'puzzles/test/lent.jpg', hideUntil: new DateTimeImmutable('+30 days')); + $this->setImage($browser, PuzzleFixture::PUZZLE_3000, 'puzzles/test/borrowed.jpg', hideUntil: new DateTimeImmutable('+30 days')); + $this->setImage($browser, PuzzleFixture::PUZZLE_2000, 'puzzles/test/visible.jpg', hideUntil: null); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 6); + $this->assertNull($this->itemOf($items, PuzzleFixture::PUZZLE_500_03)['image']); + $this->assertNull($this->itemOf($items, PuzzleFixture::PUZZLE_3000)['image']); + $this->assertSame('puzzles/test/visible.jpg', $this->itemOf($items, PuzzleFixture::PUZZLE_2000)['image']); + } + + /** + * Measured (2026-08-19): authentication 1 (PAT) / 3 (OAuth2) / 1-2 + * (client_credentials), the two item queries (lent, borrowed), statistics + * 1, the token owner's profile 1, then per entitlement solves 1, difficulty + * 1 and predictions <= 4; /players adds the listed profile 1. + */ + public function testQueryBudgets(): void + { + $browser = self::createClient(); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 6, 'non-member PAT'); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 11, 'member PAT'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 13, 'member authorization-code token'); + + $this->setLendBorrowListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 7, 'client_credentials token on /players'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 14, 'member authorization-code token on /players'); + } + + /** + * A member pays the same number of queries for two entries as for thirteen. + * PLAYER_ADMIN borrows one puzzle in the fixtures (PUZZLE_500_05, which they + * solved); a lent-out PUZZLE_9000 (never solved) is added first so that both + * sizes take the complete prediction path - GetPlayerPredictions runs its + * statistical query only for puzzles the member has not solved - and the + * comparison is about the size only; then eleven more. + */ + public function testQueryCountDoesNotGrowWithTheListSize(): void + { + $browser = self::createClient(); + $this->seedLentPuzzles($browser, PlayerFixture::PLAYER_ADMIN, [PuzzleFixture::PUZZLE_9000]); + $this->authenticatePat($browser, PlayerFixture::PLAYER_ADMIN); + + // warm-up (see WishlistEndpointTest) + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertSame([PuzzleFixture::PUZZLE_9000, PuzzleFixture::PUZZLE_500_05], $this->column($this->items($browser, PlayerFixture::PLAYER_ADMIN, 2), 'puzzle_id')); + $atTwo = $this->queryCount($browser); + + $this->seedLentPuzzles($browser, PlayerFixture::PLAYER_ADMIN, [ + PuzzleFixture::PUZZLE_500_01, + PuzzleFixture::PUZZLE_500_02, + PuzzleFixture::PUZZLE_500_03, + PuzzleFixture::PUZZLE_1000_01, + PuzzleFixture::PUZZLE_1000_02, + PuzzleFixture::PUZZLE_1500_01, + PuzzleFixture::PUZZLE_2000, + PuzzleFixture::PUZZLE_3000, + PuzzleFixture::PUZZLE_4000, + PuzzleFixture::PUZZLE_5000, + PuzzleFixture::PUZZLE_6000, + ]); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_ADMIN, 13); + $this->assertSame($atTwo, $this->queryCount($browser), 'The same number of queries for 13 entries as for 2'); + } + + public function testOpenApiDocumentsBothPaths(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $this->assertOpenApiDocumentsPaths($browser, ['/api/v1/me/lend-borrow', '/api/v1/players/{playerId}/lend-borrow']); + } + + private function myPath(): string + { + return '/api/v1/me/lend-borrow'; + } + + private function playerPath(string $playerId): string + { + return '/api/v1/players/' . $playerId . '/lend-borrow'; + } + + /** + * The player lends the given puzzles to somebody entered by name. + * + * @param list $puzzleIds + */ + private function seedLentPuzzles(KernelBrowser $browser, string $ownerId, array $puzzleIds): void + { + $entityManager = $this->entityManager($browser); + + $owner = $entityManager->find(Player::class, $ownerId); + $this->assertNotNull($owner); + + foreach ($puzzleIds as $puzzleId) { + $puzzle = $entityManager->find(Puzzle::class, $puzzleId); + $this->assertNotNull($puzzle); + + $entityManager->persist(new LentPuzzle( + id: Uuid::uuid7(), + puzzle: $puzzle, + ownerPlayer: $owner, + ownerName: null, + currentHolderPlayer: null, + currentHolderName: 'Budget test', + lentAt: new DateTimeImmutable(), + )); + } + + $entityManager->flush(); + $entityManager->clear(); + } +} diff --git a/tests/Controller/Api/V1/LibraryEndpointTest.php b/tests/Controller/Api/V1/LibraryEndpointTest.php new file mode 100644 index 00000000..f1574672 --- /dev/null +++ b/tests/Controller/Api/V1/LibraryEndpointTest.php @@ -0,0 +1,314 @@ +, + * unsolved: Section, + * wishlist: Section, + * lend_borrow: array{lent_count: int, borrowed_count: int, visibility: string}, + * sell_swap: array{count: int}, + * solved: Section, + * } + */ +final class LibraryEndpointTest extends WebTestCase +{ + use PuzzleLibraryEndpointTestHelpers; + use QueryCountAssertions; + + public function testAuthentication(): void + { + $browser = self::createClient(); + + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['profile:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath('not-a-uuid')); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testOwnLibraryIsCompleteWhateverTheVisibilitySettings(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $raw = $this->decodeJson($browser); + $this->assertEqualsCanonicalizing(['player_id', 'collections', 'unsolved', 'wishlist', 'lend_borrow', 'sell_swap', 'solved'], array_keys($raw)); + + $library = $this->library($browser); + $this->assertSame(PlayerFixture::PLAYER_WITH_STRIPE, $library['player_id']); + + // the system collection first, then the custom ones newest first, each with its count + $this->assertSame( + [ + ['collection_id' => 'default', 'name' => 'Default Collection', 'description' => null, 'visibility' => 'public', 'item_count' => 5], + ['collection_id' => CollectionFixture::COLLECTION_STRIPE_TREFL, 'name' => 'My Trefl Collection', 'description' => 'All my Trefl puzzles', 'visibility' => 'public', 'item_count' => 3], + ['collection_id' => CollectionFixture::COLLECTION_PUBLIC, 'name' => 'My Ravensburger Collection', 'description' => 'All my favorite Ravensburger puzzles', 'visibility' => 'public', 'item_count' => 8], + ], + $library['collections'], + ); + $this->assertSame(['count' => 7, 'visibility' => 'private'], $library['unsolved']); + $this->assertSame(['count' => 3, 'visibility' => 'private'], $library['wishlist']); + $this->assertSame(['lent_count' => 4, 'borrowed_count' => 2, 'visibility' => 'private'], $library['lend_borrow']); + $this->assertSame(['count' => 7], $library['sell_swap']); + $this->assertSame(['count' => 10, 'visibility' => 'private'], $library['solved']); + + // a non-member with everything private sees their own complete library too + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $library = $this->library($browser); + $this->assertSame(PlayerFixture::PLAYER_REGULAR, $library['player_id']); + $this->assertSame( + [ + ['collection_id' => 'default', 'name' => 'Default Collection', 'description' => null, 'visibility' => 'private', 'item_count' => 3], + ['collection_id' => CollectionFixture::COLLECTION_FAVORITES, 'name' => 'Completed Favorites', 'description' => null, 'visibility' => 'private', 'item_count' => 2], + ['collection_id' => CollectionFixture::COLLECTION_PRIVATE, 'name' => 'Wishlist', 'description' => 'Puzzles I want to buy', 'visibility' => 'private', 'item_count' => 4], + ], + $library['collections'], + ); + $this->assertSame(['count' => 2, 'visibility' => 'private'], $library['unsolved']); + $this->assertSame(['count' => 5, 'visibility' => 'private'], $library['wishlist']); + $this->assertSame(['lent_count' => 4, 'borrowed_count' => 1, 'visibility' => 'private'], $library['lend_borrow']); + $this->assertSame(['count' => 0], $library['sell_swap']); + $this->assertSame(['count' => 11, 'visibility' => 'private'], $library['solved']); + + // the visibility the owner set is reported as is + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_REGULAR, CollectionVisibility::Public); + $browser->request('GET', $this->myPath()); + $this->assertSame(['count' => 5, 'visibility' => 'public'], $this->library($browser)['wishlist']); + } + + /** + * Another player's library as the website shows it to a visitor: public + * sections with their counts, private ones as count 0 + "private"; public + * collections only. + */ + public function testAnotherPlayersLibraryShowsThePublicSectionsOnly(): void + { + $browser = self::createClient(); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $library = $this->library($browser); + $this->assertSame(PlayerFixture::PLAYER_WITH_STRIPE, $library['player_id']); + // the system collection is public, both custom collections are public + $this->assertSame(['default', CollectionFixture::COLLECTION_STRIPE_TREFL, CollectionFixture::COLLECTION_PUBLIC], array_column($library['collections'], 'collection_id')); + $this->assertSame([5, 3, 8], array_column($library['collections'], 'item_count')); + $this->assertSame(['count' => 0, 'visibility' => 'private'], $library['unsolved']); + $this->assertSame(['count' => 0, 'visibility' => 'private'], $library['wishlist']); + $this->assertSame(['lent_count' => 0, 'borrowed_count' => 0, 'visibility' => 'private'], $library['lend_borrow']); + // always public on the website + $this->assertSame(['count' => 7], $library['sell_swap']); + $this->assertSame(['count' => 0, 'visibility' => 'private'], $library['solved']); + + // sections the owner opens up are counted + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->setUnsolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->setLendBorrowListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->setSolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $library = $this->library($browser); + $this->assertSame(['count' => 7, 'visibility' => 'public'], $library['unsolved']); + $this->assertSame(['count' => 3, 'visibility' => 'public'], $library['wishlist']); + $this->assertSame(['lent_count' => 4, 'borrowed_count' => 2, 'visibility' => 'public'], $library['lend_borrow']); + $this->assertSame(['count' => 10, 'visibility' => 'public'], $library['solved']); + + // a machine token is a stranger too + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertSame(['count' => 3, 'visibility' => 'public'], $this->library($browser)['wishlist']); + + // PLAYER_REGULAR keeps everything private: no collections at all (system and custom ones private) + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_REGULAR)); + $library = $this->library($browser); + $this->assertSame([], $library['collections']); + $this->assertSame(['count' => 0, 'visibility' => 'private'], $library['unsolved']); + $this->assertSame(['count' => 0], $library['sell_swap']); + + // ... but a public system collection shows up with its count + $this->setPuzzleCollectionVisibility($browser, PlayerFixture::PLAYER_REGULAR, CollectionVisibility::Public); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_REGULAR)); + $this->assertSame( + [['collection_id' => 'default', 'name' => 'Default Collection', 'description' => null, 'visibility' => 'public', 'item_count' => 3]], + $this->library($browser)['collections'], + ); + + // the player behind the token gets their complete library through /players + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_REGULAR)); + $library = $this->library($browser); + $this->assertCount(3, $library['collections']); + $this->assertSame(['count' => 5, 'visibility' => 'private'], $library['wishlist']); + $this->assertSame(['lent_count' => 4, 'borrowed_count' => 1, 'visibility' => 'private'], $library['lend_borrow']); + } + + /** + * A private profile is zeroed throughout for everybody but the owner, and + * no count query runs; the owner gets the full summary. + */ + public function testPrivateProfileIsZeroedWithoutCountQueries(): void + { + $browser = self::createClient(); + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_PRIVATE, CollectionVisibility::Public); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertResponseIsSuccessful(); + $library = $this->library($browser); + $this->assertSame(PlayerFixture::PLAYER_PRIVATE, $library['player_id']); + $this->assertSame([], $library['collections']); + $this->assertSame(['count' => 0, 'visibility' => 'private'], $library['unsolved']); + // public on the website's settings page, private behind the private profile + $this->assertSame(['count' => 0, 'visibility' => 'private'], $library['wishlist']); + $this->assertSame(['lent_count' => 0, 'borrowed_count' => 0, 'visibility' => 'private'], $library['lend_borrow']); + $this->assertSame(['count' => 0], $library['sell_swap']); + $this->assertSame(['count' => 0, 'visibility' => 'private'], $library['solved']); + // authentication (3) + the listed profile + the token owner's profile + $this->assertQueryCountAtMost($browser, 5, 'private profile short-circuit'); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertSame(['count' => 0, 'visibility' => 'private'], $this->library($browser)['wishlist']); + $this->assertQueryCountAtMost($browser, 2, 'private profile short-circuit, machine token'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_PRIVATE, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $library = $this->library($browser); + $this->assertSame(['count' => 1, 'visibility' => 'public'], $library['wishlist']); + $this->assertSame(['count' => 1, 'visibility' => 'private'], $library['unsolved']); + $this->assertSame('default', $library['collections'][0]['collection_id'] ?? null); + } + + /** + * Measured (2026-08-19): authentication 1 (PAT) / 3 (OAuth2) / 1-2 + * (client_credentials), the owner's profile 1, then one count query per + * visible section: custom collections 1, system collection 1, unsolved 2 + * (collections + borrowed), wishlist 1, lent 1, borrowed 1, sell/swap 1, + * solved 1 - 9 for a complete library; /players adds the listed profile 1 + * (the token owner's profile is the visibility check, not for a machine + * token). A stranger pays only for the public sections. + */ + public function testQueryBudgets(): void + { + $browser = self::createClient(); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 11, 'own library, PAT'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 13, 'own library, authorization-code token'); + + // a stranger: the public system collection, the custom collections and the sell/swap count only + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 8, 'another player, private sections skipped'); + + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->setUnsolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->setLendBorrowListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->setSolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 14, 'another player, everything public, authorization-code token'); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 11, 'another player, everything public, machine token'); + } + + public function testOpenApiDocumentsBothPaths(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $this->assertOpenApiDocumentsPaths($browser, ['/api/v1/me/library', '/api/v1/players/{playerId}/library']); + } + + private function myPath(): string + { + return '/api/v1/me/library'; + } + + private function playerPath(string $playerId): string + { + return '/api/v1/players/' . $playerId . '/library'; + } + + /** + * @return Library + */ + private function library(KernelBrowser $browser): array + { + $this->assertResponseIsSuccessful(); + + /** @var Library $library */ + $library = $this->decodeJson($browser); + + return $library; + } +} diff --git a/tests/Controller/Api/V1/PuzzleLibraryEndpointTestHelpers.php b/tests/Controller/Api/V1/PuzzleLibraryEndpointTestHelpers.php new file mode 100644 index 00000000..32a9e7a1 --- /dev/null +++ b/tests/Controller/Api/V1/PuzzleLibraryEndpointTestHelpers.php @@ -0,0 +1,333 @@ + the four trailing objects of every library item, in this order */ + private const array INSIGHT_KEYS = ['statistics', 'difficulty', 'prediction', 'solves']; + + private function authenticatePat(KernelBrowser $browser, string $playerId): void + { + PatTestHelper::addBearerToken($browser, PatTestHelper::createToken($browser, $playerId)); + } + + /** + * @param array $scopes + */ + private function authenticateOAuth2(KernelBrowser $browser, string $playerId, array $scopes): void + { + $token = OAuth2TestHelper::createAccessToken( + $browser, + OAuth2ClientFixture::CONFIDENTIAL_CLIENT_ID, + $playerId, + $scopes, + ); + + OAuth2TestHelper::addBearerToken($browser, $token); + } + + /** + * @param array $scopes + */ + private function authenticateClientCredentials(KernelBrowser $browser, array $scopes): void + { + // sub = aud = client id is exactly what the client_credentials grant issues + $token = OAuth2TestHelper::createAccessToken( + $browser, + OAuth2ClientFixture::CONFIDENTIAL_CLIENT_ID, + OAuth2ClientFixture::CONFIDENTIAL_CLIENT_ID, + $scopes, + ); + + OAuth2TestHelper::addBearerToken($browser, $token); + } + + private function setWishListVisibility(KernelBrowser $browser, string $playerId, CollectionVisibility $visibility): void + { + $this->changePlayer($browser, $playerId, static fn (Player $player) => $player->changeWishListVisibility($visibility)); + } + + private function setUnsolvedPuzzlesVisibility(KernelBrowser $browser, string $playerId, CollectionVisibility $visibility): void + { + $this->changePlayer($browser, $playerId, static fn (Player $player) => $player->changeUnsolvedPuzzlesVisibility($visibility)); + } + + private function setLendBorrowListVisibility(KernelBrowser $browser, string $playerId, CollectionVisibility $visibility): void + { + $this->changePlayer($browser, $playerId, static fn (Player $player) => $player->changeLendBorrowListVisibility($visibility)); + } + + private function setSolvedPuzzlesVisibility(KernelBrowser $browser, string $playerId, CollectionVisibility $visibility): void + { + $this->changePlayer($browser, $playerId, static fn (Player $player) => $player->changeSolvedPuzzlesVisibility($visibility)); + } + + private function setPuzzleCollectionVisibility(KernelBrowser $browser, string $playerId, CollectionVisibility $visibility): void + { + $this->changePlayer($browser, $playerId, static fn (Player $player) => $player->changePuzzleCollectionVisibility($visibility)); + } + + private function optOutOfTimePredictions(KernelBrowser $browser, string $playerId): void + { + $this->changePlayer($browser, $playerId, static fn (Player $player) => $player->changeTimePredictionsOptedOut(true)); + } + + /** + * @param callable(Player): void $change + */ + private function changePlayer(KernelBrowser $browser, string $playerId, callable $change): void + { + $entityManager = $this->entityManager($browser); + + $player = $entityManager->find(Player::class, $playerId); + $this->assertNotNull($player); + + $change($player); + $entityManager->flush(); + $entityManager->clear(); + } + + private function seedDifficulty(KernelBrowser $browser, string $puzzleId, float $score, MetricConfidence $confidence): void + { + $entityManager = $this->entityManager($browser); + + $puzzle = $entityManager->find(Puzzle::class, $puzzleId); + $this->assertNotNull($puzzle); + + $difficulty = $entityManager->find(PuzzleDifficulty::class, $puzzleId) ?? new PuzzleDifficulty($puzzle); + $difficulty->updateDifficulty($score, $confidence, 20, new DateTimeImmutable()); + + $entityManager->persist($difficulty); + $entityManager->flush(); + $entityManager->clear(); + } + + private function setImage(KernelBrowser $browser, string $puzzleId, string $image, null|DateTimeImmutable $hideUntil): void + { + $entityManager = $this->entityManager($browser); + + $puzzle = $entityManager->find(Puzzle::class, $puzzleId); + $this->assertNotNull($puzzle); + + $puzzle->image = $image; + $puzzle->hideImageUntil = $hideUntil; + $entityManager->flush(); + $entityManager->clear(); + } + + private function entityManager(KernelBrowser $browser): EntityManagerInterface + { + /** @var ContainerInterface $container */ + $container = $browser->getContainer(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get('doctrine.orm.entity_manager'); + + return $entityManager; + } + + /** + * @return array + */ + private function decodeJson(KernelBrowser $browser): array + { + $content = $browser->getResponse()->getContent(); + $this->assertIsString($content); + + /** @var array $decoded */ + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + + /** + * The decoded "items" of a list response, asserting the wrapper shape. + * + * @return list> + */ + private function items(KernelBrowser $browser, string $expectedPlayerId, int $expectedCount): array + { + $response = $this->decodeJson($browser); + // the wrapper of every list endpoint (the serializer emits the declared + // "items" property before the promoted ones - the existing style) + $this->assertEqualsCanonicalizing(['player_id', 'count', 'items'], array_keys($response)); + $this->assertSame($expectedPlayerId, $response['player_id']); + $this->assertSame($expectedCount, $response['count']); + $this->assertIsArray($response['items']); + $this->assertCount($expectedCount, $response['items']); + + /** @var list> $items */ + $items = array_values($response['items']); + + return $items; + } + + /** + * The item of the given puzzle (the first one, where a list may repeat a puzzle). + * + * @param list> $items + * + * @return array + */ + private function itemOf(array $items, string $puzzleId): array + { + foreach ($items as $item) { + if ($item['puzzle_id'] === $puzzleId) { + return $item; + } + } + + $this->fail(sprintf('Puzzle %s is not in the response', $puzzleId)); + } + + /** + * @param list> $items + * + * @return list + */ + private function column(array $items, string $key): array + { + return array_map(static fn (array $item): mixed => $item[$key] ?? null, $items); + } + + /** + * @return list + */ + private function keys(mixed $value): array + { + $this->assertIsArray($value); + + return array_keys($value); + } + + /** + * The four insight objects close every item, in a fixed order; statistics + * is always an object, the other three are objects exactly when the token + * is entitled. + * + * @param array $item + */ + private function assertInsightsGated(array $item, bool $difficulty, bool $prediction, bool $solves): void + { + $this->assertSame(self::INSIGHT_KEYS, array_slice(array_keys($item), -4)); + $this->assertSame(['solved_times', 'solo', 'duo', 'team'], $this->keys($item['statistics'])); + $this->assertSame($difficulty, is_array($item['difficulty'] ?? null), 'difficulty'); + $this->assertSame($prediction, is_array($item['prediction'] ?? null), 'prediction'); + $this->assertSame($solves, is_array($item['solves'] ?? null), 'solves'); + } + + /** + * @param array $item + * + * @return Statistics + */ + private function statisticsOf(array $item): array + { + $this->assertIsArray($item['statistics'] ?? null); + + /** @var Statistics $statistics */ + $statistics = $item['statistics']; + + return $statistics; + } + + /** + * @param array $item + * + * @return Difficulty + */ + private function difficultyOf(array $item): array + { + $this->assertIsArray($item['difficulty'] ?? null, 'difficulty is null'); + + /** @var Difficulty $difficulty */ + $difficulty = $item['difficulty']; + + return $difficulty; + } + + /** + * @param array $item + * + * @return Prediction + */ + private function predictionOf(array $item): array + { + $this->assertIsArray($item['prediction'] ?? null, 'prediction is null'); + + /** @var Prediction $prediction */ + $prediction = $item['prediction']; + + return $prediction; + } + + /** + * @param array $item + * + * @return Solves + */ + private function solvesOf(array $item): array + { + $this->assertIsArray($item['solves'] ?? null, 'solves is null'); + + /** @var Solves $solves */ + $solves = $item['solves']; + + return $solves; + } + + /** + * The OpenAPI document is generated from the attributes; both paths of a + * list pair must be in it, each as a GET with a summary and a description. + * + * @param list $paths + */ + private function assertOpenApiDocumentsPaths(KernelBrowser $browser, array $paths): void + { + $browser->request('GET', '/api/docs.jsonopenapi'); + $this->assertResponseIsSuccessful(); + + $document = $this->decodeJson($browser); + $this->assertIsArray($document['paths'] ?? null); + + foreach ($paths as $path) { + $this->assertArrayHasKey($path, $document['paths'], sprintf('OpenAPI does not document %s', $path)); + /** @var array{get?: array{tags?: list, summary?: string, description?: string}} $pathItem */ + $pathItem = $document['paths'][$path]; + $this->assertArrayHasKey('get', $pathItem); + $this->assertSame(['Puzzle Library'], $pathItem['get']['tags'] ?? null); + $this->assertNotSame('', trim($pathItem['get']['summary'] ?? ''), sprintf('%s has no summary', $path)); + $this->assertNotSame('', trim($pathItem['get']['description'] ?? ''), sprintf('%s has no description', $path)); + } + } +} diff --git a/tests/Controller/Api/V1/SellSwapEndpointTest.php b/tests/Controller/Api/V1/SellSwapEndpointTest.php new file mode 100644 index 00000000..d0212d49 --- /dev/null +++ b/tests/Controller/Api/V1/SellSwapEndpointTest.php @@ -0,0 +1,413 @@ + */ + private const array ITEM_KEYS = ['item_id', 'puzzle_id', 'puzzle_name', 'manufacturer_name', 'pieces_count', 'image', 'listing_type', 'price', 'currency', 'condition', 'comment', 'is_reserved', 'is_published_on_marketplace', 'added_at', ...self::INSIGHT_KEYS]; + + public function testAuthentication(): void + { + $browser = self::createClient(); + + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['profile:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath('00000000-0000-0000-0000-000000000000')); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testOwnListCarriesThePublicOfferFields(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + + $this->assertSame( + [ + SellSwapListItemFixture::SELLSWAP_07, + SellSwapListItemFixture::SELLSWAP_06, + SellSwapListItemFixture::SELLSWAP_05, + SellSwapListItemFixture::SELLSWAP_04, + SellSwapListItemFixture::SELLSWAP_03, + SellSwapListItemFixture::SELLSWAP_02, + SellSwapListItemFixture::SELLSWAP_01, + ], + $this->column($items, 'item_id'), + ); + + foreach ($items as $item) { + $this->assertSame(self::ITEM_KEYS, array_keys($item)); + $this->assertIsString($item['added_at']); + $this->assertNotFalse(DateTimeImmutable::createFromFormat(DATE_ATOM, $item['added_at'])); + $this->assertTrue($item['is_published_on_marketplace']); + } + + // sell: price in the seller's currency + $sell = $this->itemOf($items, PuzzleFixture::PUZZLE_500_01); + $this->assertSame('Puzzle 1', $sell['puzzle_name']); + $this->assertSame('Ravensburger', $sell['manufacturer_name']); + $this->assertSame(500, $sell['pieces_count']); + $this->assertNull($sell['image']); + $this->assertSame('sell', $sell['listing_type']); + $this->assertSame(25.0, $sell['price']); + $this->assertSame('GBP', $sell['currency']); + $this->assertSame('like_new', $sell['condition']); + $this->assertSame('Perfect condition, only solved once', $sell['comment']); + $this->assertFalse($sell['is_reserved']); + + // swap: no price, no currency + $swap = $this->itemOf($items, PuzzleFixture::PUZZLE_500_02); + $this->assertSame('swap', $swap['listing_type']); + $this->assertNull($swap['price']); + $this->assertNull($swap['currency']); + $this->assertSame('normal', $swap['condition']); + $this->assertNull($swap['comment']); + + // both, missing pieces + $both = $this->itemOf($items, PuzzleFixture::PUZZLE_1500_01); + $this->assertSame('both', $both['listing_type']); + $this->assertSame(60.0, $both['price']); + $this->assertSame('missing_pieces', $both['condition']); + + // reserved - for whom is the seller's business and not in the response + $reserved = $this->itemOf($items, PuzzleFixture::PUZZLE_500_03); + $this->assertTrue($reserved['is_reserved']); + $this->assertSame('not_so_good', $reserved['condition']); + $this->assertSame('Some wear on box', $reserved['comment']); + $this->assertArrayNotHasKey('reserved_for', $reserved); + + // custom currency, an offer off the marketplace + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_ADMIN, 6); + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_500_01); + $this->assertSame(SellSwapListItemFixture::SELLSWAP_10, $item['item_id']); + $this->assertSame(22.0, $item['price']); + $this->assertSame('Kč', $item['currency']); + $this->assertFalse($item['is_published_on_marketplace']); + + // an empty list + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_REGULAR, 0)); + } + + public function testInsightGatesOnTheOwnList(): void + { + $browser = self::createClient(); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_500_01, 1.18, MetricConfidence::Medium); + + // member PAT: PLAYER_WITH_STRIPE solved PUZZLE_500_01 once (2100 s) + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame('challenging', $this->difficultyOf($item)['level']); + $this->assertSame(11, $this->statisticsOf($item)['solved_times']); + $this->assertTrue($this->predictionOf($item)['is_personalized']); + $this->assertSame(2100, $this->predictionOf($item)['last_time_seconds']); + $this->assertSame(1, $this->solvesOf($item)['solo']['count']); + // never solved by the seller: zeros, statistical prediction, synthesised difficulty + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_1000_03); + $this->assertSame('insufficient', $this->difficultyOf($item)['confidence']); + $this->assertFalse($this->predictionOf($item)['is_personalized']); + $this->assertSame(0, $this->solvesOf($item)['solo']['count']); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: false); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + + $this->optOutOfTimePredictions($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: true); + } + + /** + * The list is public on the website: everybody sees it - a stranger, a + * machine token, a non-member; only a private profile hides it. + */ + public function testAnotherPlayersListIsPublic(): void + { + $browser = self::createClient(); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_500_01, 1.18, MetricConfidence::Medium); + + // a non-member stranger without results:read: the public fields + statistics only + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + $this->assertSame(self::ITEM_KEYS, array_keys($items[0])); + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_500_01); + $this->assertSame(25.0, $item['price']); + $this->assertSame('GBP', $item['currency']); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: false); + + // PLAYER_ADMIN (member) solved PUZZLE_500_01 (last 1780 s): their own prediction, the seller's solves + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame('challenging', $this->difficultyOf($item)['level']); + $this->assertSame(1780, $this->predictionOf($item)['last_time_seconds']); + $this->assertSame(2100, $this->solvesOf($item)['solo']['last_time_seconds']); + + // a machine token + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + + // the owner through /players, as under /me + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame(2100, $this->predictionOf($item)['last_time_seconds']); + } + + public function testPrivateProfileIsZeroedWithoutBatchQueries(): void + { + $browser = self::createClient(); + $this->seedSellSwapItems($browser, PlayerFixture::PLAYER_PRIVATE, [PuzzleFixture::PUZZLE_500_01]); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_PRIVATE, 0)); + $this->assertQueryCountAtMost($browser, 5, 'private profile short-circuit'); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->items($browser, PlayerFixture::PLAYER_PRIVATE, 0); + + // the private player sees their own + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_PRIVATE, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertSame([PuzzleFixture::PUZZLE_500_01], $this->column($this->items($browser, PlayerFixture::PLAYER_PRIVATE, 1), 'puzzle_id')); + } + + public function testEmbargoedImageIsNull(): void + { + $browser = self::createClient(); + $this->setImage($browser, PuzzleFixture::PUZZLE_1000_03, 'puzzles/test/box.jpg', hideUntil: new DateTimeImmutable('+30 days')); + $this->setImage($browser, PuzzleFixture::PUZZLE_500_01, 'puzzles/test/other.jpg', hideUntil: null); + $this->authenticateClientCredentials($browser, ['collections:read']); + + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + $this->assertNull($this->itemOf($items, PuzzleFixture::PUZZLE_1000_03)['image']); + $this->assertSame('puzzles/test/other.jpg', $this->itemOf($items, PuzzleFixture::PUZZLE_500_01)['image']); + } + + /** + * Measured (2026-08-19): authentication 1 (PAT) / 3 (OAuth2) / 1-2 + * (client_credentials), the token owner's profile 1 (it carries the + * currency; memoised, the insights reuse it), the items 1, statistics 1, + * then per entitlement solves 1, difficulty 1 and predictions <= 4; + * /players adds the listed profile 1. An empty list runs no batch at all. + */ + public function testQueryBudgets(): void + { + $browser = self::createClient(); + + // empty list, non-member PAT: authentication, profile, items + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 3, 'non-member PAT, empty list (no batch)'); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 10, 'member PAT'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 12, 'member authorization-code token'); + + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 6, 'client_credentials token on /players'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 8, 'non-member authorization-code token on /players'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 13, 'member authorization-code token on /players'); + } + + /** + * A member pays the same number of queries for six offers as for nineteen + * (PLAYER_ADMIN's fixture list holds a puzzle they have not solved, so both + * sizes take the complete prediction path). + */ + public function testQueryCountDoesNotGrowWithTheListSize(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_ADMIN); + + // warm-up (see WishlistEndpointTest) + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_ADMIN, 6); + $atSix = $this->queryCount($browser); + + $this->seedSellSwapItems($browser, PlayerFixture::PLAYER_ADMIN, [ + PuzzleFixture::PUZZLE_500_02, + PuzzleFixture::PUZZLE_500_03, + PuzzleFixture::PUZZLE_1000_03, + PuzzleFixture::PUZZLE_1000_04, + PuzzleFixture::PUZZLE_1000_05, + PuzzleFixture::PUZZLE_300, + PuzzleFixture::PUZZLE_1500_02, + PuzzleFixture::PUZZLE_2000, + PuzzleFixture::PUZZLE_3000, + PuzzleFixture::PUZZLE_4000, + PuzzleFixture::PUZZLE_5000, + PuzzleFixture::PUZZLE_6000, + PuzzleFixture::PUZZLE_9000, + ]); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_ADMIN, 19); + $this->assertSame($atSix, $this->queryCount($browser), 'The same number of queries for 19 offers as for 6'); + } + + public function testOpenApiDocumentsBothPaths(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $this->assertOpenApiDocumentsPaths($browser, ['/api/v1/me/sell-swap', '/api/v1/players/{playerId}/sell-swap']); + } + + private function myPath(): string + { + return '/api/v1/me/sell-swap'; + } + + private function playerPath(string $playerId): string + { + return '/api/v1/players/' . $playerId . '/sell-swap'; + } + + /** + * @param list $puzzleIds + */ + private function seedSellSwapItems(KernelBrowser $browser, string $playerId, array $puzzleIds): void + { + $entityManager = $this->entityManager($browser); + + $player = $entityManager->find(Player::class, $playerId); + $this->assertNotNull($player); + + foreach ($puzzleIds as $puzzleId) { + $puzzle = $entityManager->find(Puzzle::class, $puzzleId); + $this->assertNotNull($puzzle); + + $entityManager->persist(new SellSwapListItem( + id: Uuid::uuid7(), + player: $player, + puzzle: $puzzle, + listingType: ListingType::Sell, + price: 10.0, + condition: PuzzleCondition::Normal, + comment: null, + addedAt: new DateTimeImmutable(), + )); + } + + $entityManager->flush(); + $entityManager->clear(); + } +} diff --git a/tests/Controller/Api/V1/UnsolvedPuzzlesEndpointTest.php b/tests/Controller/Api/V1/UnsolvedPuzzlesEndpointTest.php new file mode 100644 index 00000000..ff8a6a1f --- /dev/null +++ b/tests/Controller/Api/V1/UnsolvedPuzzlesEndpointTest.php @@ -0,0 +1,391 @@ + */ + private const array ITEM_KEYS = ['puzzle_id', 'puzzle_name', 'manufacturer_name', 'pieces_count', 'image', 'added_at', 'is_borrowed', ...self::INSIGHT_KEYS]; + + public function testAuthentication(): void + { + $browser = self::createClient(); + + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['profile:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath('00000000-0000-0000-0000-000000000000')); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testOwnListIsBorrowedFirstThenCollectionsNewestFirst(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + + $this->assertSame( + [ + PuzzleFixture::PUZZLE_3000, + PuzzleFixture::PUZZLE_500_05, + PuzzleFixture::PUZZLE_500_04, + PuzzleFixture::PUZZLE_300, + PuzzleFixture::PUZZLE_1000_05, + PuzzleFixture::PUZZLE_1000_04, + PuzzleFixture::PUZZLE_1000_03, + ], + $this->column($items, 'puzzle_id'), + ); + $this->assertSame([true, false, false, false, false, false, false], $this->column($items, 'is_borrowed')); + + foreach ($items as $item) { + $this->assertSame(self::ITEM_KEYS, array_keys($item)); + $this->assertIsString($item['added_at']); + $this->assertNotFalse(DateTimeImmutable::createFromFormat(DATE_ATOM, $item['added_at'])); + } + + $borrowed = $items[0]; + $this->assertSame('Puzzle 15', $borrowed['puzzle_name']); + $this->assertSame('Trefl', $borrowed['manufacturer_name']); + $this->assertSame(3000, $borrowed['pieces_count']); + $this->assertNull($borrowed['image']); + + // a puzzle in two collections of the player is listed once (PUZZLE_500_04: COLLECTION_PUBLIC and the wishlist) + $this->assertCount(1, array_filter($items, static fn (array $item): bool => $item['puzzle_id'] === PuzzleFixture::PUZZLE_500_04)); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_REGULAR, 2); + $this->assertSame([PuzzleFixture::PUZZLE_1500_02, PuzzleFixture::PUZZLE_3000], $this->column($items, 'puzzle_id')); + $this->assertSame([false, false], $this->column($items, 'is_borrowed')); + } + + public function testInsightGatesOnTheOwnList(): void + { + $browser = self::createClient(); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_3000, 0.9, MetricConfidence::Low); + + // non-member PAT: statistics and (zero) solves - unsolved by definition + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_REGULAR, 2), PuzzleFixture::PUZZLE_3000); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + $this->assertSame(0, $this->solvesOf($item)['solo']['count']); + + // member PAT: difficulty (seeded / synthesised), a statistical prediction, zero solves + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $browser->request('GET', $this->myPath()); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_3000); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame(['score' => 0.9, 'level' => 'average', 'confidence' => 'low', 'sample_size' => 20], $item['difficulty']); + $this->assertFalse($this->predictionOf($item)['is_personalized']); + $this->assertSame(0, $this->solvesOf($item)['solo']['count']); + $this->assertSame('insufficient', $this->difficultyOf($this->itemOf($items, PuzzleFixture::PUZZLE_300))['confidence']); + + // member authorization-code token: prediction and solves need results:read + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_3000); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: false); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_3000); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + + // opted out: no prediction + $this->optOutOfTimePredictions($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_3000); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: true); + } + + public function testAnotherPlayersListFollowsItsVisibility(): void + { + $browser = self::createClient(); + + // private (the fixture default) + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 0)); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 0); + + // the owner through /players + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + + // public + $this->setUnsolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + $this->assertSame(self::ITEM_KEYS, array_keys($items[0])); + $this->assertTrue($items[0]['is_borrowed']); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + } + + /** + * On somebody else's public list: difficulty follows the token owner, the + * prediction is the token owner's own, the solves are the list owner's + * (zeros here - unsolved) and need results:read. + */ + public function testInsightsOnAnotherPlayersList(): void + { + $browser = self::createClient(); + $this->setUnsolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_500_05, 1.4, MetricConfidence::High); + + // PLAYER_ADMIN (member) solved PUZZLE_500_05; PLAYER_WITH_STRIPE has not - the + // prediction is the visitor's (personalised), the solves the owner's (zero) + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_05); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame('hard', $this->difficultyOf($item)['level']); + $this->assertTrue($this->predictionOf($item)['is_personalized']); + $this->assertSame(0, $this->solvesOf($item)['solo']['count']); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_05); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: false); + + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7), PuzzleFixture::PUZZLE_500_05); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + } + + public function testPrivateProfileIsZeroedWithoutBatchQueries(): void + { + $browser = self::createClient(); + $this->setUnsolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_PRIVATE, CollectionVisibility::Public); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_PRIVATE, 0)); + $this->assertQueryCountAtMost($browser, 5, 'private profile short-circuit'); + + // the private player sees their own (PUZZLE_500_04 unsolved) + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_PRIVATE, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertSame([PuzzleFixture::PUZZLE_500_04], $this->column($this->items($browser, PlayerFixture::PLAYER_PRIVATE, 1), 'puzzle_id')); + } + + public function testEmbargoedImageIsNull(): void + { + $browser = self::createClient(); + // one borrowed, one from a collection - both queries apply the embargo + $this->setImage($browser, PuzzleFixture::PUZZLE_3000, 'puzzles/test/borrowed.jpg', hideUntil: new DateTimeImmutable('+30 days')); + $this->setImage($browser, PuzzleFixture::PUZZLE_500_05, 'puzzles/test/collection.jpg', hideUntil: new DateTimeImmutable('+30 days')); + $this->setImage($browser, PuzzleFixture::PUZZLE_300, 'puzzles/test/visible.jpg', hideUntil: new DateTimeImmutable('-1 day')); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 7); + $this->assertNull($this->itemOf($items, PuzzleFixture::PUZZLE_3000)['image']); + $this->assertNull($this->itemOf($items, PuzzleFixture::PUZZLE_500_05)['image']); + $this->assertSame('puzzles/test/visible.jpg', $this->itemOf($items, PuzzleFixture::PUZZLE_300)['image']); + } + + /** + * Measured (2026-08-19): authentication 1 (PAT) / 3 (OAuth2) / 1-2 + * (client_credentials), the two item queries (collections, borrowed), + * statistics 1, the token owner's profile 1, then per entitlement solves 1, + * difficulty 1 and predictions <= 4; /players adds the listed profile 1. + */ + public function testQueryBudgets(): void + { + $browser = self::createClient(); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 6, 'non-member PAT'); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 11, 'member PAT'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 13, 'member authorization-code token'); + + $this->setUnsolvedPuzzlesVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 7, 'client_credentials token on /players'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 14, 'member authorization-code token on /players'); + } + + /** + * A member pays the same number of queries for one unsolved puzzle as + * for twelve (PLAYER_ADMIN has one in the fixtures; eleven more unsolved + * puzzles are added to their system collection). + */ + public function testQueryCountDoesNotGrowWithTheListSize(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_ADMIN); + + // warm-up (see WishlistEndpointTest) + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertSame([PuzzleFixture::PUZZLE_500_04], $this->column($this->items($browser, PlayerFixture::PLAYER_ADMIN, 1), 'puzzle_id')); + $atOne = $this->queryCount($browser); + + $this->seedSystemCollectionItems($browser, PlayerFixture::PLAYER_ADMIN, [ + PuzzleFixture::PUZZLE_1000_02, + PuzzleFixture::PUZZLE_1000_03, + PuzzleFixture::PUZZLE_1000_04, + PuzzleFixture::PUZZLE_1000_05, + PuzzleFixture::PUZZLE_300, + PuzzleFixture::PUZZLE_1500_02, + PuzzleFixture::PUZZLE_2000, + PuzzleFixture::PUZZLE_3000, + PuzzleFixture::PUZZLE_4000, + PuzzleFixture::PUZZLE_5000, + PuzzleFixture::PUZZLE_6000, + ]); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_ADMIN, 12); + $this->assertSame($atOne, $this->queryCount($browser), 'The same number of queries for 12 unsolved puzzles as for 1'); + } + + public function testOpenApiDocumentsBothPaths(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $this->assertOpenApiDocumentsPaths($browser, ['/api/v1/me/unsolved-puzzles', '/api/v1/players/{playerId}/unsolved-puzzles']); + } + + private function myPath(): string + { + return '/api/v1/me/unsolved-puzzles'; + } + + private function playerPath(string $playerId): string + { + return '/api/v1/players/' . $playerId . '/unsolved-puzzles'; + } + + /** + * @param list $puzzleIds + */ + private function seedSystemCollectionItems(KernelBrowser $browser, string $playerId, array $puzzleIds): void + { + $entityManager = $this->entityManager($browser); + + $player = $entityManager->find(Player::class, $playerId); + $this->assertNotNull($player); + + foreach ($puzzleIds as $puzzleId) { + $puzzle = $entityManager->find(Puzzle::class, $puzzleId); + $this->assertNotNull($puzzle); + + $entityManager->persist(new CollectionItem( + id: Uuid::uuid7(), + collection: null, + player: $player, + puzzle: $puzzle, + comment: null, + addedAt: new DateTimeImmutable(), + )); + } + + $entityManager->flush(); + $entityManager->clear(); + } +} diff --git a/tests/Controller/Api/V1/WishlistEndpointTest.php b/tests/Controller/Api/V1/WishlistEndpointTest.php new file mode 100644 index 00000000..de623d20 --- /dev/null +++ b/tests/Controller/Api/V1/WishlistEndpointTest.php @@ -0,0 +1,422 @@ + */ + private const array ITEM_KEYS = ['wishlist_item_id', 'puzzle_id', 'puzzle_name', 'manufacturer_name', 'pieces_count', 'image', 'added_at', ...self::INSIGHT_KEYS]; + + public function testAuthentication(): void + { + $browser = self::createClient(); + + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + // a PAT is /me only + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + // a machine token has no "me" + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + // collections:read is the scope of the whole puzzle library + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['profile:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath('not-a-uuid')); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testOwnWishlistIsCompleteWithPatAndAuthorizationCodeToken(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_REGULAR, 5); + + // newest first, the website's order + $this->assertSame( + [PuzzleFixture::PUZZLE_500_04, PuzzleFixture::PUZZLE_500_05, PuzzleFixture::PUZZLE_6000, PuzzleFixture::PUZZLE_5000, PuzzleFixture::PUZZLE_4000], + $this->column($items, 'puzzle_id'), + ); + + foreach ($items as $item) { + $this->assertSame(self::ITEM_KEYS, array_keys($item)); + } + + $item = $items[0]; + $this->assertSame(WishListItemFixture::WISHLIST_09, $item['wishlist_item_id']); + $this->assertSame('Puzzle 4', $item['puzzle_name']); + $this->assertSame('Trefl', $item['manufacturer_name']); + $this->assertSame(500, $item['pieces_count']); + $this->assertNull($item['image']); + $this->assertIsString($item['added_at']); + $this->assertNotFalse(DateTimeImmutable::createFromFormat(DATE_ATOM, $item['added_at'])); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_REGULAR, 5); + } + + /** + * The gates of a collection item: statistics always; difficulty for a + * member; prediction for a member who has not opted out, with PAT or + * results:read; solves (the owner's own) with PAT or results:read. + */ + public function testInsightGatesOnTheOwnWishlist(): void + { + $browser = self::createClient(); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_500_01, 1.18, MetricConfidence::Medium); + + // non-member PAT + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_REGULAR, 5), PuzzleFixture::PUZZLE_500_04); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + $this->assertSame(0, $this->solvesOf($item)['solo']['count']); + + // member PAT: PLAYER_WITH_STRIPE solved PUZZLE_500_01 once (2100 s) + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3); + $this->assertSame([PuzzleFixture::PUZZLE_500_01, PuzzleFixture::PUZZLE_3000, PuzzleFixture::PUZZLE_9000], $this->column($items, 'puzzle_id')); + + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame(['score' => 1.18, 'level' => 'challenging', 'confidence' => 'medium', 'sample_size' => 20], $item['difficulty']); + $this->assertSame(11, $this->statisticsOf($item)['solved_times']); + $this->assertTrue($this->predictionOf($item)['is_personalized']); + $this->assertSame(1, $this->predictionOf($item)['personal_solve_count']); + $this->assertSame(2100, $this->predictionOf($item)['last_time_seconds']); + $this->assertSame(1, $this->solvesOf($item)['solo']['count']); + $this->assertSame(2100, $this->solvesOf($item)['solo']['best_time_seconds']); + + // an unscored, never-solved puzzle: objects, not null + $item = $this->itemOf($items, PuzzleFixture::PUZZLE_9000); + $this->assertSame(['score' => null, 'level' => null, 'confidence' => 'insufficient', 'sample_size' => 0], $item['difficulty']); + $this->assertFalse($this->predictionOf($item)['is_personalized']); + $this->assertSame(0, $this->solvesOf($item)['solo']['count']); + + // member authorization-code token without results:read: difficulty only + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: false); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + + // opted out of time predictions: difficulty and solves stay, the prediction goes + $this->optOutOfTimePredictions($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $browser->request('GET', $this->myPath()); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: false, solves: true); + } + + /** + * The website's rule: another player's wishlist is visible when they made + * it public; the player behind the token sees their own through /players too. + */ + public function testAnotherPlayersWishlistFollowsItsVisibility(): void + { + $browser = self::createClient(); + + // private (the fixture default) - zeroed for a stranger and a machine token + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 0)); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 0); + + // ... but complete for its owner + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3); + + // public - everybody + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3); + $this->assertSame([PuzzleFixture::PUZZLE_500_01, PuzzleFixture::PUZZLE_3000, PuzzleFixture::PUZZLE_9000], $this->column($items, 'puzzle_id')); + $this->assertSame(self::ITEM_KEYS, array_keys($items[0])); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3); + } + + /** + * On somebody else's public wishlist: difficulty follows the token owner's + * membership, the prediction is the token owner's own (what the website + * shows a visitor), the solves are the list owner's and need results:read. + */ + public function testInsightsOnAnotherPlayersWishlist(): void + { + $browser = self::createClient(); + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + $this->seedDifficulty($browser, PuzzleFixture::PUZZLE_500_01, 1.18, MetricConfidence::Medium); + + // PLAYER_ADMIN (member) has their own solo history on PUZZLE_500_01 (last 1780 s); + // the list owner PLAYER_WITH_STRIPE solved it once in 2100 s + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: true, prediction: true, solves: true); + $this->assertSame('challenging', $this->difficultyOf($item)['level']); + $this->assertTrue($this->predictionOf($item)['is_personalized']); + $this->assertSame(1780, $this->predictionOf($item)['last_time_seconds']); + $this->assertSame(2100, $this->solvesOf($item)['solo']['last_time_seconds']); + + // a non-member visitor: no difficulty, no prediction; the owner's solves only with results:read + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: false); + $this->assertSame(11, $this->statisticsOf($item)['solved_times']); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_REGULAR, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + $this->assertSame(1, $this->solvesOf($item)['solo']['count']); + + // a machine token: never a member, never a prediction; the owner's public solves with results:read + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $item = $this->itemOf($this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3), PuzzleFixture::PUZZLE_500_01); + $this->assertInsightsGated($item, difficulty: false, prediction: false, solves: true); + } + + public function testPrivateProfileIsZeroedWithoutBatchQueries(): void + { + $browser = self::createClient(); + // even a public wishlist is hidden behind a private profile + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_PRIVATE, CollectionVisibility::Public); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertResponseIsSuccessful(); + $this->assertSame([], $this->items($browser, PlayerFixture::PLAYER_PRIVATE, 0)); + // authentication (3) + the listed profile + the token owner's profile - nothing else + $this->assertQueryCountAtMost($browser, 5, 'private profile short-circuit'); + + $this->authenticateClientCredentials($browser, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->items($browser, PlayerFixture::PLAYER_PRIVATE, 0); + + // the private player sees their own + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_PRIVATE, ['collections:read']); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_PRIVATE)); + $this->assertSame([PuzzleFixture::PUZZLE_4000], $this->column($this->items($browser, PlayerFixture::PLAYER_PRIVATE, 1), 'puzzle_id')); + } + + public function testEmbargoedImageIsNull(): void + { + $browser = self::createClient(); + $this->setImage($browser, PuzzleFixture::PUZZLE_9000, 'puzzles/test/box.jpg', hideUntil: new DateTimeImmutable('+30 days')); + $this->setImage($browser, PuzzleFixture::PUZZLE_3000, 'puzzles/test/other.jpg', hideUntil: null); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $items = $this->items($browser, PlayerFixture::PLAYER_WITH_STRIPE, 3); + $this->assertNull($this->itemOf($items, PuzzleFixture::PUZZLE_9000)['image']); + $this->assertSame('puzzles/test/other.jpg', $this->itemOf($items, PuzzleFixture::PUZZLE_3000)['image']); + } + + /** + * Measured (2026-08-19): authentication 1 (PAT) / 3 (OAuth2: access token, + * player, consent usage) / 1-2 (client_credentials), the items 1, statistics + * 1, the token owner's profile 1, then per entitlement solves 1, difficulty + * 1 (member) and the token owner's predictions <= 4 (member with PAT / + * results:read); /players adds the listed player's profile 1. + */ + public function testQueryBudgets(): void + { + $browser = self::createClient(); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 5, 'non-member PAT (items, statistics, profile, solves)'); + + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 10, 'member PAT (items, statistics, profile, difficulty, predictions, solves)'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 12, 'member authorization-code token'); + + $this->setWishListVisibility($browser, PlayerFixture::PLAYER_WITH_STRIPE, CollectionVisibility::Public); + + $this->authenticateClientCredentials($browser, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 6, 'client_credentials token on /players (profile, items, statistics, owner solves)'); + + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_ADMIN, ['collections:read', 'results:read']); + $this->startCountingQueries($browser); + $browser->request('GET', $this->playerPath(PlayerFixture::PLAYER_WITH_STRIPE)); + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, 13, 'member authorization-code token on /players (profile, items, statistics, own profile, difficulty, own predictions, owner solves)'); + } + + /** + * A member pays the same number of queries for a wishlist of one puzzle + * as for one of twelve. + */ + public function testQueryCountDoesNotGrowWithTheListSize(): void + { + $browser = self::createClient(); + $this->seedWishlistItems($browser, PlayerFixture::PLAYER_ADMIN, [PuzzleFixture::PUZZLE_9000]); + $this->authenticatePat($browser, PlayerFixture::PLAYER_ADMIN); + + // warm-up: a token's first request may find entities the helper saved in + // the entity manager (one lookup fewer) - a test artefact that would skew + // the comparison + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_ADMIN, 1); + $atOne = $this->queryCount($browser); + + $this->seedWishlistItems($browser, PlayerFixture::PLAYER_ADMIN, [ + PuzzleFixture::PUZZLE_500_01, + PuzzleFixture::PUZZLE_500_02, + PuzzleFixture::PUZZLE_500_03, + PuzzleFixture::PUZZLE_1000_01, + PuzzleFixture::PUZZLE_1000_02, + PuzzleFixture::PUZZLE_1500_01, + PuzzleFixture::PUZZLE_2000, + PuzzleFixture::PUZZLE_3000, + PuzzleFixture::PUZZLE_4000, + PuzzleFixture::PUZZLE_5000, + PuzzleFixture::PUZZLE_6000, + ]); + + $this->startCountingQueries($browser); + $browser->request('GET', $this->myPath()); + $this->assertResponseIsSuccessful(); + $this->items($browser, PlayerFixture::PLAYER_ADMIN, 12); + $this->assertSame($atOne, $this->queryCount($browser), 'The same number of queries for 12 items as for 1'); + } + + public function testOpenApiDocumentsBothPaths(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $this->assertOpenApiDocumentsPaths($browser, ['/api/v1/me/wishlist', '/api/v1/players/{playerId}/wishlist']); + } + + private function myPath(): string + { + return '/api/v1/me/wishlist'; + } + + private function playerPath(string $playerId): string + { + return '/api/v1/players/' . $playerId . '/wishlist'; + } + + /** + * @param list $puzzleIds + */ + private function seedWishlistItems(KernelBrowser $browser, string $playerId, array $puzzleIds): void + { + $entityManager = $this->entityManager($browser); + + $player = $entityManager->find(Player::class, $playerId); + $this->assertNotNull($player); + + foreach ($puzzleIds as $puzzleId) { + $puzzle = $entityManager->find(Puzzle::class, $puzzleId); + $this->assertNotNull($puzzle); + + $entityManager->persist(new WishListItem( + id: Uuid::uuid7(), + player: $player, + puzzle: $puzzle, + removeOnCollectionAdd: false, + addedAt: new DateTimeImmutable(), + )); + } + + $entityManager->flush(); + $entityManager->clear(); + } +} diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 4eb8f784..d45f6247 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -584,6 +584,7 @@ for_developers: table_section_me: "Your own data (/me)" table_section_players: "Other players' public data" table_section_puzzles: "Puzzle catalog" + table_section_library: "Puzzle library (collections:read)" summary_pat: "PAT — your own data only. No scopes needed, full access to all /me endpoints." summary_auth_code: "Auth Code — all endpoints. Access depends on granted scopes. Write scopes available." summary_client_cred: "Client Credentials — read-only access to any public player. No /me endpoints."