e4f811581a
CI / test (push) Has been cancelled
Three coordinated UX changes touching defaults and the settings sheet.
1. Server defaults: DeviceService::linkToUser now sets timezone =
user.timezone and wakeTimes = [12*60] (noon-daily) when creating a
new Device row OR transferring ownership on takeover. Replaces the
prior "1440-min interval anchored to last-seen-time" default that
could land a recipient's first photo at 3 am.
2. PWA propagation note: now mentions "briefly disconnect and reconnect
the frame's power" as the immediate-refresh gesture. Pairs with the
existing X-Boot-Reason: cold force-resync — the firmware already
honors a power-cycle as a deliberate refresh request, but users had
no way to discover that.
3. Remove-this-frame: replaced the native window.confirm() with an
in-sheet confirmation panel showing the explanatory text. Inline
keeps the gesture inside the existing sheet flow and gives the
destructive button a fixed location, instead of a floating native
dialog that varies per browser. The confirm body explicitly says
"this can't be undone" to match the irreversibility.
Tests:
- DeviceServiceTest: new-device default, takeover-resets-with-default,
UTC fallback when user has empty timezone.
- SetupControllerTest: claim-takes-over-defaults updated to assert
[12*60] wakeTimes.
- HomeView.test: 4 cases covering open-confirm, yes-confirm, cancel,
propagation-note text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
13 KiB
PHP
326 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller;
|
|
|
|
use App\Entity\Device;
|
|
use App\Entity\DeviceImageHistory;
|
|
use App\Entity\Image;
|
|
use App\Enum\DeviceModel;
|
|
use App\Enum\Orientation;
|
|
use App\Tests\Functional\AppWebTestCase;
|
|
|
|
class SetupControllerTest extends AppWebTestCase
|
|
{
|
|
private const MAC = 'AA:BB:CC:00:11:22';
|
|
|
|
// S-01: anonymous GET /setup/{mac} → 200 (shows login/register form)
|
|
public function test_setup_index_anonymous_renders(): void
|
|
{
|
|
$this->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 (new link) → redirects to configure
|
|
public function test_setup_index_authenticated_new_device_redirects_to_configure(): void
|
|
{
|
|
$user = $this->createUser('setup03@example.com');
|
|
$this->loginAs($user);
|
|
|
|
$this->client->request('GET', '/setup/' . self::MAC);
|
|
|
|
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
|
|
}
|
|
|
|
// 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
|
|
{
|
|
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
|
|
'registration_form' => [
|
|
'email' => 'setupnew@example.com',
|
|
'plainPassword' => [
|
|
'first' => 'securepass123',
|
|
'second' => 'securepass123',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
|
|
}
|
|
|
|
// 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
|
|
{
|
|
$user = $this->createUser('setup05@example.com');
|
|
|
|
$device = new Device();
|
|
$device->setMac(self::MAC);
|
|
$device->setUser($user);
|
|
$this->em()->persist($device);
|
|
$this->em()->flush();
|
|
|
|
$deviceId = $device->getId();
|
|
|
|
$this->loginAs($user);
|
|
$this->client->request('POST', '/setup/' . self::MAC . '/configure', [
|
|
'name' => 'Kitchen Frame',
|
|
'orientation' => 'landscape',
|
|
'rotation_interval_minutes' => '1440',
|
|
'uniqueness_window' => '10',
|
|
]);
|
|
|
|
$this->assertResponseRedirects('/');
|
|
|
|
$this->em()->clear();
|
|
$saved = $this->em()->find(Device::class, $deviceId);
|
|
$this->assertSame('Kitchen Frame', $saved->getName());
|
|
}
|
|
|
|
// S-06: POST /setup/{mac}/login with valid credentials → redirects to configure
|
|
public function test_login_valid_credentials_redirects_to_configure(): void
|
|
{
|
|
$this->createUser('setuplogin@example.com', 'testpass');
|
|
|
|
$this->client->request('POST', '/setup/' . self::MAC . '/login', [
|
|
'_username' => 'setuplogin@example.com',
|
|
'_password' => 'testpass',
|
|
]);
|
|
|
|
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
|
|
}
|
|
|
|
// 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: authenticated GET /setup/{mac}/configure → 200
|
|
public function test_configure_get_renders_form(): 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->assertResponseIsSuccessful();
|
|
}
|
|
|
|
// 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
|
|
{
|
|
// 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('/setup/' . self::MAC . '/configure');
|
|
|
|
$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');
|
|
}
|
|
}
|