d11ddff912
CI / test (push) Has been cancelled
Frame settings now offer two update-frequency modes: "at specific times" or "every X minutes". Times are stored as an int[] of minutes-since-midnight, allowing multiple slots per day at minute granularity. Backend computes the earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM dropdowns with + Add / trash, a live "next update" preview, and a note that changes only take effect at the device's next sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
315 lines
12 KiB
PHP
315 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller;
|
|
|
|
use App\Entity\Device;
|
|
use App\Entity\Image;
|
|
use App\Enum\DeviceModel;
|
|
use App\Enum\Orientation;
|
|
use App\Tests\Functional\AppWebTestCase;
|
|
|
|
class DeviceApiControllerTest extends AppWebTestCase
|
|
{
|
|
private function makeDevice(string $mac, $user): Device
|
|
{
|
|
$device = new Device();
|
|
$device->setMac($mac);
|
|
$device->setName('Frame ' . $mac);
|
|
$device->setUser($user);
|
|
$device->setModel(DeviceModel::V1);
|
|
$device->setOrientation(Orientation::Landscape);
|
|
$this->em()->persist($device);
|
|
$this->em()->flush();
|
|
return $device;
|
|
}
|
|
|
|
private function makeImage($user): Image
|
|
{
|
|
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
|
$this->em()->persist($image);
|
|
$this->em()->flush();
|
|
return $image;
|
|
}
|
|
|
|
public function test_list_returns_own_devices(): void
|
|
{
|
|
$user = $this->createUser('list@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A1', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('GET', '/api/devices');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertCount(1, $data);
|
|
$this->assertSame($device->getMac(), $data[0]['mac']);
|
|
}
|
|
|
|
public function test_list_unauthenticated_returns_redirect(): void
|
|
{
|
|
$this->client->request('GET', '/api/devices');
|
|
|
|
// form_login firewall redirects unauthenticated requests to /login
|
|
$this->assertResponseRedirects('/login');
|
|
}
|
|
|
|
public function test_patch_updates_name_orientation_and_interval(): void
|
|
{
|
|
$user = $this->createUser('patch@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A2', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode([
|
|
'name' => 'New Name',
|
|
'orientation' => 'portrait',
|
|
'rotationIntervalMinutes' => 30,
|
|
]));
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertSame('New Name', $data['name']);
|
|
$this->assertSame('portrait', $data['orientation']);
|
|
$this->assertSame(30, $data['rotationIntervalMinutes']);
|
|
}
|
|
|
|
public function test_patch_wrong_users_device_returns_404(): void
|
|
{
|
|
$owner = $this->createUser('owner@example.com');
|
|
$other = $this->createUser('other@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A3', $owner);
|
|
$client = $this->loginAs($other);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['name' => 'Hack']));
|
|
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
public function test_put_lock_sets_locked_image_id(): void
|
|
{
|
|
$user = $this->createUser('lock@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A4', $user);
|
|
$image = $this->makeImage($user);
|
|
$image->approveForDevice($device);
|
|
$this->em()->flush();
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['imageId' => $image->getId()]));
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertSame($image->getId(), $data['lockedImageId']);
|
|
}
|
|
|
|
// A-06: PUT /lock with image not approved for the device → 422
|
|
public function test_put_lock_unapproved_image_returns_422(): void
|
|
{
|
|
$user = $this->createUser('lock422@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A8', $user);
|
|
$image = $this->makeImage($user);
|
|
// Image is owned by user but NOT approved for this device
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['imageId' => $image->getId()]));
|
|
|
|
$this->assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
public function test_put_lock_image_not_owned_by_user_returns_404(): void
|
|
{
|
|
$user1 = $this->createUser('lockown1@example.com');
|
|
$user2 = $this->createUser('lockown2@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A5', $user1);
|
|
$image = $this->makeImage($user2);
|
|
$client = $this->loginAs($user1);
|
|
|
|
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['imageId' => $image->getId()]));
|
|
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
public function test_delete_lock_clears_locked_image_id(): void
|
|
{
|
|
$user = $this->createUser('unlock@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A6', $user);
|
|
$image = $this->makeImage($user);
|
|
$device->setLockedImage($image);
|
|
$this->em()->flush();
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertNull($data['lockedImageId']);
|
|
}
|
|
|
|
public function test_put_lock_wrong_users_device_returns_404(): void
|
|
{
|
|
$owner = $this->createUser('lockwrong1@example.com');
|
|
$other = $this->createUser('lockwrong2@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:A7', $owner);
|
|
$image = $this->makeImage($other);
|
|
$client = $this->loginAs($other);
|
|
|
|
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['imageId' => $image->getId()]));
|
|
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
public function test_patch_empty_name_returns_422(): void
|
|
{
|
|
$user = $this->createUser('patchname@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:B1', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['name' => '']));
|
|
|
|
$this->assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
public function test_patch_invalid_orientation_returns_422(): void
|
|
{
|
|
$user = $this->createUser('patchorient@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:B2', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['orientation' => 'diagonal']));
|
|
|
|
$this->assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
public function test_patch_invalid_timezone_returns_422(): void
|
|
{
|
|
$user = $this->createUser('patchtzone@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:B3', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['timezone' => 'Not/Real']));
|
|
|
|
$this->assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
public function test_patch_sets_wake_times(): void
|
|
{
|
|
$user = $this->createUser('patchwake@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
// 6:00 AM, 3:00 PM, 7:30 PM expressed as minutes since midnight
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['wakeTimes' => [6 * 60, 15 * 60, 19 * 60 + 30]]));
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertSame([360, 900, 1170], $data['wakeTimes']);
|
|
}
|
|
|
|
public function test_patch_rejects_out_of_range_wake_times(): void
|
|
{
|
|
$user = $this->createUser('patchwakebad@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:B7', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['wakeTimes' => [1500]]));
|
|
|
|
$this->assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
public function test_patch_clears_wake_times_with_empty_array(): void
|
|
{
|
|
$user = $this->createUser('patchwakeclear@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:B8', $user);
|
|
$device->setWakeTimes([6 * 60, 18 * 60]);
|
|
$em = static::getContainer()->get('doctrine')->getManager();
|
|
$em->flush();
|
|
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['wakeTimes' => []]));
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertSame([], $data['wakeTimes']);
|
|
}
|
|
|
|
public function test_patch_sets_uniqueness_window(): void
|
|
{
|
|
$user = $this->createUser('patchuniq@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:B5', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['uniquenessWindow' => 14]));
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertSame(14, $data['uniquenessWindow']);
|
|
}
|
|
|
|
public function test_patch_sets_valid_timezone(): void
|
|
{
|
|
$user = $this->createUser('patchtzvld@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:C1', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode(['timezone' => 'America/New_York']));
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$data = json_decode($client->getResponse()->getContent(), true);
|
|
$this->assertSame('America/New_York', $data['timezone']);
|
|
}
|
|
|
|
public function test_lock_with_no_image_id_returns_422(): void
|
|
{
|
|
$user = $this->createUser('locknoimgid@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:C2', $user);
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], json_encode([]));
|
|
|
|
$this->assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
public function test_unlock_wrong_users_device_returns_404(): void
|
|
{
|
|
$owner = $this->createUser('unlockown@example.com');
|
|
$other = $this->createUser('unlockoth@example.com');
|
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:C3', $owner);
|
|
$client = $this->loginAs($other);
|
|
|
|
$client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');
|
|
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
}
|