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>
541 lines
20 KiB
PHP
541 lines
20 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;
|
||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
|
||
|
||
class ImageApiControllerTest extends AppWebTestCase
|
||
{
|
||
private string $fixturePath;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->fixturePath = dirname(__DIR__, 2) . '/fixtures/test.jpg';
|
||
}
|
||
|
||
private function makeUploadedFile(): UploadedFile
|
||
{
|
||
// Copy to a temp file so the upload doesn't consume the original
|
||
$tmp = tempnam(sys_get_temp_dir(), 'img') . '.jpg';
|
||
copy($this->fixturePath, $tmp);
|
||
return new UploadedFile($tmp, 'test.jpg', 'image/jpeg', null, true);
|
||
}
|
||
|
||
public function test_upload_creates_image_and_returns_201(): void
|
||
{
|
||
$user = $this->createUser('upload@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], [
|
||
'file' => $this->makeUploadedFile(),
|
||
]);
|
||
|
||
$this->assertResponseStatusCodeSame(201);
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
$this->assertArrayHasKey('id', $data);
|
||
|
||
$image = $this->em()->find(Image::class, $data['id']);
|
||
$this->assertNotNull($image);
|
||
$this->assertSame($user->getId(), $image->getUser()->getId());
|
||
}
|
||
|
||
public function test_upload_dispatches_render_message(): void
|
||
{
|
||
$user = $this->createUser('upload_msg@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], [
|
||
'file' => $this->makeUploadedFile(),
|
||
]);
|
||
|
||
$this->assertResponseStatusCodeSame(201);
|
||
|
||
/** @var InMemoryTransport $transport */
|
||
$transport = static::getContainer()->get('messenger.transport.image_processing');
|
||
$this->assertGreaterThan(0, count($transport->get()), 'Expected RenderImageMessage(s) to be dispatched');
|
||
}
|
||
|
||
public function test_upload_without_file_returns_422(): void
|
||
{
|
||
$user = $this->createUser('noupload@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], [], ['CONTENT_TYPE' => 'application/json'], '{}');
|
||
|
||
$this->assertResponseStatusCodeSame(422);
|
||
}
|
||
|
||
public function test_list_returns_users_images(): void
|
||
{
|
||
$user = $this->createUser('listimg@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('GET', '/api/images');
|
||
|
||
$this->assertResponseIsSuccessful();
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
$this->assertCount(1, $data);
|
||
$this->assertSame($image->getId(), $data[0]['id']);
|
||
}
|
||
|
||
public function test_delete_soft_deletes_image(): void
|
||
{
|
||
$user = $this->createUser('delimg@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$imageId = $image->getId();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('DELETE', '/api/images/' . $imageId);
|
||
|
||
$this->assertResponseStatusCodeSame(204);
|
||
|
||
$this->em()->clear();
|
||
$reloaded = $this->em()->find(Image::class, $imageId);
|
||
$this->assertNotNull($reloaded);
|
||
$this->assertNotNull($reloaded->getDeletedAt());
|
||
}
|
||
|
||
public function test_delete_wrong_users_image_returns_404(): void
|
||
{
|
||
$owner = $this->createUser('delown@example.com');
|
||
$other = $this->createUser('deloth@example.com');
|
||
$image = (new Image())->setUser($owner)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($other);
|
||
|
||
$client->request('DELETE', '/api/images/' . $image->getId());
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
/**
|
||
* IMG-06: reprocess endpoint returns 200 and re-queues render messages.
|
||
*
|
||
* We first upload an image (which creates the RenderedAsset records via the controller),
|
||
* then call reprocess on the same image to verify it resets assets and dispatches new renders.
|
||
*/
|
||
public function test_reprocess_returns_200(): void
|
||
{
|
||
$user = $this->createUser('reprocess@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
// Step 1: Upload so the controller creates RenderedAssets and storage directory
|
||
$client->request('POST', '/api/images', [], [
|
||
'file' => $this->makeUploadedFile(),
|
||
]);
|
||
$this->assertResponseStatusCodeSame(201);
|
||
$uploadData = json_decode($client->getResponse()->getContent(), true);
|
||
$imageId = $uploadData['id'];
|
||
|
||
// Step 2: Reprocess
|
||
$client->request('POST', '/api/images/' . $imageId . '/reprocess', [], [
|
||
'file' => $this->makeUploadedFile(),
|
||
]);
|
||
|
||
$this->assertResponseStatusCodeSame(200);
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
$this->assertSame($imageId, $data['id']);
|
||
}
|
||
|
||
public function test_upload_unsupported_mime_returns_422(): void
|
||
{
|
||
$user = $this->createUser('badmime@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$tmp = tempnam(sys_get_temp_dir(), 'txt') . '.txt';
|
||
file_put_contents($tmp, 'This is plain text content');
|
||
$file = new UploadedFile($tmp, 'test.txt', 'text/plain', null, true);
|
||
|
||
$client->request('POST', '/api/images', [], ['file' => $file]);
|
||
|
||
$this->assertResponseStatusCodeSame(422);
|
||
}
|
||
|
||
public function test_thumbnail_returns_200_after_upload(): void
|
||
{
|
||
$user = $this->createUser('thumb200@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
|
||
$this->assertResponseStatusCodeSame(201);
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
|
||
$client->request('GET', '/api/images/' . $data['id'] . '/thumbnail');
|
||
$this->assertResponseIsSuccessful();
|
||
}
|
||
|
||
public function test_thumbnail_returns_404_for_unknown_image(): void
|
||
{
|
||
$user = $this->createUser('thumb404@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('GET', '/api/images/999999/thumbnail');
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_thumbnail_returns_404_when_file_not_present(): void
|
||
{
|
||
$user = $this->createUser('thumbnofile@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('GET', '/api/images/' . $image->getId() . '/thumbnail');
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_original_returns_200_after_upload(): void
|
||
{
|
||
$user = $this->createUser('orig200@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
|
||
$this->assertResponseStatusCodeSame(201);
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
|
||
$client->request('GET', '/api/images/' . $data['id'] . '/original');
|
||
$this->assertResponseIsSuccessful();
|
||
}
|
||
|
||
public function test_original_returns_404_for_unknown_image(): void
|
||
{
|
||
$user = $this->createUser('orig404@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('GET', '/api/images/999999/original');
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_share_success_returns_204(): void
|
||
{
|
||
$sender = $this->createUser('sharesend@example.com');
|
||
$recipient = $this->createUser('sharerecip@example.com');
|
||
|
||
$image = (new Image())->setUser($sender)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
|
||
$client = $this->loginAs($sender);
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], json_encode(['recipientEmail' => 'sharerecip@example.com']));
|
||
|
||
$this->assertResponseStatusCodeSame(204);
|
||
}
|
||
|
||
public function test_share_invalid_email_returns_422(): void
|
||
{
|
||
$user = $this->createUser('shareinvalid@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], json_encode(['recipientEmail' => 'not-an-email']));
|
||
|
||
$this->assertResponseStatusCodeSame(422);
|
||
}
|
||
|
||
public function test_share_nonexistent_recipient_returns_422(): void
|
||
{
|
||
$user = $this->createUser('sharenorecip@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], json_encode(['recipientEmail' => 'nobody@example.com']));
|
||
|
||
$this->assertResponseStatusCodeSame(422);
|
||
}
|
||
|
||
public function test_share_nonexistent_image_returns_404(): void
|
||
{
|
||
$user = $this->createUser('sharenoimg@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/999999/share', [], [], [
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], json_encode(['recipientEmail' => 'someone@example.com']));
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_hard_delete_request_returns_204(): void
|
||
{
|
||
$user = $this->createUser('hdrequser@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/hard-delete-request');
|
||
|
||
$this->assertResponseStatusCodeSame(204);
|
||
}
|
||
|
||
public function test_hard_delete_request_unknown_image_returns_404(): void
|
||
{
|
||
$user = $this->createUser('hdrnotfound@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/999999/hard-delete-request');
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_approve_and_revoke_device(): void
|
||
{
|
||
$user = $this->createUser('approvrevoke@example.com');
|
||
$device = new Device();
|
||
$device->setMac('AA:BB:CC:FF:01:01');
|
||
$device->setName('Frame');
|
||
$device->setUser($user);
|
||
$device->setModel(DeviceModel::V1);
|
||
$device->setOrientation(Orientation::Landscape);
|
||
$this->em()->persist($device);
|
||
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
|
||
$client = $this->loginAs($user);
|
||
|
||
// Approve
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/approve/' . $device->getId());
|
||
$this->assertResponseIsSuccessful();
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
$this->assertContains($device->getId(), $data['approvedDeviceIds']);
|
||
|
||
// Revoke
|
||
$client->request('DELETE', '/api/images/' . $image->getId() . '/approve/' . $device->getId());
|
||
$this->assertResponseIsSuccessful();
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
$this->assertNotContains($device->getId(), $data['approvedDeviceIds']);
|
||
}
|
||
|
||
public function test_approve_unknown_device_returns_404(): void
|
||
{
|
||
$user = $this->createUser('apprunkndev@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/approve/999999');
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_approve_unknown_image_returns_404(): void
|
||
{
|
||
$user = $this->createUser('apprunknimg@example.com');
|
||
$device = new Device();
|
||
$device->setMac('AA:BB:CC:FF:02:01');
|
||
$device->setName('Frame2');
|
||
$device->setUser($user);
|
||
$device->setModel(DeviceModel::V1);
|
||
$device->setOrientation(Orientation::Landscape);
|
||
$this->em()->persist($device);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/999999/approve/' . $device->getId());
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_revoke_unknown_image_returns_404(): void
|
||
{
|
||
$user = $this->createUser('revokeunknimg@example.com');
|
||
$device = new Device();
|
||
$device->setMac('AA:BB:CC:FF:03:01');
|
||
$device->setName('Frame3');
|
||
$device->setUser($user);
|
||
$device->setModel(DeviceModel::V1);
|
||
$device->setOrientation(Orientation::Landscape);
|
||
$this->em()->persist($device);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('DELETE', '/api/images/999999/approve/' . $device->getId());
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_upload_too_large_returns_422(): void
|
||
{
|
||
$user = $this->createUser('toolarge@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
// Create a file > MAX_BYTES (30 MB).
|
||
// Use UPLOAD_ERR_CANT_WRITE so HttpKernelBrowser::filterFiles() does not replace
|
||
// it with an empty-path stub (filterFiles only replaces *valid* oversized files).
|
||
// The controller's $file->getSize() check still sees the real file size.
|
||
$tmp = tempnam(sys_get_temp_dir(), 'large') . '.jpg';
|
||
$fp = fopen($tmp, 'wb');
|
||
$chunk = str_repeat("\0", 1024 * 256); // 256 KB chunks
|
||
for ($i = 0; $i < 121; $i++) { // 121 × 256 KB ≈ 31 MB > MAX_BYTES
|
||
fwrite($fp, $chunk);
|
||
}
|
||
fclose($fp);
|
||
unset($chunk);
|
||
|
||
$file = new UploadedFile($tmp, 'large.jpg', 'image/jpeg', \UPLOAD_ERR_CANT_WRITE, true);
|
||
$client->request('POST', '/api/images', [], ['file' => $file]);
|
||
@unlink($tmp);
|
||
|
||
$this->assertResponseStatusCodeSame(422);
|
||
}
|
||
|
||
public function test_upload_with_composited_and_original(): void
|
||
{
|
||
$user = $this->createUser('uploadcomp@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], [
|
||
'file' => $this->makeUploadedFile(),
|
||
'original' => $this->makeUploadedFile(),
|
||
]);
|
||
|
||
$this->assertResponseStatusCodeSame(201);
|
||
}
|
||
|
||
public function test_upload_with_crop_and_sticker_params(): void
|
||
{
|
||
$user = $this->createUser('uploadmeta@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [
|
||
'cropParams' => '{"x":0,"y":0,"w":100,"h":100}',
|
||
'stickerState' => '{"stickers":[]}',
|
||
'cropOrientation' => 'portrait',
|
||
], [
|
||
'file' => $this->makeUploadedFile(),
|
||
]);
|
||
|
||
$this->assertResponseStatusCodeSame(201);
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
$this->assertNotNull($data['cropParams']);
|
||
$this->assertNotNull($data['stickerState']);
|
||
$this->assertSame('portrait', $data['cropOrientation']);
|
||
}
|
||
|
||
public function test_original_returns_404_when_no_file_on_disk(): void
|
||
{
|
||
$user = $this->createUser('orignofile@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('GET', '/api/images/' . $image->getId() . '/original');
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_reprocess_unknown_image_returns_404(): void
|
||
{
|
||
$user = $this->createUser('repronotfound@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/999999/reprocess', [], ['file' => $this->makeUploadedFile()]);
|
||
|
||
$this->assertResponseStatusCodeSame(404);
|
||
}
|
||
|
||
public function test_reprocess_without_file_returns_422(): void
|
||
{
|
||
$user = $this->createUser('reprocessnofile@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
|
||
$this->assertResponseStatusCodeSame(201);
|
||
$imageId = json_decode($client->getResponse()->getContent(), true)['id'];
|
||
|
||
$client->request('POST', '/api/images/' . $imageId . '/reprocess');
|
||
|
||
$this->assertResponseStatusCodeSame(422);
|
||
}
|
||
|
||
public function test_reprocess_with_crop_and_sticker_metadata(): void
|
||
{
|
||
$user = $this->createUser('reprocessmeta@example.com');
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
|
||
$this->assertResponseStatusCodeSame(201);
|
||
$imageId = json_decode($client->getResponse()->getContent(), true)['id'];
|
||
|
||
$client->request('POST', '/api/images/' . $imageId . '/reprocess', [
|
||
'cropParams' => '{"x":10,"y":10}',
|
||
'stickerState' => '{"stickers":[]}',
|
||
'cropOrientation' => 'landscape',
|
||
], [
|
||
'file' => $this->makeUploadedFile(),
|
||
]);
|
||
|
||
$this->assertResponseStatusCodeSame(200);
|
||
$data = json_decode($client->getResponse()->getContent(), true);
|
||
$this->assertNotNull($data['cropParams']);
|
||
$this->assertNotNull($data['stickerState']);
|
||
$this->assertSame('landscape', $data['cropOrientation']);
|
||
}
|
||
|
||
public function test_share_with_self_returns_422(): void
|
||
{
|
||
$user = $this->createUser('shareself@example.com');
|
||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($user);
|
||
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], json_encode(['recipientEmail' => 'shareself@example.com']));
|
||
|
||
$this->assertResponseStatusCodeSame(422);
|
||
}
|
||
|
||
public function test_share_idempotent_returns_204(): void
|
||
{
|
||
$sender = $this->createUser('shareidem_send@example.com');
|
||
$recipient = $this->createUser('shareidem_recip@example.com');
|
||
$image = (new Image())->setUser($sender)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
$client = $this->loginAs($sender);
|
||
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], json_encode(['recipientEmail' => 'shareidem_recip@example.com']));
|
||
$this->assertResponseStatusCodeSame(204);
|
||
|
||
// Second call hits the idempotent ($existing) return path
|
||
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
|
||
'CONTENT_TYPE' => 'application/json',
|
||
], json_encode(['recipientEmail' => 'shareidem_recip@example.com']));
|
||
$this->assertResponseStatusCodeSame(204);
|
||
}
|
||
}
|