From c7c793a084bbec5ee6d5d72619f07d9fbf4f5bb4 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:10:54 +0100 Subject: [PATCH 1/8] fix(e2e): mock gutendex.com to fix flaky E2E write tests The BookCreate and BookEdit E2E tests depend on the external gutendex.com API for both browser-side autocomplete and server-side book URL validation. When gutendex.com is slow or unreachable, the tests fail intermittently. This adds gutendex.com mocking following the same pattern already used for openlibrary.org: - Docker network alias in compose.e2e.yaml - HTTPS mock server routing for gutendex search/book endpoints - Playwright browser-level route mocks in AbstractPage - Mock JSON fixtures for Asimov search results - Parameterized TLS verify_peer for PHP scoped HTTP clients Co-Authored-By: Claude Opus 4.6 (1M context) --- api/.env | 1 + api/config/packages/framework.yaml | 4 ++ compose.e2e.yaml | 7 +++ e2e/mock-server/server.js | 12 ++++-- e2e/tests/admin/pages/AbstractPage.ts | 14 ++++++ e2e/tests/mocks/gutendex.com/books/31547.json | 18 ++++++++ e2e/tests/mocks/gutendex.com/books/41547.json | 18 ++++++++ .../mocks/gutendex.com/search/Asimov.json | 43 +++++++++++++++++++ 8 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 e2e/tests/mocks/gutendex.com/books/31547.json create mode 100644 e2e/tests/mocks/gutendex.com/books/41547.json create mode 100644 e2e/tests/mocks/gutendex.com/search/Asimov.json diff --git a/api/.env b/api/.env index 10629fa72..da4a9020f 100644 --- a/api/.env +++ b/api/.env @@ -59,3 +59,4 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=https://localhost ###< symfony/routing ### +HTTP_CLIENT_VERIFY_PEER=1 diff --git a/api/config/packages/framework.yaml b/api/config/packages/framework.yaml index 55fdc7bfd..dbb448fb4 100644 --- a/api/config/packages/framework.yaml +++ b/api/config/packages/framework.yaml @@ -23,8 +23,12 @@ framework: base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/' open_library.client: base_uri: 'https://openlibrary.org/' + verify_peer: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' + verify_host: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' gutendex.client: base_uri: 'https://gutendex.com/' + verify_peer: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' + verify_host: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' when@test: framework: diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 6264b9a74..03c705368 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -10,6 +10,13 @@ services: aliases: - openlibrary.org - covers.openlibrary.org + - gutendex.com + + php: + environment: + HTTP_CLIENT_VERIFY_PEER: "0" + depends_on: + - mock-openlibrary pwa: environment: diff --git a/e2e/mock-server/server.js b/e2e/mock-server/server.js index a6b83b6bf..91dcdffe8 100644 --- a/e2e/mock-server/server.js +++ b/e2e/mock-server/server.js @@ -12,8 +12,8 @@ if (!fs.existsSync(KEY_PATH) || !fs.existsSync(CERT_PATH)) { console.log("Generating self-signed certificates..."); execSync( `openssl req -x509 -newkey rsa:2048 -keyout ${KEY_PATH} -out ${CERT_PATH} ` + - `-days 365 -nodes -subj '/CN=openlibrary.org' ` + - `-addext 'subjectAltName=DNS:openlibrary.org,DNS:covers.openlibrary.org'` + `-days 365 -nodes -subj '/CN=mock-server' ` + + `-addext 'subjectAltName=DNS:openlibrary.org,DNS:covers.openlibrary.org,DNS:gutendex.com'` ); } @@ -26,12 +26,18 @@ const server = https.createServer( // Try direct file path first (e.g. /books/OL2055137M.json) let filePath = path.join(MOCKS_DIR, host, url.pathname); - // Handle search.json?q=Title Author&limit=10 -> search/Title-Author.json + // Handle openlibrary search: /search.json?q=Title Author&limit=10 -> search/Title-Author.json if (url.pathname === "/search.json" && url.searchParams.has("q")) { const query = url.searchParams.get("q").replace(/\s+/g, "-"); filePath = path.join(MOCKS_DIR, host, "search", `${query}.json`); } + // Handle gutendex search: /books?search=Asimov -> search/Asimov.json + if (host === "gutendex.com" && url.pathname === "/books" && url.searchParams.has("search")) { + const query = url.searchParams.get("search").replace(/\s+/g, "-"); + filePath = path.join(MOCKS_DIR, host, "search", `${query}.json`); + } + if (fs.existsSync(filePath)) { const ext = path.extname(filePath); res.writeHead(200, { diff --git a/e2e/tests/admin/pages/AbstractPage.ts b/e2e/tests/admin/pages/AbstractPage.ts index 8185c3e0d..1ef784b17 100644 --- a/e2e/tests/admin/pages/AbstractPage.ts +++ b/e2e/tests/admin/pages/AbstractPage.ts @@ -34,5 +34,19 @@ export abstract class AbstractPage { await this.page.route(/^https:\/\/covers\.openlibrary.org\/b\/id\/(.+)\.jpg$/, (route) => route.fulfill({ path: "tests/mocks/covers.openlibrary.org/b/id/4066031-M.jpg", })); + // Gutendex mocks + await this.page.route(/^https:\/\/gutendex\.com\/books\?search=/, (route) => { + const url = new URL(route.request().url()); + const query = url.searchParams.get("search")?.replace(/\s+/g, "-") ?? "unknown"; + return route.fulfill({ + path: `tests/mocks/gutendex.com/search/${query}.json`, + }); + }); + await this.page.route(/^https:\/\/gutendex\.com\/books\/(\d+)\.json$/, (route) => { + const match = route.request().url().match(/\/books\/(\d+)\.json/); + return route.fulfill({ + path: `tests/mocks/gutendex.com/books/${match?.[1]}.json`, + }); + }); } } diff --git a/e2e/tests/mocks/gutendex.com/books/31547.json b/e2e/tests/mocks/gutendex.com/books/31547.json new file mode 100644 index 000000000..e4d8c0313 --- /dev/null +++ b/e2e/tests/mocks/gutendex.com/books/31547.json @@ -0,0 +1,18 @@ +{ + "id": 31547, + "title": "Let's Get Together", + "authors": [ + { + "name": "Asimov, Isaac", + "birth_year": 1920, + "death_year": 1992 + } + ], + "subjects": ["Science fiction"], + "bookshelves": [], + "languages": ["en"], + "copyright": false, + "media_type": "Text", + "formats": {}, + "download_count": 1000 +} diff --git a/e2e/tests/mocks/gutendex.com/books/41547.json b/e2e/tests/mocks/gutendex.com/books/41547.json new file mode 100644 index 000000000..33c597d68 --- /dev/null +++ b/e2e/tests/mocks/gutendex.com/books/41547.json @@ -0,0 +1,18 @@ +{ + "id": 41547, + "title": "The Genetic Effects of Radiation", + "authors": [ + { + "name": "Asimov, Isaac", + "birth_year": 1920, + "death_year": 1992 + } + ], + "subjects": ["Science"], + "bookshelves": [], + "languages": ["en"], + "copyright": false, + "media_type": "Text", + "formats": {}, + "download_count": 500 +} diff --git a/e2e/tests/mocks/gutendex.com/search/Asimov.json b/e2e/tests/mocks/gutendex.com/search/Asimov.json new file mode 100644 index 000000000..89146319c --- /dev/null +++ b/e2e/tests/mocks/gutendex.com/search/Asimov.json @@ -0,0 +1,43 @@ +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 31547, + "title": "Let's Get Together", + "authors": [ + { + "name": "Asimov, Isaac", + "birth_year": 1920, + "death_year": 1992 + } + ], + "subjects": ["Science fiction"], + "bookshelves": [], + "languages": ["en"], + "copyright": false, + "media_type": "Text", + "formats": {}, + "download_count": 1000 + }, + { + "id": 41547, + "title": "The Genetic Effects of Radiation", + "authors": [ + { + "name": "Asimov, Isaac", + "birth_year": 1920, + "death_year": 1992 + } + ], + "subjects": ["Science"], + "bookshelves": [], + "languages": ["en"], + "copyright": false, + "media_type": "Text", + "formats": {}, + "download_count": 500 + } + ] +} From 2cd55ac6b8a0a3e239ad30c5d44708e4cac68736 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:20:31 +0100 Subject: [PATCH 2/8] fix(e2e): always return same gutendex search results MUI Autocomplete triggers onInputChange with the full selected label (e.g. "Let's Get Together - Asimov, Isaac") which fires a new search. Return the same Asimov results for any gutendex search query. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/mock-server/server.js | 6 +++--- e2e/tests/admin/pages/AbstractPage.ts | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/e2e/mock-server/server.js b/e2e/mock-server/server.js index 91dcdffe8..5015a4c37 100644 --- a/e2e/mock-server/server.js +++ b/e2e/mock-server/server.js @@ -32,10 +32,10 @@ const server = https.createServer( filePath = path.join(MOCKS_DIR, host, "search", `${query}.json`); } - // Handle gutendex search: /books?search=Asimov -> search/Asimov.json + // Handle gutendex search: /books?search=... -> always return search/Asimov.json + // (MUI Autocomplete triggers additional searches with the full selected label) if (host === "gutendex.com" && url.pathname === "/books" && url.searchParams.has("search")) { - const query = url.searchParams.get("search").replace(/\s+/g, "-"); - filePath = path.join(MOCKS_DIR, host, "search", `${query}.json`); + filePath = path.join(MOCKS_DIR, host, "search", "Asimov.json"); } if (fs.existsSync(filePath)) { diff --git a/e2e/tests/admin/pages/AbstractPage.ts b/e2e/tests/admin/pages/AbstractPage.ts index 1ef784b17..2e6337341 100644 --- a/e2e/tests/admin/pages/AbstractPage.ts +++ b/e2e/tests/admin/pages/AbstractPage.ts @@ -34,12 +34,11 @@ export abstract class AbstractPage { await this.page.route(/^https:\/\/covers\.openlibrary.org\/b\/id\/(.+)\.jpg$/, (route) => route.fulfill({ path: "tests/mocks/covers.openlibrary.org/b/id/4066031-M.jpg", })); - // Gutendex mocks + // Gutendex mocks — always return the same search results (MUI Autocomplete + // triggers additional searches with the full selected label as query) await this.page.route(/^https:\/\/gutendex\.com\/books\?search=/, (route) => { - const url = new URL(route.request().url()); - const query = url.searchParams.get("search")?.replace(/\s+/g, "-") ?? "unknown"; return route.fulfill({ - path: `tests/mocks/gutendex.com/search/${query}.json`, + path: "tests/mocks/gutendex.com/search/Asimov.json", }); }); await this.page.route(/^https:\/\/gutendex\.com\/books\/(\d+)\.json$/, (route) => { From e7a8df74f1b4cc197509f44c3f070b6f6119f7ef Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:34:25 +0100 Subject: [PATCH 3/8] fix(e2e): mount mock cert in PHP container trust store The %env(bool:...)% approach doesn't work because the Symfony DI container is compiled at Docker build time with verify_peer: true. Instead, mount the mock server's self-signed certificate into the PHP container and append it to the system CA bundle via SSL_CERT_FILE. This lets PHP's cURL trust the mock gutendex.com without changing any Symfony configuration. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/.env | 1 - api/config/packages/framework.yaml | 4 ---- compose.e2e.yaml | 6 +++++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/api/.env b/api/.env index da4a9020f..10629fa72 100644 --- a/api/.env +++ b/api/.env @@ -59,4 +59,3 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=https://localhost ###< symfony/routing ### -HTTP_CLIENT_VERIFY_PEER=1 diff --git a/api/config/packages/framework.yaml b/api/config/packages/framework.yaml index dbb448fb4..55fdc7bfd 100644 --- a/api/config/packages/framework.yaml +++ b/api/config/packages/framework.yaml @@ -23,12 +23,8 @@ framework: base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/' open_library.client: base_uri: 'https://openlibrary.org/' - verify_peer: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' - verify_host: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' gutendex.client: base_uri: 'https://gutendex.com/' - verify_peer: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' - verify_host: '%env(bool:HTTP_CLIENT_VERIFY_PEER)%' when@test: framework: diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 03c705368..8db41c644 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -13,8 +13,12 @@ services: - gutendex.com php: + volumes: + - ./e2e/mock-server:/mock-certs:ro environment: - HTTP_CLIENT_VERIFY_PEER: "0" + # Trust mock server's self-signed cert for gutendex.com / openlibrary.org + SSL_CERT_FILE: /etc/ssl/certs/ca-certificates-with-mock.crt + entrypoint: ["/bin/sh", "-c", "cat /etc/ssl/certs/ca-certificates.crt /mock-certs/cert.pem > /etc/ssl/certs/ca-certificates-with-mock.crt && exec docker-entrypoint \"$$@\"", "--"] depends_on: - mock-openlibrary From cdb5d485fb6ee40169880eeeb23a57cac3f7f182 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:40:49 +0100 Subject: [PATCH 4/8] fix(e2e): wait for mock cert before starting PHP The mock server generates the cert at startup. The PHP container needs to wait for it to exist before concatenating it to the CA bundle. Co-Authored-By: Claude Opus 4.6 (1M context) --- compose.e2e.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 8db41c644..001690733 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -18,7 +18,7 @@ services: environment: # Trust mock server's self-signed cert for gutendex.com / openlibrary.org SSL_CERT_FILE: /etc/ssl/certs/ca-certificates-with-mock.crt - entrypoint: ["/bin/sh", "-c", "cat /etc/ssl/certs/ca-certificates.crt /mock-certs/cert.pem > /etc/ssl/certs/ca-certificates-with-mock.crt && exec docker-entrypoint \"$$@\"", "--"] + entrypoint: ["/bin/sh", "-c", "while [ ! -f /mock-certs/cert.pem ]; do sleep 0.1; done && cat /etc/ssl/certs/ca-certificates.crt /mock-certs/cert.pem > /etc/ssl/certs/ca-certificates-with-mock.crt && exec docker-entrypoint \"$$@\"", "--"] depends_on: - mock-openlibrary From 00fa4f1409880a793149275c86ba7fb322333cbd Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:53:09 +0100 Subject: [PATCH 5/8] fix(e2e): generate cert on host and trust in PHP container Instead of overriding the PHP entrypoint (which broke the container), generate the mock server certificate on the CI host before starting services, then run update-ca-certificates inside the PHP container after it's up. The mock server reuses the pre-generated cert. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 9 +++++++++ compose.e2e.yaml | 6 +----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2677a1649..d90615b93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,9 +169,18 @@ jobs: keycloak.cache-from=type=gha,scope=keycloak-${{ github.ref }} keycloak.cache-from=type=gha,scope=keycloak-refs/heads/main keycloak.cache-to=type=gha,scope=keycloak-${{ github.ref }}-e2e,mode=max + - + name: Generate Mock Server Certificates + run: | + openssl req -x509 -newkey rsa:2048 -keyout e2e/mock-server/key.pem -out e2e/mock-server/cert.pem \ + -days 365 -nodes -subj '/CN=mock-server' \ + -addext 'subjectAltName=DNS:openlibrary.org,DNS:covers.openlibrary.org,DNS:gutendex.com' - name: Start Services run: docker compose up --wait --no-build + - + name: Trust Mock Server Certificate + run: docker compose exec -T php update-ca-certificates - name: Update API Platform if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.minimum-stability != 'stable') }} diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 001690733..78a6d9465 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -14,11 +14,7 @@ services: php: volumes: - - ./e2e/mock-server:/mock-certs:ro - environment: - # Trust mock server's self-signed cert for gutendex.com / openlibrary.org - SSL_CERT_FILE: /etc/ssl/certs/ca-certificates-with-mock.crt - entrypoint: ["/bin/sh", "-c", "while [ ! -f /mock-certs/cert.pem ]; do sleep 0.1; done && cat /etc/ssl/certs/ca-certificates.crt /mock-certs/cert.pem > /etc/ssl/certs/ca-certificates-with-mock.crt && exec docker-entrypoint \"$$@\"", "--"] + - ./e2e/mock-server/cert.pem:/usr/local/share/ca-certificates/mock-server.crt:ro depends_on: - mock-openlibrary From 8c2f2849498706361734dc38826b5bbdb91957c8 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:16:24 +0100 Subject: [PATCH 6/8] fix(api): remove related bookmarks and reviews before deleting a book BookRemoveProcessor now deletes bookmarks and reviews referencing the book before removing it, preventing a ForeignKeyConstraintViolation on the bookmark table. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/State/Processor/BookRemoveProcessor.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/api/src/State/Processor/BookRemoveProcessor.php b/api/src/State/Processor/BookRemoveProcessor.php index 6aa937d65..d2201001c 100644 --- a/api/src/State/Processor/BookRemoveProcessor.php +++ b/api/src/State/Processor/BookRemoveProcessor.php @@ -8,6 +8,8 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Book; +use App\Repository\BookmarkRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; /** @@ -21,6 +23,8 @@ public function __construct( #[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, + private EntityManagerInterface $entityManager, + private BookmarkRepository $bookmarkRepository, ) { } @@ -29,6 +33,15 @@ public function __construct( */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void { + // Remove related bookmarks and reviews before deleting the book + foreach ($this->bookmarkRepository->findBy(['book' => $data]) as $bookmark) { + $this->entityManager->remove($bookmark); + } + foreach ($data->reviews as $review) { + $this->entityManager->remove($review); + } + $this->entityManager->flush(); + // remove entity $this->removeProcessor->process($data, $operation, $uriVariables, $context); } From 8dc506047963f2812c0e67ae8bad20b7385b9850 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:19:37 +0100 Subject: [PATCH 7/8] fix(api): add missing ON DELETE CASCADE on bookmark.book_id FK The Bookmark entity has onDelete: 'CASCADE' on the book relation but the migration never added ON DELETE CASCADE to the actual FK constraint. This caused a ForeignKeyConstraintViolationException when deleting a book that had bookmarks. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/migrations/Version20260318100000.php | 28 +++++++++++++++++++ .../State/Processor/BookRemoveProcessor.php | 13 --------- 2 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 api/migrations/Version20260318100000.php diff --git a/api/migrations/Version20260318100000.php b/api/migrations/Version20260318100000.php new file mode 100644 index 000000000..e5a5a66da --- /dev/null +++ b/api/migrations/Version20260318100000.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D16A2B381'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D16A2B381 FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D16A2B381'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D16A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } +} diff --git a/api/src/State/Processor/BookRemoveProcessor.php b/api/src/State/Processor/BookRemoveProcessor.php index d2201001c..6aa937d65 100644 --- a/api/src/State/Processor/BookRemoveProcessor.php +++ b/api/src/State/Processor/BookRemoveProcessor.php @@ -8,8 +8,6 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Book; -use App\Repository\BookmarkRepository; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; /** @@ -23,8 +21,6 @@ public function __construct( #[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, - private EntityManagerInterface $entityManager, - private BookmarkRepository $bookmarkRepository, ) { } @@ -33,15 +29,6 @@ public function __construct( */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void { - // Remove related bookmarks and reviews before deleting the book - foreach ($this->bookmarkRepository->findBy(['book' => $data]) as $bookmark) { - $this->entityManager->remove($bookmark); - } - foreach ($data->reviews as $review) { - $this->entityManager->remove($review); - } - $this->entityManager->flush(); - // remove entity $this->removeProcessor->process($data, $operation, $uriVariables, $context); } From 4341d0cb9f3570a608cbee8a187864b76d297d9d Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:57:36 +0100 Subject: [PATCH 8/8] fix(api): add #[\Override] attributes to migration methods Co-Authored-By: Claude Opus 4.6 (1M context) --- api/migrations/Version20260318100000.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/migrations/Version20260318100000.php b/api/migrations/Version20260318100000.php index e5a5a66da..a6db9299c 100644 --- a/api/migrations/Version20260318100000.php +++ b/api/migrations/Version20260318100000.php @@ -9,17 +9,20 @@ final class Version20260318100000 extends AbstractMigration { + #[\Override] public function getDescription(): string { return 'Add ON DELETE CASCADE to bookmark.book_id foreign key'; } + #[\Override] public function up(Schema $schema): void { $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D16A2B381'); $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D16A2B381 FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); } + #[\Override] public function down(Schema $schema): void { $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D16A2B381');