diff --git a/api/src/HttpCache/TagCollector.php b/api/src/HttpCache/TagCollector.php index d1e1c84119..9f3ebbe224 100644 --- a/api/src/HttpCache/TagCollector.php +++ b/api/src/HttpCache/TagCollector.php @@ -8,6 +8,9 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Serializer\TagCollectorInterface; use App\Entity\HasId; +use Doctrine\Common\Util\ClassUtils; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\AssociationMapping; /** * Collects cache tags during normalization. @@ -17,7 +20,10 @@ class TagCollector implements TagCollectorInterface { public const IRI_RELATION_DELIMITER = '#'; - public function __construct(private ResponseTagger $responseTagger) {} + public function __construct( + private ResponseTagger $responseTagger, + private EntityManagerInterface $em + ) {} /** * Collect cache tags for cache invalidation. @@ -37,7 +43,7 @@ public function collect(array $context = []): void { } if (isset($context['property_metadata'])) { - $this->addCacheTagsForRelation($context, $iri, $context['property_metadata']); + $this->addCacheTagsForRelation($object, $context, $iri, $context['property_metadata']); return; } @@ -59,7 +65,7 @@ private function addCacheTagForResource(string $iri): void { $this->responseTagger->addTags([$iri]); } - private function addCacheTagsForRelation(array $context, string $iri, ApiProperty $propertyMetadata): void { + private function addCacheTagsForRelation(mixed $object, array $context, string $iri, ApiProperty $propertyMetadata): void { if (isset($propertyMetadata->getExtraProperties()['cacheDependencies'])) { foreach ($propertyMetadata->getExtraProperties()['cacheDependencies'] as $dependency) { $cacheTag = $iri.PurgeHttpCacheListener::IRI_RELATION_DELIMITER.$dependency; @@ -69,6 +75,13 @@ private function addCacheTagsForRelation(array $context, string $iri, ApiPropert return; } + $associationMappings = $this->em->getClassMetadata(ClassUtils::getClass($object))->getAssociationMappings(); + $associationMapping = $associationMappings[$context['api_attribute']] ?? null; + // we have currently no use-case where we rely on a ManyToOne for tag invalidation + if ($associationMapping instanceof AssociationMapping && $associationMapping->isManyToOne()) { + return; + } + $cacheTag = $iri.PurgeHttpCacheListener::IRI_RELATION_DELIMITER.$context['api_attribute']; $this->responseTagger->addTags([$cacheTag]); } diff --git a/api/tests/HttpCache/TagCollectorTest.php b/api/tests/HttpCache/TagCollectorTest.php index 254c468350..fa7ba99133 100644 --- a/api/tests/HttpCache/TagCollectorTest.php +++ b/api/tests/HttpCache/TagCollectorTest.php @@ -7,6 +7,8 @@ use App\HttpCache\ResponseTagger; use App\HttpCache\TagCollector; use App\Tests\HttpCache\Entity\Dummy; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -20,11 +22,17 @@ class TagCollectorTest extends TestCase { private TagCollectorInterface $tagCollector; private ObjectProphecy $responseTaggerProphecy; + private ObjectProphecy $em; + private ObjectProphecy $classMetadata; protected function setUp(): void { // given $this->responseTaggerProphecy = $this->prophesize(ResponseTagger::class); - $this->tagCollector = new TagCollector($this->responseTaggerProphecy->reveal()); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->classMetadata = $this->prophesize(ClassMetadata::class); + $this->em->getClassMetadata(Argument::any())->willReturn($this->classMetadata); + $this->classMetadata->getAssociationMappings(Argument::any())->willReturn([]); + $this->tagCollector = new TagCollector($this->responseTaggerProphecy->reveal(), $this->em->reveal()); } public function testNoTagForEmptyContext() { diff --git a/e2e/specs/httpCache/activities.cy.js b/e2e/specs/httpCache/activities.cy.js index b8572aced5..f990a71d2a 100644 --- a/e2e/specs/httpCache/activities.cy.js +++ b/e2e/specs/httpCache/activities.cy.js @@ -16,42 +16,48 @@ const collectionXKeys = /** * activitiy "Snowboardfahren" */ - 'a13fadc97610 a13fadc97610#scheduleEntries a13fadc97610#camp a13fadc97610#category a13fadc97610#progressLabel a13fadc97610#activityResponsibles a13fadc97610#rootContentNode ' + + 'a13fadc97610 a13fadc97610#scheduleEntries a13fadc97610#activityResponsibles a13fadc97610#rootContentNode ' + /* embedded progress labels: */ - 'af92782262d7 af92782262d7#camp a13fadc97610#embeddedProgressLabel ' + + 'af92782262d7 ' + + 'a13fadc97610#embeddedProgressLabel ' + /* embedded schedule entries: */ /* (the first schedule entry also includes the period id) */ - '29c9e9a07d82 7fa4564a5d5d 29c9e9a07d82#period 29c9e9a07d82#activity 29c9e9a07d82#day ' + - 'f08d69cae18a f08d69cae18a#period f08d69cae18a#activity f08d69cae18a#day ' + - '7e8086d94633 7e8086d94633#period 7e8086d94633#activity 7e8086d94633#day ' + + '29c9e9a07d82 7fa4564a5d5d 29c9e9a07d82#day ' + + 'f08d69cae18a f08d69cae18a#day ' + + '7e8086d94633 7e8086d94633#day ' + 'a13fadc97610#embeddedScheduleEntries ' + /* embedded activitiy responsibles: */ - '06743ccfeedd 06743ccfeedd#activity 06743ccfeedd#campCollaboration ' + - '21bc6661c569 21bc6661c569#activity 21bc6661c569#campCollaboration ' + - 'a13fadc97610#embeddedActivityResponsibles a13fadc97610#contentNodes ' + + '06743ccfeedd ' + + '21bc6661c569 ' + + 'a13fadc97610#embeddedActivityResponsibles ' + + 'a13fadc97610#contentNodes ' + /** * activity "Skifahren" */ - 'b29d387cc403 b29d387cc403#scheduleEntries b29d387cc403#camp b29d387cc403#category b29d387cc403#progressLabel b29d387cc403#activityResponsibles ' + + 'b29d387cc403 b29d387cc403#scheduleEntries b29d387cc403#activityResponsibles b29d387cc403#rootContentNode ' + /* embedded progress labels: */ - 'b29d387cc403#rootContentNode b29d387cc403#embeddedProgressLabel ' + + 'b29d387cc403#embeddedProgressLabel ' + /* embedded schedule entries: */ - 'e68f4e47517a e68f4e47517a#period e68f4e47517a#activity e68f4e47517a#day ' + - 'f0883e931649 f0883e931649#period f0883e931649#activity f0883e931649#day ' + - 'ee85308a97d1 ee85308a97d1#period ee85308a97d1#activity ee85308a97d1#day ' + - 'f89a1501dbb6 f89a1501dbb6#period f89a1501dbb6#activity f89a1501dbb6#day ' + + 'e68f4e47517a e68f4e47517a#day ' + + 'f0883e931649 f0883e931649#day ' + + 'ee85308a97d1 ee85308a97d1#day ' + + 'f89a1501dbb6 f89a1501dbb6#day ' + /* embedded activitiy responsibles: */ 'b29d387cc403#embeddedScheduleEntries ' + - 'a9a760e36fd8 a9a760e36fd8#activity a9a760e36fd8#campCollaboration b29d387cc403#embeddedActivityResponsibles ' + + 'a9a760e36fd8 ' + + 'b29d387cc403#embeddedActivityResponsibles ' + 'b29d387cc403#contentNodes ' + /* collection URI (for detecting addition of new activities) */ '/api/camps/70ca971c992f/activities' describe('cache test: /camps/{campId}/activities', () => { + beforeEach(() => { + cy.wrap(Cypress.session.clearAllSavedSessions()) + }) + it('caches /camps/{campId}/activities separately for each login', () => { const uri = `/api/camps/${skilagerCampId}/activities` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // first request is a cache miss @@ -75,7 +81,6 @@ describe('cache test: /camps/{campId}/activities', () => { const activityId = '3d1e5c91ceb2' // bring data into defined state - Cypress.session.clearAllSavedSessions() cy.login(bruceWayneUser) cy.apiPatch(`/api/activities/${activityId}`, { title: 'Breakfast', @@ -105,7 +110,6 @@ describe('cache test: /camps/{campId}/activities', () => { it('invalidates /camps/{campId}/activities for new activity', () => { const uri = `/api/camps/${grgrCampId}/activities` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache @@ -142,7 +146,6 @@ describe('cache test: /camps/{campId}/activities', () => { it('invalidates /camps/{campId}/activities when adding a scheduleEntry', () => { const uri = `/api/camps/${grgrCampId}/activities` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache @@ -176,7 +179,6 @@ describe('cache test: /camps/{campId}/activities', () => { const progressLabelId = '82547049ea38' // bring data into defined state - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) cy.apiPatch(`/api/activity_progress_labels/${progressLabelId}`, { title: 'Planned', @@ -199,7 +201,6 @@ describe('cache test: /camps/{campId}/activities', () => { it('invalidates /camps/{campId}/activities when adding an activity responsible', () => { const uri = `/api/camps/${grgrCampId}/activities` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache @@ -229,7 +230,6 @@ describe('cache test: /camps/{campId}/activities', () => { it('invalidates /camps/{campId}/activities when changing the period dates (moveScheduleEntries: true)', () => { const uri = `/api/camps/${grgrCampId}/activities` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache @@ -262,7 +262,6 @@ describe('cache test: /camps/{campId}/activities', () => { it('invalidates /camps/{campId}/activities when changing the period dates (moveScheduleEntries: false)', () => { const uri = `/api/camps/${grgrCampId}/activities` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache diff --git a/e2e/specs/httpCache/categories.cy.js b/e2e/specs/httpCache/categories.cy.js index 637960aba5..2981164128 100644 --- a/e2e/specs/httpCache/categories.cy.js +++ b/e2e/specs/httpCache/categories.cy.js @@ -13,21 +13,24 @@ const collectionXKeys = /* campCollaboration for bipiUser */ 'b0bdb7202a9d ' + /* Category ES */ - 'ebfd46a1c181 ebfd46a1c181#camp ebfd46a1c181#preferredContentTypes ebfd46a1c181#rootContentNode ebfd46a1c181#contentNodes ' + + 'ebfd46a1c181 ebfd46a1c181#preferredContentTypes ebfd46a1c181#rootContentNode ebfd46a1c181#contentNodes ' + /* Category LA */ - '1a869b162875 1a869b162875#camp 1a869b162875#preferredContentTypes 1a869b162875#rootContentNode 1a869b162875#contentNodes ' + + '1a869b162875 1a869b162875#preferredContentTypes 1a869b162875#rootContentNode 1a869b162875#contentNodes ' + /* Category LP */ - 'dfa531302823 dfa531302823#camp dfa531302823#preferredContentTypes dfa531302823#rootContentNode dfa531302823#contentNodes ' + + 'dfa531302823 dfa531302823#preferredContentTypes dfa531302823#rootContentNode dfa531302823#contentNodes ' + /* Category LS */ - 'a023e85227ac a023e85227ac#camp a023e85227ac#preferredContentTypes a023e85227ac#rootContentNode a023e85227ac#contentNodes ' + + 'a023e85227ac a023e85227ac#preferredContentTypes a023e85227ac#rootContentNode a023e85227ac#contentNodes ' + /* collection URI (for detecting addition of new categories) */ '/api/camps/3c79b99ab424/categories' describe('cache test: /camps/{campId}/categories', () => { + beforeEach(() => { + cy.wrap(Cypress.session.clearAllSavedSessions()) + }) + it('caches /camps/{campId}/categories separately for each login', () => { const uri = `/api/camps/${grgrCampId}/categories` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // first request is a cache miss @@ -50,7 +53,6 @@ describe('cache test: /camps/{campId}/categories', () => { const uri = `/api/camps/${loremIpsumCampId}/categories` // bring data into defined state - Cypress.session.clearAllSavedSessions() cy.login(bruceWayneUser) cy.apiPatch('/api/categories/c5e1bc565094', { name: 'old_name', @@ -80,7 +82,6 @@ describe('cache test: /camps/{campId}/categories', () => { it('invalidates /camps/{campId}/categories for new category', () => { const uri = `/api/camps/${grgrCampId}/categories` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache @@ -111,7 +112,6 @@ describe('cache test: /camps/{campId}/categories', () => { }) it('invalidates cached data when user leaves a camp', () => { - Cypress.session.clearAllSavedSessions() const uri = `/api/camps/${grgrCampId}/categories` cy.intercept('PATCH', '/api/camp_collaborations/**').as('camp_collaboration') diff --git a/e2e/specs/httpCache/checklists.cy.js b/e2e/specs/httpCache/checklists.cy.js index f0dd208a9b..a8515d6bde 100644 --- a/e2e/specs/httpCache/checklists.cy.js +++ b/e2e/specs/httpCache/checklists.cy.js @@ -5,15 +5,18 @@ const collectionXKeys = /* campCollaboration for bipiUser */ '146c0608237f ' + /* checklist entry */ - 'ebbd0c61eb85 ebbd0c61eb85#camp ' + + 'ebbd0c61eb85 ' + /* collection URI (for detecting addition of new checklists) */ '/api/camps/5d28f99890bc/checklists' describe('cache test: /camps/checklists', () => { + beforeEach(() => { + cy.wrap(Cypress.session.clearAllSavedSessions()) + }) + it('caches /camp/{campId}/checklists separately for each login', () => { const uri = `/api/camps/${basiskursCampId}/checklists` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // first request is a cache miss @@ -36,7 +39,6 @@ describe('cache test: /camps/checklists', () => { const uri = `/api/camps/${basiskursCampId}/checklists` // bring data into defined state - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) cy.apiPatch('/api/checklists/ebbd0c61eb85', { name: 'Training targets', @@ -62,7 +64,6 @@ describe('cache test: /camps/checklists', () => { () => { const uri = `/api/camps/${basiskursCampId}/checklists` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache diff --git a/e2e/specs/httpCache/content-types.cy.js b/e2e/specs/httpCache/content-types.cy.js index f39c708170..8e8c0b48b7 100644 --- a/e2e/specs/httpCache/content-types.cy.js +++ b/e2e/specs/httpCache/content-types.cy.js @@ -6,10 +6,13 @@ const collectionXKeys = 'a4211c11211c c462edd869f3 5e2028c55ee4 a4211c112939 f17470519474 1a0f84e322c8 3ef17bd1df72 4f0c657fecef 44dcc7493c65 cfccaecd4bad 318e064ea0c9 /api/content_types' describe('cache test: /content-types', () => { + beforeEach(() => { + cy.wrap(Cypress.session.clearAllSavedSessions()) + }) + it('caches collection separately for each login', () => { const uri = '/api/content_types' - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // first request is a cache miss @@ -32,7 +35,6 @@ describe('cache test: /content-types', () => { const contentTypeId = '318e064ea0c9' const uri = `/api/content_types/${contentTypeId}` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // first request is a cache miss diff --git a/e2e/specs/httpCache/scheduleEntries.cy.js b/e2e/specs/httpCache/scheduleEntries.cy.js index 027d47bcd0..8eabf2d146 100644 --- a/e2e/specs/httpCache/scheduleEntries.cy.js +++ b/e2e/specs/httpCache/scheduleEntries.cy.js @@ -15,21 +15,24 @@ const collectionXKeys = '10d8f02ce5b4 ' + /* scheduleEntries + links */ /* the first scheduleEntry also includes the period id 7fa4564a5d5d */ - 'e68f4e47517a 7fa4564a5d5d e68f4e47517a#period e68f4e47517a#activity e68f4e47517a#day ' + - 'f0883e931649 f0883e931649#period f0883e931649#activity f0883e931649#day ' + - '29c9e9a07d82 29c9e9a07d82#period 29c9e9a07d82#activity 29c9e9a07d82#day ' + - 'ee85308a97d1 ee85308a97d1#period ee85308a97d1#activity ee85308a97d1#day ' + - 'f08d69cae18a f08d69cae18a#period f08d69cae18a#activity f08d69cae18a#day ' + - '7e8086d94633 7e8086d94633#period 7e8086d94633#activity 7e8086d94633#day ' + - 'f89a1501dbb6 f89a1501dbb6#period f89a1501dbb6#activity f89a1501dbb6#day ' + + 'e68f4e47517a 7fa4564a5d5d e68f4e47517a#day ' + + 'f0883e931649 f0883e931649#day ' + + '29c9e9a07d82 29c9e9a07d82#day ' + + 'ee85308a97d1 ee85308a97d1#day ' + + 'f08d69cae18a f08d69cae18a#day ' + + '7e8086d94633 7e8086d94633#day ' + + 'f89a1501dbb6 f89a1501dbb6#day ' + /* collection URI (for detecting addition of new schedule entries) */ '/api/periods/7fa4564a5d5d/schedule_entries' describe('cache test: /periods/{periodId}/scheduleEntries', () => { + beforeEach(() => { + cy.wrap(Cypress.session.clearAllSavedSessions()) + }) + it('caches /periods/{periodId}/schedule_entries separately for each login', () => { const uri = `/api/periods/${skilagerPeriodId}/schedule_entries` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // first request is a cache miss @@ -53,7 +56,6 @@ describe('cache test: /periods/{periodId}/scheduleEntries', () => { const scheduleEntryId = '12f34c89ce11' // bring data into defined state - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) cy.apiPatch(`/api/schedule_entries/${scheduleEntryId}`, { start: '2025-05-10T16:00:00+00:00', @@ -83,7 +85,6 @@ describe('cache test: /periods/{periodId}/scheduleEntries', () => { it('invalidates /periods/{periodId}/schedule_entries for new scheduleEntry', () => { const uri = `/api/periods/${grgrPeriodId}/schedule_entries` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache @@ -117,7 +118,6 @@ describe('cache test: /periods/{periodId}/scheduleEntries', () => { const uri2 = `/api/periods/${harrySecondPeriodId}/schedule_entries` const scheduleEntryId = '9a4173c9bb73' - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache @@ -156,7 +156,6 @@ describe('cache test: /periods/{periodId}/scheduleEntries', () => { it('invalidates /periods/{periodId}/schedule_entries when changing the period dates', () => { const uri = `/api/periods/${grgrPeriodId}/schedule_entries` - Cypress.session.clearAllSavedSessions() cy.login(bipiUser) // warm up cache diff --git a/e2e/support/commands.js b/e2e/support/commands.js index c2172d82fb..f82fa53583 100644 --- a/e2e/support/commands.js +++ b/e2e/support/commands.js @@ -27,7 +27,9 @@ import 'cypress-wait-until' Cypress.Commands.add('login', (identifier) => { - cy.session(identifier, () => { + const randomSessionId = + Date.now().toString(36) + Math.random().toString(36).substring(2) + cy.session(randomSessionId, () => { cy.request({ method: 'POST', url: Cypress.env('API_ROOT_URL') + '/authentication_token',