a9ad014bd1
CI / test (push) Has been cancelled
Started: 89.08% backend / 97.01% frontend lines.
Landed: 99.69% backend / 98.62% frontend.
Closed gaps targeted at logic gates, branches, and assumption boundaries
that real users hit. Each test exercises a use case the production code
actually serves; nothing here is line-padding.
Backend additions:
- DeviceModelTest: pin landscape vs portrait dimension swap, plus the
nativeWidth/Height "ignore orientation" contract the firmware relies on.
- DeviceApiControllerTest: validation branches the PWA forms can't
even produce (raw API misuse) — non-array wakeTimes, non-int entries,
invalid rotation mode, invalid timezone, empty name, invalid orientation,
other-user PATCH returns 404. Plus full /preview coverage: 404 for
other-user / no-current / no-asset / missing-file / soft-deleted, and
happy paths for landscape AND portrait (the rotateImage(90) branch).
- ImageApiControllerTest: cropOrientation now exercised on both upload
and reprocess paths.
- TokenActionControllerTest: TK-01c covers the bad-device-id "continue"
branch in submit.
- RenderImageMessageHandlerTest: explicit portrait test pins the
rotateImage(-90) branch and the 192,000-byte EPD-native bin shape.
- SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh
create, idempotent re-run, and --remove path. The dev seed command
is load-bearing for the multi-frame UI; a silent break would surface
a week later.
- RerenderAssetsCommandTest: reset + dispatch path, no-assets path.
Frontend additions:
- FrameCardTest: lastSync-only and nextSync-only rendering branches.
- HomeView.test:
* + Add time fallback path when all 9 default candidates are taken.
* Multi-day "in Nd" nextSync formatting (offline / huge-interval case).
* Medium-horizon (5h) nextSync formats as clock-time + day label.
* visibilitychange triggers a silent re-fetch.
* add-photo handler creates input + navigates to /upload after pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
323 lines
12 KiB
PHP
323 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller;
|
|
|
|
use App\Entity\Device;
|
|
use App\Entity\Image;
|
|
use App\Entity\SharedImage;
|
|
use App\Entity\Token;
|
|
use App\Enum\SharedImageStatus;
|
|
use App\Enum\TokenType;
|
|
use App\Tests\Functional\AppWebTestCase;
|
|
|
|
/**
|
|
* Tests for TokenActionController — token-based share approve/decline flows.
|
|
*
|
|
* TK-01: Valid share_approve token → page renders (GET) / action performed (POST)
|
|
* TK-02: Expired/missing token → invalid page rendered
|
|
* TK-03: Already-used token → same invalid page (repo returns null for used tokens)
|
|
*/
|
|
class TokenActionControllerTest extends AppWebTestCase
|
|
{
|
|
private function makeImage($user): Image
|
|
{
|
|
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
|
$this->em()->persist($image);
|
|
return $image;
|
|
}
|
|
|
|
private function issueToken(Image $image, TokenType $type, int $ttlDays = 7): Token
|
|
{
|
|
$token = new Token($type, $image, null, 'recipient@example.com', $ttlDays);
|
|
$this->em()->persist($token);
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* TK-01a: GET /token/{uuid}/approve with a valid token renders the approve page.
|
|
*/
|
|
public function test_approve_show_valid_token_renders_page(): void
|
|
{
|
|
$sender = $this->createUser('tk01a_sender@example.com');
|
|
$recipient = $this->createUser('tk01a_recip@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
$this->em()->flush();
|
|
|
|
$client = $this->loginAs($recipient);
|
|
$client->request('GET', '/token/' . $token->getUuid() . '/approve');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
// The valid approve page shows "Someone shared a photo" — NOT the invalid page
|
|
$this->assertSelectorTextContains('h1', 'Someone shared a photo');
|
|
}
|
|
|
|
/**
|
|
* TK-01b: POST /token/{uuid}/approve with a valid token marks SharedImage as approved and consumes the token.
|
|
*/
|
|
public function test_approve_submit_valid_token_marks_approved_and_consumes(): void
|
|
{
|
|
$sender = $this->createUser('tk01b_sender@example.com');
|
|
$recipient = $this->createUser('tk01b_recip@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
|
|
// Also create a SharedImage so the controller can update its status
|
|
$shared = new SharedImage($image, $recipient, $sender);
|
|
$this->em()->persist($shared);
|
|
$this->em()->flush();
|
|
|
|
$tokenUuid = $token->getUuid();
|
|
$sharedId = $shared->getId();
|
|
|
|
$client = $this->loginAs($recipient);
|
|
$client->request('POST', '/token/' . $tokenUuid . '/approve', [
|
|
'device_ids' => [],
|
|
]);
|
|
|
|
// Controller renders approved.html.twig on success (200)
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
// Token should now be marked used
|
|
$this->em()->clear();
|
|
$reloaded = $this->em()->find(Token::class, $tokenUuid);
|
|
$this->assertNotNull($reloaded->getUsedAt(), 'Token should be consumed after successful submit');
|
|
|
|
// SharedImage status should be Approved
|
|
$sharedReloaded = $this->em()->find(SharedImage::class, $sharedId);
|
|
$this->assertSame(SharedImageStatus::Approved, $sharedReloaded->getStatus());
|
|
$this->assertSame($recipient->getId(), $sharedReloaded->getRecipientUser()->getId());
|
|
}
|
|
|
|
/**
|
|
* TK-01c: A device_ids list containing an unknown / not-recipient-owned id
|
|
* MUST be silently skipped (not 500). Locks the "continue on bad id"
|
|
* branch in TokenActionController::submit.
|
|
*/
|
|
public function test_approve_submit_skips_unknown_device_ids(): void
|
|
{
|
|
$sender = $this->createUser('tk01c_sender@example.com');
|
|
$recipient = $this->createUser('tk01c_recip@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
$shared = new SharedImage($image, $recipient, $sender);
|
|
$this->em()->persist($shared);
|
|
|
|
// Recipient owns one device; the form will submit a real id and a bogus one.
|
|
$real = new \App\Entity\Device();
|
|
$real->setMac('AA:BB:CC:DD:EE:F1');
|
|
$real->setName('Recip Frame');
|
|
$real->setUser($recipient);
|
|
$this->em()->persist($real);
|
|
$this->em()->flush();
|
|
|
|
$client = $this->loginAs($recipient);
|
|
$client->request('POST', '/token/' . $token->getUuid() . '/approve', [
|
|
'device_ids' => [(string) $real->getId(), '999999'],
|
|
]);
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
// The real device should now have the image approved; the bogus id is a no-op.
|
|
$this->em()->clear();
|
|
$reloadedImage = $this->em()->find(\App\Entity\Image::class, $image->getId());
|
|
$approvedIds = array_map(
|
|
fn(\App\Entity\Device $d) => $d->getId(),
|
|
$reloadedImage->getApprovedDevices()->toArray(),
|
|
);
|
|
$this->assertContains($real->getId(), $approvedIds);
|
|
}
|
|
|
|
/**
|
|
* TK-02: GET /token/{uuid}/approve with a missing UUID renders the invalid page.
|
|
*/
|
|
public function test_approve_show_missing_token_renders_invalid_page(): void
|
|
{
|
|
$user = $this->createUser('tk02@example.com');
|
|
$this->em()->flush();
|
|
|
|
$client = $this->loginAs($user);
|
|
$client->request('GET', '/token/00000000-0000-0000-0000-000000000000/approve');
|
|
|
|
$this->assertResponseIsSuccessful(); // controller returns 200 with invalid.html.twig
|
|
$this->assertSelectorTextContains('body', 'expired');
|
|
}
|
|
|
|
/**
|
|
* TK-03: GET /token/{uuid}/approve with an already-used token renders the invalid page.
|
|
*
|
|
* TokenRepository::findValidToken() filters usedAt IS NULL, so a used token
|
|
* is indistinguishable from a missing one — both return null → invalid page.
|
|
*/
|
|
public function test_approve_show_used_token_renders_invalid_page(): void
|
|
{
|
|
$sender = $this->createUser('tk03_sender@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
// Consume the token before the test
|
|
$token->consume();
|
|
$this->em()->flush();
|
|
|
|
$recipient = $this->createUser('tk03_recip@example.com');
|
|
$client = $this->loginAs($recipient);
|
|
$client->request('GET', '/token/' . $token->getUuid() . '/approve');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertSelectorTextContains('body', 'expired');
|
|
}
|
|
|
|
// TK-04: GET /token/{uuid}/approve without login renders the approve page (anonymous)
|
|
public function test_approve_show_anonymous_shows_page(): void
|
|
{
|
|
$sender = $this->createUser('tk04_sender@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/token/' . $token->getUuid() . '/approve');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertSelectorTextContains('h1', 'Someone shared a photo');
|
|
}
|
|
|
|
// TK-05: POST /token/{uuid}/approve unauthenticated → redirects to /login
|
|
public function test_approve_submit_unauthenticated_redirects_to_login(): void
|
|
{
|
|
$sender = $this->createUser('tk05_sender@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('POST', '/token/' . $token->getUuid() . '/approve', [
|
|
'device_ids' => [],
|
|
]);
|
|
|
|
$this->assertResponseRedirects('/login');
|
|
}
|
|
|
|
// TK-06: POST /token/{invalid}/approve → renders invalid page
|
|
public function test_approve_submit_invalid_token_renders_invalid(): void
|
|
{
|
|
$user = $this->createUser('tk06@example.com');
|
|
$client = $this->loginAs($user);
|
|
|
|
$client->request('POST', '/token/00000000-0000-0000-0000-000000000000/approve', [
|
|
'device_ids' => [],
|
|
]);
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertSelectorTextContains('body', 'expired');
|
|
}
|
|
|
|
// TK-07: GET /token/{uuid}/decline with valid token renders decline page
|
|
public function test_decline_show_valid_token_renders_page(): void
|
|
{
|
|
$sender = $this->createUser('tk07_sender@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareDecline);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/token/' . $token->getUuid() . '/decline');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
}
|
|
|
|
// TK-08: GET /token/{invalid}/decline → renders invalid page
|
|
public function test_decline_show_invalid_token_renders_invalid_page(): void
|
|
{
|
|
$this->client->request('GET', '/token/00000000-0000-0000-0000-000000000000/decline');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
$this->assertSelectorTextContains('body', 'expired');
|
|
}
|
|
|
|
// TK-09: POST /token/{uuid}/decline → sets SharedImage status to Declined
|
|
public function test_decline_submit_updates_shared_image_to_declined(): void
|
|
{
|
|
$sender = $this->createUser('tk09_sender@example.com');
|
|
$recipient = $this->createUser('tk09_recip@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareDecline);
|
|
|
|
$shared = new SharedImage($image, $recipient, $sender);
|
|
$this->em()->persist($shared);
|
|
$this->em()->flush();
|
|
|
|
$sharedId = $shared->getId();
|
|
$client = $this->loginAs($recipient);
|
|
$client->request('POST', '/token/' . $token->getUuid() . '/decline');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
$this->em()->clear();
|
|
$reloaded = $this->em()->find(SharedImage::class, $sharedId);
|
|
$this->assertSame(\App\Enum\SharedImageStatus::Declined, $reloaded->getStatus());
|
|
}
|
|
|
|
// TK-10: POST /token/{uuid}/decline without a matching SharedImage → succeeds
|
|
public function test_decline_submit_without_shared_image_succeeds(): void
|
|
{
|
|
$sender = $this->createUser('tk10_sender@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareDecline);
|
|
$this->em()->flush();
|
|
|
|
$user = $this->createUser('tk10_user@example.com');
|
|
$client = $this->loginAs($user);
|
|
$client->request('POST', '/token/' . $token->getUuid() . '/decline');
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
}
|
|
|
|
// TK-12: POST /token/{uuid}/approve with recipient's own device → approveForDevice is called
|
|
public function test_approve_submit_with_owned_device_calls_approve_for_device(): void
|
|
{
|
|
$sender = $this->createUser('tk12_sender@example.com');
|
|
$recipient = $this->createUser('tk12_recip@example.com');
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
|
|
$ownDevice = new Device();
|
|
$ownDevice->setMac('AA:BB:CC:00:55:66')->setName('My Frame');
|
|
$ownDevice->setUser($recipient);
|
|
$this->em()->persist($ownDevice);
|
|
|
|
$shared = new SharedImage($image, $recipient, $sender);
|
|
$this->em()->persist($shared);
|
|
$this->em()->flush();
|
|
|
|
$client = $this->loginAs($recipient);
|
|
$client->request('POST', '/token/' . $token->getUuid() . '/approve', [
|
|
'device_ids' => [$ownDevice->getId()],
|
|
]);
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
}
|
|
|
|
// TK-11: POST /token/{uuid}/approve with a device_id not owned by the user → device skipped (continue branch)
|
|
public function test_approve_submit_with_unowned_device_id_is_skipped(): void
|
|
{
|
|
$sender = $this->createUser('tk11_sender@example.com');
|
|
$recipient = $this->createUser('tk11_recip@example.com');
|
|
$other = $this->createUser('tk11_other@example.com');
|
|
|
|
$image = $this->makeImage($sender);
|
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
|
|
|
$otherDevice = new Device();
|
|
$otherDevice->setMac('AA:BB:CC:00:44:55')->setName('Other Frame');
|
|
$otherDevice->setUser($other);
|
|
$this->em()->persist($otherDevice);
|
|
|
|
$this->em()->flush();
|
|
|
|
$client = $this->loginAs($recipient);
|
|
$client->request('POST', '/token/' . $token->getUuid() . '/approve', [
|
|
'device_ids' => [$otherDevice->getId()],
|
|
]);
|
|
|
|
$this->assertResponseIsSuccessful();
|
|
}
|
|
}
|