client->request('GET', '/setup/' . self::MAC); $this->assertResponseIsSuccessful(); } // S-02: authenticated with named device → redirects to spa (/) public function test_setup_index_authenticated_named_device_redirects_to_spa(): void { $user = $this->createUser('setup02@example.com'); $device = new Device(); $device->setMac(self::MAC); $device->setName('Living Room'); $device->setUser($user); $this->em()->persist($device); $this->em()->flush(); $this->loginAs($user); $this->client->request('GET', '/setup/' . self::MAC); $this->assertResponseRedirects('/'); } // S-03: authenticated with no existing device → links and redirects to // /?setup= so the SPA opens its settings sheet for first-time setup. public function test_setup_index_authenticated_new_device_redirects_to_spa_setup(): void { $user = $this->createUser('setup03@example.com'); $this->loginAs($user); $this->client->request('GET', '/setup/' . self::MAC); $this->assertResponseRedirects(); $location = $this->client->getResponse()->headers->get('Location'); $this->assertStringContainsString('/?setup=', $location, 'redirects to SPA with setup query'); } // S-04: POST /register links + redirects to SPA with ?setup=. public function test_setup_register_creates_user_and_redirects_to_spa_setup(): void { $this->client->request('POST', '/setup/' . self::MAC . '/register', [ 'registration_form' => [ 'email' => 'setupnew@example.com', 'plainPassword' => [ 'first' => 'securepass123', 'second' => 'securepass123', ], ], ]); $this->assertResponseRedirects(); $this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location')); } // S-05: legacy /configure POST is now a redirect to the SPA — the // first-time settings UI lives in the live PWA, not Twig. public function test_legacy_configure_post_redirects_to_spa_setup(): void { $user = $this->createUser('setup05@example.com'); $device = new Device(); $device->setMac(self::MAC); $device->setUser($user); $this->em()->persist($device); $this->em()->flush(); $this->loginAs($user); // Old form POST — controller doesn't process the body, just redirects. $this->client->request('POST', '/setup/' . self::MAC . '/configure', [ 'name' => 'Kitchen Frame', ]); $this->assertResponseRedirects(); $this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location')); } // S-06: /login → links + redirects to SPA setup. public function test_login_valid_credentials_redirects_to_spa_setup(): void { $this->createUser('setuplogin@example.com', 'testpass'); $this->client->request('POST', '/setup/' . self::MAC . '/login', [ '_username' => 'setuplogin@example.com', '_password' => 'testpass', ]); $this->assertResponseRedirects(); $this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location')); } // S-07: POST /setup/{mac}/login with wrong password → redirects to index public function test_login_invalid_credentials_redirects_to_index(): void { $this->createUser('setupbadlogin@example.com', 'testpass'); $this->client->request('POST', '/setup/' . self::MAC . '/login', [ '_username' => 'setupbadlogin@example.com', '_password' => 'wrongpass', ]); $this->assertResponseRedirects('/setup/' . self::MAC); } // S-08: GET /setup/{mac}/configure also redirects (legacy, kept for // any in-flight bookmarks). public function test_legacy_configure_get_redirects_to_spa(): void { $user = $this->createUser('setupconfigget@example.com'); $device = new Device(); $device->setMac(self::MAC); $device->setUser($user); $this->em()->persist($device); $this->em()->flush(); $this->loginAs($user); $this->client->request('GET', '/setup/' . self::MAC . '/configure'); $this->assertResponseRedirects(); $this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location')); } // S-10: POST /setup/{mac}/register with invalid form data → re-renders registration page public function test_setup_register_invalid_form_renders_page(): void { // Valid email (avoids TypeError) but mismatched passwords → form is invalid $this->client->request('POST', '/setup/' . self::MAC . '/register', [ 'registration_form' => [ 'email' => 'setup-invalid@example.com', 'plainPassword' => [ 'first' => 'password123', 'second' => 'different456', ], ], ]); // Symfony 7 returns 422 for submitted-but-invalid forms $this->assertResponseStatusCodeSame(422); } // ── Sell-to-friend / claim-device flow ─────────────────────────────── private function makeBoundDevice(string $mac, string $ownerEmail): array { $owner = $this->createUser($ownerEmail); $device = new Device(); $device->setMac($mac); $device->setName('Old Owner Frame'); $device->setUser($owner); $device->setModel(DeviceModel::V1); $device->setOrientation(Orientation::Landscape); $this->em()->persist($device); $this->em()->flush(); return [$owner, $device]; } // S-CLAIM-01: GET /setup/{mac} for an already-bound MAC shows the claim // checkbox so the new owner has to acknowledge what they're erasing. public function test_setup_index_shows_claim_checkbox_when_mac_already_bound(): void { [, $device] = $this->makeBoundDevice(self::MAC, 'old-owner@example.com'); $crawler = $this->client->request('GET', '/setup/' . self::MAC); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('.claim-banner', 'already linked to another account'); // Checkbox appears in BOTH the register and login panels. $this->assertCount(2, $crawler->filter('input[name="claim_device"][type="checkbox"]')); } // S-CLAIM-02: POST /register without claim_device on a bound MAC bounces // back through the setup index with an error in session. public function test_register_without_claim_checkbox_bounces_with_error(): void { $this->makeBoundDevice(self::MAC, 'old-claim02@example.com'); $this->client->request('POST', '/setup/' . self::MAC . '/register', [ 'registration_form' => [ 'email' => 'new-claim02@example.com', 'plainPassword' => [ 'first' => 'secretpass1', 'second' => 'secretpass1', ], ], ]); $this->assertResponseRedirects('/setup/' . self::MAC); // Follow the redirect and assert the error surfaces. $this->client->followRedirect(); $this->assertSelectorTextContains('.field-error', 'already linked'); // Device ownership should NOT have transferred. $this->em()->clear(); $reloaded = $this->em()->getRepository(Device::class)->findOneBy(['mac' => self::MAC]); $this->assertNotNull($reloaded->getUser()); $this->assertSame('old-claim02@example.com', $reloaded->getUser()->getEmail()); } // S-CLAIM-03: POST /register WITH claim_device=1 transfers ownership and // purges old image history + image-device approvals. public function test_register_with_claim_checkbox_transfers_ownership_and_purges_history(): void { [$oldOwner, $device] = $this->makeBoundDevice(self::MAC, 'old-claim03@example.com'); // Old owner has an image approved for this device + a history row. $image = (new Image())->setUser($oldOwner)->setOriginalFilename('p.jpg')->setStoragePath('p'); $image->approveForDevice($device); $this->em()->persist($image); $history = new DeviceImageHistory($device, $image); $this->em()->persist($history); $device->setName('Living Room')->setWakeTimes([6 * 60]); $this->em()->flush(); $deviceId = $device->getId(); $this->client->request('POST', '/setup/' . self::MAC . '/register', [ 'registration_form' => [ 'email' => 'new-claim03@example.com', 'plainPassword' => [ 'first' => 'secretpass1', 'second' => 'secretpass1', ], ], 'claim_device' => '1', ]); $this->assertResponseRedirects(); $this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location')); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $deviceId); $this->assertSame('new-claim03@example.com', $reloaded->getUser()->getEmail(), 'ownership transferred'); $this->assertSame('', $reloaded->getName(), 'name reset on takeover'); $this->assertSame([12 * 60], $reloaded->getWakeTimes(), 'wakeTimes reset to noon-daily default'); // Old history is gone. $count = (int) $this->em()->createQueryBuilder() ->select('COUNT(h.id)') ->from(DeviceImageHistory::class, 'h') ->where('h.device = :d') ->setParameter('d', $reloaded) ->getQuery() ->getSingleScalarResult(); $this->assertSame(0, $count, 'history purged'); // Old image's approval for this device is gone too. $reloadedImage = $this->em()->find(Image::class, $image->getId()); $approvedIds = array_map( fn(Device $d) => $d->getId(), $reloadedImage->getApprovedDevices()->toArray(), ); $this->assertNotContains($deviceId, $approvedIds, 'image-device approval revoked'); } // S-CLAIM-04: POST /login without claim_device on a bound MAC bounces // back with the error (login still happens — the user is now logged in, // but the device transfer didn't go through). public function test_login_without_claim_checkbox_bounces_with_error(): void { $this->makeBoundDevice(self::MAC, 'old-claim04@example.com'); $newOwner = $this->createUser('new-claim04@example.com'); $this->client->request('POST', '/setup/' . self::MAC . '/login', [ '_username' => 'new-claim04@example.com', '_password' => 'password', ]); $this->assertResponseRedirects('/setup/' . self::MAC); $this->client->followRedirect(); $this->assertSelectorTextContains('.field-error', 'already linked'); $this->em()->clear(); $reloaded = $this->em()->getRepository(Device::class)->findOneBy(['mac' => self::MAC]); $this->assertSame('old-claim04@example.com', $reloaded->getUser()->getEmail(), 'no transfer without checkbox'); } // S-CLAIM-05: GET /setup/{mac} for an already-logged-in user who is NOT // the current owner falls through to the form (showing the checkbox) // rather than silently transferring on visit. This is the "sold to a // friend whose phone is already logged in" scenario. public function test_index_falls_through_to_form_when_logged_in_user_is_not_current_owner(): void { $this->makeBoundDevice(self::MAC, 'old-claim05@example.com'); $other = $this->createUser('other-claim05@example.com'); $this->loginAs($other); $this->client->request('GET', '/setup/' . self::MAC); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('.claim-banner', 'already linked'); } }