= ({ data, hubURL, page }) => {
- {/*todo translate condition*/}
- Condition: {book["condition"].replace("https://schema.org/", "")}
+ Condition: {book["condition"].replace(/https:\/\/schema\.org\/(.+)Condition$/, "$1")}
{!!book["publicationDate"] && (
| Published on {book["publicationDate"]}
)}
diff --git a/pwa/components/bookmark/List.tsx b/pwa/components/bookmark/List.tsx
index 2224bfc51..5b4f8029b 100644
--- a/pwa/components/bookmark/List.tsx
+++ b/pwa/components/bookmark/List.tsx
@@ -8,9 +8,9 @@ import { type PagedCollection } from "@/types/collection";
import { useMercure } from "@/utils/mercure";
interface Props {
- data: PagedCollection | null
- hubURL: string | null
- page: number
+ data: PagedCollection | null;
+ hubURL: string | null;
+ page: number;
}
const getPagePath = (page: number): string => `/bookmarks?page=${page}`;
diff --git a/pwa/components/common/Error.tsx b/pwa/components/common/Error.tsx
index 17cbba2ad..71fb7db28 100644
--- a/pwa/components/common/Error.tsx
+++ b/pwa/components/common/Error.tsx
@@ -1,5 +1,5 @@
interface Props {
- message: string
+ message: string;
}
export const Error = ({ message }: Props) => (
diff --git a/pwa/components/common/Pagination.tsx b/pwa/components/common/Pagination.tsx
index e4f33241b..b40bfb117 100644
--- a/pwa/components/common/Pagination.tsx
+++ b/pwa/components/common/Pagination.tsx
@@ -8,7 +8,7 @@ import { parsePage } from "@/utils/dataAccess";
interface Props {
collection: PagedCollection;
getPagePath: (page: number) => string;
- currentPage: number;
+ currentPage: number | undefined;
}
export const Pagination = ({ collection, getPagePath, currentPage }: Props) => {
diff --git a/pwa/components/review/Form.tsx b/pwa/components/review/Form.tsx
index 82f275e28..557b5d4c2 100644
--- a/pwa/components/review/Form.tsx
+++ b/pwa/components/review/Form.tsx
@@ -9,10 +9,10 @@ import { type Book } from "@/types/Book";
import { type Review } from "@/types/Review";
interface Props {
- book: Book
- onSuccess?: (review: Review) => void,
- review?: Review
- username: string
+ book: Book;
+ onSuccess?: (review: Review) => void;
+ review?: Review;
+ username: string;
}
export const Form: FunctionComponent = ({ book, onSuccess, review, username }) => {
diff --git a/pwa/components/review/Item.tsx b/pwa/components/review/Item.tsx
index 0a168f31e..315837b8d 100644
--- a/pwa/components/review/Item.tsx
+++ b/pwa/components/review/Item.tsx
@@ -9,13 +9,13 @@ import { fetch, type FetchError, type FetchResponse } from "@/utils/dataAccess";
import { Form } from "@/components/review/Form";
interface Props {
- review: Review
- onDelete?: (review: Review) => void
- onEdit?: (review: Review) => void
+ review: Review;
+ onDelete?: (review: Review) => void;
+ onEdit?: (review: Review) => void;
}
interface DeleteParams {
- id: string
+ id: string;
}
const deleteReview = async (id: string) =>
diff --git a/pwa/components/review/List.tsx b/pwa/components/review/List.tsx
index 7f4574757..ad34cd8c3 100644
--- a/pwa/components/review/List.tsx
+++ b/pwa/components/review/List.tsx
@@ -13,8 +13,8 @@ import { Form } from "@/components/review/Form";
import { Loading } from "@/components/common/Loading";
interface Props {
- book: Book
- page: number
+ book: Book;
+ page: number;
}
export const List: FunctionComponent = ({ book, page }) => {
@@ -27,7 +27,6 @@ export const List: FunctionComponent = ({ book, page }) => {
useEffect(() => {
if (status === "loading") return;
- // todo call is done twice
(async () => {
try {
const response: FetchResponse> | undefined = await fetch(`${book["reviews"]}?itemsPerPage=5&page=${page}`);
diff --git a/pwa/pages/books/index.tsx b/pwa/pages/books/index.tsx
index a1bb737f0..b9cb64723 100644
--- a/pwa/pages/books/index.tsx
+++ b/pwa/pages/books/index.tsx
@@ -9,12 +9,13 @@ import { type FiltersProps, buildUriFromFilters } from "@/utils/book";
export const getServerSideProps: GetServerSideProps<{
data: PagedCollection | null,
hubURL: string | null,
- page: number,
filters: FiltersProps,
}> = async ({ query }) => {
- const page = query.page ? Number(query.page) : 1;
-
const filters: FiltersProps = {};
+ if (query.page) {
+ // @ts-ignore
+ filters.page = query.page;
+ }
if (query.author) {
// @ts-ignore
filters.author = query.author;
@@ -31,19 +32,23 @@ export const getServerSideProps: GetServerSideProps<{
} else if (typeof query["condition[]"] === "object") {
filters.condition = query["condition[]"];
}
+ if (query["order[title]"]) {
+ // @ts-ignore
+ filters.order = { title: query["order[title]"] };
+ }
try {
- const response: FetchResponse> | undefined = await fetch(buildUriFromFilters("/books", filters, page));
+ const response: FetchResponse> | undefined = await fetch(buildUriFromFilters("/books", filters));
if (!response?.data) {
throw new Error('Unable to retrieve data from /books.');
}
- return { props: { data: response.data, hubURL: response.hubURL, filters, page } };
+ return { props: { data: response.data, hubURL: response.hubURL, filters } };
} catch (error) {
console.error(error);
}
- return { props: { data: null, hubURL: null, filters, page } };
+ return { props: { data: null, hubURL: null, filters } };
};
export default List;
diff --git a/pwa/utils/book.ts b/pwa/utils/book.ts
index be3ec4353..e4f473bec 100644
--- a/pwa/utils/book.ts
+++ b/pwa/utils/book.ts
@@ -1,84 +1,96 @@
import slugify from "slugify";
+import { useQuery } from "react-query";
+
import { isItem } from "@/types/item";
import { type Book } from "@/types/Book";
import { type Book as OLBook } from "@/types/OpenLibrary/Book";
import { type Work } from "@/types/OpenLibrary/Work";
+interface OrderFilter {
+ title: string;
+}
+
export interface FiltersProps {
author?: string | undefined;
title?: string | undefined;
condition?: string | string[] | undefined;
+ order?: OrderFilter | undefined;
+ page?: number | undefined;
}
-export const populateBook = async (book: TData): Promise => {
- if (!isItem(book)) {
- console.error("Object sent is not in JSON-LD format.");
-
- return book;
+export const useOpenLibraryBook = (data: TData) => {
+ if (!isItem(data)) {
+ throw new Error("Object sent is not in JSON-LD format.");
}
- book["id"] = book["@id"]?.replace("/books/", "");
- book["slug"] = slugify(`${book["title"]}-${book["author"]}`, { lower: true, trim: true, remove: /[*+~.(),;'"!:@]/g });
- book["condition"] = book["condition"].substring(19, book["condition"].length-9);
+ data["id"] = data["@id"]?.replace("/books/", "");
+ data["slug"] = slugify(`${data["title"]}-${data["author"]}`, { lower: true, trim: true, remove: /[*+~.(),;'"!:@]/g });
+ data["condition"] = data["condition"].substring(19, data["condition"].length-9);
- const response = await fetch(book["book"], { method: "GET" });
- const data: OLBook = await response.json();
+ return useQuery(data["book"], async () => {
+ const response = await fetch(data["book"], { method: "GET" });
+ const book: OLBook = await response.json();
- if (typeof data["publish_date"] !== "undefined") {
- book["publicationDate"] = data["publish_date"];
- }
+ if (typeof book["publish_date"] !== "undefined") {
+ data["publicationDate"] = book["publish_date"];
+ }
- if (typeof data["covers"] !== "undefined") {
- book["images"] = {
- medium: `https://covers.openlibrary.org/b/id/${data["covers"][0]}-M.jpg`,
- large: `https://covers.openlibrary.org/b/id/${data["covers"][0]}-L.jpg`,
- };
- }
+ if (typeof book["covers"] !== "undefined") {
+ data["images"] = {
+ medium: `https://covers.openlibrary.org/b/id/${book["covers"][0]}-M.jpg`,
+ large: `https://covers.openlibrary.org/b/id/${book["covers"][0]}-L.jpg`,
+ };
+ }
- if (typeof data["description"] !== "undefined") {
- book["description"] = (typeof data["description"] === "string" ? data["description"] : data["description"]["value"]).replace( /(<([^>]+)>)/ig, '');
- }
+ if (typeof book["description"] !== "undefined") {
+ data["description"] = (typeof book["description"] === "string" ? book["description"] : book["description"]["value"]).replace( /(<([^>]+)>)/ig, '');
+ }
- // retrieve data from work if necessary
- if ((!book["description"] || !book["images"]) && typeof data["works"] !== "undefined" && data["works"].length > 0) {
- const response = await fetch(`https://openlibrary.org${data["works"][0]["key"]}.json`);
- const work: Work = await response.json();
+ // retrieve data from work if necessary
+ if ((!data["description"] || !data["images"]) && typeof book["works"] !== "undefined" && book["works"].length > 0) {
+ const response = await fetch(`https://openlibrary.org${book["works"][0]["key"]}.json`);
+ const work: Work = await response.json();
- if (!book["description"] && typeof work["description"] !== "undefined") {
- book["description"] = (typeof work["description"] === "string" ? work["description"] : work["description"]["value"]).replace( /(<([^>]+)>)/ig, '');
- }
+ if (!data["description"] && typeof work["description"] !== "undefined") {
+ data["description"] = (typeof work["description"] === "string" ? work["description"] : work["description"]["value"]).replace( /(<([^>]+)>)/ig, '');
+ }
- if (!book["images"] && typeof work["covers"] !== "undefined") {
- book["images"] = {
- medium: `https://covers.openlibrary.org/b/id/${work["covers"][0]}-M.jpg`,
- large: `https://covers.openlibrary.org/b/id/${work["covers"][0]}-L.jpg`,
- };
+ if (!data["images"] && typeof work["covers"] !== "undefined") {
+ data["images"] = {
+ medium: `https://covers.openlibrary.org/b/id/${work["covers"][0]}-M.jpg`,
+ large: `https://covers.openlibrary.org/b/id/${work["covers"][0]}-L.jpg`,
+ };
+ }
}
- }
- return book;
+ return data;
+ });
};
-export const buildUriFromFilters = (uri: string, filters: FiltersProps, page: number | undefined = undefined): string => {
+// @ts-ignore
+const filterObject = (object: object) => Object.fromEntries(Object.entries(object).filter(([, value]) => {
+ return typeof value === "object" ? Object.keys(value).length > 0 : value?.length > 0;
+}));
+
+export const buildUriFromFilters = (uri: string, filters: FiltersProps): string => {
// remove empty filters
- filters = Object.fromEntries(Object.entries(filters).filter(([, value]) => value?.length > 0));
+ filters = filterObject(filters);
const params = new URLSearchParams();
Object.keys(filters).forEach((filter: string) => {
// @ts-ignore
const value = filters[filter];
- if (typeof value === "string") {
- params.append(filter, value);
- } else if (typeof value === "object") {
+ if (typeof value === "string" || typeof value === "number") {
+ params.append(filter, value.toString());
+ } else if (Array.isArray(value)) {
value.forEach((v: string) => {
params.append(`${filter}[]`, v);
- })
+ });
+ } else if (typeof value === "object") {
+ // @ts-ignore
+ Object.entries(value).forEach(([k, v]) => params.append(`${filter}[${k}]`, v));
}
});
- if (page) {
- // @ts-ignore
- params.append("page", page);
- }
return `${uri}${params.size === 0 ? "" : `?${params.toString()}`}`;
};
From 18fc2cf4df6b9a20a20e9cd504df3d0f84c15b64 Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Mon, 7 Aug 2023 16:35:05 +0200
Subject: [PATCH 16/51] test: add tests with Mercure
---
api/composer.json | 2 +-
api/composer.lock | 23 ++--
api/src/Entity/Book.php | 20 +++-
api/src/Entity/Bookmark.php | 7 +-
api/src/Entity/Review.php | 26 ++++-
api/src/Entity/User.php | 5 +-
.../State/Processor/BookPersistProcessor.php | 19 ++-
.../State/Processor/BookRemoveProcessor.php | 59 ++++++++++
api/src/State/Processor/MercureProcessor.php | 62 ++++++++++
.../Processor/ReviewPersistProcessor.php | 17 ++-
.../State/Processor/ReviewRemoveProcessor.php | 59 ++++++++++
api/tests/Api/Admin/BookTest.php | 85 +++++++++++++-
api/tests/Api/Admin/ReviewTest.php | 55 ++++++++-
api/tests/Api/BookmarkTest.php | 108 +++++++++++++++++-
api/tests/Api/ReviewTest.php | 81 +++++++++++--
api/tests/Api/Trait/MercureTrait.php | 64 +++++++++++
api/tests/Api/Trait/SerializerTrait.php | 53 +++++++++
17 files changed, 704 insertions(+), 41 deletions(-)
create mode 100644 api/src/State/Processor/BookRemoveProcessor.php
create mode 100644 api/src/State/Processor/MercureProcessor.php
create mode 100644 api/src/State/Processor/ReviewRemoveProcessor.php
create mode 100644 api/tests/Api/Trait/MercureTrait.php
create mode 100644 api/tests/Api/Trait/SerializerTrait.php
diff --git a/api/composer.json b/api/composer.json
index 245808a63..6946b8ee2 100644
--- a/api/composer.json
+++ b/api/composer.json
@@ -12,7 +12,7 @@
"ext-ctype": "*",
"ext-iconv": "*",
"ext-xml": "*",
- "api-platform/core": "dev-fix/issues/5662",
+ "api-platform/core": "dev-demo",
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.12",
diff --git a/api/composer.lock b/api/composer.lock
index f790e88af..92ba08bc9 100644
--- a/api/composer.lock
+++ b/api/composer.lock
@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "95597546d29879426282d52cf97d5681",
+ "content-hash": "556e2634ee5ee54f81424ff814ccaa8f",
"packages": [
{
"name": "api-platform/core",
- "version": "dev-fix/issues/5662",
+ "version": "dev-demo",
"source": {
"type": "git",
"url": "https://github.com/vincentchalamon/core.git",
- "reference": "7690b1da32e885aec51a7cdbd135e797905cca45"
+ "reference": "daed45503aaa6cabfe22374654a54dc32615956d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/vincentchalamon/core/zipball/7690b1da32e885aec51a7cdbd135e797905cca45",
- "reference": "7690b1da32e885aec51a7cdbd135e797905cca45",
+ "url": "https://api.github.com/repos/vincentchalamon/core/zipball/daed45503aaa6cabfe22374654a54dc32615956d",
+ "reference": "daed45503aaa6cabfe22374654a54dc32615956d",
"shasum": ""
},
"require": {
@@ -48,10 +48,9 @@
},
"require-dev": {
"behat/behat": "^3.1",
- "behat/mink": "^1.9@dev",
+ "behat/mink": "^1.9",
"doctrine/cache": "^1.11 || ^2.1",
"doctrine/common": "^3.2.2",
- "doctrine/data-fixtures": "^1.2.2",
"doctrine/dbal": "^3.4.0",
"doctrine/doctrine-bundle": "^1.12 || ^2.0",
"doctrine/mongodb-odm": "^2.2",
@@ -72,8 +71,8 @@
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-symfony": "^1.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
- "ramsey/uuid": "^3.7 || ^4.0",
- "ramsey/uuid-doctrine": "^1.4",
+ "ramsey/uuid": "^3.9.7 || ^4.0",
+ "ramsey/uuid-doctrine": "^1.4 || ^2.0",
"soyuka/contexts": "v3.3.9",
"soyuka/stubs-mongodb": "^1.0",
"symfony/asset": "^6.1",
@@ -82,7 +81,7 @@
"symfony/config": "^6.1",
"symfony/console": "^6.1",
"symfony/css-selector": "^6.1",
- "symfony/dependency-injection": "^6.1",
+ "symfony/dependency-injection": "^6.1.12",
"symfony/doctrine-bridge": "^6.1",
"symfony/dom-crawler": "^6.1",
"symfony/error-handler": "^6.1",
@@ -170,7 +169,7 @@
"Swagger"
],
"support": {
- "source": "https://github.com/vincentchalamon/core/tree/fix/issues/5662"
+ "source": "https://github.com/vincentchalamon/core/tree/demo"
},
"funding": [
{
@@ -178,7 +177,7 @@
"url": "https://tidelift.com/funding/github/packagist/api-platform/core"
}
],
- "time": "2023-08-04T16:20:47+00:00"
+ "time": "2023-08-07T07:08:54+00:00"
},
{
"name": "brick/math",
diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php
index 00f796c46..84bfe958a 100644
--- a/api/src/Entity/Book.php
+++ b/api/src/Entity/Book.php
@@ -18,6 +18,7 @@
use App\Enum\BookCondition;
use App\Repository\BookRepository;
use App\State\Processor\BookPersistProcessor;
+use App\State\Processor\BookRemoveProcessor;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
@@ -39,6 +40,7 @@
itemUriTemplate: '/admin/books/{id}{._format}'
),
new Post(
+ // Mercure publish is done manually in MercureProcessor through BookPersistProcessor
processor: BookPersistProcessor::class,
itemUriTemplate: '/admin/books/{id}{._format}'
),
@@ -48,15 +50,21 @@
// https://github.com/api-platform/admin/issues/370
new Put(
uriTemplate: '/admin/books/{id}{._format}',
+ // Mercure publish is done manually in MercureProcessor through BookPersistProcessor
processor: BookPersistProcessor::class
),
new Delete(
- uriTemplate: '/admin/books/{id}{._format}'
+ uriTemplate: '/admin/books/{id}{._format}',
+ // Mercure publish is done manually in MercureProcessor through BookRemoveProcessor
+ processor: BookRemoveProcessor::class
),
],
- normalizationContext: ['groups' => ['Book:read:admin', 'Enum:read']],
+ normalizationContext: [
+ 'item_uri_template' => '/admin/books/{id}{._format}',
+ 'groups' => ['Book:read:admin', 'Enum:read'],
+ 'skip_null_values' => true,
+ ],
denormalizationContext: ['groups' => ['Book:write']],
- mercure: true, // todo ensure mercure message is sent to "/books/*" too
security: 'is_granted("ROLE_ADMIN")'
)]
#[ApiResource(
@@ -67,7 +75,11 @@
),
new Get(),
],
- normalizationContext: ['groups' => ['Book:read', 'Enum:read']]
+ normalizationContext: [
+ 'item_uri_template' => '/books/{id}{._format}',
+ 'groups' => ['Book:read', 'Enum:read'],
+ 'skip_null_values' => true,
+ ]
)]
#[ORM\Entity(repositoryClass: BookRepository::class)]
#[UniqueEntity(fields: ['book'])]
diff --git a/api/src/Entity/Bookmark.php b/api/src/Entity/Bookmark.php
index 17e751aac..b4d46ac43 100644
--- a/api/src/Entity/Bookmark.php
+++ b/api/src/Entity/Bookmark.php
@@ -9,7 +9,7 @@
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
-use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Repository\BookmarkRepository;
@@ -30,13 +30,16 @@
types: ['https://schema.org/BookmarkAction'],
operations: [
new GetCollection(),
- new Get(),
+ new Delete(
+ security: 'is_granted("ROLE_USER") and object.user === user'
+ ),
new Post(
processor: BookmarkPersistProcessor::class
),
],
normalizationContext: [
'groups' => ['Bookmark:read'],
+ 'skip_null_values' => true,
IriTransformerNormalizer::CONTEXT_KEY => [
'book' => '/books/{id}{._format}',
],
diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php
index d27198146..71e0611e6 100644
--- a/api/src/Entity/Review.php
+++ b/api/src/Entity/Review.php
@@ -10,12 +10,14 @@
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\NotExposed;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\ReviewRepository;
use App\Serializer\IriTransformerNormalizer;
use App\State\Processor\ReviewPersistProcessor;
+use App\State\Processor\ReviewRemoveProcessor;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
@@ -41,10 +43,14 @@
),
// https://github.com/api-platform/admin/issues/370
new Put(
- uriTemplate: '/admin/reviews/{id}{._format}'
+ uriTemplate: '/admin/reviews/{id}{._format}',
+ // Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
+ processor: ReviewPersistProcessor::class
),
new Delete(
- uriTemplate: '/admin/reviews/{id}{._format}'
+ uriTemplate: '/admin/reviews/{id}{._format}',
+ // Mercure publish is done manually in MercureProcessor through ReviewRemoveProcessor
+ processor: ReviewRemoveProcessor::class
),
],
normalizationContext: [
@@ -52,10 +58,11 @@
'book' => '/admin/books/{id}{._format}',
'user' => '/admin/users/{id}{._format}',
],
+ 'item_uri_template' => '/admin/reviews/{id}{._format}',
+ 'skip_null_values' => true,
'groups' => ['Review:read', 'Review:read:admin'],
],
denormalizationContext: ['groups' => ['Review:write']],
- mercure: true,
security: 'is_granted("ROLE_ADMIN")'
)]
#[ApiResource(
@@ -69,7 +76,7 @@
itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}',
paginationClientItemsPerPage: true
),
- new Get(
+ new NotExposed(
uriTemplate: '/books/{bookId}/reviews/{id}{._format}',
uriVariables: [
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
@@ -78,6 +85,7 @@
),
new Post(
security: 'is_granted("ROLE_USER")',
+ // Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
processor: ReviewPersistProcessor::class,
itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}'
),
@@ -87,7 +95,9 @@
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
'id' => new Link(fromClass: Review::class),
],
- security: 'is_granted("ROLE_USER") and user == object.user'
+ security: 'is_granted("ROLE_USER") and user == object.user',
+ // Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
+ processor: ReviewPersistProcessor::class
),
new Delete(
uriTemplate: '/books/{bookId}/reviews/{id}{._format}',
@@ -95,7 +105,9 @@
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
'id' => new Link(fromClass: Review::class),
],
- security: 'is_granted("ROLE_USER") and user == object.user'
+ security: 'is_granted("ROLE_USER") and user == object.user',
+ // Mercure publish is done manually in MercureProcessor through ReviewRemoveProcessor
+ processor: ReviewRemoveProcessor::class
),
],
normalizationContext: [
@@ -103,6 +115,8 @@
'book' => '/books/{id}{._format}',
'user' => '/users/{id}{._format}',
],
+ 'item_uri_template' => '/books/{bookId}/reviews/{id}{._format}',
+ 'skip_null_values' => true,
'groups' => ['Review:read'],
],
denormalizationContext: ['groups' => ['Review:write']]
diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php
index 4c2e3e59e..1a5e69aa3 100644
--- a/api/src/Entity/User.php
+++ b/api/src/Entity/User.php
@@ -32,7 +32,10 @@
security: 'is_granted("ROLE_USER") and object.getUserIdentifier() === user.getUserIdentifier()'
),
],
- normalizationContext: ['groups' => ['User:read']]
+ normalizationContext: [
+ 'groups' => ['User:read'],
+ 'skip_null_values' => true,
+ ]
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
diff --git a/api/src/State/Processor/BookPersistProcessor.php b/api/src/State/Processor/BookPersistProcessor.php
index a8eff8115..4b0af418b 100644
--- a/api/src/State/Processor/BookPersistProcessor.php
+++ b/api/src/State/Processor/BookPersistProcessor.php
@@ -17,7 +17,9 @@
{
public function __construct(
#[Autowire(service: PersistProcessor::class)]
- private ProcessorInterface $processor,
+ private ProcessorInterface $persistProcessor,
+ #[Autowire(service: MercureProcessor::class)]
+ private ProcessorInterface $mercureProcessor,
private HttpClientInterface $client,
private DecoderInterface $decoder
) {
@@ -40,7 +42,20 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
}
// save entity
- $this->processor->process($data, $operation, $uriVariables, $context);
+ $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+
+ // publish on Mercure
+ // todo find a way to do it in API Platform
+ foreach (['/admin/books/{id}{._format}', '/books/{id}{._format}'] as $uriTemplate) {
+ $this->mercureProcessor->process(
+ $data,
+ $operation,
+ $uriVariables,
+ $context + [
+ 'item_uri_template' => $uriTemplate,
+ ]
+ );
+ }
return $data;
}
diff --git a/api/src/State/Processor/BookRemoveProcessor.php b/api/src/State/Processor/BookRemoveProcessor.php
new file mode 100644
index 000000000..60154b9b7
--- /dev/null
+++ b/api/src/State/Processor/BookRemoveProcessor.php
@@ -0,0 +1,59 @@
+removeProcessor->process($data, $operation, $uriVariables, $context);
+
+ // publish on Mercure
+ // todo find a way to do it in API Platform
+ foreach (['/admin/books/{id}{._format}', '/books/{id}{._format}'] as $uriTemplate) {
+ $iri = $this->iriConverter->getIriFromResource(
+ $object,
+ UrlGeneratorInterface::ABS_URL,
+ $this->resourceMetadataCollectionFactory->create(Book::class)->getOperation($uriTemplate)
+ );
+ $this->mercureProcessor->process(
+ $object,
+ $operation,
+ $uriVariables,
+ $context + [
+ 'item_uri_template' => $uriTemplate,
+ 'data' => json_encode(['@id' => $iri]),
+ ]
+ );
+ }
+
+ return $data;
+ }
+}
diff --git a/api/src/State/Processor/MercureProcessor.php b/api/src/State/Processor/MercureProcessor.php
new file mode 100644
index 000000000..5ee7ad674
--- /dev/null
+++ b/api/src/State/Processor/MercureProcessor.php
@@ -0,0 +1,62 @@
+resourceMetadataCollectionFactory->create($data::class)->getOperation($context['item_uri_template']);
+ }
+ if (!isset($context['topics'])) {
+ $context['topics'] = [$this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_URL, $operation)];
+ }
+ if (!isset($context['data'])) {
+ $context['data'] = $this->serializer->serialize(
+ $data,
+ key($this->formats),
+ ($operation->getNormalizationContext() ?? [] + [
+ 'item_uri_template' => $context['item_uri_template'] ?? null,
+ ])
+ );
+ }
+
+ $this->hubRegistry->getHub()->publish(new Update(
+ topics: $context['topics'],
+ data: $context['data']
+ ));
+
+ return $data;
+ }
+}
diff --git a/api/src/State/Processor/ReviewPersistProcessor.php b/api/src/State/Processor/ReviewPersistProcessor.php
index d589e9de7..0f7407453 100644
--- a/api/src/State/Processor/ReviewPersistProcessor.php
+++ b/api/src/State/Processor/ReviewPersistProcessor.php
@@ -17,7 +17,9 @@
public function __construct(
#[Autowire(service: ReviewRepository::class)]
private ObjectRepository $repository,
- private Security $security
+ private Security $security,
+ #[Autowire(service: MercureProcessor::class)]
+ private ProcessorInterface $mercureProcessor
) {
}
@@ -32,6 +34,19 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
// save entity
$this->repository->save($data, true);
+ // publish on Mercure
+ // todo find a way to do it in API Platform
+ foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) {
+ $this->mercureProcessor->process(
+ $data,
+ $operation,
+ $uriVariables,
+ $context + [
+ 'item_uri_template' => $uriTemplate,
+ ]
+ );
+ }
+
return $data;
}
}
diff --git a/api/src/State/Processor/ReviewRemoveProcessor.php b/api/src/State/Processor/ReviewRemoveProcessor.php
new file mode 100644
index 000000000..81228e224
--- /dev/null
+++ b/api/src/State/Processor/ReviewRemoveProcessor.php
@@ -0,0 +1,59 @@
+removeProcessor->process($data, $operation, $uriVariables, $context);
+
+ // publish on Mercure
+ // todo find a way to do it in API Platform
+ foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) {
+ $iri = $this->iriConverter->getIriFromResource(
+ $object,
+ UrlGeneratorInterface::ABS_URL,
+ $this->resourceMetadataCollectionFactory->create(Review::class)->getOperation($uriTemplate)
+ );
+ $this->mercureProcessor->process(
+ $object,
+ $operation,
+ $uriVariables,
+ $context + [
+ 'item_uri_template' => $uriTemplate,
+ 'data' => json_encode(['@id' => $iri]),
+ ]
+ );
+ }
+
+ return $data;
+ }
+}
diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php
index a0f584360..1f9e56f47 100644
--- a/api/tests/Api/Admin/BookTest.php
+++ b/api/tests/Api/Admin/BookTest.php
@@ -8,10 +8,14 @@
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\DataFixtures\Factory\BookFactory;
use App\DataFixtures\Factory\UserFactory;
+use App\Entity\Book;
use App\Enum\BookCondition;
+use App\Repository\BookRepository;
use App\Security\OidcTokenGenerator;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
+use App\Tests\Api\Trait\MercureTrait;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mercure\Update;
use Zenstruck\Foundry\FactoryCollection;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -19,6 +23,7 @@
final class BookTest extends ApiTestCase
{
use Factories;
+ use MercureTrait;
use ResetDatabase;
use UsersDataProviderTrait;
@@ -299,6 +304,7 @@ public function getInvalidData(): iterable
/**
* @group apiCall
+ * @group mercure
*/
public function testAsAdminUserICanCreateABook(): void
{
@@ -306,7 +312,7 @@ public function testAsAdminUserICanCreateABook(): void
'email' => UserFactory::createOneAdmin()->email,
]);
- $this->client->request('POST', '/admin/books', [
+ $response = $this->client->request('POST', '/admin/books', [
'auth_bearer' => $token,
'json' => [
'book' => 'https://openlibrary.org/books/OL28346544M.json',
@@ -321,6 +327,32 @@ public function testAsAdminUserICanCreateABook(): void
'condition' => BookCondition::NewCondition->value,
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json'));
+ $id = preg_replace('/^.*\/(.+)$/', '$1', $response->toArray()['@id']);
+ /** @var Book $book */
+ $book = self::getContainer()->get(BookRepository::class)->find($id);
+ self::assertCount(2, self::getMercureMessages());
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/admin/books/'.$book->getId()],
+ data: self::serialize(
+ $book,
+ 'jsonld',
+ self::getOperationNormalizationContext(Book::class, '/admin/books/{id}{._format}')
+ ),
+ ),
+ self::getMercureMessage()
+ );
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/books/'.$book->getId()],
+ data: self::serialize(
+ $book,
+ 'jsonld',
+ self::getOperationNormalizationContext(Book::class, '/books/{id}{._format}')
+ ),
+ ),
+ self::getMercureMessage(1)
+ );
}
/**
@@ -401,12 +433,14 @@ public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, ar
/**
* @group apiCall
+ * @group mercure
*/
public function testAsAdminUserICanUpdateABook(): void
{
$book = BookFactory::createOne([
'book' => 'https://openlibrary.org/books/OL28346544M.json',
]);
+ self::getMercureHub()->reset();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => UserFactory::createOneAdmin()->email,
@@ -425,6 +459,29 @@ public function testAsAdminUserICanUpdateABook(): void
'condition' => BookCondition::DamagedCondition->value,
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Book/item.json'));
+ self::assertCount(2, self::getMercureMessages());
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/admin/books/'.$book->getId()],
+ data: self::serialize(
+ $book->object(),
+ 'jsonld',
+ self::getOperationNormalizationContext(Book::class, '/admin/books/{id}{._format}')
+ ),
+ ),
+ self::getMercureMessage()
+ );
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/books/'.$book->getId()],
+ data: self::serialize(
+ $book->object(),
+ 'jsonld',
+ self::getOperationNormalizationContext(Book::class, '/books/{id}{._format}')
+ ),
+ ),
+ self::getMercureMessage(1)
+ );
}
/**
@@ -467,17 +524,39 @@ public function testAsAdminUserICannotDeleteAnInvalidBook(): void
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
+ /**
+ * @group mercure
+ */
public function testAsAdminUserICanDeleteABook(): void
{
- $book = BookFactory::createOne();
+ $book = BookFactory::createOne()->disableAutoRefresh();
+ self::getMercureHub()->reset();
+ $id = $book->getId();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => UserFactory::createOneAdmin()->email,
]);
- $response = $this->client->request('DELETE', '/admin/books/'.$book->getId(), ['auth_bearer' => $token]);
+ $response = $this->client->request('DELETE', '/admin/books/'.$id, ['auth_bearer' => $token]);
self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT);
self::assertEmpty($response->getContent());
+ self::assertNull(self::getContainer()->get(BookRepository::class)->find($id));
+ self::assertCount(2, self::getMercureMessages());
+ // todo how to ensure it's a delete update
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/admin/books/'.$id],
+ data: json_encode(['@id' => 'http://localhost/admin/books/'.$id])
+ ),
+ self::getMercureMessage()
+ );
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/books/'.$id],
+ data: json_encode(['@id' => 'http://localhost/books/'.$id])
+ ),
+ self::getMercureMessage(1)
+ );
}
}
diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php
index 81afb8068..49e36bd36 100644
--- a/api/tests/Api/Admin/ReviewTest.php
+++ b/api/tests/Api/Admin/ReviewTest.php
@@ -10,11 +10,14 @@
use App\DataFixtures\Factory\ReviewFactory;
use App\DataFixtures\Factory\UserFactory;
use App\Entity\Book;
+use App\Entity\Review;
use App\Entity\User;
use App\Repository\ReviewRepository;
use App\Security\OidcTokenGenerator;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
+use App\Tests\Api\Trait\MercureTrait;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mercure\Update;
use Zenstruck\Foundry\FactoryCollection;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -22,6 +25,7 @@
final class ReviewTest extends ApiTestCase
{
use Factories;
+ use MercureTrait;
use ResetDatabase;
use UsersDataProviderTrait;
@@ -240,6 +244,9 @@ public function testAsAdminUserICannotUpdateAnInvalidReview(): void
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
+ /**
+ * @group mercure
+ */
public function testAsAdminUserICanUpdateAReview(): void
{
$review = ReviewFactory::createOne();
@@ -263,6 +270,29 @@ public function testAsAdminUserICanUpdateAReview(): void
'rating' => 5,
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json'));
+ self::assertCount(2, self::getMercureMessages());
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/admin/reviews/'.$review->getId()],
+ data: self::serialize(
+ $review->object(),
+ 'jsonld',
+ self::getOperationNormalizationContext(Review::class, '/admin/reviews/{id}{._format}')
+ ),
+ ),
+ self::getMercureMessage()
+ );
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/books/'.$review->book->getId().'/reviews/'.$review->getId()],
+ data: self::serialize(
+ $review->object(),
+ 'jsonld',
+ self::getOperationNormalizationContext(Review::class, '/books/{bookId}/reviews/{id}{._format}')
+ ),
+ ),
+ self::getMercureMessage(1)
+ );
}
/**
@@ -303,18 +333,41 @@ public function testAsAdminUserICannotDeleteAnInvalidReview(): void
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
+ /**
+ * @group mercure
+ */
public function testAsAdminUserICanDeleteAReview(): void
{
$review = ReviewFactory::createOne()->disableAutoRefresh();
$id = $review->getId();
+ $bookId = $review->book->getId();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => UserFactory::createOneAdmin()->email,
]);
- $this->client->request('DELETE', '/admin/reviews/'.$review->getId(), ['auth_bearer' => $token]);
+ $response = $this->client->request('DELETE', '/admin/reviews/'.$review->getId(), [
+ 'auth_bearer' => $token,
+ ]);
self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT);
+ self::assertEmpty($response->getContent());
self::assertNull(self::getContainer()->get(ReviewRepository::class)->find($id));
+ self::assertCount(2, self::getMercureMessages());
+ // todo how to ensure it's a delete update
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/admin/reviews/'.$id],
+ data: json_encode(['@id' => 'http://localhost/admin/reviews/'.$id])
+ ),
+ self::getMercureMessage()
+ );
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/books/'.$bookId.'/reviews/'.$id],
+ data: json_encode(['@id' => 'http://localhost/books/'.$bookId.'/reviews/'.$id])
+ ),
+ self::getMercureMessage(1)
+ );
}
}
diff --git a/api/tests/Api/BookmarkTest.php b/api/tests/Api/BookmarkTest.php
index 85c24361a..a030ecf67 100644
--- a/api/tests/Api/BookmarkTest.php
+++ b/api/tests/Api/BookmarkTest.php
@@ -9,14 +9,19 @@
use App\DataFixtures\Factory\BookFactory;
use App\DataFixtures\Factory\BookmarkFactory;
use App\DataFixtures\Factory\UserFactory;
+use App\Entity\Bookmark;
+use App\Repository\BookmarkRepository;
use App\Security\OidcTokenGenerator;
+use App\Tests\Api\Trait\MercureTrait;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mercure\Update;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
final class BookmarkTest extends ApiTestCase
{
use Factories;
+ use MercureTrait;
use ResetDatabase;
private Client $client;
@@ -114,16 +119,20 @@ public function testAsAUserICannotCreateABookmarkWithInvalidData(): void
]);
}
+ /**
+ * @group mercure
+ */
public function testAsAUserICanCreateABookmark(): void
{
$book = BookFactory::createOne(['book' => 'https://openlibrary.org/books/OL28346544M.json']);
$user = UserFactory::createOne();
+ self::getMercureHub()->reset();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => $user->email,
]);
- $this->client->request('POST', '/bookmarks', [
+ $response = $this->client->request('POST', '/bookmarks', [
'json' => [
'book' => '/books/'.$book->getId(),
],
@@ -138,5 +147,102 @@ public function testAsAUserICanCreateABookmark(): void
],
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Bookmark/item.json'));
+ $id = preg_replace('/^.*\/(.+)$/', '$1', $response->toArray()['@id']);
+ $object = self::getContainer()->get(BookmarkRepository::class)->find($id);
+ self::assertCount(1, self::getMercureMessages());
+ self::assertEquals(
+ self::getMercureMessage(),
+ new Update(
+ topics: ['http://localhost/bookmarks/'.$id],
+ data: self::serialize(
+ $object,
+ 'jsonld',
+ self::getOperationNormalizationContext(Bookmark::class, '/bookmarks/{id}{._format}')
+ )
+ )
+ );
+ }
+
+ public function testAsAnonymousICannotDeleteABookmark(): void
+ {
+ $bookmark = BookmarkFactory::createOne();
+
+ $this->client->request('DELETE', '/bookmarks/'.$bookmark->getId());
+
+ self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
+ self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
+ self::assertJsonContains([
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'Full authentication is required to access this resource.',
+ ]);
+ }
+
+ public function testAsAUserICannotDeleteABookmarkOfAnotherUser(): void
+ {
+ $bookmark = BookmarkFactory::createOne(['user' => UserFactory::createOne()]);
+
+ $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ 'email' => UserFactory::createOne()->email,
+ ]);
+
+ $this->client->request('DELETE', '/bookmarks/'.$bookmark->getId(), [
+ 'auth_bearer' => $token,
+ ]);
+
+ self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
+ self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
+ self::assertJsonContains([
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'Access Denied.',
+ ]);
+ }
+
+ public function testAsAUserICannotDeleteAnInvalidBookmark(): void
+ {
+ $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ 'email' => UserFactory::createOne()->email,
+ ]);
+
+ $this->client->request('DELETE', '/bookmarks/invalid', [
+ 'auth_bearer' => $token,
+ ]);
+
+ self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ /**
+ * @group mercure
+ */
+ public function testAsAUserICanDeleteMyBookmark(): void
+ {
+ $bookmark = BookmarkFactory::createOne()->disableAutoRefresh();
+ self::getMercureHub()->reset();
+
+ $id = $bookmark->getId();
+
+ $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ 'email' => $bookmark->user->email,
+ ]);
+
+ $response = $this->client->request('DELETE', '/bookmarks/'.$bookmark->getId(), [
+ 'auth_bearer' => $token,
+ ]);
+
+ self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT);
+ self::assertEmpty($response->getContent());
+ self::assertNull(self::getContainer()->get(BookmarkRepository::class)->find($id));
+ self::assertCount(1, self::getMercureMessages());
+ // todo how to ensure it's a delete update
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/bookmarks/'.$id],
+ data: json_encode(['@id' => '/bookmarks/'.$id])
+ ),
+ self::getMercureMessage()
+ );
}
}
diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php
index 0feb333c2..5dec70043 100644
--- a/api/tests/Api/ReviewTest.php
+++ b/api/tests/Api/ReviewTest.php
@@ -10,10 +10,13 @@
use App\DataFixtures\Factory\ReviewFactory;
use App\DataFixtures\Factory\UserFactory;
use App\Entity\Book;
+use App\Entity\Review;
use App\Entity\User;
use App\Repository\ReviewRepository;
use App\Security\OidcTokenGenerator;
+use App\Tests\Api\Trait\MercureTrait;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mercure\Update;
use Zenstruck\Foundry\FactoryCollection;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -21,6 +24,7 @@
final class ReviewTest extends ApiTestCase
{
use Factories;
+ use MercureTrait;
use ResetDatabase;
private Client $client;
@@ -201,21 +205,26 @@ public function getInvalidData(): iterable
'propertyPath' => 'book',
'message' => 'This value is not a valid URL.',
],
- ]
+ ],
];
}
+ /**
+ * @group mercure
+ */
public function testAsAUserICanAddAReviewOnABook(): void
{
+ $this->markTestIncomplete();
$book = BookFactory::createOne()->disableAutoRefresh();
ReviewFactory::createMany(5, ['book' => $book]);
$user = UserFactory::createOne();
+ self::getMercureHub()->reset();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => $user->email,
]);
- $this->client->request('POST', '/books/'.$book->getId().'/reviews', [
+ $response = $this->client->request('POST', '/books/'.$book->getId().'/reviews', [
'auth_bearer' => $token,
'json' => [
'book' => '/books/'.$book->getId(),
@@ -238,8 +247,31 @@ public function testAsAUserICanAddAReviewOnABook(): void
// if I add a review on a book with reviews, it doesn't erase the existing reviews
$reviews = self::getContainer()->get(ReviewRepository::class)->findBy(['book' => $book]);
self::assertCount(6, $reviews);
+ $id = preg_replace('/^.*\/(.+)$/', '$1', $response->toArray()['@id']);
+ /** @var Review $review */
+ $review = self::getContainer()->get(ReviewRepository::class)->find($id);
+ self::assertCount(2, self::getMercureMessages());
+ // self::assertMercureUpdateMatches(
+ // self::getMercureMessage(),
+ // ['http://localhost/admin/reviews/'.$review->getId()],
+ // self::serialize(
+ // $review,
+ // 'jsonld',
+ // self::getOperationNormalizationContext(Review::class, '/admin/reviews/{id}{._format}')
+ // )
+ // );
+ // self::assertMercureUpdateMatches(
+ // self::getMercureMessage(1),
+ // ['http://localhost/books/'.$review->book->getId().'/reviews/'.$review->getId()],
+ // self::serialize(
+ // $review,
+ // 'jsonld',
+ // self::getOperationNormalizationContext(Review::class, '/books/{bookId}/reviews/{id}{._format}')
+ // )
+ // );
}
+ // todo invalid test, should return 405 or similar
public function testAsAnonymousICannotGetAnInvalidReview(): void
{
$book = BookFactory::createOne();
@@ -249,6 +281,7 @@ public function testAsAnonymousICannotGetAnInvalidReview(): void
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
+ // todo invalid test, should return 405 or similar
public function testAsAnonymousICanGetABookReview(): void
{
$review = ReviewFactory::createOne();
@@ -335,9 +368,13 @@ public function testAsAUserICannotUpdateAnInvalidBookReview(): void
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
+ /**
+ * @group mercure
+ */
public function testAsAUserICanUpdateMyBookReview(): void
{
$review = ReviewFactory::createOne();
+ self::getMercureHub()->reset();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => $review->user->email,
@@ -361,6 +398,17 @@ public function testAsAUserICanUpdateMyBookReview(): void
'rating' => 5,
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json'));
+ self::assertCount(2, self::getMercureMessages());
+ self::assertMercureUpdateMatchesJsonSchema(
+ update: self::getMercureMessage(),
+ topics: ['http://localhost/admin/reviews/'.$review->getId()],
+ jsonSchema: file_get_contents(__DIR__.'/Admin/schemas/Review/item.json')
+ );
+ self::assertMercureUpdateMatchesJsonSchema(
+ update: self::getMercureMessage(1),
+ topics: ['http://localhost/books/'.$review->book->getId().'/reviews/'.$review->getId()],
+ jsonSchema: file_get_contents(__DIR__.'/schemas/Review/item.json')
+ );
}
public function testAsAnonymousICannotDeleteABookReview(): void
@@ -409,30 +457,49 @@ public function testAsAUserICannotDeleteAnInvalidBookReview(): void
'email' => UserFactory::createOne()->email,
]);
- $this->client->request('PATCH', '/books/'.$book->getId().'/reviews/invalid', [
+ $this->client->request('DELETE', '/books/'.$book->getId().'/reviews/invalid', [
'auth_bearer' => $token,
- 'headers' => [
- 'Content-Type' => 'application/merge-patch+json',
- ],
]);
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
+ /**
+ * @group mercure
+ */
public function testAsAUserICanDeleteMyBookReview(): void
{
$review = ReviewFactory::createOne()->disableAutoRefresh();
+ self::getMercureHub()->reset();
$id = $review->getId();
+ $bookId = $review->book->getId();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => $review->user->email,
]);
- $this->client->request('DELETE', '/books/'.$review->book->getId().'/reviews/'.$review->getId(), [
+ $response = $this->client->request('DELETE', '/books/'.$bookId.'/reviews/'.$id, [
'auth_bearer' => $token,
]);
self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT);
+ self::assertEmpty($response->getContent());
self::assertNull(self::getContainer()->get(ReviewRepository::class)->find($id));
+ self::assertCount(2, self::getMercureMessages());
+ // todo how to ensure it's a delete update
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/admin/reviews/'.$id],
+ data: json_encode(['@id' => 'http://localhost/admin/reviews/'.$id])
+ ),
+ self::getMercureMessage()
+ );
+ self::assertEquals(
+ new Update(
+ topics: ['http://localhost/books/'.$bookId.'/reviews/'.$id],
+ data: json_encode(['@id' => 'http://localhost/books/'.$bookId.'/reviews/'.$id])
+ ),
+ self::getMercureMessage(1)
+ );
}
}
diff --git a/api/tests/Api/Trait/MercureTrait.php b/api/tests/Api/Trait/MercureTrait.php
new file mode 100644
index 000000000..c3ee65c21
--- /dev/null
+++ b/api/tests/Api/Trait/MercureTrait.php
@@ -0,0 +1,64 @@
+ $update['object'], static::getMercureHub($hubName)->getMessages());
+ }
+
+ public static function getMercureMessage(int $index = 0, string $hubName = null): ?Update
+ {
+ return static::getMercureMessages($hubName)[$index] ?? null;
+ }
+
+ private static function getMercureRegistry(): HubRegistry
+ {
+ $container = static::getContainer();
+ if ($container->has(HubRegistry::class)) {
+ return $container->get(HubRegistry::class);
+ }
+
+ static::fail('A client must have Mercure enabled to make update assertions. Did you forget to require symfony/mercure?');
+ }
+
+ private static function getMercureHub(string $name = null): TraceableHub
+ {
+ $hub = static::getMercureRegistry()->getHub($name);
+ if (!$hub instanceof TraceableHub) {
+ static::fail('Debug mode must be enabled to make Mercure update assertions.');
+ }
+
+ return $hub;
+ }
+
+ /**
+ * @throws \JsonException
+ */
+ public static function assertMercureUpdateMatchesJsonSchema(Update $update, array $topics, array|object|string $jsonSchema = '', bool $private = false, string $id = null, string $type = null, int $retry = null, string $message = ''): void
+ {
+ static::assertSame($topics, $update->getTopics(), $message);
+ static::assertThat(json_decode($update->getData(), true, \JSON_THROW_ON_ERROR), new MatchesJsonSchema($jsonSchema), $message);
+ static::assertSame($private, $update->isPrivate(), $message);
+ static::assertSame($id, $update->getId(), $message);
+ static::assertSame($type, $update->getType(), $message);
+ static::assertSame($retry, $update->getRetry(), $message);
+ }
+}
diff --git a/api/tests/Api/Trait/SerializerTrait.php b/api/tests/Api/Trait/SerializerTrait.php
new file mode 100644
index 000000000..6f90ef019
--- /dev/null
+++ b/api/tests/Api/Trait/SerializerTrait.php
@@ -0,0 +1,53 @@
+has(SerializerInterface::class)) {
+ return $container->get(SerializerInterface::class)->serialize($data, $format, $context);
+ }
+
+ static::fail('A client must have Serializer enabled to make serialization. Did you forget to require symfony/serializer?');
+ }
+
+ public static function getOperationNormalizationContext(string $resourceClass, string $operationName = null): array
+ {
+ if ($resourceMetadataFactoryCollection = self::getResourceMetadataCollectionFactory()) {
+ $operation = $resourceMetadataFactoryCollection->create($resourceClass)->getOperation($operationName);
+ } else {
+ $operation = $operationName ? (new Get())->withName($operationName) : new Get();
+ }
+
+ return $operation->getNormalizationContext() ?? [];
+ }
+
+ /**
+ * todo Remove once merged in ApiTestAssertionsTrait.
+ */
+ private static function getResourceMetadataCollectionFactory(): ?ResourceMetadataCollectionFactoryInterface
+ {
+ $container = static::getContainer();
+
+ try {
+ $resourceMetadataFactoryCollection = $container->get('api_platform.metadata.resource.metadata_collection_factory');
+ } catch (ServiceNotFoundException) {
+ return null;
+ }
+
+ return $resourceMetadataFactoryCollection;
+ }
+}
From b8a3a4b3ae6c1ccf57474cfe7d56bca4c11bdd7a Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Thu, 10 Aug 2023 09:31:19 +0200
Subject: [PATCH 17/51] test: fix tests
---
api/composer.lock | 8 +-
api/config/packages/api_platform.yaml | 2 +
api/src/Entity/Book.php | 2 -
api/src/Entity/Review.php | 2 -
.../State/Processor/BookPersistProcessor.php | 3 +-
.../State/Processor/BookRemoveProcessor.php | 5 +-
api/src/State/Processor/MercureProcessor.php | 6 +-
.../Processor/ReviewPersistProcessor.php | 10 +-
.../State/Processor/ReviewRemoveProcessor.php | 5 +-
api/tests/Api/Admin/BookTest.php | 101 ++++++++----
api/tests/Api/Admin/ReviewTest.php | 6 +-
api/tests/Api/BookmarkTest.php | 18 +--
api/tests/Api/ReviewTest.php | 146 ++++++++++++------
api/tests/Api/Trait/SerializerTrait.php | 2 +-
pwa/pages/books/[id]/[slug]/index.tsx | 1 +
15 files changed, 198 insertions(+), 119 deletions(-)
diff --git a/api/composer.lock b/api/composer.lock
index 92ba08bc9..66ab28b61 100644
--- a/api/composer.lock
+++ b/api/composer.lock
@@ -12,12 +12,12 @@
"source": {
"type": "git",
"url": "https://github.com/vincentchalamon/core.git",
- "reference": "daed45503aaa6cabfe22374654a54dc32615956d"
+ "reference": "d6c3019671c7d668fa0cc8a624fc5bc17a26ddd2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/vincentchalamon/core/zipball/daed45503aaa6cabfe22374654a54dc32615956d",
- "reference": "daed45503aaa6cabfe22374654a54dc32615956d",
+ "url": "https://api.github.com/repos/vincentchalamon/core/zipball/d6c3019671c7d668fa0cc8a624fc5bc17a26ddd2",
+ "reference": "d6c3019671c7d668fa0cc8a624fc5bc17a26ddd2",
"shasum": ""
},
"require": {
@@ -177,7 +177,7 @@
"url": "https://tidelift.com/funding/github/packagist/api-platform/core"
}
],
- "time": "2023-08-07T07:08:54+00:00"
+ "time": "2023-08-08T18:08:58+00:00"
},
{
"name": "brick/math",
diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml
index 7552c905f..5fe081e2d 100644
--- a/api/config/packages/api_platform.yaml
+++ b/api/config/packages/api_platform.yaml
@@ -13,6 +13,8 @@ api_platform:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
+ extra_properties:
+ standard_put: true
oauth:
enabled: true
clientId: '%env(OIDC_SWAGGER_CLIENT_ID)%'
diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php
index 84bfe958a..a6e49cf0d 100644
--- a/api/src/Entity/Book.php
+++ b/api/src/Entity/Book.php
@@ -60,7 +60,6 @@
),
],
normalizationContext: [
- 'item_uri_template' => '/admin/books/{id}{._format}',
'groups' => ['Book:read:admin', 'Enum:read'],
'skip_null_values' => true,
],
@@ -76,7 +75,6 @@
new Get(),
],
normalizationContext: [
- 'item_uri_template' => '/books/{id}{._format}',
'groups' => ['Book:read', 'Enum:read'],
'skip_null_values' => true,
]
diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php
index 71e0611e6..3a60a8c20 100644
--- a/api/src/Entity/Review.php
+++ b/api/src/Entity/Review.php
@@ -58,7 +58,6 @@
'book' => '/admin/books/{id}{._format}',
'user' => '/admin/users/{id}{._format}',
],
- 'item_uri_template' => '/admin/reviews/{id}{._format}',
'skip_null_values' => true,
'groups' => ['Review:read', 'Review:read:admin'],
],
@@ -115,7 +114,6 @@
'book' => '/books/{id}{._format}',
'user' => '/users/{id}{._format}',
],
- 'item_uri_template' => '/books/{bookId}/reviews/{id}{._format}',
'skip_null_values' => true,
'groups' => ['Review:read'],
],
diff --git a/api/src/State/Processor/BookPersistProcessor.php b/api/src/State/Processor/BookPersistProcessor.php
index 4b0af418b..a6e9f41d3 100644
--- a/api/src/State/Processor/BookPersistProcessor.php
+++ b/api/src/State/Processor/BookPersistProcessor.php
@@ -42,10 +42,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
}
// save entity
- $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ $data = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// publish on Mercure
- // todo find a way to do it in API Platform
foreach (['/admin/books/{id}{._format}', '/books/{id}{._format}'] as $uriTemplate) {
$this->mercureProcessor->process(
$data,
diff --git a/api/src/State/Processor/BookRemoveProcessor.php b/api/src/State/Processor/BookRemoveProcessor.php
index 60154b9b7..de79ac002 100644
--- a/api/src/State/Processor/BookRemoveProcessor.php
+++ b/api/src/State/Processor/BookRemoveProcessor.php
@@ -6,7 +6,7 @@
use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Api\UrlGeneratorInterface;
-use ApiPlatform\Doctrine\Common\State\RemoveProcessor as DoctrineRemoveProcessor;
+use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
@@ -16,7 +16,7 @@
final readonly class BookRemoveProcessor implements ProcessorInterface
{
public function __construct(
- #[Autowire(service: DoctrineRemoveProcessor::class)]
+ #[Autowire(service: RemoveProcessor::class)]
private ProcessorInterface $removeProcessor,
#[Autowire(service: MercureProcessor::class)]
private ProcessorInterface $mercureProcessor,
@@ -36,7 +36,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$this->removeProcessor->process($data, $operation, $uriVariables, $context);
// publish on Mercure
- // todo find a way to do it in API Platform
foreach (['/admin/books/{id}{._format}', '/books/{id}{._format}'] as $uriTemplate) {
$iri = $this->iriConverter->getIriFromResource(
$object,
diff --git a/api/src/State/Processor/MercureProcessor.php b/api/src/State/Processor/MercureProcessor.php
index 5ee7ad674..8604b46e6 100644
--- a/api/src/State/Processor/MercureProcessor.php
+++ b/api/src/State/Processor/MercureProcessor.php
@@ -46,9 +46,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$context['data'] = $this->serializer->serialize(
$data,
key($this->formats),
- ($operation->getNormalizationContext() ?? [] + [
- 'item_uri_template' => $context['item_uri_template'] ?? null,
- ])
+ ($operation->getNormalizationContext() ?? []) + (isset($context['item_uri_template']) ? [
+ 'item_uri_template' => $context['item_uri_template'],
+ ] : [])
);
}
diff --git a/api/src/State/Processor/ReviewPersistProcessor.php b/api/src/State/Processor/ReviewPersistProcessor.php
index 0f7407453..5bf4af7e9 100644
--- a/api/src/State/Processor/ReviewPersistProcessor.php
+++ b/api/src/State/Processor/ReviewPersistProcessor.php
@@ -4,19 +4,18 @@
namespace App\State\Processor;
+use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Review;
-use App\Repository\ReviewRepository;
-use Doctrine\Persistence\ObjectRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ReviewPersistProcessor implements ProcessorInterface
{
public function __construct(
- #[Autowire(service: ReviewRepository::class)]
- private ObjectRepository $repository,
+ #[Autowire(service: PersistProcessor::class)]
+ private ProcessorInterface $persistProcessor,
private Security $security,
#[Autowire(service: MercureProcessor::class)]
private ProcessorInterface $mercureProcessor
@@ -32,10 +31,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$data->publishedAt = new \DateTimeImmutable();
// save entity
- $this->repository->save($data, true);
+ $data = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// publish on Mercure
- // todo find a way to do it in API Platform
foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) {
$this->mercureProcessor->process(
$data,
diff --git a/api/src/State/Processor/ReviewRemoveProcessor.php b/api/src/State/Processor/ReviewRemoveProcessor.php
index 81228e224..05efb2ce9 100644
--- a/api/src/State/Processor/ReviewRemoveProcessor.php
+++ b/api/src/State/Processor/ReviewRemoveProcessor.php
@@ -6,7 +6,7 @@
use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Api\UrlGeneratorInterface;
-use ApiPlatform\Doctrine\Common\State\RemoveProcessor as DoctrineRemoveProcessor;
+use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
@@ -16,7 +16,7 @@
final readonly class ReviewRemoveProcessor implements ProcessorInterface
{
public function __construct(
- #[Autowire(service: DoctrineRemoveProcessor::class)]
+ #[Autowire(service: RemoveProcessor::class)]
private ProcessorInterface $removeProcessor,
#[Autowire(service: MercureProcessor::class)]
private ProcessorInterface $mercureProcessor,
@@ -36,7 +36,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$this->removeProcessor->process($data, $operation, $uriVariables, $context);
// publish on Mercure
- // todo find a way to do it in API Platform
foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) {
$iri = $this->iriConverter->getIriFromResource(
$object,
diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php
index 1f9e56f47..9352ccf04 100644
--- a/api/tests/Api/Admin/BookTest.php
+++ b/api/tests/Api/Admin/BookTest.php
@@ -250,9 +250,9 @@ public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $
}
/**
- * @dataProvider getInvalidData
+ * @dataProvider getInvalidDataOnCreate
*/
- public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, array $violations): void
+ public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, int $statusCode, array $expected): void
{
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => UserFactory::createOneAdmin()->email,
@@ -263,40 +263,78 @@ public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, ar
'json' => $data,
]);
- self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
+ self::assertResponseStatusCodeSame($statusCode);
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
- self::assertJsonContains([
- '@context' => '/contexts/ConstraintViolationList',
- '@type' => 'ConstraintViolationList',
- 'hydra:title' => 'An error occurred',
- 'violations' => $violations,
- ]);
+ self::assertJsonContains($expected);
}
- public function getInvalidData(): iterable
+ public function getInvalidDataOnCreate(): iterable
{
- yield [
+ yield 'no data' => [
[],
+ Response::HTTP_UNPROCESSABLE_ENTITY,
[
- [
- 'propertyPath' => 'book',
- 'message' => 'This value should not be blank.',
- ],
- [
- 'propertyPath' => 'condition',
- 'message' => 'This value should not be null.',
+ '@context' => '/contexts/ConstraintViolationList',
+ '@type' => 'ConstraintViolationList',
+ 'hydra:title' => 'An error occurred',
+ 'violations' => [
+ [
+ 'propertyPath' => 'book',
+ 'message' => 'This value should not be blank.',
+ ],
+ [
+ 'propertyPath' => 'condition',
+ 'message' => 'This value should not be null.',
+ ],
],
],
];
- yield [
+ yield from $this->getInvalidData();
+ }
+
+ public function getInvalidData(): iterable
+ {
+ yield 'empty data' => [
+ [
+ 'book' => '',
+ 'condition' => '',
+ ],
+ Response::HTTP_BAD_REQUEST,
+ [
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'The data must belong to a backed enumeration of type '.BookCondition::class,
+ ],
+ ];
+ yield 'invalid condition' => [
+ [
+ 'book' => 'https://openlibrary.org/books/OL28346544M.json',
+ 'condition' => 'invalid condition',
+ ],
+ Response::HTTP_BAD_REQUEST,
+ [
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'The data must belong to a backed enumeration of type '.BookCondition::class,
+ ],
+ ];
+ yield 'invalid book' => [
[
'book' => 'invalid book',
'condition' => BookCondition::NewCondition->value,
],
+ Response::HTTP_UNPROCESSABLE_ENTITY,
[
- [
- 'propertyPath' => 'book',
- 'message' => 'This value is not a valid URL.',
+ '@context' => '/contexts/ConstraintViolationList',
+ '@type' => 'ConstraintViolationList',
+ 'hydra:title' => 'An error occurred',
+ 'violations' => [
+ [
+ 'propertyPath' => 'book',
+ 'message' => 'This value is not a valid URL.',
+ ],
],
],
];
@@ -408,27 +446,22 @@ public function testAsAdminUserICannotUpdateAnInvalidBook(): void
/**
* @dataProvider getInvalidData
*/
- public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, array $violations): void
+ public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, int $statusCode, array $expected): void
{
- BookFactory::createOne();
+ $book = BookFactory::createOne();
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => UserFactory::createOneAdmin()->email,
]);
- $this->client->request('PUT', '/admin/books/invalid', [
+ $this->client->request('PUT', '/admin/books/'.$book->getId(), [
'auth_bearer' => $token,
'json' => $data,
]);
- self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
+ self::assertResponseStatusCodeSame($statusCode);
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
- self::assertJsonContains([
- '@context' => '/contexts/ConstraintViolationList',
- '@type' => 'ConstraintViolationList',
- 'hydra:title' => 'An error occurred',
- 'violations' => $violations,
- ]);
+ self::assertJsonContains($expected);
}
/**
@@ -449,6 +482,10 @@ public function testAsAdminUserICanUpdateABook(): void
$this->client->request('PUT', '/admin/books/'.$book->getId(), [
'auth_bearer' => $token,
'json' => [
+ /* @see https://github.com/api-platform/core/blob/main/src/Serializer/ItemNormalizer.php */
+ 'id' => '/books/'.$book->getId(),
+ // Must set all data because of standard PUT
+ 'book' => 'https://openlibrary.org/books/OL28346544M.json',
'condition' => BookCondition::DamagedCondition->value,
],
]);
diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php
index 49e36bd36..ae5e10566 100644
--- a/api/tests/Api/Admin/ReviewTest.php
+++ b/api/tests/Api/Admin/ReviewTest.php
@@ -249,7 +249,8 @@ public function testAsAdminUserICannotUpdateAnInvalidReview(): void
*/
public function testAsAdminUserICanUpdateAReview(): void
{
- $review = ReviewFactory::createOne();
+ $book = BookFactory::createOne();
+ $review = ReviewFactory::createOne(['book' => $book]);
$token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
'email' => UserFactory::createOneAdmin()->email,
@@ -258,6 +259,9 @@ public function testAsAdminUserICanUpdateAReview(): void
$this->client->request('PUT', '/admin/reviews/'.$review->getId(), [
'auth_bearer' => $token,
'json' => [
+ // Must set all data because of standard PUT
+ 'book' => '/admin/books/'.$book->getId(),
+ 'letter' => null,
'body' => 'Very good book!',
'rating' => 5,
],
diff --git a/api/tests/Api/BookmarkTest.php b/api/tests/Api/BookmarkTest.php
index a030ecf67..8a17b8841 100644
--- a/api/tests/Api/BookmarkTest.php
+++ b/api/tests/Api/BookmarkTest.php
@@ -15,6 +15,7 @@
use App\Tests\Api\Trait\MercureTrait;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;
+use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -97,25 +98,22 @@ public function testAsAUserICannotCreateABookmarkWithInvalidData(): void
'email' => UserFactory::createOne()->email,
]);
+ $uuid = Uuid::v7()->__toString();
+
$this->client->request('POST', '/bookmarks', [
'json' => [
- 'book' => '/books/invalid',
+ 'book' => '/books/'.$uuid,
],
'auth_bearer' => $token,
]);
- self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
+ self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
self::assertJsonContains([
- '@context' => '/contexts/ConstraintViolationList',
- '@type' => 'ConstraintViolationList',
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
'hydra:title' => 'An error occurred',
- 'violations' => [
- [
- 'propertyPath' => 'book',
- 'message' => 'This value is not valid.',
- ],
- ],
+ 'hydra:description' => 'Item not found for "/books/'.$uuid.'".',
]);
}
diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php
index 5dec70043..8fe8415df 100644
--- a/api/tests/Api/ReviewTest.php
+++ b/api/tests/Api/ReviewTest.php
@@ -17,6 +17,7 @@
use App\Tests\Api\Trait\MercureTrait;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;
+use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\FactoryCollection;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -152,7 +153,7 @@ public function testAsAnonymousICannotAddAReviewOnABook(): void
/**
* @dataProvider getInvalidData
*/
- public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data, array $violations): void
+ public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data, int $statusCode, array $expected): void
{
$book = BookFactory::createOne();
@@ -165,57 +166,100 @@ public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data,
'json' => $data,
]);
- self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
+ self::assertResponseStatusCodeSame($statusCode);
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
- self::assertJsonContains([
- '@context' => '/contexts/ConstraintViolationList',
- '@type' => 'ConstraintViolationList',
- 'hydra:title' => 'An error occurred',
- 'violations' => $violations,
- ]);
+ self::assertJsonContains($expected);
}
public function getInvalidData(): iterable
{
- yield [
+ $uuid = Uuid::v7()->__toString();
+
+ yield 'empty data' => [
[],
+ Response::HTTP_UNPROCESSABLE_ENTITY,
[
- [
- 'propertyPath' => 'book',
- 'message' => 'This value should not be null.',
- ],
- [
- 'propertyPath' => 'body',
- 'message' => 'This value should not be blank.',
- ],
- [
- 'propertyPath' => 'rating',
- 'message' => 'This value should not be null.',
+ '@context' => '/contexts/ConstraintViolationList',
+ '@type' => 'ConstraintViolationList',
+ 'hydra:title' => 'An error occurred',
+ 'violations' => [
+ [
+ 'propertyPath' => 'body',
+ 'message' => 'This value should not be blank.',
+ ],
+ [
+ 'propertyPath' => 'rating',
+ 'message' => 'This value should not be null.',
+ ],
],
],
];
- yield [
+ yield 'invalid book data' => [
[
'book' => 'invalid book',
'body' => 'Very good book!',
'rating' => 5,
],
+ Response::HTTP_BAD_REQUEST,
[
- [
- 'propertyPath' => 'book',
- 'message' => 'This value is not a valid URL.',
- ],
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'Invalid IRI "invalid book".',
+ ],
+ ];
+ yield 'invalid book identifier' => [
+ [
+ 'book' => '/books/'.$uuid,
+ 'body' => 'Very good book!',
+ 'rating' => 5,
+ ],
+ Response::HTTP_BAD_REQUEST,
+ [
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'Item not found for "/books/'.$uuid.'".',
],
];
}
+ public function testAsAUserICannotAddAReviewWithValidDataOnAnInvalidBook(): void
+ {
+ $book = BookFactory::createOne();
+ ReviewFactory::createMany(5, ['book' => $book]);
+ $user = UserFactory::createOne();
+ self::getMercureHub()->reset();
+
+ $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ 'email' => $user->email,
+ ]);
+
+ $this->client->request('POST', '/books/invalid/reviews', [
+ 'auth_bearer' => $token,
+ 'json' => [
+ 'book' => '/books/'.$book->getId(),
+ 'body' => 'Very good book!',
+ 'rating' => 5,
+ ],
+ ]);
+
+ self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
+ self::assertJsonContains([
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'Invalid identifier value or configuration.',
+ ]);
+ }
+
/**
* @group mercure
*/
public function testAsAUserICanAddAReviewOnABook(): void
{
- $this->markTestIncomplete();
- $book = BookFactory::createOne()->disableAutoRefresh();
+ $book = BookFactory::createOne();
ReviewFactory::createMany(5, ['book' => $book]);
$user = UserFactory::createOne();
self::getMercureHub()->reset();
@@ -245,33 +289,24 @@ public function testAsAUserICanAddAReviewOnABook(): void
]);
self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json'));
// if I add a review on a book with reviews, it doesn't erase the existing reviews
- $reviews = self::getContainer()->get(ReviewRepository::class)->findBy(['book' => $book]);
+ $reviews = self::getContainer()->get(ReviewRepository::class)->findBy(['book' => $book->object()]);
self::assertCount(6, $reviews);
$id = preg_replace('/^.*\/(.+)$/', '$1', $response->toArray()['@id']);
/** @var Review $review */
$review = self::getContainer()->get(ReviewRepository::class)->find($id);
self::assertCount(2, self::getMercureMessages());
- // self::assertMercureUpdateMatches(
- // self::getMercureMessage(),
- // ['http://localhost/admin/reviews/'.$review->getId()],
- // self::serialize(
- // $review,
- // 'jsonld',
- // self::getOperationNormalizationContext(Review::class, '/admin/reviews/{id}{._format}')
- // )
- // );
- // self::assertMercureUpdateMatches(
- // self::getMercureMessage(1),
- // ['http://localhost/books/'.$review->book->getId().'/reviews/'.$review->getId()],
- // self::serialize(
- // $review,
- // 'jsonld',
- // self::getOperationNormalizationContext(Review::class, '/books/{bookId}/reviews/{id}{._format}')
- // )
- // );
+ self::assertMercureUpdateMatchesJsonSchema(
+ update: self::getMercureMessage(),
+ topics: ['http://localhost/admin/reviews/'.$review->getId()],
+ jsonSchema: file_get_contents(__DIR__.'/Admin/schemas/Review/item.json')
+ );
+ self::assertMercureUpdateMatchesJsonSchema(
+ update: self::getMercureMessage(1),
+ topics: ['http://localhost/books/'.$book->getId().'/reviews/'.$review->getId()],
+ jsonSchema: file_get_contents(__DIR__.'/schemas/Review/item.json')
+ );
}
- // todo invalid test, should return 405 or similar
public function testAsAnonymousICannotGetAnInvalidReview(): void
{
$book = BookFactory::createOne();
@@ -279,18 +314,29 @@ public function testAsAnonymousICannotGetAnInvalidReview(): void
$this->client->request('GET', '/books/'.$book->getId().'/reviews/invalid');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
+ self::assertJsonContains([
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'This route does not aim to be called.',
+ ]);
}
- // todo invalid test, should return 405 or similar
public function testAsAnonymousICanGetABookReview(): void
{
$review = ReviewFactory::createOne();
$this->client->request('GET', '/books/'.$review->book->getId().'/reviews/'.$review->getId());
- self::assertResponseIsSuccessful();
+ self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
- self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json'));
+ self::assertJsonContains([
+ '@context' => '/contexts/Error',
+ '@type' => 'hydra:Error',
+ 'hydra:title' => 'An error occurred',
+ 'hydra:description' => 'This route does not aim to be called.',
+ ]);
}
public function testAsAnonymousICannotUpdateABookReview(): void
diff --git a/api/tests/Api/Trait/SerializerTrait.php b/api/tests/Api/Trait/SerializerTrait.php
index 6f90ef019..02711a195 100644
--- a/api/tests/Api/Trait/SerializerTrait.php
+++ b/api/tests/Api/Trait/SerializerTrait.php
@@ -32,7 +32,7 @@ public static function getOperationNormalizationContext(string $resourceClass, s
$operation = $operationName ? (new Get())->withName($operationName) : new Get();
}
- return $operation->getNormalizationContext() ?? [];
+ return ($operation->getNormalizationContext() ?? []) + ['item_uri_template' => $operation->getUriTemplate()];
}
/**
diff --git a/pwa/pages/books/[id]/[slug]/index.tsx b/pwa/pages/books/[id]/[slug]/index.tsx
index d8e88cf95..ab3db2e0c 100644
--- a/pwa/pages/books/[id]/[slug]/index.tsx
+++ b/pwa/pages/books/[id]/[slug]/index.tsx
@@ -14,6 +14,7 @@ export const getServerSideProps: GetServerSideProps<{
if (!response?.data) {
throw new Error(`Unable to retrieve data from /books/${id}.`);
}
+ console.log(response.data);
return { props: { data: response.data, hubURL: response.hubURL, page: page ? Number(page) : 1 } };
} catch (error) {
From c187d3ec09987ae30fed61f94ef6f2acfe6435de Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Thu, 10 Aug 2023 18:05:28 +0200
Subject: [PATCH 18/51] test: add unit tests
---
api/composer.json | 1 +
api/composer.lock | 2 +-
api/config/packages/security.yaml | 5 +-
.../BookmarkQueryCollectionExtension.php | 3 +-
api/src/Repository/BookRepository.php | 2 +
api/src/Repository/BookmarkRepository.php | 2 +
api/src/Repository/ReviewRepository.php | 2 +
api/src/Repository/UserRepository.php | 2 +
api/src/Security/Core/UserProvider.php | 1 +
api/src/Security/OidcTokenGenerator.php | 74 ---------
.../State/Processor/BookPersistProcessor.php | 2 +-
.../Processor/BookmarkPersistProcessor.php | 15 +-
.../Processor/ReviewPersistProcessor.php | 8 +-
api/tests/Api/Admin/BookTest.php | 35 ++--
api/tests/Api/Admin/ReviewTest.php | 25 +--
api/tests/Api/Admin/UserTest.php | 9 +-
api/tests/Api/BookmarkTest.php | 15 +-
api/tests/Api/ReviewTest.php | 21 +--
api/tests/Api/Trait/SecurityTrait.php | 56 +++++++
.../BookmarkQueryCollectionExtensionTest.php | 126 +++++++++++++++
api/tests/Security/Core/UserProviderTest.php | 149 ++++++++++++++++++
api/tests/Serializer/BookNormalizerTest.php | 93 +++++++++++
.../IriTransformerNormalizerTest.php | 138 ++++++++++++++++
.../Processor/BookPersistProcessorTest.php | 138 ++++++++++++++++
.../Processor/BookRemoveProcessorTest.php | 107 +++++++++++++
.../BookmarkPersistProcessorTest.php | 58 +++++++
.../State/Processor/MercureProcessorTest.php | 110 +++++++++++++
.../Processor/ReviewPersistProcessorTest.php | 76 +++++++++
.../Processor/ReviewRemoveProcessorTest.php | 106 +++++++++++++
29 files changed, 1243 insertions(+), 138 deletions(-)
delete mode 100644 api/src/Security/OidcTokenGenerator.php
create mode 100644 api/tests/Api/Trait/SecurityTrait.php
create mode 100644 api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php
create mode 100644 api/tests/Security/Core/UserProviderTest.php
create mode 100644 api/tests/Serializer/BookNormalizerTest.php
create mode 100644 api/tests/Serializer/IriTransformerNormalizerTest.php
create mode 100644 api/tests/State/Processor/BookPersistProcessorTest.php
create mode 100644 api/tests/State/Processor/BookRemoveProcessorTest.php
create mode 100644 api/tests/State/Processor/BookmarkPersistProcessorTest.php
create mode 100644 api/tests/State/Processor/MercureProcessorTest.php
create mode 100644 api/tests/State/Processor/ReviewPersistProcessorTest.php
create mode 100644 api/tests/State/Processor/ReviewRemoveProcessorTest.php
diff --git a/api/composer.json b/api/composer.json
index 6946b8ee2..0085a0fa0 100644
--- a/api/composer.json
+++ b/api/composer.json
@@ -20,6 +20,7 @@
"nelmio/cors-bundle": "^2.2",
"phpstan/phpdoc-parser": "^1.16",
"symfony/asset": "6.3.*",
+ "symfony/clock": "6.3.*",
"symfony/console": "6.3.*",
"symfony/dotenv": "6.3.*",
"symfony/expression-language": "6.3.*",
diff --git a/api/composer.lock b/api/composer.lock
index 66ab28b61..021b461f3 100644
--- a/api/composer.lock
+++ b/api/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "556e2634ee5ee54f81424ff814ccaa8f",
+ "content-hash": "2947cff93e0b37a3ddb3109f97d6dc5e",
"packages": [
{
"name": "api-platform/core",
diff --git a/api/config/packages/security.yaml b/api/config/packages/security.yaml
index 3812b15aa..55fb5f7b3 100644
--- a/api/config/packages/security.yaml
+++ b/api/config/packages/security.yaml
@@ -53,9 +53,12 @@ when@test:
issuers: [ '%env(OIDC_SERVER_URL)%' ]
algorithm: 'ES256'
key: '%app.oidc.jwk%'
+ # required by App\Tests\Api\Trait\SecurityTrait
+ parameters:
+ app.oidc.issuer: '%env(OIDC_SERVER_URL)%'
services:
- # required by App\Security\OidcTokenGenerator
app.security.jwk:
parent: 'security.access_token_handler.oidc.jwk'
+ public: true
arguments:
$json: '%app.oidc.jwk%'
diff --git a/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php b/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php
index d09c47c62..a333cf4fe 100644
--- a/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php
+++ b/api/src/Doctrine/Orm/Extension/BookmarkQueryCollectionExtension.php
@@ -30,7 +30,8 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator
return;
}
- $queryBuilder->andWhere(sprintf('%s.user = :user', $queryBuilder->getRootAliases()[0]))
+ $queryBuilder
+ ->andWhere(sprintf('%s.user = :user', $queryBuilder->getRootAliases()[0]))
->setParameter('user', $user);
}
}
diff --git a/api/src/Repository/BookRepository.php b/api/src/Repository/BookRepository.php
index f2ca183fa..6eac3239b 100644
--- a/api/src/Repository/BookRepository.php
+++ b/api/src/Repository/BookRepository.php
@@ -1,5 +1,7 @@
repository->findOneBy(['email' => $identifier]) ?: new User();
+ $user->email = $identifier;
if (!isset($attributes['sub'])) {
throw new UnsupportedUserException('Property "sub" is missing in token attributes.');
diff --git a/api/src/Security/OidcTokenGenerator.php b/api/src/Security/OidcTokenGenerator.php
deleted file mode 100644
index e898bbaf8..000000000
--- a/api/src/Security/OidcTokenGenerator.php
+++ /dev/null
@@ -1,74 +0,0 @@
-__toString();
- $claims += [
- 'sub' => $sub,
- 'iat' => $time,
- 'nbf' => $time,
- 'exp' => $time + 3600,
- 'iss' => $this->issuer,
- 'aud' => $this->audience,
- 'given_name' => 'John',
- 'family_name' => 'DOE',
- ];
- if (empty($claims['sub'])) {
- $claims['sub'] = $sub;
- }
- if (empty($claims['iat'])) {
- $claims['iat'] = $time;
- }
- if (empty($claims['nbf'])) {
- $claims['nbf'] = $time;
- }
- if (empty($claims['exp'])) {
- $claims['exp'] = $time + 3600;
- }
-
- return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([
- $this->signatureAlgorithm,
- ])))->create()
- ->withPayload(json_encode($claims))
- ->addSignature($this->jwk, ['alg' => $this->signatureAlgorithm->name()])
- ->build()
- );
- }
-}
diff --git a/api/src/State/Processor/BookPersistProcessor.php b/api/src/State/Processor/BookPersistProcessor.php
index a6e9f41d3..4d72bc11a 100644
--- a/api/src/State/Processor/BookPersistProcessor.php
+++ b/api/src/State/Processor/BookPersistProcessor.php
@@ -35,7 +35,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$data->author = null;
if (isset($book['authors'][0]['key'])) {
- $author = $this->getData('https://openlibrary.org'.$book['authors'][0]['key']);
+ $author = $this->getData('https://openlibrary.org'.$book['authors'][0]['key'].'.json');
if (isset($author['name'])) {
$data->author = $author['name'];
}
diff --git a/api/src/State/Processor/BookmarkPersistProcessor.php b/api/src/State/Processor/BookmarkPersistProcessor.php
index f86880b19..17d1b222a 100644
--- a/api/src/State/Processor/BookmarkPersistProcessor.php
+++ b/api/src/State/Processor/BookmarkPersistProcessor.php
@@ -4,20 +4,21 @@
namespace App\State\Processor;
+use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Bookmark;
-use App\Repository\BookmarkRepository;
-use Doctrine\Persistence\ObjectRepository;
+use Psr\Clock\ClockInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class BookmarkPersistProcessor implements ProcessorInterface
{
public function __construct(
- #[Autowire(service: BookmarkRepository::class)]
- private ObjectRepository $repository,
- private Security $security
+ #[Autowire(service: PersistProcessor::class)]
+ private ProcessorInterface $persistProcessor,
+ private Security $security,
+ private ClockInterface $clock
) {
}
@@ -27,10 +28,10 @@ public function __construct(
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Bookmark
{
$data->user = $this->security->getUser();
- $data->bookmarkedAt = new \DateTimeImmutable();
+ $data->bookmarkedAt = $this->clock->now();
// save entity
- $this->repository->save($data, true);
+ $data = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
return $data;
}
diff --git a/api/src/State/Processor/ReviewPersistProcessor.php b/api/src/State/Processor/ReviewPersistProcessor.php
index 5bf4af7e9..ce5c73e33 100644
--- a/api/src/State/Processor/ReviewPersistProcessor.php
+++ b/api/src/State/Processor/ReviewPersistProcessor.php
@@ -8,6 +8,7 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Review;
+use Psr\Clock\ClockInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -16,9 +17,10 @@
public function __construct(
#[Autowire(service: PersistProcessor::class)]
private ProcessorInterface $persistProcessor,
- private Security $security,
#[Autowire(service: MercureProcessor::class)]
- private ProcessorInterface $mercureProcessor
+ private ProcessorInterface $mercureProcessor,
+ private Security $security,
+ private ClockInterface $clock
) {
}
@@ -28,7 +30,7 @@ public function __construct(
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Review
{
$data->user = $this->security->getUser();
- $data->publishedAt = new \DateTimeImmutable();
+ $data->publishedAt = $this->clock->now();
// save entity
$data = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php
index 9352ccf04..36c47ea0b 100644
--- a/api/tests/Api/Admin/BookTest.php
+++ b/api/tests/Api/Admin/BookTest.php
@@ -11,9 +11,9 @@
use App\Entity\Book;
use App\Enum\BookCondition;
use App\Repository\BookRepository;
-use App\Security\OidcTokenGenerator;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
use App\Tests\Api\Trait\MercureTrait;
+use App\Tests\Api\Trait\SecurityTrait;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;
use Zenstruck\Foundry\FactoryCollection;
@@ -25,6 +25,7 @@ final class BookTest extends ApiTestCase
use Factories;
use MercureTrait;
use ResetDatabase;
+ use SecurityTrait;
use UsersDataProviderTrait;
private Client $client;
@@ -41,7 +42,7 @@ public function testAsNonAdminUserICannotGetACollectionOfBooks(int $expectedCode
{
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -66,7 +67,7 @@ public function testAsAdminUserICanGetACollectionOfBooks(FactoryCollection $fact
{
$factory->create();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -126,7 +127,7 @@ public function testAsAdminUserICanGetACollectionOfBooksOrderedByTitle(): void
BookFactory::createOne(['title' => 'Nemesis']);
BookFactory::createOne(['title' => 'I, Robot']);
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -149,7 +150,7 @@ public function testAsAnyUserICannotGetAnInvalidBook(?UserFactory $userFactory):
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -176,7 +177,7 @@ public function testAsNonAdminUserICannotGetABook(int $expectedCode, string $hyd
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -201,7 +202,7 @@ public function testAsAdminUserICanGetABook(): void
{
$book = BookFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -226,7 +227,7 @@ public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $
{
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -254,7 +255,7 @@ public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $
*/
public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, int $statusCode, array $expected): void
{
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -346,7 +347,7 @@ public function getInvalidData(): iterable
*/
public function testAsAdminUserICanCreateABook(): void
{
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -402,7 +403,7 @@ public function testAsNonAdminUserICannotUpdateBook(int $expectedCode, string $h
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -429,7 +430,7 @@ public function testAsAdminUserICannotUpdateAnInvalidBook(): void
{
BookFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -450,7 +451,7 @@ public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, in
{
$book = BookFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -475,7 +476,7 @@ public function testAsAdminUserICanUpdateABook(): void
]);
self::getMercureHub()->reset();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -530,7 +531,7 @@ public function testAsNonAdminUserICannotDeleteABook(int $expectedCode, string $
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -552,7 +553,7 @@ public function testAsAdminUserICannotDeleteAnInvalidBook(): void
{
BookFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -570,7 +571,7 @@ public function testAsAdminUserICanDeleteABook(): void
self::getMercureHub()->reset();
$id = $book->getId();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php
index ae5e10566..90d370402 100644
--- a/api/tests/Api/Admin/ReviewTest.php
+++ b/api/tests/Api/Admin/ReviewTest.php
@@ -13,9 +13,9 @@
use App\Entity\Review;
use App\Entity\User;
use App\Repository\ReviewRepository;
-use App\Security\OidcTokenGenerator;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
use App\Tests\Api\Trait\MercureTrait;
+use App\Tests\Api\Trait\SecurityTrait;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;
use Zenstruck\Foundry\FactoryCollection;
@@ -27,6 +27,7 @@ final class ReviewTest extends ApiTestCase
use Factories;
use MercureTrait;
use ResetDatabase;
+ use SecurityTrait;
use UsersDataProviderTrait;
private Client $client;
@@ -43,7 +44,7 @@ public function testAsNonAdminUserICannotGetACollectionOfReviews(int $expectedCo
{
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -68,7 +69,7 @@ public function testAsAdminUserICanGetACollectionOfReviews(FactoryCollection $fa
{
$factory->create();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -151,7 +152,7 @@ public function testAsNonAdminUserICannotGetAReview(int $expectedCode, string $h
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -171,7 +172,7 @@ public function testAsNonAdminUserICannotGetAReview(int $expectedCode, string $h
public function testAsAdminUserICannotGetAnInvalidReview(): void
{
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -184,7 +185,7 @@ public function testAsAdminUserICanGetAReview(): void
{
$review = ReviewFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -204,7 +205,7 @@ public function testAsNonAdminUserICannotUpdateAReview(int $expectedCode, string
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -229,7 +230,7 @@ public function testAsNonAdminUserICannotUpdateAReview(int $expectedCode, string
public function testAsAdminUserICannotUpdateAnInvalidReview(): void
{
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -252,7 +253,7 @@ public function testAsAdminUserICanUpdateAReview(): void
$book = BookFactory::createOne();
$review = ReviewFactory::createOne(['book' => $book]);
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -308,7 +309,7 @@ public function testAsNonAdminUserICannotDeleteAReview(int $expectedCode, string
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -328,7 +329,7 @@ public function testAsNonAdminUserICannotDeleteAReview(int $expectedCode, string
public function testAsAdminUserICannotDeleteAnInvalidReview(): void
{
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -346,7 +347,7 @@ public function testAsAdminUserICanDeleteAReview(): void
$id = $review->getId();
$bookId = $review->book->getId();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
diff --git a/api/tests/Api/Admin/UserTest.php b/api/tests/Api/Admin/UserTest.php
index 270575a39..f693efc95 100644
--- a/api/tests/Api/Admin/UserTest.php
+++ b/api/tests/Api/Admin/UserTest.php
@@ -8,8 +8,8 @@
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\DataFixtures\Factory\UserFactory;
use App\Repository\UserRepository;
-use App\Security\OidcTokenGenerator;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
+use App\Tests\Api\Trait\SecurityTrait;
use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -18,6 +18,7 @@ final class UserTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
+ use SecurityTrait;
use UsersDataProviderTrait;
private Client $client;
@@ -36,7 +37,7 @@ public function testAsNonAdminUserICannotGetAUser(int $expectedCode, string $hyd
$options = [];
if ($userFactory) {
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -58,7 +59,7 @@ public function testAsAdminUserICanGetAUser(): void
{
$user = UserFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -82,7 +83,7 @@ public function testAsAUserIAmUpdatedOnLogin(): void
])->disableAutoRefresh();
$sub = Uuid::v7()->__toString();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'sub' => $sub,
'email' => $user->email,
'given_name' => 'Chuck',
diff --git a/api/tests/Api/BookmarkTest.php b/api/tests/Api/BookmarkTest.php
index 8a17b8841..e533a85ee 100644
--- a/api/tests/Api/BookmarkTest.php
+++ b/api/tests/Api/BookmarkTest.php
@@ -11,8 +11,8 @@
use App\DataFixtures\Factory\UserFactory;
use App\Entity\Bookmark;
use App\Repository\BookmarkRepository;
-use App\Security\OidcTokenGenerator;
use App\Tests\Api\Trait\MercureTrait;
+use App\Tests\Api\Trait\SecurityTrait;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Uid\Uuid;
@@ -24,6 +24,7 @@ final class BookmarkTest extends ApiTestCase
use Factories;
use MercureTrait;
use ResetDatabase;
+ use SecurityTrait;
private Client $client;
@@ -57,7 +58,7 @@ public function testAsAUserICanGetACollectionOfMyBookmarksWithoutFilters(): void
$user = UserFactory::createOne();
BookmarkFactory::createMany(40, ['user' => $user]);
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $user->email,
]);
@@ -94,7 +95,7 @@ public function testAsAnonymousICannotCreateABookmark(): void
public function testAsAUserICannotCreateABookmarkWithInvalidData(): void
{
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -126,7 +127,7 @@ public function testAsAUserICanCreateABookmark(): void
$user = UserFactory::createOne();
self::getMercureHub()->reset();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $user->email,
]);
@@ -181,7 +182,7 @@ public function testAsAUserICannotDeleteABookmarkOfAnotherUser(): void
{
$bookmark = BookmarkFactory::createOne(['user' => UserFactory::createOne()]);
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -201,7 +202,7 @@ public function testAsAUserICannotDeleteABookmarkOfAnotherUser(): void
public function testAsAUserICannotDeleteAnInvalidBookmark(): void
{
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -222,7 +223,7 @@ public function testAsAUserICanDeleteMyBookmark(): void
$id = $bookmark->getId();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $bookmark->user->email,
]);
diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php
index 8fe8415df..4faa69b1b 100644
--- a/api/tests/Api/ReviewTest.php
+++ b/api/tests/Api/ReviewTest.php
@@ -13,8 +13,8 @@
use App\Entity\Review;
use App\Entity\User;
use App\Repository\ReviewRepository;
-use App\Security\OidcTokenGenerator;
use App\Tests\Api\Trait\MercureTrait;
+use App\Tests\Api\Trait\SecurityTrait;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Uid\Uuid;
@@ -27,6 +27,7 @@ final class ReviewTest extends ApiTestCase
use Factories;
use MercureTrait;
use ResetDatabase;
+ use SecurityTrait;
private Client $client;
@@ -157,7 +158,7 @@ public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data,
{
$book = BookFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -231,7 +232,7 @@ public function testAsAUserICannotAddAReviewWithValidDataOnAnInvalidBook(): void
$user = UserFactory::createOne();
self::getMercureHub()->reset();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $user->email,
]);
@@ -264,7 +265,7 @@ public function testAsAUserICanAddAReviewOnABook(): void
$user = UserFactory::createOne();
self::getMercureHub()->reset();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $user->email,
]);
@@ -367,7 +368,7 @@ public function testAsAUserICannotUpdateABookReviewOfAnotherUser(): void
{
$review = ReviewFactory::createOne(['user' => UserFactory::createOne()]);
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -396,7 +397,7 @@ public function testAsAUserICannotUpdateAnInvalidBookReview(): void
{
$book = BookFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -422,7 +423,7 @@ public function testAsAUserICanUpdateMyBookReview(): void
$review = ReviewFactory::createOne();
self::getMercureHub()->reset();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $review->user->email,
]);
@@ -477,7 +478,7 @@ public function testAsAUserICannotDeleteABookReviewOfAnotherUser(): void
{
$review = ReviewFactory::createOne(['user' => UserFactory::createOne()]);
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -499,7 +500,7 @@ public function testAsAUserICannotDeleteAnInvalidBookReview(): void
{
$book = BookFactory::createOne();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -520,7 +521,7 @@ public function testAsAUserICanDeleteMyBookReview(): void
$id = $review->getId();
$bookId = $review->book->getId();
- $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([
+ $token = $this->generateToken([
'email' => $review->user->email,
]);
diff --git a/api/tests/Api/Trait/SecurityTrait.php b/api/tests/Api/Trait/SecurityTrait.php
new file mode 100644
index 000000000..c05b3a719
--- /dev/null
+++ b/api/tests/Api/Trait/SecurityTrait.php
@@ -0,0 +1,56 @@
+get('security.access_token_handler.oidc.signature.ES256');
+ $jwk = $container->get('app.security.jwk');
+ $audience = $container->getParameter('app.oidc.aud');
+ $issuer = $container->getParameter('app.oidc.issuer');
+
+ // Defaults
+ $time = time();
+ $sub = Uuid::v7()->__toString();
+ $claims += [
+ 'sub' => $sub,
+ 'iat' => $time,
+ 'nbf' => $time,
+ 'exp' => $time + 3600,
+ 'iss' => $issuer,
+ 'aud' => $audience,
+ 'given_name' => 'John',
+ 'family_name' => 'DOE',
+ ];
+ if (empty($claims['sub'])) {
+ $claims['sub'] = $sub;
+ }
+ if (empty($claims['iat'])) {
+ $claims['iat'] = $time;
+ }
+ if (empty($claims['nbf'])) {
+ $claims['nbf'] = $time;
+ }
+ if (empty($claims['exp'])) {
+ $claims['exp'] = $time + 3600;
+ }
+
+ return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([
+ $signatureAlgorithm,
+ ])))->create()
+ ->withPayload(json_encode($claims))
+ ->addSignature($jwk, ['alg' => $signatureAlgorithm->name()])
+ ->build()
+ );
+ }
+}
diff --git a/api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php b/api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php
new file mode 100644
index 000000000..33a178bae
--- /dev/null
+++ b/api/tests/Doctrine/Orm/Extension/BookmarkQueryCollectionExtensionTest.php
@@ -0,0 +1,126 @@
+securityMock = $this->createMock(Security::class);
+ $this->userMock = $this->createMock(UserInterface::class);
+ $this->queryBuilderMock = $this->createMock(QueryBuilder::class);
+ $this->queryNameGeneratorMock = $this->createMock(QueryNameGeneratorInterface::class);
+ $this->operationMock = $this->createMock(Operation::class);
+
+ $this->extension = new BookmarkQueryCollectionExtension($this->securityMock);
+ }
+
+ public function testItFiltersBookmarksQueryOnCurrentUser(): void
+ {
+ $this->operationMock
+ ->expects($this->once())
+ ->method('getName')
+ ->willReturn('_api_/bookmarks{._format}_get_collection');
+ $this->securityMock
+ ->expects($this->once())
+ ->method('getUser')
+ ->willReturn($this->userMock);
+ $this->queryBuilderMock
+ ->expects($this->once())
+ ->method('getRootAliases')
+ ->willReturn(['o']);
+ $this->queryBuilderMock
+ ->expects($this->once())
+ ->method('andWhere')
+ ->with('o.user = :user')
+ ->willReturn($this->queryBuilderMock);
+ $this->queryBuilderMock
+ ->expects($this->once())
+ ->method('setParameter')
+ ->with('user', $this->userMock)
+ ->willReturn($this->queryBuilderMock);
+
+ $this->extension->applyToCollection(
+ $this->queryBuilderMock,
+ $this->queryNameGeneratorMock,
+ Bookmark::class,
+ $this->operationMock
+ );
+ }
+
+ public function testItIgnoresInvalidResourceClass(): void
+ {
+ $this->operationMock->expects($this->never())->method('getName');
+ $this->securityMock->expects($this->never())->method('getUser');
+ $this->queryBuilderMock->expects($this->never())->method('getRootAliases');
+ $this->queryBuilderMock->expects($this->never())->method('andWhere');
+ $this->queryBuilderMock->expects($this->never())->method('setParameter');
+
+ $this->extension->applyToCollection(
+ $this->queryBuilderMock,
+ $this->queryNameGeneratorMock,
+ \stdClass::class,
+ $this->operationMock
+ );
+ }
+
+ public function testItIgnoresInvalidOperation(): void
+ {
+ $this->operationMock
+ ->expects($this->once())
+ ->method('getName')
+ ->willReturn('_api_/books{._format}_get_collection');
+ $this->securityMock->expects($this->never())->method('getUser');
+ $this->queryBuilderMock->expects($this->never())->method('getRootAliases');
+ $this->queryBuilderMock->expects($this->never())->method('andWhere');
+ $this->queryBuilderMock->expects($this->never())->method('setParameter');
+
+ $this->extension->applyToCollection(
+ $this->queryBuilderMock,
+ $this->queryNameGeneratorMock,
+ Bookmark::class,
+ $this->operationMock
+ );
+ }
+
+ public function testItIgnoresInvalidUser(): void
+ {
+ $this->operationMock
+ ->expects($this->once())
+ ->method('getName')
+ ->willReturn('_api_/bookmarks{._format}_get_collection');
+ $this->securityMock
+ ->expects($this->once())
+ ->method('getUser')
+ ->willReturn(null);
+ $this->queryBuilderMock->expects($this->never())->method('getRootAliases');
+ $this->queryBuilderMock->expects($this->never())->method('andWhere');
+ $this->queryBuilderMock->expects($this->never())->method('setParameter');
+
+ $this->extension->applyToCollection(
+ $this->queryBuilderMock,
+ $this->queryNameGeneratorMock,
+ Bookmark::class,
+ $this->operationMock
+ );
+ }
+}
diff --git a/api/tests/Security/Core/UserProviderTest.php b/api/tests/Security/Core/UserProviderTest.php
new file mode 100644
index 000000000..36e738628
--- /dev/null
+++ b/api/tests/Security/Core/UserProviderTest.php
@@ -0,0 +1,149 @@
+registryMock = $this->createMock(ManagerRegistry::class);
+ $this->managerMock = $this->createMock(ObjectManager::class);
+ $this->repositoryMock = $this->createMock(UserRepository::class);
+ $this->userMock = $this->createMock(User::class);
+
+ $this->provider = new UserProvider($this->registryMock, $this->repositoryMock);
+ }
+
+ public function testItDoesNotSupportAnInvalidClass(): void
+ {
+ $this->assertFalse($this->provider->supportsClass(\stdClass::class));
+ }
+
+ public function testItSupportsAValidClass(): void
+ {
+ $this->assertTrue($this->provider->supportsClass(User::class));
+ }
+
+ public function testItCannotRefreshAnInvalidObject(): void
+ {
+ $this->expectException(UnsupportedUserException::class);
+
+ $objectMock = $this->createMock(UserInterface::class);
+ $this->registryMock
+ ->expects($this->once())
+ ->method('getManagerForClass')
+ ->with($objectMock::class)
+ ->willReturn(null);
+
+ $this->provider->refreshUser($objectMock);
+ }
+
+ public function testItRefreshesAValidObject(): void
+ {
+ $objectMock = $this->createMock(UserInterface::class);
+ $this->registryMock
+ ->expects($this->once())
+ ->method('getManagerForClass')
+ ->with($objectMock::class)
+ ->willReturn($this->managerMock);
+ $this->managerMock
+ ->expects($this->once())
+ ->method('refresh')
+ ->with($objectMock)
+ ->willReturn($this->managerMock);
+
+ $this->assertSame($objectMock, $this->provider->refreshUser($objectMock));
+ }
+
+ /**
+ * @dataProvider getInvalidAttributes
+ */
+ public function testItCannotLoadUserIfAttributeIsMissing(array $attributes): void
+ {
+ $this->expectException(UnsupportedUserException::class);
+
+ $this->repositoryMock
+ ->expects($this->once())
+ ->method('findOneBy')
+ ->with(['email' => 'john.doe@example.com'])
+ ->willReturn($this->userMock);
+ $this->repositoryMock->expects($this->never())->method('save');
+
+ $this->provider->loadUserByIdentifier('john.doe@example.com', $attributes);
+ }
+
+ public function getInvalidAttributes(): iterable
+ {
+ yield 'missing sub' => [[]];
+ yield 'missing given_name' => [[
+ 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
+ ]];
+ yield 'missing family_name' => [[
+ 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
+ 'given_name' => 'John',
+ ]];
+ }
+
+ public function testItLoadsUserFromAttributes(): void
+ {
+ $this->repositoryMock
+ ->expects($this->once())
+ ->method('findOneBy')
+ ->with(['email' => 'john.doe@example.com'])
+ ->willReturn($this->userMock);
+ $this->repositoryMock
+ ->expects($this->once())
+ ->method('save')
+ ->with($this->userMock);
+
+ $this->assertSame($this->userMock, $this->provider->loadUserByIdentifier('john.doe@example.com', [
+ 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
+ 'given_name' => 'John',
+ 'family_name' => 'DOE',
+ ]));
+ }
+
+ public function testItCreatesAUserFromAttributes(): void
+ {
+ $expectedUser = new User();
+ $expectedUser->firstName = 'John';
+ $expectedUser->lastName = 'DOE';
+ $expectedUser->sub = Uuid::fromString('ba86c94b-efeb-4452-a0b4-93ed3c889156');
+ $expectedUser->email = 'john.doe@example.com';
+
+ $this->repositoryMock
+ ->expects($this->once())
+ ->method('findOneBy')
+ ->with(['email' => 'john.doe@example.com'])
+ ->willReturn(null);
+ $this->repositoryMock
+ ->expects($this->once())
+ ->method('save')
+ ->with($expectedUser);
+
+ $this->assertEquals($expectedUser, $this->provider->loadUserByIdentifier('john.doe@example.com', [
+ 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
+ 'given_name' => 'John',
+ 'family_name' => 'DOE',
+ ]));
+ }
+}
diff --git a/api/tests/Serializer/BookNormalizerTest.php b/api/tests/Serializer/BookNormalizerTest.php
new file mode 100644
index 000000000..18978793d
--- /dev/null
+++ b/api/tests/Serializer/BookNormalizerTest.php
@@ -0,0 +1,93 @@
+normalizerMock = $this->createMock(NormalizerInterface::class);
+ $this->routerMock = $this->createMock(RouterInterface::class);
+ $this->repositoryMock = $this->createMock(ReviewRepository::class);
+ $this->objectMock = $this->createMock(Book::class);
+
+ $this->normalizer = new BookNormalizer($this->routerMock, $this->repositoryMock);
+ $this->normalizer->setNormalizer($this->normalizerMock);
+ }
+
+ public function testItDoesNotSupportInvalidObjectClass(): void
+ {
+ $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass()));
+ }
+
+ public function testItDoesNotSupportInvalidContext(): void
+ {
+ $this->assertFalse($this->normalizer->supportsNormalization($this->objectMock, null, [BookNormalizer::class => true]));
+ }
+
+ public function testItSupportsValidObjectClassAndContext(): void
+ {
+ $this->assertTrue($this->normalizer->supportsNormalization($this->objectMock));
+ }
+
+ public function testItNormalizesData(): void
+ {
+ $expectedObject = $this->objectMock;
+ $expectedObject->reviews = '/books/a528046c-7ba1-4acc-bff2-b5390ab17d41/reviews';
+ $expectedObject->rating = 3;
+
+ $this->objectMock
+ ->expects($this->once())
+ ->method('getId')
+ ->willReturn(Uuid::fromString('a528046c-7ba1-4acc-bff2-b5390ab17d41'));
+ $this->routerMock
+ ->expects($this->once())
+ ->method('generate')
+ ->with('_api_/books/{bookId}/reviews{._format}_get_collection', ['bookId' => 'a528046c-7ba1-4acc-bff2-b5390ab17d41'])
+ ->willReturn('/books/a528046c-7ba1-4acc-bff2-b5390ab17d41/reviews');
+ $this->repositoryMock
+ ->expects($this->once())
+ ->method('getAverageRating')
+ ->with($this->objectMock)
+ ->willReturn(3);
+ $this->normalizerMock
+ ->expects($this->once())
+ ->method('normalize')
+ ->with($expectedObject, null, [BookNormalizer::class => true])
+ ->willReturn([
+ 'book' => 'https://openlibrary.org/books/OL28346544M.json',
+ 'title' => 'Foundation',
+ 'author' => 'Isaac Asimov',
+ 'condition' => BookCondition::NewCondition->value,
+ 'reviews' => '/books/a528046c-7ba1-4acc-bff2-b5390ab17d41/reviews',
+ 'rating' => 3,
+ ]);
+
+ $this->assertEquals([
+ 'book' => 'https://openlibrary.org/books/OL28346544M.json',
+ 'title' => 'Foundation',
+ 'author' => 'Isaac Asimov',
+ 'condition' => BookCondition::NewCondition->value,
+ 'reviews' => '/books/a528046c-7ba1-4acc-bff2-b5390ab17d41/reviews',
+ 'rating' => 3,
+ ], $this->normalizer->normalize($this->objectMock));
+ }
+}
diff --git a/api/tests/Serializer/IriTransformerNormalizerTest.php b/api/tests/Serializer/IriTransformerNormalizerTest.php
new file mode 100644
index 000000000..226c8b538
--- /dev/null
+++ b/api/tests/Serializer/IriTransformerNormalizerTest.php
@@ -0,0 +1,138 @@
+normalizerMock = $this->createMock(NormalizerInterface::class);
+ $this->iriConverterMock = $this->createMock(IriConverterInterface::class);
+ $this->operationMetadataFactoryMock = $this->createMock(OperationMetadataFactoryInterface::class);
+ $this->operationMock = $this->createMock(Operation::class);
+ $this->objectMock = new \stdClass();
+ $this->objectMock->book = $this->createMock(\stdClass::class);
+ $this->objectMock->user = $this->createMock(\stdClass::class);
+
+ $this->normalizer = new IriTransformerNormalizer($this->iriConverterMock, $this->operationMetadataFactoryMock);
+ $this->normalizer->setNormalizer($this->normalizerMock);
+ }
+
+ public function testItDoesNotSupportInvalidData(): void
+ {
+ $this->assertFalse($this->normalizer->supportsNormalization(null));
+ $this->assertFalse($this->normalizer->supportsNormalization([]));
+ $this->assertFalse($this->normalizer->supportsNormalization('string'));
+ $this->assertFalse($this->normalizer->supportsNormalization(12345));
+ $this->assertFalse($this->normalizer->supportsNormalization(new ArrayCollection([$this->objectMock])));
+ }
+
+ public function testItDoesNotSupportInvalidContext(): void
+ {
+ $this->assertFalse($this->normalizer->supportsNormalization($this->objectMock));
+ $this->assertFalse($this->normalizer->supportsNormalization($this->objectMock, null, [IriTransformerNormalizer::class => true]));
+ }
+
+ public function testItDoesNotSupportInvalidFormat(): void
+ {
+ $this->assertFalse($this->normalizer->supportsNormalization($this->objectMock, null, [
+ IriTransformerNormalizer::CONTEXT_KEY => [
+ 'book' => '/books/{id}{._format}',
+ ],
+ ]));
+ $this->assertFalse($this->normalizer->supportsNormalization($this->objectMock, 'json', [
+ IriTransformerNormalizer::CONTEXT_KEY => [
+ 'book' => '/books/{id}{._format}',
+ ],
+ ]));
+ $this->assertFalse($this->normalizer->supportsNormalization($this->objectMock, 'xml', [
+ IriTransformerNormalizer::CONTEXT_KEY => [
+ 'book' => '/books/{id}{._format}',
+ ],
+ ]));
+ }
+
+ public function testItSupportsValidObjectClassAndContext(): void
+ {
+ $this->assertTrue($this->normalizer->supportsNormalization($this->objectMock, 'jsonld', [
+ IriTransformerNormalizer::CONTEXT_KEY => [
+ 'book' => '/books/{id}{._format}',
+ ],
+ ]));
+ }
+
+ public function testItNormalizesData(): void
+ {
+ $this->normalizerMock
+ ->expects($this->once())
+ ->method('normalize')
+ ->with($this->objectMock, 'jsonld', [
+ IriTransformerNormalizer::class => true,
+ IriTransformerNormalizer::CONTEXT_KEY => [
+ 'ignore' => 'lorem ipsum',
+ 'book' => '/books/{id}{._format}',
+ 'user' => '/users/{id}{._format}',
+ ],
+ ])
+ ->willReturn([
+ 'book' => '/admin/books/a528046c-7ba1-4acc-bff2-b5390ab17d41',
+ 'user' => [
+ '@id' => '/admin/users/b960cf9e-8f1a-4690-8923-623c1d049d41',
+ ],
+ ]);
+ $this->operationMetadataFactoryMock
+ ->expects($this->exactly(2))
+ ->method('create')
+ ->withConsecutive(
+ ['/books/{id}{._format}'],
+ ['/users/{id}{._format}'],
+ )
+ ->willReturnOnConsecutiveCalls(
+ $this->operationMock,
+ $this->operationMock,
+ );
+ $this->iriConverterMock
+ ->expects($this->exactly(2))
+ ->method('getIriFromResource')
+ ->withConsecutive(
+ [$this->objectMock->book, UrlGeneratorInterface::ABS_PATH, $this->operationMock],
+ [$this->objectMock->book, UrlGeneratorInterface::ABS_PATH, $this->operationMock],
+ )
+ ->willReturnOnConsecutiveCalls(
+ '/books/a528046c-7ba1-4acc-bff2-b5390ab17d41',
+ '/users/b960cf9e-8f1a-4690-8923-623c1d049d41',
+ );
+
+ $this->assertEquals([
+ 'book' => '/books/a528046c-7ba1-4acc-bff2-b5390ab17d41',
+ 'user' => [
+ '@id' => '/users/b960cf9e-8f1a-4690-8923-623c1d049d41',
+ ],
+ ], $this->normalizer->normalize($this->objectMock, 'jsonld', [
+ IriTransformerNormalizer::CONTEXT_KEY => [
+ 'ignore' => 'lorem ipsum',
+ 'book' => '/books/{id}{._format}',
+ 'user' => '/users/{id}{._format}',
+ ],
+ ]));
+ }
+}
diff --git a/api/tests/State/Processor/BookPersistProcessorTest.php b/api/tests/State/Processor/BookPersistProcessorTest.php
new file mode 100644
index 000000000..4b10b4cf2
--- /dev/null
+++ b/api/tests/State/Processor/BookPersistProcessorTest.php
@@ -0,0 +1,138 @@
+persistProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->mercureProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->clientMock = $this->createMock(HttpClientInterface::class);
+ $this->responseMock = $this->createMock(ResponseInterface::class);
+ $this->decoderMock = $this->createMock(DecoderInterface::class);
+ $this->objectMock = $this->createMock(Book::class);
+ $this->objectMock->book = 'https://openlibrary.org/books/OL28346544M.json';
+ $this->operationMock = $this->createMock(Operation::class);
+
+ $this->processor = new BookPersistProcessor(
+ $this->persistProcessorMock,
+ $this->mercureProcessorMock,
+ $this->clientMock,
+ $this->decoderMock
+ );
+ }
+
+ public function testItUpdatesBookDataBeforeSaveAndSendMercureUpdates(): void
+ {
+ $expectedData = $this->objectMock;
+ $expectedData->title = 'Foundation';
+ $expectedData->author = 'Isaac Asimov';
+
+ $this->clientMock
+ ->expects($this->exactly(2))
+ ->method('request')
+ ->withConsecutive(
+ [
+ Request::METHOD_GET, 'https://openlibrary.org/books/OL28346544M.json', [
+ 'headers' => [
+ 'Accept' => 'application/json',
+ ],
+ ]
+ ],
+ [
+ Request::METHOD_GET, 'https://openlibrary.org/authors/OL34221A.json', [
+ 'headers' => [
+ 'Accept' => 'application/json',
+ ],
+ ]
+ ],
+ )
+ ->willReturnOnConsecutiveCalls($this->responseMock, $this->responseMock);
+ $this->responseMock
+ ->expects($this->exactly(2))
+ ->method('getContent')
+ ->willReturnOnConsecutiveCalls(
+ json_encode([
+ 'title' => 'Foundation',
+ 'authors' => [
+ ['key' => '/authors/OL34221A']
+ ],
+ ]),
+ json_encode([
+ 'name' => 'Isaac Asimov',
+ ]),
+ );
+ $this->decoderMock
+ ->expects($this->exactly(2))
+ ->method('decode')
+ ->withConsecutive(
+ [
+ json_encode([
+ 'title' => 'Foundation',
+ 'authors' => [
+ ['key' => '/authors/OL34221A']
+ ],
+ ]),
+ 'json'
+ ],
+ [
+ json_encode([
+ 'name' => 'Isaac Asimov',
+ ]),
+ 'json'
+ ],
+ )
+ ->willReturnOnConsecutiveCalls(
+ [
+ 'title' => 'Foundation',
+ 'authors' => [
+ ['key' => '/authors/OL34221A']
+ ],
+ ],
+ [
+ 'name' => 'Isaac Asimov',
+ ],
+ );
+ $this->persistProcessorMock
+ ->expects($this->once())
+ ->method('process')
+ ->with($expectedData, $this->operationMock, [], [])
+ ->willReturn($expectedData);
+ $this->mercureProcessorMock
+ ->expects($this->exactly(2))
+ ->method('process')
+ ->withConsecutive(
+ [$expectedData, $this->operationMock, [], ['item_uri_template' => '/admin/books/{id}{._format}']],
+ [$expectedData, $this->operationMock, [], ['item_uri_template' => '/books/{id}{._format}']],
+ )
+ ->willReturnOnConsecutiveCalls(
+ $expectedData,
+ $expectedData,
+ );
+
+ $this->assertEquals($expectedData, $this->processor->process($this->objectMock, $this->operationMock));
+ }
+}
diff --git a/api/tests/State/Processor/BookRemoveProcessorTest.php b/api/tests/State/Processor/BookRemoveProcessorTest.php
new file mode 100644
index 000000000..74cf1c838
--- /dev/null
+++ b/api/tests/State/Processor/BookRemoveProcessorTest.php
@@ -0,0 +1,107 @@
+removeProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->mercureProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->resourceMetadataCollectionFactoryMock = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
+ $this->resourceMetadataCollection = new ResourceMetadataCollection(Book::class, [
+ new ApiResource(operations: [new Get('/admin/books/{id}{._format}')]),
+ new ApiResource(operations: [new Get('/books/{id}{._format}')]),
+ ]);
+ $this->iriConverterMock = $this->createMock(IriConverterInterface::class);
+ $this->objectMock = $this->createMock(Book::class);
+ $this->operationMock = $this->createMock(Operation::class);
+
+ $this->processor = new BookRemoveProcessor(
+ $this->removeProcessorMock,
+ $this->mercureProcessorMock,
+ $this->resourceMetadataCollectionFactoryMock,
+ $this->iriConverterMock
+ );
+ }
+
+ public function testItRemovesBookAndSendMercureUpdates(): void
+ {
+ $this->removeProcessorMock
+ ->expects($this->once())
+ ->method('process')
+ ->with($this->objectMock, $this->operationMock, [], []);
+ $this->resourceMetadataCollectionFactoryMock
+ ->expects($this->exactly(2))
+ ->method('create')
+ ->withConsecutive(
+ [Book::class],
+ [Book::class],
+ )
+ ->willReturnOnConsecutiveCalls(
+ $this->resourceMetadataCollection,
+ $this->resourceMetadataCollection,
+ );
+ $this->iriConverterMock
+ ->expects($this->exactly(2))
+ ->method('getIriFromResource')
+ ->withConsecutive(
+ [$this->objectMock, UrlGeneratorInterface::ABS_URL, new Get('/admin/books/{id}{._format}')],
+ [$this->objectMock, UrlGeneratorInterface::ABS_URL, new Get('/books/{id}{._format}')],
+ )
+ ->willReturnOnConsecutiveCalls(
+ '/admin/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6',
+ '/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6',
+ );
+ $this->mercureProcessorMock
+ ->expects($this->exactly(2))
+ ->method('process')
+ ->withConsecutive(
+ [
+ $this->objectMock,
+ $this->operationMock,
+ [],
+ [
+ 'item_uri_template' => '/admin/books/{id}{._format}',
+ 'data' => json_encode(['@id' => '/admin/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6']),
+ ]
+ ],
+ [
+ $this->objectMock,
+ $this->operationMock,
+ [],
+ [
+ 'item_uri_template' => '/books/{id}{._format}',
+ 'data' => json_encode(['@id' => '/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6']),
+ ]
+ ],
+ );
+
+ $this->processor->process($this->objectMock, $this->operationMock);
+ }
+}
diff --git a/api/tests/State/Processor/BookmarkPersistProcessorTest.php b/api/tests/State/Processor/BookmarkPersistProcessorTest.php
new file mode 100644
index 000000000..cbce69546
--- /dev/null
+++ b/api/tests/State/Processor/BookmarkPersistProcessorTest.php
@@ -0,0 +1,58 @@
+persistProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->securityMock = $this->createMock(Security::class);
+ $this->userMock = $this->createMock(User::class);
+ $this->objectMock = $this->createMock(Bookmark::class);
+ $this->operationMock = $this->createMock(Operation::class);
+ $this->clockMock = new MockClock();
+
+ $this->processor = new BookmarkPersistProcessor($this->persistProcessorMock, $this->securityMock, $this->clockMock);
+ }
+
+ public function testItUpdatesBookmarkDataBeforeSave(): void
+ {
+ $expectedData = $this->objectMock;
+ $expectedData->user = $this->userMock;
+ $expectedData->bookmarkedAt = $this->clockMock->now();
+
+ $this->securityMock
+ ->expects($this->once())
+ ->method('getUser')
+ ->willReturn($this->userMock);
+ $this->persistProcessorMock
+ ->expects($this->once())
+ ->method('process')
+ ->with($expectedData, $this->operationMock, [], [])
+ ->willReturn($expectedData);
+
+ $this->assertEquals($expectedData, $this->processor->process($this->objectMock, $this->operationMock));
+ }
+}
diff --git a/api/tests/State/Processor/MercureProcessorTest.php b/api/tests/State/Processor/MercureProcessorTest.php
new file mode 100644
index 000000000..6192d096a
--- /dev/null
+++ b/api/tests/State/Processor/MercureProcessorTest.php
@@ -0,0 +1,110 @@
+serializerMock = $this->createMock(SerializerInterface::class);
+ $this->hubMock = $this->createMock(HubInterface::class);
+ $this->hubRegistry = new HubRegistry($this->hubMock);
+ $this->resourceMetadataCollectionFactoryMock = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
+ $this->resourceMetadataCollection = new ResourceMetadataCollection(Book::class, [
+ new ApiResource(operations: [new Get('/admin/books/{id}{._format}')]),
+ new ApiResource(operations: [new Get('/books/{id}{._format}')]),
+ ]);
+ $this->iriConverterMock = $this->createMock(IriConverterInterface::class);
+ $this->objectMock = $this->createMock(Book::class);
+ $this->operationMock = $this->createMock(Operation::class);
+
+ $this->processor = new MercureProcessor(
+ $this->serializerMock,
+ $this->hubRegistry,
+ $this->iriConverterMock,
+ $this->resourceMetadataCollectionFactoryMock,
+ ['jsonld' => null, 'json' => null]
+ );
+ }
+
+ public function testItSendsAMercureUpdate(): void
+ {
+ $this->resourceMetadataCollectionFactoryMock->expects($this->never())->method('create');
+ $this->iriConverterMock
+ ->expects($this->once())
+ ->method('getIriFromResource')
+ ->with($this->objectMock, UrlGeneratorInterface::ABS_URL, $this->operationMock)
+ ->willReturn('/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6');
+ $this->operationMock
+ ->expects($this->once())
+ ->method('getNormalizationContext')
+ ->willReturn(null);
+ $this->serializerMock
+ ->expects($this->once())
+ ->method('serialize')
+ ->with($this->objectMock, 'jsonld', [])
+ ->willReturn(json_encode(['foo' => 'bar']));
+ $this->hubMock
+ ->expects($this->once())
+ ->method('publish')
+ ->with($this->equalTo(new Update(
+ topics: ['/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6'],
+ data: json_encode(['foo' => 'bar']),
+ )));
+
+ $this->processor->process($this->objectMock, $this->operationMock);
+ }
+
+ public function testItSendsAMercureUpdateWithContextOptions(): void
+ {
+ $this->resourceMetadataCollectionFactoryMock
+ ->expects($this->once())
+ ->method('create')
+ ->with($this->objectMock::class)
+ ->willReturn($this->resourceMetadataCollection);
+ $this->iriConverterMock->expects($this->never())->method('getIriFromResource');
+ $this->operationMock->expects($this->never())->method('getNormalizationContext');
+ $this->serializerMock->expects($this->never())->method('serialize');
+ $this->hubMock
+ ->expects($this->once())
+ ->method('publish')
+ ->with($this->equalTo(new Update(
+ topics: ['/admin/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6'],
+ data: json_encode(['bar' => 'baz']),
+ )));
+
+ $this->processor->process($this->objectMock, $this->operationMock, [], [
+ 'item_uri_template' => '/admin/books/{id}{._format}',
+ 'topics' => ['/admin/books/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6'],
+ 'data' => json_encode(['bar' => 'baz']),
+ ]);
+ }
+}
diff --git a/api/tests/State/Processor/ReviewPersistProcessorTest.php b/api/tests/State/Processor/ReviewPersistProcessorTest.php
new file mode 100644
index 000000000..094229b20
--- /dev/null
+++ b/api/tests/State/Processor/ReviewPersistProcessorTest.php
@@ -0,0 +1,76 @@
+persistProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->mercureProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->securityMock = $this->createMock(Security::class);
+ $this->userMock = $this->createMock(User::class);
+ $this->objectMock = $this->createMock(Review::class);
+ $this->operationMock = $this->createMock(Operation::class);
+ $this->clockMock = new MockClock();
+
+ $this->processor = new ReviewPersistProcessor(
+ $this->persistProcessorMock,
+ $this->mercureProcessorMock,
+ $this->securityMock,
+ $this->clockMock
+ );
+ }
+
+ public function testItUpdatesBookmarkDataBeforeSaveAndSendMercureUpdates(): void
+ {
+ $expectedData = $this->objectMock;
+ $expectedData->user = $this->userMock;
+ $expectedData->publishedAt = $this->clockMock->now();
+
+ $this->securityMock
+ ->expects($this->once())
+ ->method('getUser')
+ ->willReturn($this->userMock);
+ $this->persistProcessorMock
+ ->expects($this->once())
+ ->method('process')
+ ->with($expectedData, $this->operationMock, [], [])
+ ->willReturn($expectedData);
+ $this->mercureProcessorMock
+ ->expects($this->exactly(2))
+ ->method('process')
+ ->withConsecutive(
+ [$expectedData, $this->operationMock, [], ['item_uri_template' => '/admin/reviews/{id}{._format}']],
+ [$expectedData, $this->operationMock, [], ['item_uri_template' => '/books/{bookId}/reviews/{id}{._format}']],
+ )
+ ->willReturnOnConsecutiveCalls(
+ $expectedData,
+ $expectedData,
+ );
+
+ $this->assertEquals($expectedData, $this->processor->process($this->objectMock, $this->operationMock));
+ }
+}
diff --git a/api/tests/State/Processor/ReviewRemoveProcessorTest.php b/api/tests/State/Processor/ReviewRemoveProcessorTest.php
new file mode 100644
index 000000000..db17081d9
--- /dev/null
+++ b/api/tests/State/Processor/ReviewRemoveProcessorTest.php
@@ -0,0 +1,106 @@
+removeProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->mercureProcessorMock = $this->createMock(ProcessorInterface::class);
+ $this->resourceMetadataCollectionFactoryMock = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
+ $this->resourceMetadataCollection = new ResourceMetadataCollection(Review::class, [
+ new ApiResource(operations: [new Get('/admin/reviews/{id}{._format}')]),
+ new ApiResource(operations: [new Get('/books/{bookId}/reviews/{id}{._format}')]),
+ ]);
+ $this->iriConverterMock = $this->createMock(IriConverterInterface::class);
+ $this->objectMock = $this->createMock(Review::class);
+ $this->operationMock = $this->createMock(Operation::class);
+
+ $this->processor = new ReviewRemoveProcessor(
+ $this->removeProcessorMock,
+ $this->mercureProcessorMock,
+ $this->resourceMetadataCollectionFactoryMock,
+ $this->iriConverterMock
+ );
+ }
+
+ public function testItRemovesBookAndSendMercureUpdates(): void
+ {
+ $this->removeProcessorMock
+ ->expects($this->once())
+ ->method('process')
+ ->with($this->objectMock, $this->operationMock, [], []);
+ $this->resourceMetadataCollectionFactoryMock
+ ->expects($this->exactly(2))
+ ->method('create')
+ ->withConsecutive(
+ [Review::class],
+ [Review::class],
+ )
+ ->willReturnOnConsecutiveCalls(
+ $this->resourceMetadataCollection,
+ $this->resourceMetadataCollection,
+ );
+ $this->iriConverterMock
+ ->expects($this->exactly(2))
+ ->method('getIriFromResource')
+ ->withConsecutive(
+ [$this->objectMock, UrlGeneratorInterface::ABS_URL, new Get('/admin/reviews/{id}{._format}')],
+ [$this->objectMock, UrlGeneratorInterface::ABS_URL, new Get('/books/{bookId}/reviews/{id}{._format}')],
+ )
+ ->willReturnOnConsecutiveCalls(
+ '/admin/reviews/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6',
+ '/books/8ad70d36-abaf-4c9b-aeaa-7ec63e6ca6f3/reviews/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6',
+ );
+ $this->mercureProcessorMock
+ ->expects($this->exactly(2))
+ ->method('process')
+ ->withConsecutive(
+ [
+ $this->objectMock,
+ $this->operationMock,
+ [],
+ [
+ 'item_uri_template' => '/admin/reviews/{id}{._format}',
+ 'data' => json_encode(['@id' => '/admin/reviews/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6']),
+ ]
+ ],
+ [
+ $this->objectMock,
+ $this->operationMock,
+ [],
+ [
+ 'item_uri_template' => '/books/{bookId}/reviews/{id}{._format}',
+ 'data' => json_encode(['@id' => '/books/8ad70d36-abaf-4c9b-aeaa-7ec63e6ca6f3/reviews/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6']),
+ ]
+ ],
+ );
+
+ $this->processor->process($this->objectMock, $this->operationMock);
+ }
+}
From ea94d77a42ff756cf5c2f5d5569eff67d40b8866 Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Thu, 10 Aug 2023 18:10:30 +0200
Subject: [PATCH 19/51] feat: configure PWA with Vulcain
---
pwa/pages/bookmarks/index.tsx | 2 +-
pwa/pages/books/[id]/[slug]/index.tsx | 7 +++++--
pwa/utils/book.ts | 1 -
3 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/pwa/pages/bookmarks/index.tsx b/pwa/pages/bookmarks/index.tsx
index 899a9aad7..39db25598 100644
--- a/pwa/pages/bookmarks/index.tsx
+++ b/pwa/pages/bookmarks/index.tsx
@@ -29,7 +29,7 @@ export const getServerSideProps: GetServerSideProps<{
const response: FetchResponse> | undefined = await fetch(`/bookmarks?page=${Number(page ?? 1)}`, {
headers: {
// @ts-ignore
- Authorization: `Bearer ${session?.accessToken}`
+ Authorization: `Bearer ${session?.accessToken}`,
}
});
if (!response?.data) {
diff --git a/pwa/pages/books/[id]/[slug]/index.tsx b/pwa/pages/books/[id]/[slug]/index.tsx
index ab3db2e0c..45282dc44 100644
--- a/pwa/pages/books/[id]/[slug]/index.tsx
+++ b/pwa/pages/books/[id]/[slug]/index.tsx
@@ -10,11 +10,14 @@ export const getServerSideProps: GetServerSideProps<{
page: number, // required for reviews pagination, prevents useRouter
}> = async ({ query: { id, page } }) => {
try {
- const response: FetchResponse | undefined = await fetch(`/books/${id}`);
+ const response: FetchResponse | undefined = await fetch(`/books/${id}`, {
+ headers: {
+ Preload: "/books/*/reviews",
+ }
+ });
if (!response?.data) {
throw new Error(`Unable to retrieve data from /books/${id}.`);
}
- console.log(response.data);
return { props: { data: response.data, hubURL: response.hubURL, page: page ? Number(page) : 1 } };
} catch (error) {
diff --git a/pwa/utils/book.ts b/pwa/utils/book.ts
index e4f473bec..5db7210d8 100644
--- a/pwa/utils/book.ts
+++ b/pwa/utils/book.ts
@@ -67,7 +67,6 @@ export const useOpenLibraryBook = (data: TData) => {
});
};
-// @ts-ignore
const filterObject = (object: object) => Object.fromEntries(Object.entries(object).filter(([, value]) => {
return typeof value === "object" ? Object.keys(value).length > 0 : value?.length > 0;
}));
From d40a61d9caad6518ede4383c84d4a4e45004e4fe Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Mon, 21 Aug 2023 09:59:08 +0200
Subject: [PATCH 20/51] test: update api-platform/core dependency
---
api/composer.json | 8 +------
api/composer.lock | 42 ++++++++++++++++--------------------
api/src/Entity/Review.php | 2 ++
api/tests/Api/ReviewTest.php | 3 ---
4 files changed, 22 insertions(+), 33 deletions(-)
diff --git a/api/composer.json b/api/composer.json
index 0085a0fa0..67bbd02b7 100644
--- a/api/composer.json
+++ b/api/composer.json
@@ -1,18 +1,12 @@
{
"type": "project",
"license": "MIT",
- "repositories": [
- {
- "type": "vcs",
- "url": "git@github.com:vincentchalamon/core.git"
- }
- ],
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-xml": "*",
- "api-platform/core": "dev-demo",
+ "api-platform/core": "3.1.x-dev",
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.12",
diff --git a/api/composer.lock b/api/composer.lock
index 021b461f3..67613ab51 100644
--- a/api/composer.lock
+++ b/api/composer.lock
@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "2947cff93e0b37a3ddb3109f97d6dc5e",
+ "content-hash": "51b61f485c4305effda0483967f341b4",
"packages": [
{
"name": "api-platform/core",
- "version": "dev-demo",
+ "version": "3.1.x-dev",
"source": {
"type": "git",
- "url": "https://github.com/vincentchalamon/core.git",
- "reference": "d6c3019671c7d668fa0cc8a624fc5bc17a26ddd2"
+ "url": "https://github.com/api-platform/core.git",
+ "reference": "a774f4c51167dbbe585269f14a7c51a3f9e38c3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/vincentchalamon/core/zipball/d6c3019671c7d668fa0cc8a624fc5bc17a26ddd2",
- "reference": "d6c3019671c7d668fa0cc8a624fc5bc17a26ddd2",
+ "url": "https://api.github.com/repos/api-platform/core/zipball/a774f4c51167dbbe585269f14a7c51a3f9e38c3c",
+ "reference": "a774f4c51167dbbe585269f14a7c51a3f9e38c3c",
"shasum": ""
},
"require": {
@@ -139,12 +139,7 @@
"ApiPlatform\\": "src/"
}
},
- "autoload-dev": {
- "psr-4": {
- "ApiPlatform\\Tests\\": "tests/",
- "App\\": "tests/Fixtures/app/var/tmp/src/"
- }
- },
+ "notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
@@ -158,26 +153,27 @@
"description": "Build a fully-featured hypermedia or GraphQL API in minutes!",
"homepage": "https://api-platform.com",
"keywords": [
- "API",
- "GraphQL",
- "HAL",
"Hydra",
"JSON-LD",
- "JSONAPI",
- "OpenAPI",
- "REST",
- "Swagger"
+ "api",
+ "graphql",
+ "hal",
+ "jsonapi",
+ "openapi",
+ "rest",
+ "swagger"
],
"support": {
- "source": "https://github.com/vincentchalamon/core/tree/demo"
+ "issues": "https://github.com/api-platform/core/issues",
+ "source": "https://github.com/api-platform/core/tree/3.1"
},
"funding": [
{
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/packagist/api-platform/core"
+ "url": "https://tidelift.com/funding/github/packagist/api-platform/core",
+ "type": "tidelift"
}
],
- "time": "2023-08-08T18:08:58+00:00"
+ "time": "2023-08-23T07:41:20+00:00"
},
{
"name": "brick/math",
diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php
index 3a60a8c20..d89f954f2 100644
--- a/api/src/Entity/Review.php
+++ b/api/src/Entity/Review.php
@@ -14,6 +14,7 @@
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
+use ApiPlatform\State\CreateProvider;
use App\Repository\ReviewRepository;
use App\Serializer\IriTransformerNormalizer;
use App\State\Processor\ReviewPersistProcessor;
@@ -86,6 +87,7 @@
security: 'is_granted("ROLE_USER")',
// Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
processor: ReviewPersistProcessor::class,
+ provider: CreateProvider::class,
itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}'
),
new Patch(
diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php
index 4faa69b1b..b0fd2502c 100644
--- a/api/tests/Api/ReviewTest.php
+++ b/api/tests/Api/ReviewTest.php
@@ -135,7 +135,6 @@ public function testAsAnonymousICannotAddAReviewOnABook(): void
$this->client->request('POST', '/books/'.$book->getId().'/reviews', [
'json' => [
- 'book' => '/books/'.$book->getId(),
'body' => 'Very good book!',
'rating' => 5,
],
@@ -239,7 +238,6 @@ public function testAsAUserICannotAddAReviewWithValidDataOnAnInvalidBook(): void
$this->client->request('POST', '/books/invalid/reviews', [
'auth_bearer' => $token,
'json' => [
- 'book' => '/books/'.$book->getId(),
'body' => 'Very good book!',
'rating' => 5,
],
@@ -272,7 +270,6 @@ public function testAsAUserICanAddAReviewOnABook(): void
$response = $this->client->request('POST', '/books/'.$book->getId().'/reviews', [
'auth_bearer' => $token,
'json' => [
- 'book' => '/books/'.$book->getId(),
'body' => 'Very good book!',
'rating' => 5,
],
From 5a4940a5b66fb911d78d4fa3e1590f5b53ac8845 Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Wed, 23 Aug 2023 16:10:06 +0200
Subject: [PATCH 21/51] feat: add Souin
---
api/Dockerfile | 4 ++--
api/config/packages/api_platform.yaml | 19 +++++++++++++++++++
api/docker/caddy/Caddyfile | 5 +++--
docker-compose.prod.yml | 8 ++++++++
helm/api-platform/templates/configmap.yaml | 1 +
helm/api-platform/templates/deployment.yaml | 7 +++++++
helm/api-platform/values.yaml | 7 +++++++
7 files changed, 47 insertions(+), 4 deletions(-)
diff --git a/api/Dockerfile b/api/Dockerfile
index d036635e9..b526f1c01 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -118,8 +118,8 @@ ARG TARGETARCH
WORKDIR /srv/app
-# Download Caddy compiled with the Mercure and Vulcain modules
-ADD --chmod=500 https://caddyserver.com/api/download?os=linux&arch=$TARGETARCH&p=github.com/dunglas/mercure/caddy&p=github.com/dunglas/vulcain/caddy /usr/bin/caddy
+# Download Caddy compiled with the Mercure, Vulcain and Souin modules
+ADD --chmod=500 https://caddyserver.com/api/download?os=linux&arch=$TARGETARCH&p=github.com/dunglas/mercure/caddy&p=github.com/dunglas/vulcain/caddy&p=github.com/caddyserver/cache-handler /usr/bin/caddy
COPY --link docker/caddy/Caddyfile /etc/caddy/Caddyfile
diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml
index 5fe081e2d..88924a20b 100644
--- a/api/config/packages/api_platform.yaml
+++ b/api/config/packages/api_platform.yaml
@@ -9,6 +9,8 @@ api_platform:
graphql:
graphql_playground: false
mercure: ~
+ http_cache:
+ public: true
defaults:
stateless: true
cache_headers:
@@ -28,6 +30,23 @@ api_platform:
scopes:
openid: (required) Indicates that the application intends to use OIDC to verify the user's identity
+when@prod:
+ parameters:
+ # The api url that is called to invalidate cached resources
+ # Can't be set in .env file cause it's only available on prod env
+ env(SOUIN_API_URL): http://caddy/souin-api/souin
+
+ api_platform:
+ http_cache:
+ invalidation:
+ enabled: true
+ purger: 'api_platform.http_cache.purger.souin'
+ urls: ['%env(SOUIN_API_URL)%']
+ defaults:
+ cache_headers:
+ max_age: 0
+ shared_max_age: 3600
+
services:
app.filter.review.admin.search:
class: 'ApiPlatform\Doctrine\Orm\Filter\SearchFilter'
diff --git a/api/docker/caddy/Caddyfile b/api/docker/caddy/Caddyfile
index 969c0dc11..6b95ed9f1 100644
--- a/api/docker/caddy/Caddyfile
+++ b/api/docker/caddy/Caddyfile
@@ -1,12 +1,13 @@
{
- # Debug
- {$CADDY_DEBUG}
+ {$CADDY_GLOBAL_OPTIONS}
}
{$SERVER_NAME}
log
+{$CADDY_CACHE}
+
# Matches requests for OIDC routes
@oidc expression path('/oidc/*')
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 54913f199..172f41dbd 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -27,6 +27,14 @@ services:
environment:
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
+ CADDY_GLOBAL_OPTIONS: |
+ order cache before rewrite
+ cache {
+ api {
+ souin
+ }
+ }
+ CADDY_CACHE: cache
database:
environment:
diff --git a/helm/api-platform/templates/configmap.yaml b/helm/api-platform/templates/configmap.yaml
index 4606c1a68..2f4c83781 100644
--- a/helm/api-platform/templates/configmap.yaml
+++ b/helm/api-platform/templates/configmap.yaml
@@ -13,6 +13,7 @@ data:
mercure-url: "http://{{ include "api-platform.fullname" . }}/.well-known/mercure"
mercure-public-url: {{ .Values.mercure.publicUrl | default "http://127.0.0.1/.well-known/mercure" | quote }}
mercure-extra-directives: {{ .Values.mercure.extraDirectives | quote }}
+ caddy-global-options: {{ .Values.caddy.globalOptions | quote }}
oidc-server-url: "https://{{ (first .Values.ingress.hosts).host }}/oidc/realms/demo"
oidc-server-url-internal: "http://{{ include "api-platform.fullname" . }}/oidc/realms/demo"
next-auth-url: "https://{{ (first .Values.ingress.hosts).host }}/api/auth"
diff --git a/helm/api-platform/templates/deployment.yaml b/helm/api-platform/templates/deployment.yaml
index 8d286086c..db4bb6809 100644
--- a/helm/api-platform/templates/deployment.yaml
+++ b/helm/api-platform/templates/deployment.yaml
@@ -40,6 +40,13 @@ spec:
value: {{ include "api-platform.fullname" . }}-pwa:3000
- name: OIDC_UPSTREAM
value: {{ .Release.Name }}-keycloak:80
+ - name: CADDY_CACHE
+ value: cache
+ - name: CADDY_GLOBAL_OPTIONS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "api-platform.fullname" . }}
+ key: caddy-global-options
- name: MERCURE_EXTRA_DIRECTIVES
valueFrom:
configMapKeyRef:
diff --git a/helm/api-platform/values.yaml b/helm/api-platform/values.yaml
index 9085b266d..8523a0415 100644
--- a/helm/api-platform/values.yaml
+++ b/helm/api-platform/values.yaml
@@ -34,6 +34,13 @@ caddy:
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
+ globalOptions: |
+ order cache before rewrite
+ cache {
+ api {
+ souin
+ }
+ }
# You may prefer using the managed version in production: https://mercure.rocks
mercure:
From b72f499aca45fc1f5d922cd9a5844f28996775d9 Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Thu, 24 Aug 2023 20:00:46 +0200
Subject: [PATCH 22/51] test: add e2e tests
---
.github/workflows/ci.yml | 153 ++++++++-----
.../DataFixtures/Factory/ReviewFactory.php | 2 +-
.../DataFixtures/Story/DefaultBookStory.php | 4 +-
.../Story/DefaultBookmarkStory.php | 3 +
.../Story/DefaultReviewsStory.php | 5 +
api/src/Entity/Review.php | 2 +
docker-compose.prod.yml | 7 -
docker-compose.yml | 3 +-
pwa/components/book/Filters.tsx | 12 +-
pwa/components/book/List.tsx | 7 +-
pwa/components/book/Show.tsx | 12 +-
pwa/components/bookmark/List.tsx | 16 +-
pwa/components/review/Form.tsx | 6 +-
pwa/components/review/List.tsx | 6 +-
pwa/package.json | 4 +-
pwa/pages/bookmarks/index.tsx | 5 +-
pwa/playwright.config.ts | 40 ++--
pwa/pnpm-lock.yaml | 20 ++
pwa/tests/BookView.spec.ts | 215 ++++++++++++++++++
pwa/tests/BookmarksList.spec.ts | 113 +++++++++
pwa/tests/Books.spec.ts | 73 ------
pwa/tests/BooksList.spec.ts | 172 ++++++++++++++
pwa/tests/Homepage.spec.ts | 66 +++---
pwa/tests/Page.ts | 40 ----
pwa/tests/Reviews.spec.ts | 74 ------
pwa/tests/User.spec.ts | 29 +++
pwa/tests/pages/AbstractPage.ts | 14 ++
pwa/tests/pages/BookPage.ts | 61 +++++
pwa/tests/pages/BookmarkPage.ts | 13 ++
pwa/tests/pages/UserPage.ts | 4 +
pwa/tests/test.ts | 30 +++
31 files changed, 877 insertions(+), 334 deletions(-)
create mode 100644 pwa/tests/BookView.spec.ts
create mode 100644 pwa/tests/BookmarksList.spec.ts
delete mode 100644 pwa/tests/Books.spec.ts
create mode 100644 pwa/tests/BooksList.spec.ts
delete mode 100644 pwa/tests/Page.ts
delete mode 100644 pwa/tests/Reviews.spec.ts
create mode 100644 pwa/tests/User.spec.ts
create mode 100644 pwa/tests/pages/AbstractPage.ts
create mode 100644 pwa/tests/pages/BookPage.ts
create mode 100644 pwa/tests/pages/BookmarkPage.ts
create mode 100644 pwa/tests/pages/UserPage.ts
create mode 100644 pwa/tests/test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5d37d30cb..59dc67867 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,10 +18,6 @@ jobs:
permissions:
contents: 'read'
id-token: 'write'
- env:
- PHP_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/php:latest
- CADDY_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/caddy:latest
- PWA_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/pwa:latest
steps:
-
name: Checkout
@@ -30,7 +26,7 @@ jobs:
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
- name: Build Docker images
+ name: Build Docker Images
uses: docker/bake-action@v3
with:
pull: true
@@ -43,72 +39,127 @@ jobs:
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max
-
- name: Start services
+ name: Start Services
run: docker compose up --wait --no-build
-
- name: Wait for services
+ name: Debug Services
+ if: failure()
run: |
- while status="$(docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" "$(docker compose ps -q php)")"; do
- case $status in
- starting) sleep 1;;
- healthy) exit 0;;
- unhealthy)
- docker compose ps
- docker compose logs
- exit 1
- ;;
- esac
- done
- exit 1
- -
- name: Check HTTP reachability
+ docker compose ps
+ docker compose logs
+ -
+ name: Check HTTP Reachability
run: curl -v -o /dev/null http://localhost
-
- name: Check API reachability
+ name: Check API Reachability
run: curl -vk -o /dev/null https://localhost
-
- name: Check PWA reachability
+ name: Check PWA Reachability
run: "curl -vk -o /dev/null -H 'Accept: text/html' https://localhost"
-
- name: Create test database
+ name: Create Test Database
run: |
docker compose exec -T php bin/console -e test doctrine:database:create
docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction
-
- name: PHPUnit
+ name: Run PHPUnit Tests
run: docker compose exec -T php bin/phpunit
-
- name: Install pnpm
+ name: Doctrine Schema Validator
+ run: docker compose exec -T php bin/console doctrine:schema:validate
+ -
+ name: Run Psalm Analysis
+ run: docker compose exec -T php vendor/bin/psalm
+ -
+ name: Run PWA Lint
+ run: docker compose exec -T pwa pnpm lint
+
+ # run e2e tests iso-prod
+ e2e-tests:
+ name: E2E Tests
+ runs-on: ubuntu-latest
+ permissions:
+ contents: 'read'
+ id-token: 'write'
+ env:
+ PHP_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/php:latest
+ APP_SECRET: "ba63418865d58089f7f070e0a437b6d16b1fb970"
+ CADDY_MERCURE_JWT_SECRET: "f8675b65055fc9f1ccdc21e425c00798633d5556"
+ PWA_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/pwa:latest
+ NEXTAUTH_SECRET: "0efafa22ed0e5f4d1875777584eebeebf14068f1"
+ CADDY_DOCKER_IMAGE: eu.gcr.io/${{ secrets.GKE_PROJECT }}/caddy:latest
+ POSTGRES_PASSWORD: "01c3b2511ddbff2838fa39cc3b823037e1627397"
+ KEYCLOAK_POSTGRES_PASSWORD: "b8ef720708474177fa169a5c3fec495e04660f44"
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@v3
+ -
+ name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+ -
+ name: Build Docker Images
+ uses: docker/bake-action@v3
+ with:
+ pull: true
+ load: true
+ files: |
+ docker-compose.yml
+ docker-compose.prod.yml
+ set: |
+ *.cache-from=type=gha,scope=${{github.ref}}-e2e
+ *.cache-from=type=gha,scope=${{github.ref}}
+ *.cache-from=type=gha,scope=refs/heads/main
+ *.cache-to=type=gha,scope=${{github.ref}}-e2e,mode=max
+ -
+ name: Start Services
+ run: docker compose up --wait --no-build
+ -
+ name: Debug Services
+ if: failure()
+ run: |
+ docker compose ps
+ docker compose logs
+ -
+ name: Cache Playwright Binaries
+ uses: actions/cache@v3
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright
+ -
+ name: Install PNPM
uses: pnpm/action-setup@v2
with:
version: 8.6.2
-
- name: Doctrine Schema Validator
- run: docker compose exec -T php bin/console doctrine:schema:validate
+ name: Install Dependencies
+ working-directory: pwa
+ run: pnpm install
-
- name: Psalm
- run: docker compose exec -T php vendor/bin/psalm
-# -
-# name: Cache playwright binaries
-# uses: actions/cache@v3
-# with:
-# path: ~/.cache/ms-playwright
-# key: ${{ runner.os }}-playwright
-# -
-# name: Install Playwright dependencies
-# working-directory: pwa
-# run: pnpm playwright install
-# -
-# name: Run Playwright
-# working-directory: pwa
-# # use 1 worker to prevent conflict between write and read scenarios
-# run: pnpm exec playwright test --workers=1
-# -
-# uses: actions/upload-artifact@v3
-# if: failure()
-# with:
-# name: playwright-screenshots
-# path: pwa/test-results
+ name: Install Playwright Browsers
+ working-directory: pwa
+ run: pnpm exec playwright install --with-deps
+ -
+ name: Run Playwright @read
+ working-directory: pwa
+ run: pnpm exec playwright test --grep @read
+ -
+ name: Run Playwright @write
+ working-directory: pwa
+ # use 1 worker to prevent conflict between write scenarios
+ run: pnpm exec playwright test --grep @write --workers=1
+ -
+ uses: actions/upload-artifact@v3
+ if: failure()
+ with:
+ name: playwright-screenshots
+ path: pwa/test-results
+ -
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: playwright-report
+ path: pwa/playwright-report
lint:
name: Docker Lint
diff --git a/api/src/DataFixtures/Factory/ReviewFactory.php b/api/src/DataFixtures/Factory/ReviewFactory.php
index ad8a6d90f..c5f6ddb90 100644
--- a/api/src/DataFixtures/Factory/ReviewFactory.php
+++ b/api/src/DataFixtures/Factory/ReviewFactory.php
@@ -65,7 +65,7 @@ protected function getDefaults(): array
return [
'user' => lazy(fn () => UserFactory::randomOrCreate()),
'book' => lazy(fn () => BookFactory::randomOrCreate()),
- 'publishedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
+ 'publishedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime('-1 week')),
'body' => self::faker()->text(),
'rating' => self::faker()->numberBetween(0, 5),
];
diff --git a/api/src/DataFixtures/Story/DefaultBookStory.php b/api/src/DataFixtures/Story/DefaultBookStory.php
index 991c5a589..2aa57c357 100644
--- a/api/src/DataFixtures/Story/DefaultBookStory.php
+++ b/api/src/DataFixtures/Story/DefaultBookStory.php
@@ -22,7 +22,7 @@ public function __construct(
public function build(): void
{
BookFactory::createOne([
- 'condition' => BookCondition::NewCondition,
+ 'condition' => BookCondition::UsedCondition,
'book' => 'https://openlibrary.org/books/OL6095440M.json',
'title' => 'Foundation',
'author' => 'Isaac Asimov',
@@ -37,7 +37,7 @@ public function build(): void
$books = $this->getData($uri);
foreach ($books as $book) {
$datum = [
- 'condition' => BookCondition::NewCondition,
+ 'condition' => BookCondition::cases()[array_rand(BookCondition::cases())],
'book' => 'https://openlibrary.org'.$book['key'].'.json',
'title' => $book['title'],
];
diff --git a/api/src/DataFixtures/Story/DefaultBookmarkStory.php b/api/src/DataFixtures/Story/DefaultBookmarkStory.php
index ac18183bd..44bbe91cc 100644
--- a/api/src/DataFixtures/Story/DefaultBookmarkStory.php
+++ b/api/src/DataFixtures/Story/DefaultBookmarkStory.php
@@ -17,6 +17,9 @@ public function build(): void
'book' => BookFactory::find(['book' => 'https://openlibrary.org/books/OL6095440M.json']),
'user' => UserFactory::find(['email' => 'john.doe@example.com']),
]);
+ BookmarkFactory::createMany(30, [
+ 'user' => UserFactory::find(['email' => 'john.doe@example.com']),
+ ]);
BookmarkFactory::createMany(99);
}
diff --git a/api/src/DataFixtures/Story/DefaultReviewsStory.php b/api/src/DataFixtures/Story/DefaultReviewsStory.php
index 887705d28..b12dcded1 100644
--- a/api/src/DataFixtures/Story/DefaultReviewsStory.php
+++ b/api/src/DataFixtures/Story/DefaultReviewsStory.php
@@ -13,10 +13,15 @@ final class DefaultReviewsStory extends Story
{
public function build(): void
{
+ ReviewFactory::createMany(30, [
+ 'book' => BookFactory::find(['book' => 'https://openlibrary.org/books/OL6095440M.json']),
+ 'publishedAt' => \DateTimeImmutable::createFromMutable(ReviewFactory::faker()->dateTime('-1 week')),
+ ]);
ReviewFactory::createOne([
'book' => BookFactory::find(['book' => 'https://openlibrary.org/books/OL6095440M.json']),
'user' => UserFactory::find(['email' => 'john.doe@example.com']),
'rating' => 5,
+ 'publishedAt' => new \DateTimeImmutable('-1 day'),
]);
ReviewFactory::createMany(99);
diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php
index d89f954f2..2d4e9da28 100644
--- a/api/src/Entity/Review.php
+++ b/api/src/Entity/Review.php
@@ -33,6 +33,7 @@
*/
#[ApiResource(
types: ['https://schema.org/Review'],
+ order: ['publishedAt' => 'DESC'],
operations: [
new GetCollection(
uriTemplate: '/admin/reviews{._format}',
@@ -67,6 +68,7 @@
)]
#[ApiResource(
types: ['https://schema.org/Review'],
+ order: ['publishedAt' => 'DESC'],
uriTemplate: '/books/{bookId}/reviews{._format}',
uriVariables: [
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 172f41dbd..dda4329a1 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -9,15 +9,12 @@ services:
target: php_prod
environment:
APP_SECRET: ${APP_SECRET}
- MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET}
pwa:
image: ${PWA_DOCKER_IMAGE}
build:
context: ./pwa
target: prod
- environment:
- NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
caddy:
image: ${CADDY_DOCKER_IMAGE}
@@ -36,10 +33,6 @@ services:
}
CADDY_CACHE: cache
- database:
- environment:
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
-
keycloak:
environment:
KEYCLOAK_PRODUCTION: "true"
diff --git a/docker-compose.yml b/docker-compose.yml
index 66fce967b..1e312d4ac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,6 +13,7 @@ services:
timeout: 3s
retries: 3
start_period: 30s
+ test: ["CMD", "docker-healthcheck"]
environment: &php-env
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
@@ -120,7 +121,7 @@ services:
KEYCLOAK_PASSWORD: ${KEYCLOAK_PASSWORD:-!ChangeMe!}
KEYCLOAK_AVAILABILITYCHECK_ENABLED: true
KEYCLOAK_AVAILABILITYCHECK_TIMEOUT: 120s
- IMPORT_FILES_LOCATIONS: '/config/*'
+ IMPORT_FILES_LOCATIONS: "/config/*"
depends_on:
- keycloak
volumes:
diff --git a/pwa/components/book/Filters.tsx b/pwa/components/book/Filters.tsx
index da98db165..36a7dbf69 100644
--- a/pwa/components/book/Filters.tsx
+++ b/pwa/components/book/Filters.tsx
@@ -55,7 +55,7 @@ export const Filters: FunctionComponent = ({ filters, mutation }) => (
Author
} control={
{
+ data-testid="filter-author" variant="standard" className="w-full" onChange={(e) => {
handleChange(e);
debounce(submitForm, 1000)();
}}
@@ -67,7 +67,7 @@ export const Filters: FunctionComponent = ({ filters, mutation }) => (
Title
} control={
{
+ data-testid="filter-title" variant="standard" className="w-full" onChange={(e) => {
handleChange(e);
debounce(submitForm, 1000)();
}}
@@ -78,7 +78,7 @@ export const Filters: FunctionComponent = ({ filters, mutation }) => (
Condition
-
- }
+ }
checked={!!values?.condition?.includes("https://schema.org/NewCondition")}
value="https://schema.org/NewCondition"
onChange={(e) => {
@@ -88,7 +88,7 @@ export const Filters: FunctionComponent = ({ filters, mutation }) => (
/>
-
- }
+ }
checked={!!values?.condition?.includes("https://schema.org/DamagedCondition")}
value="https://schema.org/DamagedCondition"
onChange={(e) => {
@@ -98,7 +98,7 @@ export const Filters: FunctionComponent = ({ filters, mutation }) => (
/>
-
- }
+ }
checked={!!values?.condition?.includes("https://schema.org/RefurbishedCondition")}
value="https://schema.org/RefurbishedCondition"
onChange={(e) => {
@@ -108,7 +108,7 @@ export const Filters: FunctionComponent = ({ filters, mutation }) => (
/>
-
- }
+ }
checked={!!values?.condition?.includes("https://schema.org/UsedCondition")}
value="https://schema.org/UsedCondition"
onChange={(e) => {
diff --git a/pwa/components/book/List.tsx b/pwa/components/book/List.tsx
index 288f9d6d5..1a734ff3b 100644
--- a/pwa/components/book/List.tsx
+++ b/pwa/components/book/List.tsx
@@ -56,6 +56,7 @@ export const List: NextPage = ({ data, hubURL, filters }) => {