Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/fixtures/formTestData.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
App\Entity\FormTestDatum:
formTestDatum:
text: 'Hello'
multilineText: "Line one\nLine two"
# angle brackets are escaped because nelmio/alice parses <...> as expressions
html: '\<p\>Rich \<strong\>text\</strong\>\</p\>'
number: 42
flag: true
color: '#1976d2'
date: '2024-01-15'
time: '<(new DateTime("2024-01-15T09:30:00+00:00"))>'
language: 'en'
languageMultiselect: ['en', 'de']
32 changes: 32 additions & 0 deletions api/migrations/schema/Version20260620065038.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260620065038 extends AbstractMigration {
public function getDescription(): string {
return 'Add form_test_datum table (shared singleton row for testing API form components)';
}

public function up(Schema $schema): void {
$this->addSql('CREATE TABLE form_test_datum (id VARCHAR(16) NOT NULL, createTime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updateTime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, text VARCHAR(255) DEFAULT NULL, multilineText TEXT DEFAULT NULL, html TEXT DEFAULT NULL, number INT DEFAULT NULL, flag BOOLEAN DEFAULT false NOT NULL, color VARCHAR(9) DEFAULT NULL, date VARCHAR(10) DEFAULT NULL, time TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, language VARCHAR(16) DEFAULT NULL, languageMultiselect JSON NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_19A00CB39D468A55 ON form_test_datum (createTime)');
$this->addSql('CREATE INDEX IDX_19A00CB355AA53E2 ON form_test_datum (updateTime)');

// Seed the single shared row here (not via fixtures) so it exists in
// every environment, including production. There is exactly one row and
// all authenticated users read/write it.
$this->addSql("INSERT INTO form_test_datum (id, createTime, updateTime, text, multilineText, html, number, flag, color, date, time, language, languageMultiselect) VALUES ('0123456789ab', '2024-01-01 00:00:00', '2024-01-01 00:00:00', 'Hello', 'Line one\nLine two', '<p>Rich <strong>text</strong></p>', 42, true, '#1976d2', '2024-01-15', '2024-01-15 09:30:00', 'en', '[\"en\", \"de\"]')");
}

public function down(Schema $schema): void {
$this->addSql('DROP TABLE form_test_datum');
}
}
135 changes: 135 additions & 0 deletions api/src/Entity/FormTestDatum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;

/**
* A single shared row used to exercise the API-backed form components from
* tests (e.g. Playwright). There is exactly one instance and every
* authenticated user reads and writes that same row, which makes it possible to
* change a value in one browser tab and observe the change after reloading in
* another tab. No create/delete operations are exposed.
*/
#[ApiResource(
operations: [
new Get(
security: 'is_authenticated()'
),
new GetCollection(
security: 'is_authenticated()'
),
new Patch(
security: 'is_authenticated()'
),
],
normalizationContext: ['groups' => ['read']],
denormalizationContext: ['groups' => ['write']],
)]
#[ORM\Entity]
class FormTestDatum extends BaseEntity {
/**
* Single-line text, for ApiTextField / ApiSelect.
*/
#[ApiProperty(example: 'Hello')]
#[Groups(['read', 'write'])]
#[Assert\Length(max: 255)]
#[ORM\Column(type: 'string', length: 255, nullable: true)]
public ?string $text = null;

/**
* Multi-line text, for ApiTextarea.
*/
#[ApiProperty(example: "Line one\nLine two")]
#[Groups(['read', 'write'])]
#[ORM\Column(type: 'text', nullable: true)]
public ?string $multilineText = null;

/**
* HTML, for ApiRichtext.
*/
#[ApiProperty(example: '<p>Rich <strong>text</strong></p>')]
#[Groups(['read', 'write'])]
#[ORM\Column(type: 'text', nullable: true)]
public ?string $html = null;

/**
* Whole number, for ApiNumberField.
*/
#[ApiProperty(example: 42)]
#[Groups(['read', 'write'])]
#[ORM\Column(type: 'integer', nullable: true)]
public ?int $number = null;

/**
* Boolean, for ApiCheckbox / ApiSwitch.
*/
#[ApiProperty(example: true)]
#[Groups(['read', 'write'])]
#[ORM\Column(type: 'boolean', options: ['default' => false])]
public bool $flag = false;

/**
* Hex color string, for ApiColorField / ApiColorPicker.
*/
#[ApiProperty(example: '#1976d2')]
#[Groups(['read', 'write'])]
#[Assert\Length(max: 9)]
#[ORM\Column(type: 'string', length: 9, nullable: true)]
public ?string $color = null;

/**
* ISO date string (YYYY-MM-DD), for ApiDatePicker.
*/
#[ApiProperty(example: '2024-01-15')]
#[Groups(['read', 'write'])]
#[Assert\Length(max: 10)]
#[ORM\Column(type: 'string', length: 10, nullable: true)]
public ?string $date = null;

/**
* Time of day, for ApiTimePicker.
*
* Stored as a datetime (in UTC) and exchanged as an ISO 8601 string
* (e.g. "2024-01-15T09:30:00+00:00"), which the ApiTimePicker / ETimePicker
* read and write with their default valueFormat "YYYY-MM-DDTHH:mm:ssZ".
*/
#[ApiProperty(example: '2024-01-15T09:30:00+00:00')]
#[Context(
normalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d\TH:i:sP'],
denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d\TH:i:sP']
)]
#[Groups(['read', 'write'])]
#[ORM\Column(type: 'datetime', nullable: true)]
public ?\DateTime $time = null;

/**
* Single locale string (e.g. "en"), for a single-value ApiSelect.
*/
#[ApiProperty(example: 'en')]
#[Groups(['read', 'write'])]
#[Assert\Length(max: 16)]
#[ORM\Column(type: 'string', length: 16, nullable: true)]
public ?string $language = null;

/**
* List of locale strings (e.g. ["en", "de"]), for a multiple ApiSelect.
*
* @var string[]
*/
#[ApiProperty(example: ['en', 'de'])]
#[Groups(['read', 'write'])]
#[ORM\Column(type: 'json')]
public array $languageMultiselect = [];
}
110 changes: 110 additions & 0 deletions api/tests/Api/FormTestData/FormTestDatumTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace App\Tests\Api\FormTestData;

use App\Entity\FormTestDatum;
use App\Tests\Api\ECampApiTestCase;

/**
* Guards the contract of the shared FormTestDatum singleton used to test the
* API form components: authentication is required, there is exactly one row
* that every authenticated user can read and patch, and it cannot be created
* or deleted.
*
* @internal
*/
class FormTestDatumTest extends ECampApiTestCase {
public function testGetCollectionIsDeniedForAnonymousUser() {
static::createBasicClient()->request('GET', '/form_test_data');
$this->assertResponseStatusCodeSame(401);
}

public function testGetItemIsDeniedForAnonymousUser() {
static::createBasicClient()->request('GET', $this->itemIri());
$this->assertResponseStatusCodeSame(401);
}

public function testPatchIsDeniedForAnonymousUser() {
static::createBasicClient()->request('PATCH', $this->itemIri(), [
'json' => ['text' => 'nope'],
'headers' => ['Content-Type' => 'application/merge-patch+json'],
]);
$this->assertResponseStatusCodeSame(401);
}

public function testGetCollectionContainsExactlyTheSingleRow() {
static::createClientWithCredentials()->request('GET', '/form_test_data');
$this->assertResponseStatusCodeSame(200);
$this->assertJsonContains(['totalItems' => 1]);
}

public function testGetItemReturnsTheRow() {
static::createClientWithCredentials()->request('GET', $this->itemIri());
$this->assertResponseStatusCodeSame(200);
$this->assertJsonContains([
'text' => 'Hello',
'multilineText' => "Line one\nLine two",
'html' => '<p>Rich <strong>text</strong></p>',
'number' => 42,
'flag' => true,
'color' => '#1976d2',
'date' => '2024-01-15',
'time' => '2024-01-15T09:30:00+00:00',
'language' => 'en',
'languageMultiselect' => ['en', 'de'],
]);
}

public function testPatchUpdatesFields() {
static::createClientWithCredentials()->request('PATCH', $this->itemIri(), [
'json' => ['text' => 'updated', 'number' => 7, 'flag' => false],
'headers' => ['Content-Type' => 'application/merge-patch+json'],
]);
$this->assertResponseStatusCodeSame(200);
$this->assertJsonContains([
'text' => 'updated',
'number' => 7,
'flag' => false,
]);
}

public function testPatchUpdatesLanguageFields() {
static::createClientWithCredentials()->request('PATCH', $this->itemIri(), [
'json' => ['language' => 'fr', 'languageMultiselect' => ['fr', 'it']],
'headers' => ['Content-Type' => 'application/merge-patch+json'],
]);
$this->assertResponseStatusCodeSame(200);
$this->assertJsonContains([
'language' => 'fr',
'languageMultiselect' => ['fr', 'it'],
]);
}

public function testPatchUpdatesTimeField() {
static::createClientWithCredentials()->request('PATCH', $this->itemIri(), [
'json' => ['time' => '2024-02-16T18:45:00+00:00'],
'headers' => ['Content-Type' => 'application/merge-patch+json'],
]);
$this->assertResponseStatusCodeSame(200);
$this->assertJsonContains(['time' => '2024-02-16T18:45:00+00:00']);
}

public function testCreateIsNotAllowed() {
static::createClientWithCredentials()->request('POST', '/form_test_data', [
'json' => ['text' => 'new'],
]);
$this->assertResponseStatusCodeSame(405);
}

public function testDeleteIsNotAllowed() {
static::createClientWithCredentials()->request('DELETE', $this->itemIri());
$this->assertResponseStatusCodeSame(405);
}

private function itemIri(): string {
/** @var FormTestDatum $entity */
$entity = static::getFixture('formTestDatum');

return '/form_test_data/'.$entity->getId();
}
}
1 change: 1 addition & 0 deletions api/tests/Api/SnapshotTests/EndpointPerformanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ private static function getCollectionEndpoints() {
'/auth/resend_activation' => false,
'/content_nodes' => false,
'/checklist_items' => false,
'/form_test_data' => false,
'/invitations' => false,
'/material_items' => false,
'/personal_invitations' => false,
Expand Down
1 change: 1 addition & 0 deletions api/tests/Api/SnapshotTests/ReadItemFixtureMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public static function get(string $collectionEndpoint, array $fixtures): mixed {
'/comments' => $fixtures['comment1'],
'/day_responsibles' => $fixtures['dayResponsible1'],
'/days' => $fixtures['day1period1'],
'/form_test_data' => $fixtures['formTestDatum'],
'/material_items' => $fixtures['materialItem1'],
'/material_lists' => $fixtures['materialList1'],
'/content_node/material_nodes' => $fixtures['materialNode2'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"_embedded": {
"items": [
{
"_links": {
"self": {
"href": "escaped_value"
}
},
"color": "escaped_value",
"date": "escaped_value",
"flag": "escaped_value",
"html": "escaped_value",
"id": "escaped_value",
"language": "escaped_value",
"languageMultiselect": [
"escaped_value",
"escaped_value"
],
"multilineText": "escaped_value",
"number": "escaped_value",
"text": "escaped_value",
"time": "escaped_value"
}
]
},
"_links": {
"items": [
{
"href": "escaped_value"
}
],
"self": {
"href": "escaped_value"
}
},
"totalItems": "escaped_value"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"_links": {
"self": {
"href": "escaped_value"
}
},
"color": "escaped_value",
"date": "escaped_value",
"flag": "escaped_value",
"html": "escaped_value",
"id": "escaped_value",
"language": "escaped_value",
"languageMultiselect": [
"escaped_value",
"escaped_value"
],
"multilineText": "escaped_value",
"number": "escaped_value",
"text": "escaped_value",
"time": "escaped_value"
}
Loading
Loading