diff --git a/docs/features/api/README.md b/docs/features/api/README.md index 3b221dd7..499799e4 100644 --- a/docs/features/api/README.md +++ b/docs/features/api/README.md @@ -78,7 +78,7 @@ hand-typed `SOLVING_TIMES` variant silently matched nothing until 2026-08 (PR #1 | GET | `/api/v1/me/results?type=solo\|duo\|team` | PAT or `results:read`. Each result also carries the puzzle's `statistics` (public) and `difficulty` (members, else `null`) - see Insights on lists below | | GET | `/api/v1/me/puzzles/{puzzleId}/predicted-time` | PAT or `results:read` | | GET | `/api/v1/me/statistics` | PAT or `statistics:read` | -| POST | `/api/v1/me/solving-times` | PAT or `solving-times:write` | +| POST | `/api/v1/me/solving-times` | PAT or `solving-times:write`. The response carries the parsed `time_seconds` and `prediction` - the time prediction that applied *before* this solve (solo times, token owner a member who has not opted out, PAT or `results:read`; else `null`) - see the POST section below | | PUT | `/api/v1/me/solving-times/{timeId}` | PAT or `solving-times:write` | | GET | `/api/v1/me/collections` | PAT or `collections:read` | | GET | `/api/v1/me/collections/{id}/items` | PAT or `collections:read`. Each item also carries `statistics` (public), `difficulty` (members), `prediction` (members, not opted out, PAT or `results:read`) and `solves` (own history, PAT or `results:read`) - see Insights on lists below | @@ -253,6 +253,21 @@ The three blocks the profile page shows, under the page's own gates (`templates/ - `round_id`: optional, nullable. When set, the time is linked to that competition round and automatically to its competition. An invalid or unknown `round_id` returns 404. - Photo uploads not supported via API (use the website) +Response (`SolvingTimeResponse`, shared with `PUT …/solving-times/{timeId}`): + +```json +{ + "time_id": "uuid", "puzzle_id": "uuid", "time_seconds": 5025, "finished_at": "2025-12-01T14:30:00+00:00", + "first_attempt": true, "unboxed": false, "comment": "Optional comment", "round_id": null, + "prediction": { "predicted_seconds": 1890, "range_low_seconds": 1607, "range_high_seconds": 2174, + "is_personalized": true, "personal_solve_count": 1, "predicted_attempt_number": 2, "last_time_seconds": 2100 } +} +``` + +- `time_seconds` - the submitted `time` parsed with the same parser the handler stores from (`SolvingTime::fromUserInput`), so it is the number that lands in the database (`"1:23:45"` ⇒ `5025`, `"25:10"` ⇒ `1510`). Filled on `POST` since PR 4 of the expansion plan (it used to be `null`); still `null` on `PUT`. +- `prediction` - the time prediction that applied **before** this solve, the one the website shows on the added-time recap page (`AddedTimeRecapController`): the new time is excluded from the prediction query, so `personal_solve_count` is the count before it, `predicted_attempt_number` the attempt this time was, and `last_time_seconds` the previous solve - an app can show "12% faster than predicted" right after submitting. Same shape as the `prediction` object on puzzle cards (Puzzles above). Gates, exactly as on the recap page plus the API's own read rule: **solo** time (`group_players` empty), a time present, the token owner a **member** who has **not opted out** of time predictions, and PAT or `results:read` on the token (`solving-times:write` alone writes, it does not read insights) - `null` otherwise; a `client_credentials` token never reaches `/me/*` at all. When present, the object is always complete: the statistical (baseline × difficulty) estimate comes with `is_personalized: false` and the three `personal_*`/`last_*` fields `null`; every field `null` when there is nothing to predict from. Always `null` on `PUT` (the "before" prediction is a property of creating a time). +- **Query cost** (asserted in `CreateSolvingTimePredictionEndpointTest`, request only, PAT): the create itself already runs the `PuzzleSolved` event's synchronous recalculations (statistics, intelligence, wishlist removal, notifications), so its count depends on the data - measured 2026-08-19 with the prediction switched off: 28-35 for a solo time, 21 for a duo time. On top of that the feature adds the owner profile (1, `ApiTokenOwner`, only when the time is solo and the token may read results) and `GetPlayerPrediction::forPuzzle` (≤ 5: personal - solves, pieces, player ratio, global ratio(s); statistical - solves + 1) only for an eligible request; a group time adds nothing. + ### Privacy - `/api/v1/me/*` always returns full data for the token owner diff --git a/docs/features/api/v1-expansion-plan.md b/docs/features/api/v1-expansion-plan.md index 563a48ec..710620fe 100644 --- a/docs/features/api/v1-expansion-plan.md +++ b/docs/features/api/v1-expansion-plan.md @@ -158,7 +158,7 @@ The motivating case: a collection with 500+ puzzles where the app wants, per ite ## 7. PR 4 — `prediction` in the `POST /me/solving-times` response -After dispatch, when the time is **solo** (`group_players` empty/null), parsed time is non-null, owner is a member and not opted out: `prediction = TimePredictionResponse` from `GetPlayerPrediction::forPuzzle($playerId, $puzzleId, excludeTimeId: $timeId)` (the prediction *before* this solve — what the added-time recap shows); else `null`. Also fill `time_seconds` using the same parser the handler uses (today it is always `null` in the create response — filling it is additive). `SolvingTimeResponse` gains trailing `prediction: ?TimePredictionResponse = null` (the PUT response keeps `null`). Budget: + profile 1 + prediction ≤5, only for eligible requests. Tests: member solo with history (seeded) ⇒ object, `personal_solve_count` equals the count *before* this time; non-member ⇒ null; group time ⇒ null; opted-out ⇒ null; `time_seconds` filled; existing create tests unchanged. +After dispatch, when the time is **solo** (`group_players` empty/null), parsed time is non-null, owner is a member and not opted out, and the token may read results (PAT or `results:read` - §2's rule, the write scope alone does not read insights; corrected when PR 4 shipped): `prediction = TimePredictionResponse` from `GetPlayerPrediction::forPuzzle($playerId, $puzzleId, excludeTimeId: $timeId)` (the prediction *before* this solve — what the added-time recap shows); else `null`. Also fill `time_seconds` using the same parser the handler uses (today it is always `null` in the create response — filling it is additive). `SolvingTimeResponse` gains trailing `prediction: ?TimePredictionResponse = null` (the PUT response keeps `null`). Budget: + profile 1 + prediction ≤5, only for eligible requests. Tests: member solo with history (seeded) ⇒ object, `personal_solve_count` equals the count *before* this time; non-member ⇒ null; group time ⇒ null; opted-out ⇒ null; `time_seconds` filled; existing create tests unchanged. ## 7b. PR 5 — the puzzle library: wishlist, lend/borrow, unsolved, sell/swap (read-only) + library summary @@ -232,7 +232,7 @@ Waves (dependencies): **wave 1** PR 0 ∥ PR 1 (independent) → **wave 2** PR 2 - [x] PR 2 `GET /puzzles/{id}` (2026-08-19; the detail is `PuzzleResponseFactory::card()` of the overview - no separate `PuzzleInsightsAssembler`, the factory from PR 1 already is that assembler; `MyPredictedTimeResponseProvider` now gates through `ApiTokenOwner` and flattens `TimePredictionResponse`/`PuzzleDifficultyResponse` via `PredictedTimeResponse::fromInsights()`, output byte-identical; `PuzzleOverview::hideUntil` added (loaded by `GetPuzzleOverview::byId` only) for the N5 404; measured: cc 2-3, non-member 5 PAT / 7 OAuth2, member 9-10 PAT / 11-12 OAuth2 - ceilings 4 / 6 / 8 / 11 / 13) - [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) -- [ ] PR 4 `prediction` on `POST /me/solving-times` +- [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 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/CreateSolvingTimeInput.php b/src/Api/V1/CreateSolvingTimeInput.php index c6b71408..bf03e6d2 100644 --- a/src/Api/V1/CreateSolvingTimeInput.php +++ b/src/Api/V1/CreateSolvingTimeInput.php @@ -14,7 +14,18 @@ operations: [ new Post( uriTemplate: '/v1/me/solving-times', - openapi: new OpenApiOperation(tags: ['My Results & Solving Times']), + openapi: new OpenApiOperation( + tags: ['My Results & Solving Times'], + summary: 'Add a solving time for the token owner', + description: 'time is HH:MM:SS or MM:SS; the response carries it parsed as time_seconds. ' + . 'group_players (player codes prefixed with #, or plain names for guests) makes the time a duo/team one. ' + . 'The response also carries prediction - the time prediction that applied *before* this solve, ' + . 'what the website shows on the added-time recap: the new time is excluded, so personal_solve_count is the count before it. ' + . 'Puzzle Insights are members-only and self-only, exactly as on the website: prediction is null for a group time, ' + . 'a non-member, an owner who opted out of time predictions, and for an OAuth2 token without results:read ' + . '(the write scope alone does not read insights; a personal access token always can). ' + . 'When present, every field inside is null and is_personalized false if there is nothing to predict from.', + ), security: "is_granted('ROLE_PAT') or is_granted('ROLE_OAUTH2_SOLVING-TIMES:WRITE')", output: SolvingTimeResponse::class, processor: CreateSolvingTimeProcessor::class, diff --git a/src/Api/V1/CreateSolvingTimeProcessor.php b/src/Api/V1/CreateSolvingTimeProcessor.php index dcccc3f1..c04abf47 100644 --- a/src/Api/V1/CreateSolvingTimeProcessor.php +++ b/src/Api/V1/CreateSolvingTimeProcessor.php @@ -9,8 +9,11 @@ use DateTimeImmutable; use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\Message\AddPuzzleSolvingTime; +use SpeedPuzzling\Web\Query\GetPlayerPrediction; use SpeedPuzzling\Web\Repository\CompetitionRoundRepository; use SpeedPuzzling\Web\Security\ApiUser; +use SpeedPuzzling\Web\Services\Api\ApiTokenOwner; +use SpeedPuzzling\Web\Value\SolvingTime; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Messenger\MessageBusInterface; @@ -24,6 +27,8 @@ public function __construct( private Security $security, private MessageBusInterface $messageBus, private CompetitionRoundRepository $competitionRoundRepository, + private ApiTokenOwner $tokenOwner, + private GetPlayerPrediction $getPlayerPrediction, ) { } @@ -71,15 +76,53 @@ public function process(mixed $data, Operation $operation, array $uriVariables = ), ); + // The same parser the handler stores from (SolvingTime::fromUserInput); + // the input regex guarantees the HH:MM:SS / MM:SS shape it asserts. + $timeSeconds = SolvingTime::fromUserInput($data->time)->seconds; + return new SolvingTimeResponse( time_id: $timeId->toString(), puzzle_id: $data->puzzle_id, - time_seconds: null, + time_seconds: $timeSeconds, finished_at: $finishedAt?->format('c'), first_attempt: $data->first_attempt, unboxed: $data->unboxed, comment: $data->comment, round_id: $data->round_id, + prediction: $this->predictionBefore($data, $timeSeconds, $timeId->toString()), + ); + } + + /** + * The prediction that applied *before* this solve - what the website's added-time + * recap shows (AddedTimeRecapController: solo, time present, owner not opted out; + * the template reveals it to members only). The new time is excluded from the + * prediction query, so personal_solve_count is the count before it. Null when the + * token is not entitled to one: group time, no time, not a member, opted out, or an + * OAuth2 token without results:read (the write scope alone does not grant reading + * insights - the same PAT / results:read rule as every other prediction object). + * The cheap checks come first so a non-eligible request costs at most the one + * owner-profile query. + */ + private function predictionBefore(CreateSolvingTimeInput $data, null|int $timeSeconds, string $timeId): null|TimePredictionResponse + { + // A non-empty group_players list always makes a duo/team time (or fails in the handler) + if ($data->group_players !== [] || $timeSeconds === null) { + return null; + } + + if ($this->tokenOwner->canReadResults() === false || $this->tokenOwner->isMember() === false) { + return null; + } + + $profile = $this->tokenOwner->profile(); + + if ($profile === null || $profile->timePredictionsOptedOut) { + return null; + } + + return TimePredictionResponse::fromResult( + $this->getPlayerPrediction->forPuzzle($profile->playerId, $data->puzzle_id, excludeTimeId: $timeId), ); } } diff --git a/src/Api/V1/SolvingTimeResponse.php b/src/Api/V1/SolvingTimeResponse.php index 7ab6ad79..02bef1b5 100644 --- a/src/Api/V1/SolvingTimeResponse.php +++ b/src/Api/V1/SolvingTimeResponse.php @@ -15,6 +15,12 @@ public function __construct( public bool $unboxed, public null|string $comment, public null|string $round_id = null, + /** + * POST only: the time prediction that applied *before* this solve (the one the + * added-time recap page shows) - solo times, token owner a member who has not + * opted out, PAT or results:read. Null otherwise, and always null on PUT. + */ + public null|TimePredictionResponse $prediction = null, ) { } } diff --git a/tests/Controller/Api/V1/CreateSolvingTimePredictionEndpointTest.php b/tests/Controller/Api/V1/CreateSolvingTimePredictionEndpointTest.php new file mode 100644 index 00000000..8c5f1cb9 --- /dev/null +++ b/tests/Controller/Api/V1/CreateSolvingTimePredictionEndpointTest.php @@ -0,0 +1,459 @@ +authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $this->startCountingQueries($browser); + $this->post($browser, ['puzzle_id' => PuzzleFixture::PUZZLE_500_01, 'time' => '25:10']); + + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost( + $browser, + self::WRITE_PATH_MEMBER_SOLO_500_01 + self::OWNER_PROFILE_QUERIES + self::PREDICTION_QUERIES_MAX, + 'Member solo create with a personal prediction (PAT)', + ); + $response = $this->decode($browser); + + $this->assertSame(1510, $response['time_seconds']); + $this->assertSame(PuzzleFixture::PUZZLE_500_01, $response['puzzle_id']); + + $prediction = $this->prediction($browser); + $this->assertNotNull($prediction); + $this->assertTrue($prediction['is_personalized']); + $this->assertSame(1, $prediction['personal_solve_count']); + $this->assertSame(2, $prediction['predicted_attempt_number']); + $this->assertSame(2100, $prediction['last_time_seconds']); + $this->assertIsInt($prediction['predicted_seconds']); + $this->assertGreaterThan(0, $prediction['predicted_seconds']); + $this->assertIsInt($prediction['range_low_seconds']); + $this->assertIsInt($prediction['range_high_seconds']); + $this->assertLessThanOrEqual($prediction['predicted_seconds'], $prediction['range_low_seconds']); + $this->assertGreaterThanOrEqual($prediction['predicted_seconds'], $prediction['range_high_seconds']); + + // time_seconds is what the handler stored - same parser, same number + $this->assertSame(1510, $this->storedSeconds($browser, $response['time_id'])); + } + + /** + * No earlier solve of this puzzle, but a 500-piece baseline and a scored puzzle: + * the statistical (baseline x difficulty) prediction applied before the solve. + * The create's own synchronous recalculation rewrites the puzzle's difficulty + * row from the actual solves, so the puzzle is made scorable by seeding four + * other players' first attempts (5 indices with the new one = scored, "low"). + */ + public function testMemberWithoutHistoryGetsTheStatisticalPrediction(): void + { + $browser = self::createClient(); + + foreach ( + [ + PlayerFixture::PLAYER_REGULAR => 1900, + PlayerFixture::PLAYER_PRIVATE => 1650, + PlayerFixture::PLAYER_ADMIN => 2050, + PlayerFixture::PLAYER_WITH_FAVORITES => 2950, + ] as $playerId => $seconds + ) { + $this->seedSoloSolve($browser, $playerId, PuzzleFixture::PUZZLE_500_04, $seconds); + } + + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $this->startCountingQueries($browser); + $this->post($browser, ['puzzle_id' => PuzzleFixture::PUZZLE_500_04, 'time' => '1:23:45']); + + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost( + $browser, + self::WRITE_PATH_MEMBER_SOLO_500_04 + self::OWNER_PROFILE_QUERIES + self::PREDICTION_QUERIES_MAX, + 'Member solo create with a statistical prediction (PAT)', + ); + $response = $this->decode($browser); + + $this->assertSame(5025, $response['time_seconds']); + + $prediction = $this->prediction($browser); + $this->assertNotNull($prediction); + $this->assertFalse($prediction['is_personalized']); + $this->assertNull($prediction['personal_solve_count']); + $this->assertNull($prediction['predicted_attempt_number']); + $this->assertNull($prediction['last_time_seconds']); + $this->assertIsInt($prediction['predicted_seconds']); + $this->assertGreaterThan(0, $prediction['predicted_seconds']); + $this->assertIsInt($prediction['range_low_seconds']); + $this->assertIsInt($prediction['range_high_seconds']); + } + + /** + * Members-only, like the recap page: a non-member gets null even with three + * earlier solves of the puzzle to predict from. time_seconds is filled anyway. + */ + public function testNonMemberGetsNoPrediction(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $this->startCountingQueries($browser); + $this->post($browser, ['puzzle_id' => PuzzleFixture::PUZZLE_500_02, 'time' => '25:10']); + + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost( + $browser, + self::WRITE_PATH_NON_MEMBER_SOLO_500_02 + self::OWNER_PROFILE_QUERIES, + 'Non-member solo create (PAT)', + ); + $response = $this->decode($browser); + + $this->assertNull($this->prediction($browser)); + $this->assertSame(1510, $response['time_seconds']); + } + + /** + * A group time is a duo/team discipline - predictions are solo-only on the + * website (the recap page shows none), so the response carries null. + */ + public function testGroupTimeGetsNoPrediction(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $this->startCountingQueries($browser); + $this->post($browser, [ + 'puzzle_id' => PuzzleFixture::PUZZLE_500_01, + 'time' => '20:00', + 'group_players' => ['Guest Puzzler'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost($browser, self::WRITE_PATH_MEMBER_GROUP_500_01, 'Member group create (PAT) - no profile, no prediction query'); + $response = $this->decode($browser); + + $this->assertNull($this->prediction($browser)); + $this->assertSame(1200, $response['time_seconds']); + } + + public function testOptedOutMemberGetsNoPrediction(): void + { + $browser = self::createClient(); + $this->optOutOfTimePredictions($browser, PlayerFixture::PLAYER_WITH_STRIPE); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $this->startCountingQueries($browser); + $this->post($browser, ['puzzle_id' => PuzzleFixture::PUZZLE_500_01, 'time' => '25:10']); + + $this->assertResponseIsSuccessful(); + $this->assertQueryCountAtMost( + $browser, + self::WRITE_PATH_MEMBER_SOLO_500_01 + self::OWNER_PROFILE_QUERIES, + 'Opted-out member solo create (PAT)', + ); + $response = $this->decode($browser); + + $this->assertNull($this->prediction($browser)); + $this->assertSame(1510, $response['time_seconds']); + } + + /** + * The write scope lets a token create the time, not read the owner's insights: + * like every other prediction object it takes PAT or results:read. + */ + public function testOAuth2TokenWithWriteScopeOnlyGetsNoPrediction(): void + { + $browser = self::createClient(); + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['solving-times:write']); + + $this->post($browser, ['puzzle_id' => PuzzleFixture::PUZZLE_500_01, 'time' => '25:10']); + + $this->assertResponseIsSuccessful(); + $response = $this->decode($browser); + + $this->assertNull($this->prediction($browser)); + $this->assertSame(1510, $response['time_seconds']); + } + + public function testOAuth2TokenWithWriteAndResultsReadScopesGetsThePrediction(): void + { + $browser = self::createClient(); + $this->authenticateOAuth2($browser, PlayerFixture::PLAYER_WITH_STRIPE, ['solving-times:write', 'results:read']); + + $this->post($browser, ['puzzle_id' => PuzzleFixture::PUZZLE_500_01, 'time' => '25:10']); + + $this->assertResponseIsSuccessful(); + $response = $this->decode($browser); + + $prediction = $this->prediction($browser); + $this->assertNotNull($prediction); + $this->assertTrue($prediction['is_personalized']); + $this->assertSame(1, $prediction['personal_solve_count']); + $this->assertSame(2100, $prediction['last_time_seconds']); + } + + /** + * @return iterable + */ + public static function provideTimes(): iterable + { + yield 'HH:MM:SS' => ['1:23:45', 5025]; + yield 'MM:SS' => ['25:10', 1510]; + yield 'zero-padded hours' => ['00:25:00', 1500]; + } + + #[DataProvider('provideTimes')] + public function testTimeSecondsIsTheParsedTime(string $time, int $expectedSeconds): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_REGULAR); + + $this->post($browser, ['puzzle_id' => PuzzleFixture::PUZZLE_1000_01, 'time' => $time]); + + $this->assertResponseIsSuccessful(); + $response = $this->decode($browser); + + $this->assertSame($expectedSeconds, $response['time_seconds']); + $this->assertSame($expectedSeconds, $this->storedSeconds($browser, $response['time_id'])); + } + + /** + * PUT shares the response class; the field exists there too, always null - the + * "before" prediction is a property of creating a time. + */ + public function testUpdateResponseKeepsPredictionNull(): void + { + $browser = self::createClient(); + $this->authenticatePat($browser, PlayerFixture::PLAYER_WITH_STRIPE); + + $browser->request( + 'PUT', + self::ENDPOINT . '/' . PuzzleSolvingTimeFixture::TIME_05, + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode(['time' => '00:34:00', 'comment' => 'Updated via API']), + ); + + $this->assertResponseIsSuccessful(); + + $this->assertNull($this->prediction($browser)); + } + + /** + * @param array $payload + */ + private function post(KernelBrowser $browser, array $payload): void + { + $browser->request( + 'POST', + self::ENDPOINT, + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode($payload, JSON_THROW_ON_ERROR), + ); + } + + 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::WRITE_CLIENT_ID, + $playerId, + $scopes, + ); + + OAuth2TestHelper::addBearerToken($browser, $token); + } + + private function seedSoloSolve(KernelBrowser $browser, string $playerId, string $puzzleId, int $seconds): void + { + $entityManager = $this->entityManager($browser); + + $player = $entityManager->find(Player::class, $playerId); + $this->assertNotNull($player); + $puzzle = $entityManager->find(Puzzle::class, $puzzleId); + $this->assertNotNull($puzzle); + + $solvedAt = new DateTimeImmutable('-30 days'); + + $entityManager->persist(new PuzzleSolvingTime( + id: Uuid::uuid7(), + secondsToSolve: $seconds, + player: $player, + puzzle: $puzzle, + trackedAt: $solvedAt, + verified: true, + team: null, + finishedAt: $solvedAt, + comment: null, + finishedPuzzlePhoto: null, + firstAttempt: true, + unboxed: false, + )); + $entityManager->flush(); + $entityManager->clear(); + } + + private function optOutOfTimePredictions(KernelBrowser $browser, string $playerId): void + { + $entityManager = $this->entityManager($browser); + + $player = $entityManager->find(Player::class, $playerId); + $this->assertNotNull($player); + + $player->changeTimePredictionsOptedOut(true); + $entityManager->flush(); + $entityManager->clear(); + } + + private function storedSeconds(KernelBrowser $browser, string $timeId): int + { + /** @var ContainerInterface $container */ + $container = $browser->getContainer(); + + /** @var Connection $database */ + $database = $container->get(Connection::class); + + /** @var int|string|false $seconds */ + $seconds = $database->fetchOne('SELECT seconds_to_solve FROM puzzle_solving_time WHERE id = :id', ['id' => $timeId]); + $this->assertNotFalse($seconds); + + return (int) $seconds; + } + + private function entityManager(KernelBrowser $browser): EntityManagerInterface + { + /** @var ContainerInterface $container */ + $container = $browser->getContainer(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $container->get('doctrine.orm.entity_manager'); + + return $entityManager; + } + + /** + * The prediction object of the response - the key must always be present, + * null when the token is not entitled to one. + * + * @return null|Prediction + */ + private function prediction(KernelBrowser $browser): null|array + { + $response = $this->decodeRaw($browser); + $this->assertArrayHasKey('prediction', $response); + + if ($response['prediction'] === null) { + return null; + } + + /** @var Prediction $prediction */ + $prediction = $response['prediction']; + + return $prediction; + } + + /** + * @return SolvingTimeResponse + */ + private function decode(KernelBrowser $browser): array + { + /** @var SolvingTimeResponse $decoded */ + $decoded = $this->decodeRaw($browser); + + return $decoded; + } + + /** + * @return array + */ + private function decodeRaw(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; + } +}