Skip to content

Commit 2311779

Browse files
feat: new workflow for cases where the collection has already started and there are no diffs
1 parent eeca602 commit 2311779

5 files changed

Lines changed: 265 additions & 5 deletions

app/Audit/AuditEventListener.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ private function auditCollection($subject, $uow, string $eventType): array
177177
$payload = ['collection' => $subject];
178178

179179
if ($eventType === IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE
180-
&& !$subject->isInitialized() ) {
180+
&& (
181+
!$subject->isInitialized()
182+
|| ($subject->isInitialized() && count($subject->getDeleteDiff()) === 0)
183+
)) {
181184
if ($this->em instanceof EntityManagerInterface) {
182185
$payload['deleted_ids'] = $this->fetchManyToManyIds($subject, $this->em);
183186
}

app/Audit/AuditLogOtlpStrategy.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,16 @@ private function buildAuditLogData($entity, $subject, array $change_set, string
189189
$data['audit.collection_type'] = $this->getCollectionType($collection);
190190
if (!empty($change_set['deleted_ids'])) {
191191
$data['audit.collection_count'] = count($change_set['deleted_ids']);
192-
$data['audit.collection_current_count'] = 0;
193-
$data['audit.collection_snapshot_count'] = 0;
194-
$data['audit.collection_is_dirty'] = 'true';
192+
if ($collection->isInitialized()) {
193+
$changes = $this->getCollectionChanges($collection, $change_set);
194+
$data['audit.collection_current_count'] = $changes['current_count'];
195+
$data['audit.collection_snapshot_count'] = $changes['snapshot_count'];
196+
$data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false';
197+
} else {
198+
$data['audit.collection_current_count'] = 0;
199+
$data['audit.collection_snapshot_count'] = 0;
200+
$data['audit.collection_is_dirty'] = 'true';
201+
}
195202
} elseif ($collection->isInitialized()) {
196203
$data['audit.collection_count'] = count($collection);
197204
$changes = $this->getCollectionChanges($collection, $change_set);

tests/OpenTelemetry/Formatters/AuditEventListenerTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,59 @@ public function testFetchManyToManyIdsExecutesQuery(): void
108108

109109
$this->assertSame([10, 11], $result);
110110
}
111+
112+
public function testAuditCollectionDeleteInitializedWithoutDiffUsesJoinTableQuery(): void
113+
{
114+
$listener = new AuditEventListener();
115+
$owner = new \stdClass();
116+
117+
$mapping = ManyToManyOwningSideMapping::fromMappingArrayAndNamingStrategy([
118+
'fieldName' => 'tags',
119+
'sourceEntity' => \stdClass::class,
120+
'targetEntity' => \stdClass::class,
121+
'isOwningSide' => true,
122+
'joinTable' => [
123+
'name' => 'owner_tags',
124+
'joinColumns' => [['name' => 'owner_id', 'referencedColumnName' => 'id']],
125+
'inverseJoinColumns' => [['name' => 'tag_id', 'referencedColumnName' => 'id']],
126+
],
127+
], new DefaultNamingStrategy());
128+
129+
$em = $this->createMock(EntityManagerInterface::class);
130+
$meta = new ClassMetadata(\stdClass::class);
131+
$collection = new PersistentCollection($em, $meta, new ArrayCollection());
132+
$collection->setOwner($owner, $mapping);
133+
$collection->takeSnapshot();
134+
135+
$ownerMeta = $this->getMockBuilder(ClassMetadata::class)
136+
->disableOriginalConstructor()
137+
->getMock();
138+
$ownerMeta->method('getIdentifierValues')->with($owner)->willReturn(['id' => 123]);
139+
140+
$conn = $this->getMockBuilder(Connection::class)
141+
->disableOriginalConstructor()
142+
->getMock();
143+
$conn->method('fetchFirstColumn')->willReturn(['10', '11']);
144+
145+
$em->method('getConnection')->willReturn($conn);
146+
$em->method('getClassMetadata')->with(get_class($owner))->willReturn($ownerMeta);
147+
148+
$emProp = new \ReflectionProperty(AuditEventListener::class, 'em');
149+
$emProp->setAccessible(true);
150+
$emProp->setValue($listener, $em);
151+
152+
$method = new \ReflectionMethod(AuditEventListener::class, 'auditCollection');
153+
$method->setAccessible(true);
154+
155+
[$subject, $payload, $eventType] = $method->invoke(
156+
$listener,
157+
$collection,
158+
new \stdClass(),
159+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE
160+
);
161+
162+
$this->assertSame($owner, $subject);
163+
$this->assertSame([10, 11], $payload['deleted_ids']);
164+
$this->assertSame(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, $eventType);
165+
}
111166
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<?php
2+
3+
namespace Tests\OpenTelemetry\Formatters;
4+
5+
/**
6+
* Copyright 2026 OpenStack Foundation
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
**/
17+
18+
use App\Audit\ConcreteFormatters\PresentationCategoryGroupAuditLogFormatter;
19+
use App\Audit\Interfaces\IAuditStrategy;
20+
use Mockery;
21+
use PHPUnit\Framework\Attributes\DataProvider;
22+
use Tests\OpenTelemetry\Formatters\Support\AuditContextBuilder;
23+
use Tests\OpenTelemetry\Formatters\Support\PersistentCollectionTestHelper;
24+
use Tests\TestCase;
25+
use models\summit\PresentationCategory;
26+
use models\summit\PresentationCategoryGroup;
27+
use models\summit\Summit;
28+
29+
class PresentationCategoryGroupAuditLogFormatterManyToManyTest extends TestCase
30+
{
31+
private const GROUP_ID = 7;
32+
private const GROUP_NAME = 'Core Tracks';
33+
private const SUMMIT_ID = 1;
34+
private const SUMMIT_NAME = 'Test Summit';
35+
private const GROUP_COLOR = '#00AAFF';
36+
private const GROUP_MAX_ATTENDEE_VOTES = 5;
37+
private const FIELD_NAME = 'categories';
38+
private const TARGET_ENTITY = PresentationCategory::class;
39+
private const DP_UPDATE_WITHOUT_CONTEXT = 'update without context';
40+
private const DP_DELETE_WITHOUT_CONTEXT = 'delete without context';
41+
private const DP_UPDATE_WITHOUT_COLLECTION = 'update without collection';
42+
private const DP_DELETE_WITHOUT_COLLECTION = 'delete without collection';
43+
private const LOG_DELETED_M2M = 'deleted M2M';
44+
private const LOG_UPDATED_M2M = 'updated M2M';
45+
private const LOG_REMOVED_IDS_EMPTY = 'Removed IDs: []';
46+
private const LOG_REMOVED_IDS_PAYLOAD = 'Removed IDs: [10,11,12]';
47+
private const LOG_REMOVED_IDS_DIFF = 'Removed IDs: [1]';
48+
private const LOG_ADDED_IDS_DIFF = 'Added IDs: [3]';
49+
private const DELETED_IDS_PAYLOAD = [10, 11, 12];
50+
private const SNAPSHOT_IDS_EMPTY = [];
51+
private const CURRENT_IDS_EMPTY = [];
52+
private const SNAPSHOT_IDS_REMOVE_ONE = [1, 2, 3];
53+
private const CURRENT_IDS_REMOVE_ONE = [2, 3];
54+
private const SNAPSHOT_IDS_UPDATE = [1, 2];
55+
private const CURRENT_IDS_UPDATE = [2, 3];
56+
57+
protected function tearDown(): void
58+
{
59+
Mockery::close();
60+
parent::tearDown();
61+
}
62+
63+
#[DataProvider('providesNullCasesForManyToMany')]
64+
public function testManyToManyReturnsNullWithoutRequiredContextOrCollection(
65+
string $eventType,
66+
bool $withContext
67+
): void {
68+
$formatter = $this->makeFormatter($eventType, $withContext);
69+
$group = $this->makeGroup();
70+
71+
$result = $formatter->format($group, []);
72+
$this->assertNull($result);
73+
}
74+
75+
public function testFormatterReturnsNullForInvalidSubject(): void
76+
{
77+
$formatter = new PresentationCategoryGroupAuditLogFormatter(
78+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE
79+
);
80+
$formatter->setContext(AuditContextBuilder::default()->build());
81+
82+
$result = $formatter->format(new \stdClass(), []);
83+
84+
$this->assertNull($result);
85+
}
86+
87+
public function testManyToManyDeleteReturnsEmptyRemovedIdsWhenPayloadMissing(): void
88+
{
89+
$group = $this->makeGroup();
90+
$formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE);
91+
$collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY);
92+
93+
$result = $formatter->format($group, [
94+
'collection' => $collection,
95+
]);
96+
97+
$this->assertNotNull($result);
98+
$this->assertStringContainsString(self::LOG_DELETED_M2M, $result);
99+
$this->assertStringContainsString(self::LOG_REMOVED_IDS_EMPTY, $result);
100+
}
101+
102+
public function testManyToManyDeleteUsesDeletedIdsFromPayload(): void
103+
{
104+
$group = $this->makeGroup();
105+
$formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE);
106+
$collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY);
107+
108+
$result = $formatter->format($group, [
109+
'collection' => $collection,
110+
'deleted_ids' => self::DELETED_IDS_PAYLOAD,
111+
]);
112+
113+
$this->assertNotNull($result);
114+
$this->assertStringContainsString(self::LOG_REMOVED_IDS_PAYLOAD, $result);
115+
}
116+
117+
public function testManyToManyDeleteUsesRemovedIdsFromCollectionDiff(): void
118+
{
119+
$group = $this->makeGroup();
120+
$formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE);
121+
$collection = $this->makeCollection(self::SNAPSHOT_IDS_REMOVE_ONE, self::CURRENT_IDS_REMOVE_ONE);
122+
123+
$result = $formatter->format($group, [
124+
'collection' => $collection,
125+
]);
126+
127+
$this->assertNotNull($result);
128+
$this->assertStringContainsString(self::LOG_REMOVED_IDS_DIFF, $result);
129+
}
130+
131+
public function testManyToManyUpdateUsesAddedAndRemovedIdsFromCollectionDiff(): void
132+
{
133+
$group = $this->makeGroup();
134+
$formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE);
135+
$collection = $this->makeCollection(self::SNAPSHOT_IDS_UPDATE, self::CURRENT_IDS_UPDATE);
136+
137+
$result = $formatter->format($group, [
138+
'collection' => $collection,
139+
]);
140+
141+
$this->assertNotNull($result);
142+
$this->assertStringContainsString(self::LOG_UPDATED_M2M, $result);
143+
$this->assertStringContainsString(self::LOG_ADDED_IDS_DIFF, $result);
144+
$this->assertStringContainsString(self::LOG_REMOVED_IDS_DIFF, $result);
145+
}
146+
147+
public static function providesNullCasesForManyToMany(): array
148+
{
149+
return [
150+
self::DP_UPDATE_WITHOUT_CONTEXT => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE, false],
151+
self::DP_DELETE_WITHOUT_CONTEXT => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, false],
152+
self::DP_UPDATE_WITHOUT_COLLECTION => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE, true],
153+
self::DP_DELETE_WITHOUT_COLLECTION => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, true],
154+
];
155+
}
156+
157+
private function makeFormatter(string $eventType, bool $withContext = true): PresentationCategoryGroupAuditLogFormatter
158+
{
159+
$formatter = new PresentationCategoryGroupAuditLogFormatter($eventType);
160+
if ($withContext) {
161+
$formatter->setContext(AuditContextBuilder::default()->build());
162+
}
163+
return $formatter;
164+
}
165+
166+
private function makeCollection(array $snapshotIds, array $currentIds)
167+
{
168+
return PersistentCollectionTestHelper::buildManyToManyCollection(
169+
PresentationCategoryGroup::class,
170+
self::FIELD_NAME,
171+
self::TARGET_ENTITY,
172+
$snapshotIds,
173+
$currentIds
174+
);
175+
}
176+
177+
private function makeGroup(): PresentationCategoryGroup
178+
{
179+
$group = Mockery::mock(PresentationCategoryGroup::class, [
180+
'getId' => self::GROUP_ID,
181+
'getName' => self::GROUP_NAME,
182+
'getColor' => self::GROUP_COLOR,
183+
'getMaxAttendeeVotes' => self::GROUP_MAX_ATTENDEE_VOTES,
184+
])->makePartial();
185+
186+
$summit = Mockery::mock(Summit::class, [
187+
'getId' => self::SUMMIT_ID,
188+
'getName' => self::SUMMIT_NAME,
189+
])->makePartial();
190+
191+
$group->shouldReceive('getSummit')->andReturn($summit);
192+
193+
return $group;
194+
}
195+
}

tests/OpenTelemetry/Formatters/SummitAttendeeAuditLogFormatterManyToManyTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public function testFormatterReturnsNullForInvalidSubject(): void
9191
$this->assertNull($result);
9292
}
9393

94-
public function testManyToManyDeleteReturnsNullWithoutRemovedIds(): void
94+
public function testManyToManyDeleteReturnsMessageWithEmptyRemovedIdsWhenPayloadMissing(): void
9595
{
9696
$attendee = $this->makeAttendee();
9797

0 commit comments

Comments
 (0)