feat(setup): post-link redirects to SPA so first-setup matches live UI
CI / test (push) Has been cancelled

Twig configure page replaced with a redirect: SetupController's index,
register, login, and the legacy /configure route all post-link redirect
to /?setup=<deviceId> for unconfigured devices. The SPA's HomeView
auto-opens its existing settings sheet for that id, with the same
controls everyone uses for live edits — themed to the user's choice,
pre-populated from the device record.

Fixes Matt's report:
  - "every 6 hours" lost on save: the configure form posted
    rotation_interval_hours but the controller read
    rotation_interval_minutes, so the value silently defaulted to
    1440 every time. Now the SPA's PATCH flow handles it correctly.
  - "old settings still there in live settings": SPA settings sheet
    pre-populates from the device's current state via onEdit.
  - "uniqueness window in setup but not live settings": removed
    from the (now-deleted) Twig form; both surfaces are consistent.
  - "color scheme didn't match account": SPA respects the user's
    theme natively (data-theme on <html>), so the first-setup screen
    looks like the rest of the app.

Also adds a "Sign out of pictureFrame" link at the bottom of the
per-frame settings sheet (the existing /settings tab still has the
primary one). Easy escape hatch from a deeply-nested settings flow.

Tests:
  - SetupControllerTest: S-03/04/05/06/08 updated for new redirect
    targets, S-CLAIM-03 updated.
  - HomeView.test.ts: useRoute now mockable per-test, two new cases
    pinning the ?setup=<id> auto-open and its absence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 18:51:31 -04:00
parent ff1ae79824
commit 08d0968af0
14 changed files with 139 additions and 95 deletions
@@ -40,19 +40,22 @@ class SetupControllerTest extends AppWebTestCase
$this->assertResponseRedirects('/');
}
// S-03: authenticated with no existing device (new link) → redirects to configure
public function test_setup_index_authenticated_new_device_redirects_to_configure(): void
// S-03: authenticated with no existing device links and redirects to
// /?setup=<id> 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('/setup/' . self::MAC . '/configure');
$this->assertResponseRedirects();
$location = $this->client->getResponse()->headers->get('Location');
$this->assertStringContainsString('/?setup=', $location, 'redirects to SPA with setup query');
}
// S-04: POST /setup/{mac}/register with valid data → creates user, links device, redirects to configure
public function test_setup_register_creates_user_and_redirects_to_configure(): void
// S-04: POST /register links + redirects to SPA with ?setup=<id>.
public function test_setup_register_creates_user_and_redirects_to_spa_setup(): void
{
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
'registration_form' => [
@@ -64,11 +67,13 @@ class SetupControllerTest extends AppWebTestCase
],
]);
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
$this->assertResponseRedirects();
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
}
// S-05: POST /setup/{mac}/configure with valid name → saves device, redirects to spa
public function test_setup_configure_saves_name_and_redirects_to_spa(): void
// 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');
@@ -78,25 +83,18 @@ class SetupControllerTest extends AppWebTestCase
$this->em()->persist($device);
$this->em()->flush();
$deviceId = $device->getId();
$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',
'orientation' => 'landscape',
'rotation_interval_minutes' => '1440',
'uniqueness_window' => '10',
'name' => 'Kitchen Frame',
]);
$this->assertResponseRedirects('/');
$this->em()->clear();
$saved = $this->em()->find(Device::class, $deviceId);
$this->assertSame('Kitchen Frame', $saved->getName());
$this->assertResponseRedirects();
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
}
// S-06: POST /setup/{mac}/login with valid credentials redirects to configure
public function test_login_valid_credentials_redirects_to_configure(): void
// 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');
@@ -105,7 +103,8 @@ class SetupControllerTest extends AppWebTestCase
'_password' => 'testpass',
]);
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
$this->assertResponseRedirects();
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
}
// S-07: POST /setup/{mac}/login with wrong password → redirects to index
@@ -121,8 +120,9 @@ class SetupControllerTest extends AppWebTestCase
$this->assertResponseRedirects('/setup/' . self::MAC);
}
// S-08: authenticated GET /setup/{mac}/configure → 200
public function test_configure_get_renders_form(): void
// 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');
@@ -135,28 +135,10 @@ class SetupControllerTest extends AppWebTestCase
$this->loginAs($user);
$this->client->request('GET', '/setup/' . self::MAC . '/configure');
$this->assertResponseIsSuccessful();
$this->assertResponseRedirects();
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
}
// S-09: POST /setup/{mac}/configure with empty name → 200 (re-renders form with error)
public function test_configure_post_empty_name_renders_error(): void
{
$user = $this->createUser('setupconfigerr@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('POST', '/setup/' . self::MAC . '/configure', [
'name' => '',
'orientation' => 'landscape',
]);
$this->assertResponseIsSuccessful();
}
// S-10: POST /setup/{mac}/register with invalid form data → re-renders registration page
public function test_setup_register_invalid_form_renders_page(): void
@@ -260,7 +242,8 @@ class SetupControllerTest extends AppWebTestCase
],
'claim_device' => '1',
]);
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
$this->assertResponseRedirects();
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $deviceId);