Files
Mitgliederverwaltung/tests/Unit/MemberServiceTest.php
T
shahondin1624 c4f5f8e7fb
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
Merge pull request 'fix(MemberService): wrap multi-step writes in database transactions' (#203) from fix/missing-database-transactions into main
2026-04-29 22:11:02 +02:00

1749 lines
69 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Unit;
use OCA\Mitgliederverwaltung\Db\Address;
use OCA\Mitgliederverwaltung\Db\AddressMapper;
use OCA\Mitgliederverwaltung\Db\Email;
use OCA\Mitgliederverwaltung\Db\EmailMapper;
use OCA\Mitgliederverwaltung\Db\FamilyMapper;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Db\Phone;
use OCA\Mitgliederverwaltung\Db\PhoneMapper;
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
use OCA\Mitgliederverwaltung\Service\EncryptionService;
use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class MemberServiceTest extends TestCase {
private MemberService $service;
private MemberMapper&MockObject $memberMapper;
private MockObject $addressMapper;
private MockObject $phoneMapper;
private MockObject $emailMapper;
private FamilyMapper&MockObject $familyMapper;
private StufeHistoryMapper&MockObject $stufeHistoryMapper;
private AuditService&MockObject $auditService;
private LoggerInterface&MockObject $logger;
private IDBConnection&MockObject $db;
protected function setUp(): void {
parent::setUp();
$this->memberMapper = $this->createMock(MemberMapper::class);
$this->addressMapper = $this->getMockBuilder(AddressMapper::class)
->disableOriginalConstructor()
->onlyMethods(['findAll', 'findByMemberId', 'deleteByMemberId', 'insert', 'update', 'delete'])
->addMethods(['find'])
->getMock();
$this->phoneMapper = $this->getMockBuilder(PhoneMapper::class)
->disableOriginalConstructor()
->onlyMethods(['findAll', 'findByMemberId', 'deleteByMemberId', 'insert', 'update', 'delete'])
->addMethods(['find'])
->getMock();
$this->emailMapper = $this->getMockBuilder(EmailMapper::class)
->disableOriginalConstructor()
->onlyMethods(['findAll', 'findByMemberId', 'deleteByMemberId', 'insert', 'update', 'delete'])
->addMethods(['find'])
->getMock();
$this->familyMapper = $this->createMock(FamilyMapper::class);
$this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
$this->auditService = $this->createMock(AuditService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->db = $this->createMock(IDBConnection::class);
$this->service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
null,
$this->db
);
}
// ── Helper factories ────────────────────────────────────────────
private function createMember(
int $id = 1,
string $vorname = 'Max',
string $nachname = 'Mustermann',
string $geburtsdatum = '2010-01-15',
string $eintritt = '2020-01-01',
string $status = 'aktiv',
string $rolle = 'mitglied',
?int $familyId = null
): Member {
$member = new Member();
$member->setId($id);
$member->setVorname($vorname);
$member->setNachname($nachname);
$member->setGeburtsdatum($geburtsdatum);
$member->setEintritt($eintritt);
$member->setStatus($status);
$member->setRolle($rolle);
$member->setFamilyId($familyId);
$member->setCreatedAt('2020-01-01 00:00:00');
$member->setUpdatedAt('2020-01-01 00:00:00');
return $member;
}
private function createAddress(int $id = 1, int $memberId = 1): Address {
$address = new Address();
$address->setId($id);
$address->setMemberId($memberId);
$address->setStrasse('Musterstrasse 1');
$address->setPlz('12345');
$address->setOrt('Musterstadt');
$address->setLand('Deutschland');
$address->setIsPrimary(true);
return $address;
}
private function createPhone(int $id = 1, int $memberId = 1): Phone {
$phone = new Phone();
$phone->setId($id);
$phone->setMemberId($memberId);
$phone->setNumberE164('+4917612345678');
$phone->setLabel('Mobil');
return $phone;
}
private function createEmail(int $id = 1, int $memberId = 1): Email {
$email = new Email();
$email->setId($id);
$email->setMemberId($memberId);
$email->setEmail('max@example.com');
$email->setLabel('Privat');
return $email;
}
private function getValidMemberData(): array {
return [
'vorname' => 'Max',
'nachname' => 'Mustermann',
'geburtsdatum' => '2010-01-15',
'eintritt' => '2020-01-01',
];
}
private function mockSubEntitiesForMember(int $memberId): void {
$this->addressMapper->method('findByMemberId')
->with($memberId)
->willReturn([]);
$this->phoneMapper->method('findByMemberId')
->with($memberId)
->willReturn([]);
$this->emailMapper->method('findByMemberId')
->with($memberId)
->willReturn([]);
}
// ── find() ──────────────────────────────────────────────────────
public function testFindReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1);
$address = $this->createAddress(1, 1);
$phone = $this->createPhone(1, 1);
$email = $this->createEmail(1, 1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->addressMapper->method('findByMemberId')->with(1)->willReturn([$address]);
$this->phoneMapper->method('findByMemberId')->with(1)->willReturn([$phone]);
$this->emailMapper->method('findByMemberId')->with(1)->willReturn([$email]);
$result = $this->service->find(1);
$this->assertSame('Max', $result['vorname']);
$this->assertSame('Mustermann', $result['nachname']);
$this->assertCount(1, $result['addresses']);
$this->assertCount(1, $result['phones']);
$this->assertCount(1, $result['emails']);
}
public function testFindThrowsDoesNotExistException(): void {
$this->memberMapper->method('findById')
->willThrowException(new DoesNotExistException('Not found'));
$this->expectException(DoesNotExistException::class);
$this->service->find(999);
}
// ── findAll() ───────────────────────────────────────────────────
public function testFindAllReturnsList(): void {
$member1 = $this->createMember(1, 'Max');
$member2 = $this->createMember(2, 'Anna', 'Schmidt');
$this->memberMapper->method('findAllWithRelations')->willReturn([
$member1->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
$member2->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findAll();
$this->assertCount(2, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertSame('Anna', $result[1]['vorname']);
}
public function testFindAllWithPagination(): void {
$this->memberMapper->expects($this->once())
->method('findAllWithRelations')
->with(10, 5)
->willReturn([]);
$this->service->findAll(10, 5);
}
public function testFindAllReturnsEmptyArray(): void {
$this->memberMapper->method('findAllWithRelations')->willReturn([]);
$result = $this->service->findAll();
$this->assertSame([], $result);
}
// ── create() ────────────────────────────────────────────────────
public function testCreateValidMember(): void {
$data = $this->getValidMemberData();
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->auditService->expects($this->once())->method('logCreate');
$result = $this->service->create($data);
$this->assertSame('Max', $result['vorname']);
$this->assertSame('Mustermann', $result['nachname']);
}
public function testCreateTrimsWhitespace(): void {
$data = $this->getValidMemberData();
$data['vorname'] = ' Max ';
$data['nachname'] = ' Mustermann ';
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$this->assertSame('Max', $m->getVorname());
$this->assertSame('Mustermann', $m->getNachname());
$m->setId(1);
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->service->create($data);
}
public function testCreateSetsDefaults(): void {
$data = $this->getValidMemberData();
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$this->assertSame('mitglied', $m->getRolle());
$this->assertSame('aktiv', $m->getStatus());
$m->setId(1);
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->service->create($data);
}
public function testCreateWithSubEntities(): void {
$data = $this->getValidMemberData();
$addresses = [['strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin']];
$emails = [['email' => 'test@example.com']];
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->addressMapper->method('insert')
->willReturnCallback(function (Address $a) {
$a->setId(1);
return $a;
});
$this->emailMapper->method('insert')
->willReturnCallback(function (Email $e) {
$e->setId(1);
return $e;
});
$this->addressMapper->expects($this->once())->method('insert');
$this->emailMapper->expects($this->once())->method('insert');
$this->service->create($data, $addresses, [], $emails);
}
public function testCreateMissingVornameThrowsValidationException(): void {
$data = $this->getValidMemberData();
unset($data['vorname']);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('vorname');
$this->service->create($data);
}
public function testCreateMissingNachnameThrowsValidationException(): void {
$data = $this->getValidMemberData();
unset($data['nachname']);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('nachname');
$this->service->create($data);
}
public function testCreateWithoutGeburtsdatumSucceeds(): void {
$data = $this->getValidMemberData();
unset($data['geburtsdatum']);
$member = $this->createMember(1, geburtsdatum: '');
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')->willReturnCallback(function (Member $m) use ($member) {
$m->setId(1);
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$result = $this->service->create($data);
$this->assertSame(1, $result['id']);
}
public function testCreateMissingEintrittThrowsValidationException(): void {
$data = $this->getValidMemberData();
unset($data['eintritt']);
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('eintritt');
$this->service->create($data);
}
public function testCreateEmptyVornameThrowsValidationException(): void {
$data = $this->getValidMemberData();
$data['vorname'] = ' ';
$this->expectException(ValidationException::class);
$this->service->create($data);
}
public function testCreateFutureBirthdayThrowsValidationException(): void {
$data = $this->getValidMemberData();
$data['geburtsdatum'] = '2099-01-01';
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Zukunft');
$this->service->create($data);
}
public function testCreateVeryOldBirthdayThrowsValidationException(): void {
$data = $this->getValidMemberData();
$data['geburtsdatum'] = '1800-01-01';
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('120');
$this->service->create($data);
}
public function testCreateEintrittBeforeBirthdayThrowsValidationException(): void {
$data = $this->getValidMemberData();
$data['geburtsdatum'] = '2010-06-15';
$data['eintritt'] = '2005-01-01';
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Eintrittsdatum');
$this->service->create($data);
}
public function testCreateDuplicateThrowsDuplicateMemberException(): void {
$data = $this->getValidMemberData();
$existingMember = $this->createMember(99, 'Max', 'Mustermann', '2010-01-15');
$this->memberMapper->method('search')
->with('Mustermann')
->willReturn([$existingMember]);
$this->expectException(DuplicateMemberException::class);
$this->service->create($data);
}
public function testCreateDuplicateDetectionIsCaseInsensitive(): void {
$data = $this->getValidMemberData();
$data['vorname'] = 'max';
$data['nachname'] = 'mustermann';
$existingMember = $this->createMember(99, 'Max', 'Mustermann', '2010-01-15');
$this->memberMapper->method('search')
->willReturn([$existingMember]);
$this->expectException(DuplicateMemberException::class);
$this->service->create($data);
}
public function testCreateNoDuplicateWhenDifferentBirthday(): void {
$data = $this->getValidMemberData();
$existingMember = $this->createMember(99, 'Max', 'Mustermann', '2011-01-15');
$this->memberMapper->method('search')->willReturn([$existingMember]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$result = $this->service->create($data);
$this->assertSame('Max', $result['vorname']);
}
// ── update() ────────────────────────────────────────────────────
public function testUpdatePartialFields(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$result = $this->service->update(1, ['notizen' => 'Test']);
$this->assertSame('Max', $result['vorname']);
}
public function testUpdateTrimsVorname(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertSame('Moritz', $m->getVorname());
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->service->update(1, ['vorname' => ' Moritz ']);
}
public function testUpdateValidationTriggeredOnKeyFields(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->expectException(ValidationException::class);
$this->service->update(1, ['vorname' => '']);
}
public function testUpdateLogsAudit(): 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->auditService->expects($this->once())->method('logUpdate');
$this->service->update(1, ['notizen' => 'New notes']);
}
public function testUpdateThrowsForMissingMember(): void {
$this->memberMapper->method('findById')
->willThrowException(new DoesNotExistException('Not found'));
$this->expectException(DoesNotExistException::class);
$this->service->update(999, ['vorname' => 'Test']);
}
public function testUpdateSetsMultipleFields(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertSame('Moritz', $m->getVorname());
$this->assertSame('Schmidt', $m->getNachname());
$this->assertSame('inaktiv', $m->getStatus());
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->service->update(1, [
'vorname' => 'Moritz',
'nachname' => 'Schmidt',
'status' => 'inaktiv',
]);
}
public function testUpdateFiltersOutSystemFields(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertSame('Moritz', $m->getVorname());
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// Send editable fields plus system fields that should be ignored
$result = $this->service->update(1, [
'vorname' => 'Moritz',
'createdAt' => '1999-01-01 00:00:00',
'updatedAt' => '1999-01-01 00:00:00',
'deletedAt' => '1999-01-01 00:00:00',
'id' => 999,
]);
// createdAt should NOT have been changed to the request value
$this->assertSame('Moritz', $result['vorname']);
}
public function testUpdateAddsNewAddressWhenSyncingEmptyList(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnArgument(0);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$inserted = new Address();
$inserted->setStrasse('Musterstr. 1');
$inserted->setPlz('12345');
$inserted->setOrt('Berlin');
$inserted->setLand('Deutschland');
$inserted->setMemberId(1);
$inserted->setIsPrimary(false);
$this->addressMapper->expects($this->once())
->method('insert')
->willReturn($inserted);
$this->addressMapper->expects($this->never())->method('update');
$this->addressMapper->expects($this->never())->method('delete');
$result = $this->service->update(1, [
'vorname' => 'Moritz',
'addresses' => [
['strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland'],
],
]);
$this->assertSame('Moritz', $result['vorname']);
}
public function testUpdateUpdatesExistingAddressById(): 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')->willReturnArgument(0);
$this->addressMapper->method('findByMemberId')->willReturn([$existing]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->addressMapper->method('find')->with(42)->willReturn($existing);
$this->addressMapper->expects($this->once())
->method('update')
->willReturnArgument(0);
$this->addressMapper->expects($this->never())->method('insert');
$this->addressMapper->expects($this->never())->method('delete');
$this->service->update(1, [
'addresses' => [
['id' => 42, 'strasse' => 'Neu 1', 'plz' => '12345', 'ort' => 'Neu'],
],
]);
}
public function testUpdateDeletesAddressesNotInPayload(): void {
$member = $this->createMember(1);
$keep = new Address();
$keep->setId(42);
$keep->setMemberId(1);
$keep->setStrasse('Keep');
$keep->setPlz('11111');
$keep->setOrt('Keep');
$keep->setLand('Deutschland');
$keep->setIsPrimary(false);
$remove = new Address();
$remove->setId(43);
$remove->setMemberId(1);
$remove->setStrasse('Remove');
$remove->setPlz('22222');
$remove->setOrt('Remove');
$remove->setLand('Deutschland');
$remove->setIsPrimary(false);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnArgument(0);
$this->addressMapper->method('findByMemberId')->willReturn([$keep, $remove]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->addressMapper->method('find')->willReturnMap([
[42, $keep],
[43, $remove],
]);
$this->addressMapper->expects($this->once())->method('update')->willReturnArgument(0);
$this->addressMapper->expects($this->once())->method('delete')->with($remove);
$this->addressMapper->expects($this->never())->method('insert');
$this->service->update(1, [
'addresses' => [
['id' => 42, 'strasse' => 'Keep', 'plz' => '11111', 'ort' => 'Keep'],
],
]);
}
public function testUpdateWithoutSubEntitiesKeysDoesNotTouchAddresses(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnArgument(0);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// Only mapper calls allowed: no insert/update/delete on sub-entity mappers
$this->addressMapper->expects($this->never())->method('insert');
$this->addressMapper->expects($this->never())->method('update');
$this->addressMapper->expects($this->never())->method('delete');
$this->phoneMapper->expects($this->never())->method('insert');
$this->phoneMapper->expects($this->never())->method('update');
$this->phoneMapper->expects($this->never())->method('delete');
$this->emailMapper->expects($this->never())->method('insert');
$this->emailMapper->expects($this->never())->method('update');
$this->emailMapper->expects($this->never())->method('delete');
$this->service->update(1, ['vorname' => 'Moritz']);
}
// ── revealAllAllergies ─────────────────────────────────────────────
public function testRevealAllAllergiesDecryptsEveryMember(): void {
$m1 = $this->createMember(1, 'A', 'Aa');
$m1->setAllergienEncrypted('CT1');
$m2 = $this->createMember(2, 'B', 'Bb');
$m2->setAllergienEncrypted(null);
$m3 = $this->createMember(3, 'C', 'Cc');
$m3->setAllergienEncrypted('CT3');
$this->memberMapper->method('findAll')->willReturn([$m1, $m2, $m3]);
$encryption = $this->createMock(EncryptionService::class);
$encryption->method('decrypt')->willReturnMap([
['CT1', 'Nussallergie'],
['CT3', 'Laktose'],
]);
$service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
$encryption,
$this->db
);
$map = $service->revealAllAllergies('alice');
$this->assertSame('Nussallergie', $map[1]);
$this->assertNull($map[2]);
$this->assertSame('Laktose', $map[3]);
}
public function testRevealAllAllergiesLogsCountsNotValues(): void {
$m1 = $this->createMember(1);
$m1->setAllergienEncrypted('CT');
$this->memberMapper->method('findAll')->willReturn([$m1]);
$encryption = $this->createMock(EncryptionService::class);
$encryption->method('decrypt')->willReturn('Nuss');
$this->logger->expects($this->once())
->method('info')
->with(
$this->stringContains('Bulk-Reveal'),
$this->callback(function ($ctx) {
$flat = json_encode($ctx);
return !str_contains($flat, 'Nuss');
})
);
$service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
$encryption,
$this->db
);
$service->revealAllAllergies('alice');
}
public function testRevealAllAllergiesThrowsWithoutEncryptionService(): void {
$this->expectException(\RuntimeException::class);
$this->service->revealAllAllergies('alice');
}
public function testUpdateImportedMemberSucceeds(): void {
// Simulate a member created by import (has all fields set via import path)
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01');
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertSame('Max Updated', $m->getVorname());
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// Update with same payload structure the frontend sends
$result = $this->service->update(1, [
'vorname' => 'Max Updated',
'nachname' => 'Mustermann',
'geburtsdatum' => '2010-01-15',
'eintritt' => '2020-01-01',
'geschlecht' => null,
'rolle' => 'mitglied',
'stufeId' => null,
'austritt' => null,
'status' => 'aktiv',
'allergienEncrypted' => null,
'notizen' => null,
'zusatzNotizen' => null,
'kvTyp' => null,
'kvName' => null,
'familyId' => null,
'frozenFeeRate' => null,
'einwilligungDatum' => null,
]);
$this->assertSame('Max Updated', $result['vorname']);
}
// ── softDelete() ────────────────────────────────────────────────
public function testSoftDeleteSetsStatusGeloescht(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertSame('geloescht', $m->getStatus());
$this->assertNotNull($m->getDeletedAt());
return $m;
});
$this->addressMapper->expects($this->once())->method('deleteByMemberId')->with(1);
$this->phoneMapper->expects($this->once())->method('deleteByMemberId')->with(1);
$this->emailMapper->expects($this->once())->method('deleteByMemberId')->with(1);
$this->service->softDelete(1);
}
public function testSoftDeletePurgesSensitiveFields(): void {
$member = $this->createMember(1);
$member->setAllergienEncrypted('ENC:sensitive');
$member->setNotizen('Some notes');
$member->setZusatzNotizen('Extra notes');
$member->setKvTyp('gesetzlich');
$member->setKvName('AOK');
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertNull($m->getAllergienEncrypted());
$this->assertNull($m->getNotizen());
$this->assertNull($m->getZusatzNotizen());
$this->assertNull($m->getKvTyp());
$this->assertNull($m->getKvName());
$this->assertNull($m->getFamilyId());
$this->assertNull($m->getFrozenFeeRate());
return $m;
});
$this->service->softDelete(1);
}
public function testSoftDeleteSetsAustrittIfNotSet(): void {
$member = $this->createMember(1);
// austritt is null by default
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertNotNull($m->getAustritt());
return $m;
});
$this->service->softDelete(1);
}
public function testSoftDeleteKeepsExistingAustritt(): void {
$member = $this->createMember(1);
$member->setAustritt('2025-06-15');
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
$this->assertSame('2025-06-15', $m->getAustritt());
return $m;
});
$this->service->softDelete(1);
}
public function testSoftDeleteLogsAudit(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->auditService->expects($this->once())->method('logSoftDelete')->with('member', 1);
// Also logs the purge data via logCreate
$this->auditService->expects($this->once())->method('logCreate');
$this->service->softDelete(1);
}
public function testSoftDeleteLogsToLogger(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->logger->expects($this->once())->method('info');
$this->service->softDelete(1);
}
public function testDeleteDelegatesToSoftDelete(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->expects($this->once())->method('update');
$this->service->delete(1);
}
// ── search() ────────────────────────────────────────────────────
public function testSearchReturnsMatchingMembers(): void {
$member = $this->createMember(1);
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->search('Max');
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
public function testSearchReturnsEmptyForNoMatch(): void {
$this->memberMapper->method('searchWithRelations')->willReturn([]);
$result = $this->service->search('Nonexistent');
$this->assertSame([], $result);
}
// ── fullTextSearch() ────────────────────────────────────────────
public function testFullTextSearchReturnsResultsWithMatchContext(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearchWithRelations')
->with('Max', 20)
->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Max');
$this->assertCount(1, $result);
$this->assertArrayHasKey('matchContext', $result[0]);
$this->assertStringContainsString('Name:', $result[0]['matchContext']);
}
public function testFullTextSearchMatchesAddress(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Musterstadt', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearchWithRelations')
->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Musterstrasse');
$this->assertCount(1, $result);
$this->assertStringContainsString('Adresse:', $result[0]['matchContext']);
}
public function testFullTextSearchMatchesNotizen(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$member->setNotizen('Hat einen besonderen Wunsch fuer Training');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Training');
$this->assertCount(1, $result);
$this->assertStringContainsString('Notizen:', $result[0]['matchContext']);
}
public function testFullTextSearchMatchesZusatzNotizen(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$member->setZusatzNotizen('Zusaetzliche Information');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
// Search for something not in name or notizen, but in zusatzNotizen
$result = $this->service->fullTextSearch('zusaetzliche');
$this->assertCount(1, $result);
$this->assertStringContainsString('Zusatz-Notizen', $result[0]['matchContext']);
}
// ── findByBirthdayThisMonth() ───────────────────────────────────
public function testFindByBirthdayThisMonthCallsMapperWithCurrentMonth(): void {
$currentMonth = (int)(new \DateTime())->format('m');
$this->memberMapper->expects($this->once())
->method('findByBirthdayMonthWithRelations')
->with($currentMonth)
->willReturn([]);
$this->service->findByBirthdayThisMonth();
}
public function testFindByBirthdayThisMonthReturnsMembers(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findByBirthdayMonthWithRelations')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findByBirthdayThisMonth();
$this->assertCount(1, $result);
}
// ── findWithUnpaidFees() ────────────────────────────────────────
public function testFindWithUnpaidFeesCallsMapperWithCurrentYear(): void {
$currentYear = (int)(new \DateTime())->format('Y');
$this->memberMapper->expects($this->once())
->method('findWithUnpaidFeesWithRelations')
->with($currentYear)
->willReturn([]);
$this->service->findWithUnpaidFees();
}
public function testFindWithUnpaidFeesReturnsMembers(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findWithUnpaidFees();
$this->assertCount(1, $result);
}
// ── findFiltered() ────────────────────────────────────────────────
public function testFindFilteredDelegatesToMapper(): void {
$this->memberMapper->expects($this->once())
->method('findFilteredWithRelations')
->with('aktiv', 'mitglied', true, false)
->willReturn([]);
$this->service->findFiltered('aktiv', 'mitglied', true, false);
}
public function testFindFilteredNoFilters(): void {
$this->memberMapper->expects($this->once())
->method('findFilteredWithRelations')
->with(null, null, false, false)
->willReturn([]);
$result = $this->service->findFiltered();
$this->assertCount(0, $result);
}
public function testFindFilteredReturnsWithSubEntities(): void {
$member = $this->createMember(1);
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findFilteredWithRelations')->willReturn([$memberData]);
$result = $this->service->findFiltered('aktiv');
$this->assertCount(1, $result);
$this->assertSame(1, $result[0]['id']);
$this->assertArrayHasKey('addresses', $result[0]);
$this->assertCount(1, $result[0]['addresses']);
}
public function testFindFilteredByRolleOnly(): void {
$member = $this->createMember(1, rolle: 'erziehungsberechtigter');
$this->memberMapper->method('findFilteredWithRelations')
->with(null, 'erziehungsberechtigter', false, false)
->willReturn([$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []]]);
$result = $this->service->findFiltered(null, 'erziehungsberechtigter');
$this->assertCount(1, $result);
$this->assertSame('erziehungsberechtigter', $result[0]['rolle']);
}
public function testFindFilteredCombinedAllParams(): void {
$this->memberMapper->expects($this->once())
->method('findFilteredWithRelations')
->with('inaktiv', 'mitglied', true, true)
->willReturn([]);
$result = $this->service->findFiltered('inaktiv', 'mitglied', true, true);
$this->assertCount(0, $result);
}
// ── Sub-entity management: Addresses ────────────────────────────
public function testAddAddressCreatesAddress(): void {
$this->addressMapper->expects($this->once())
->method('insert')
->willReturnCallback(function (Address $a) {
$this->assertSame(1, $a->getMemberId());
$this->assertSame('Hauptstr. 1', $a->getStrasse());
$this->assertSame('12345', $a->getPlz());
$this->assertSame('Berlin', $a->getOrt());
$this->assertSame('Deutschland', $a->getLand());
$a->setId(1);
return $a;
});
$result = $this->service->addAddress(1, [
'strasse' => 'Hauptstr. 1',
'plz' => '12345',
'ort' => 'Berlin',
]);
$this->assertInstanceOf(Address::class, $result);
}
public function testUpdateAddressUpdatesFields(): void {
$address = $this->createAddress(1, 1);
$this->addressMapper->method('find')->with(1)->willReturn($address);
$this->addressMapper->expects($this->once())
->method('update')
->willReturnCallback(function (Address $a) {
$this->assertSame('Neue Strasse 2', $a->getStrasse());
return $a;
});
$this->service->updateAddress(1, ['strasse' => 'Neue Strasse 2']);
}
public function testDeleteAddressDeletesAddress(): void {
$address = $this->createAddress(1, 1);
$this->addressMapper->method('find')->with(1)->willReturn($address);
$this->addressMapper->expects($this->once())->method('delete')->with($address);
$this->service->deleteAddress(1);
}
// ── Sub-entity management: Phones ───────────────────────────────
public function testAddPhoneValidatesAndNormalizesNumber(): void {
$this->phoneMapper->expects($this->once())
->method('insert')
->willReturnCallback(function (Phone $p) {
$this->assertSame('+4917612345678', $p->getNumberE164());
$p->setId(1);
return $p;
});
$result = $this->service->addPhone(1, [
'numberE164' => '017612345678',
'label' => 'Mobil',
]);
$this->assertInstanceOf(Phone::class, $result);
}
public function testAddPhoneInvalidNumberThrowsValidationException(): void {
$this->expectException(ValidationException::class);
$this->service->addPhone(1, ['numberE164' => 'invalid']);
}
public function testUpdatePhoneValidatesNumber(): void {
$phone = $this->createPhone(1, 1);
$this->phoneMapper->method('find')->with(1)->willReturn($phone);
$this->phoneMapper->method('update')->willReturnCallback(fn(Phone $p) => $p);
$result = $this->service->updatePhone(1, ['numberE164' => '+4917699999999']);
$this->assertInstanceOf(Phone::class, $result);
}
public function testUpdatePhoneInvalidNumberThrowsValidationException(): void {
$phone = $this->createPhone(1, 1);
$this->phoneMapper->method('find')->with(1)->willReturn($phone);
$this->expectException(ValidationException::class);
$this->service->updatePhone(1, ['numberE164' => 'abc']);
}
public function testDeletePhoneDeletesPhone(): void {
$phone = $this->createPhone(1, 1);
$this->phoneMapper->method('find')->with(1)->willReturn($phone);
$this->phoneMapper->expects($this->once())->method('delete')->with($phone);
$this->service->deletePhone(1);
}
// ── Sub-entity management: Emails ───────────────────────────────
public function testAddEmailCreatesEmail(): void {
$this->emailMapper->expects($this->once())
->method('insert')
->willReturnCallback(function (Email $e) {
$this->assertSame(1, $e->getMemberId());
$this->assertSame('test@example.com', $e->getEmail());
$e->setId(1);
return $e;
});
$result = $this->service->addEmail(1, ['email' => ' test@example.com ']);
$this->assertInstanceOf(Email::class, $result);
}
public function testUpdateEmailUpdatesFields(): void {
$email = $this->createEmail(1, 1);
$this->emailMapper->method('find')->with(1)->willReturn($email);
$this->emailMapper->expects($this->once())
->method('update')
->willReturnCallback(function (Email $e) {
$this->assertSame('new@example.com', $e->getEmail());
return $e;
});
$this->service->updateEmail(1, ['email' => ' new@example.com ']);
}
public function testDeleteEmailDeletesEmail(): void {
$email = $this->createEmail(1, 1);
$this->emailMapper->method('find')->with(1)->willReturn($email);
$this->emailMapper->expects($this->once())->method('delete')->with($email);
$this->service->deleteEmail(1);
}
// ── countAll() ──────────────────────────────────────────────────
public function testCountAllDelegatesToMapper(): void {
$this->memberMapper->method('countAll')->willReturn(42);
$this->assertSame(42, $this->service->countAll());
}
// ── findByFamily() ──────────────────────────────────────────────
public function testFindByFamilyReturnsMembers(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findByFamily(5);
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
// ── findByStatus() ──────────────────────────────────────────────
public function testFindByStatusReturnsMembers(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findByStatus('aktiv');
$this->assertCount(1, $result);
}
// ── findArchived() ──────────────────────────────────────────────
public function testFindArchivedDelegatesToMapper(): void {
// Issue #202: findArchived() now delegates to MemberMapper::findArchived()
// which uses a SQL WHERE deleted_at IS NOT NULL query instead of
// loading all members into memory and filtering in PHP.
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
$deletedMember->setStatus('geloescht');
$deletedMember->setEintritt('2018-01-01');
$deletedMember->setAustritt('2025-12-31');
$this->memberMapper->expects($this->once())
->method('findArchived')
->with(null, null)
->willReturn([$deletedMember]);
$result = $this->service->findArchived();
$this->assertCount(1, $result);
$this->assertSame('Anna', $result[0]['vorname']);
$this->assertArrayHasKey('mitgliedsdauer', $result[0]);
$this->assertArrayHasKey('deletedAt', $result[0]);
}
public function testFindArchivedPassesPaginationToMapper(): void {
$this->memberMapper->expects($this->once())
->method('findArchived')
->with(10, 20)
->willReturn([]);
$this->service->findArchived(10, 20);
}
public function testFindArchivedReturnsEmptyArrayWhenNoArchivedMembers(): void {
$this->memberMapper->method('findArchived')->willReturn([]);
$result = $this->service->findArchived();
$this->assertSame([], $result);
}
public function testFindArchivedReturnsOnlyRetainedFields(): void {
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
$deletedMember->setEintritt('2018-01-01');
$deletedMember->setAustritt('2025-12-31');
$this->memberMapper->method('findArchived')->willReturn([$deletedMember]);
$result = $this->service->findArchived();
$this->assertCount(1, $result);
$entry = $result[0];
// Retained fields should be present
$this->assertArrayHasKey('id', $entry);
$this->assertArrayHasKey('vorname', $entry);
$this->assertArrayHasKey('nachname', $entry);
$this->assertArrayHasKey('geburtsdatum', $entry);
$this->assertArrayHasKey('eintritt', $entry);
$this->assertArrayHasKey('austritt', $entry);
$this->assertArrayHasKey('deletedAt', $entry);
$this->assertArrayHasKey('mitgliedsdauer', $entry);
// Sensitive fields should NOT be present
$this->assertArrayNotHasKey('notizen', $entry);
$this->assertArrayNotHasKey('allergienEncrypted', $entry);
$this->assertArrayNotHasKey('kvTyp', $entry);
}
// ── countArchived() ──────────────────────────────────────────────
public function testCountArchivedReturnsCorrectCount(): void {
// Issue #201: countArchived() now delegates to MemberMapper::countArchived()
// which uses a SQL COUNT query instead of loading all members into memory.
$this->memberMapper->method('countArchived')->willReturn(1);
$this->assertSame(1, $this->service->countArchived());
}
public function testCountArchivedReturnsZeroWhenNoArchived(): void {
$this->memberMapper->method('countArchived')->willReturn(0);
$this->assertSame(0, $this->service->countArchived());
}
// ── Joined methods: findAllWithRelations ──────────────────────
public function testFindAllWithRelationsReturnsMemberWithNestedSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true],
['id' => 2, 'memberId' => 1, 'label' => 'Arbeit', 'strasse' => 'Büroweg 5', 'plz' => '12346', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => false],
],
'phones' => [
['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678'],
],
'emails' => [
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com'],
],
];
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
$result = $this->service->findAll();
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(2, $result[0]['addresses']);
$this->assertCount(1, $result[0]['phones']);
$this->assertCount(1, $result[0]['emails']);
$this->assertSame('Hauptstr. 1', $result[0]['addresses'][0]['strasse']);
$this->assertSame('+4917612345678', $result[0]['phones'][0]['numberE164']);
$this->assertSame('max@example.com', $result[0]['emails'][0]['email']);
}
public function testFindAllWithRelationsHandlesMemberWithoutSubEntities(): void {
$member = $this->createMember(1);
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
$result = $this->service->findAll();
$this->assertCount(1, $result);
$this->assertCount(0, $result[0]['addresses']);
$this->assertCount(0, $result[0]['phones']);
$this->assertCount(0, $result[0]['emails']);
}
// ── Joined methods: searchWithRelations ───────────────────────
public function testSearchWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([$memberData]);
$result = $this->service->search('Max');
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(1, $result[0]['addresses']);
}
// ── Joined methods: findByFamilyWithRelations ─────────────────
public function testFindByFamilyWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
];
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([$memberData]);
$result = $this->service->findByFamily(5);
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(1, $result[0]['addresses']);
$this->assertCount(1, $result[0]['phones']);
$this->assertCount(1, $result[0]['emails']);
}
// ── Joined methods: findByStatusWithRelations ─────────────────
public function testFindByStatusWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([$memberData]);
$result = $this->service->findByStatus('aktiv');
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
// ── Joined methods: findByBirthdayMonthWithRelations ──────────
public function testFindByBirthdayMonthWithRelationsReturnsMemberWithSubEntities(): void {
$currentMonth = (int)(new \DateTime())->format('m');
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-06-15', '2020-01-01');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->expects($this->once())
->method('findByBirthdayMonthWithRelations')
->with($currentMonth)
->willReturn([$memberData]);
$result = $this->service->findByBirthdayThisMonth();
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
// ── Joined methods: findWithUnpaidFeesWithRelations ───────────
public function testFindWithUnpaidFeesWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([$memberData]);
$result = $this->service->findWithUnpaidFees();
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
// ── Joined methods: findFilteredWithRelations ─────────────────
public function testFindFilteredWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findFilteredWithRelations')
->with('aktiv', 'mitglied', false, false)
->willReturn([$memberData]);
$result = $this->service->findFiltered('aktiv', 'mitglied');
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(1, $result[0]['addresses']);
}
// ── Joined methods: fullTextSearchWithRelations ───────────────
public function testFullTextSearchWithRelationsReturnsResultsWithMatchContext(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearchWithRelations')
->with('Musterstrasse', 20)
->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Musterstrasse');
$this->assertCount(1, $result);
$this->assertArrayHasKey('matchContext', $result[0]);
$this->assertStringContainsString('Adresse:', $result[0]['matchContext']);
$this->assertStringContainsString('Musterstrasse 1', $result[0]['matchContext']);
}
// ── Backward compatibility: findAll shape ─────────────────────
public function testFindAllReturnsSameShapeAsBefore(): void {
// The new method returns the same shape as the old one:
// an array of member arrays with addresses/phones/emails sub-arrays
$member = $this->createMember(1, 'Max');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
];
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
$result = $this->service->findAll();
// Verify the shape matches what the frontend expects
$this->assertIsArray($result[0]);
$this->assertArrayHasKey('id', $result[0]);
$this->assertArrayHasKey('vorname', $result[0]);
$this->assertArrayHasKey('nachname', $result[0]);
$this->assertArrayHasKey('addresses', $result[0]);
$this->assertArrayHasKey('phones', $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']],
]);
}
}