Merge pull request 'fix(MemberService): wrap multi-step writes in database transactions' (#203) from fix/missing-database-transactions into main
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 38s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 6s

This commit was merged in pull request #203.
This commit is contained in:
2026-04-29 22:11:02 +02:00
5 changed files with 244 additions and 31 deletions
+29 -1
View File
@@ -19,6 +19,7 @@ use OCA\Mitgliederverwaltung\Validation\PhoneValidator;
use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\DB\Exception; use OCP\DB\Exception;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** /**
@@ -37,6 +38,7 @@ class MemberService {
private AuditService $auditService; private AuditService $auditService;
private LoggerInterface $logger; private LoggerInterface $logger;
private ?EncryptionService $encryptionService; private ?EncryptionService $encryptionService;
private IDBConnection $db;
public function __construct( public function __construct(
MemberMapper $memberMapper, MemberMapper $memberMapper,
@@ -47,7 +49,8 @@ class MemberService {
StufeHistoryMapper $stufeHistoryMapper, StufeHistoryMapper $stufeHistoryMapper,
AuditService $auditService, AuditService $auditService,
LoggerInterface $logger, LoggerInterface $logger,
?EncryptionService $encryptionService = null ?EncryptionService $encryptionService,
IDBConnection $db
) { ) {
$this->memberMapper = $memberMapper; $this->memberMapper = $memberMapper;
$this->addressMapper = $addressMapper; $this->addressMapper = $addressMapper;
@@ -58,6 +61,7 @@ class MemberService {
$this->auditService = $auditService; $this->auditService = $auditService;
$this->logger = $logger; $this->logger = $logger;
$this->encryptionService = $encryptionService; $this->encryptionService = $encryptionService;
$this->db = $db;
} }
/** /**
@@ -153,6 +157,8 @@ class MemberService {
$member->setCreatedAt($now); $member->setCreatedAt($now);
$member->setUpdatedAt($now); $member->setUpdatedAt($now);
$this->db->beginTransaction();
try {
/** @var Member $member */ /** @var Member $member */
$member = $this->memberMapper->insert($member); $member = $this->memberMapper->insert($member);
@@ -160,6 +166,12 @@ class MemberService {
$savedPhones = $this->savePhones($member->getId(), $phones); $savedPhones = $this->savePhones($member->getId(), $phones);
$savedEmails = $this->saveEmails($member->getId(), $emails); $savedEmails = $this->saveEmails($member->getId(), $emails);
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollback();
throw $e;
}
$this->auditService->logCreate($member->jsonSerialize(), 'member', $member->getId()); $this->auditService->logCreate($member->jsonSerialize(), 'member', $member->getId());
return $this->buildMemberResponse($member, $savedAddresses, $savedPhones, $savedEmails); return $this->buildMemberResponse($member, $savedAddresses, $savedPhones, $savedEmails);
@@ -515,6 +527,8 @@ class MemberService {
$member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s')); $member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s'));
$this->db->beginTransaction();
try {
/** @var Member $member */ /** @var Member $member */
$member = $this->memberMapper->update($member); $member = $this->memberMapper->update($member);
@@ -530,6 +544,12 @@ class MemberService {
$this->syncEmails($id, $rawData['emails']); $this->syncEmails($id, $rawData['emails']);
} }
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollback();
throw $e;
}
$this->auditService->logUpdate($oldData, $member->jsonSerialize(), 'member', $id); $this->auditService->logUpdate($oldData, $member->jsonSerialize(), 'member', $id);
return $this->find($id); return $this->find($id);
@@ -685,6 +705,8 @@ class MemberService {
$member->setCalendarEventUri(null); $member->setCalendarEventUri(null);
$member->setContactVcardUri(null); $member->setContactVcardUri(null);
$this->db->beginTransaction();
try {
$this->memberMapper->update($member); $this->memberMapper->update($member);
// Hard-delete sub-entities (addresses, phones, emails) // Hard-delete sub-entities (addresses, phones, emails)
@@ -692,6 +714,12 @@ class MemberService {
$this->phoneMapper->deleteByMemberId($id); $this->phoneMapper->deleteByMemberId($id);
$this->emailMapper->deleteByMemberId($id); $this->emailMapper->deleteByMemberId($id);
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollback();
throw $e;
}
// Audit log the soft-delete // Audit log the soft-delete
$this->auditService->logSoftDelete('member', $id); $this->auditService->logSoftDelete('member', $id);
+175 -3
View File
@@ -20,6 +20,7 @@ use OCA\Mitgliederverwaltung\Service\EncryptionService;
use OCA\Mitgliederverwaltung\Service\MemberService; use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException; use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -35,6 +36,7 @@ class MemberServiceTest extends TestCase {
private StufeHistoryMapper&MockObject $stufeHistoryMapper; private StufeHistoryMapper&MockObject $stufeHistoryMapper;
private AuditService&MockObject $auditService; private AuditService&MockObject $auditService;
private LoggerInterface&MockObject $logger; private LoggerInterface&MockObject $logger;
private IDBConnection&MockObject $db;
protected function setUp(): void { protected function setUp(): void {
parent::setUp(); parent::setUp();
@@ -59,6 +61,7 @@ class MemberServiceTest extends TestCase {
$this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class); $this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
$this->auditService = $this->createMock(AuditService::class); $this->auditService = $this->createMock(AuditService::class);
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
$this->db = $this->createMock(IDBConnection::class);
$this->service = new MemberService( $this->service = new MemberService(
$this->memberMapper, $this->memberMapper,
@@ -68,7 +71,9 @@ class MemberServiceTest extends TestCase {
$this->familyMapper, $this->familyMapper,
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger $this->logger,
null,
$this->db
); );
} }
@@ -706,7 +711,8 @@ class MemberServiceTest extends TestCase {
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger, $this->logger,
$encryption $encryption,
$this->db
); );
$map = $service->revealAllAllergies('alice'); $map = $service->revealAllAllergies('alice');
@@ -744,7 +750,8 @@ class MemberServiceTest extends TestCase {
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger, $this->logger,
$encryption $encryption,
$this->db
); );
$service->revealAllAllergies('alice'); $service->revealAllAllergies('alice');
@@ -1573,4 +1580,169 @@ class MemberServiceTest extends TestCase {
$this->assertArrayHasKey('phones', $result[0]); $this->assertArrayHasKey('phones', $result[0]);
$this->assertArrayHasKey('emails', $result[0]); $this->assertArrayHasKey('emails', $result[0]);
} }
// ── Database Transactions ───────────────────────────────────────
public function testCreateCommitsTransactionOnSuccess(): void {
$data = $this->getValidMemberData();
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('commit');
$this->db->expects($this->never())->method('rollback');
$this->service->create($data);
}
public function testCreateRollbacksTransactionWhenSubEntityInsertFails(): void {
$data = $this->getValidMemberData();
$phones = [['numberE164' => '+4917612345678']];
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('DB error');
$this->service->create($data, [], $phones, []);
}
public function testCreateRollbacksTransactionWhenAddressInsertFails(): void {
$data = $this->getValidMemberData();
$addresses = [['strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin']];
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->addressMapper->method('insert')->willThrowException(new \RuntimeException('Constraint violation'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->create($data, $addresses, [], []);
}
public function testSoftDeleteCommitsTransactionOnSuccess(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('commit');
$this->db->expects($this->never())->method('rollback');
$this->service->softDelete(1);
}
public function testSoftDeleteRollbacksTransactionWhenDeleteFails(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->phoneMapper->method('deleteByMemberId')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->softDelete(1);
}
public function testUpdateCommitsTransactionOnSuccess(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('commit');
$this->db->expects($this->never())->method('rollback');
$this->service->update(1, ['notizen' => 'Test']);
}
public function testUpdateRollbacksTransactionWhenSubEntitySyncFails(): void {
$member = $this->createMember(1);
$existing = new Address();
$existing->setId(42);
$existing->setMemberId(1);
$existing->setStrasse('Alt');
$existing->setPlz('00000');
$existing->setOrt('Alt');
$existing->setLand('Deutschland');
$existing->setIsPrimary(false);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->addressMapper->method('findByMemberId')->willReturn([$existing]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// find() is called by syncAddresses -> updateAddress
$this->addressMapper->method('find')->with(42)->willReturn($existing);
$this->addressMapper->method('update')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->update(1, [
'addresses' => [
['id' => 42, 'strasse' => 'Neu', 'plz' => '12345', 'ort' => 'Neu'],
],
]);
}
public function testUpdateRollbacksTransactionWhenPhoneSyncFails(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// syncPhones will try to insert a new phone
$this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->update(1, [
'phones' => [['numberE164' => '+4917612345678']],
]);
}
} }
+18 -5
View File
@@ -15,6 +15,7 @@ use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException; use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
use OCA\Mitgliederverwaltung\Service\MemberService; use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException; use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -36,6 +37,7 @@ class MemberValidationTest extends TestCase {
private StufeHistoryMapper&MockObject $stufeHistoryMapper; private StufeHistoryMapper&MockObject $stufeHistoryMapper;
private AuditService&MockObject $auditService; private AuditService&MockObject $auditService;
private LoggerInterface&MockObject $logger; private LoggerInterface&MockObject $logger;
private IDBConnection&MockObject $db;
protected function setUp(): void { protected function setUp(): void {
parent::setUp(); parent::setUp();
@@ -48,6 +50,7 @@ class MemberValidationTest extends TestCase {
$this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class); $this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
$this->auditService = $this->createMock(AuditService::class); $this->auditService = $this->createMock(AuditService::class);
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
$this->db = $this->createMock(IDBConnection::class);
$this->service = new MemberService( $this->service = new MemberService(
$this->memberMapper, $this->memberMapper,
@@ -57,7 +60,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper, $this->familyMapper,
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger $this->logger,
null,
$this->db
); );
// Default: no duplicates found // Default: no duplicates found
@@ -256,7 +261,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper, $this->familyMapper,
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger $this->logger,
null,
$this->db
); );
$this->expectException(DuplicateMemberException::class); $this->expectException(DuplicateMemberException::class);
@@ -292,7 +299,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper, $this->familyMapper,
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger $this->logger,
null,
$this->db
); );
$this->expectException(DuplicateMemberException::class); $this->expectException(DuplicateMemberException::class);
@@ -338,7 +347,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper, $this->familyMapper,
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger $this->logger,
null,
$this->db
); );
$this->addressMapper->method('findByMemberId')->willReturn([]); $this->addressMapper->method('findByMemberId')->willReturn([]);
@@ -377,7 +388,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper, $this->familyMapper,
$this->stufeHistoryMapper, $this->stufeHistoryMapper,
$this->auditService, $this->auditService,
$this->logger $this->logger,
null,
$this->db
); );
$this->expectException(DuplicateMemberException::class); $this->expectException(DuplicateMemberException::class);