Files
Mitgliederverwaltung/tests/Unit/MemberValidationTest.php
T
shahondin1624 e1d24d4d54 fix(MemberService): wrap multi-step writes in database transactions
Add IDBConnection dependency to MemberService and wrap create(),
update(), and softDelete() in transactions (beginTransaction/commit/
rollback). This ensures atomicity when inserting/updating members
alongside sub-entities (addresses, phones, emails) — a failure at
any step now rolls back the entire operation instead of leaving
orphaned records.

(Closes #203)
2026-04-29 22:08:38 +02:00

401 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Unit;
use OCA\Mitgliederverwaltung\Db\AddressMapper;
use OCA\Mitgliederverwaltung\Db\EmailMapper;
use OCA\Mitgliederverwaltung\Db\FamilyMapper;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Db\PhoneMapper;
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Comprehensive validation tests for MemberService.
*
* Covers date plausibility, required fields, and duplicate detection
* per Issue #66.
*/
class MemberValidationTest extends TestCase {
private MemberService $service;
private MemberMapper&MockObject $memberMapper;
private AddressMapper&MockObject $addressMapper;
private PhoneMapper&MockObject $phoneMapper;
private EmailMapper&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->createMock(AddressMapper::class);
$this->phoneMapper = $this->createMock(PhoneMapper::class);
$this->emailMapper = $this->createMock(EmailMapper::class);
$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
);
// Default: no duplicates found
$this->memberMapper->method('search')->willReturn([]);
}
// ── Helper ──────────────────────────────────────────────────────
private function validData(): array {
return [
'vorname' => 'Max',
'nachname' => 'Mustermann',
'geburtsdatum' => '2010-06-15',
'eintritt' => '2020-09-01',
'rolle' => 'mitglied',
];
}
// ── Required fields ─────────────────────────────────────────────
public function testMissingVornameThrowsValidationException(): void {
$data = $this->validData();
unset($data['vorname']);
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/vorname/');
$this->service->create($data);
}
public function testMissingNachnameThrowsValidationException(): void {
$data = $this->validData();
unset($data['nachname']);
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/nachname/');
$this->service->create($data);
}
public function testMissingGeburtsdatumIsAllowed(): void {
$data = $this->validData();
unset($data['geburtsdatum']);
// geburtsdatum is optional since Issue #162 — no exception expected
$this->memberMapper->method('search')->willReturn([]);
$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(1, $result['id']);
}
public function testMissingEintrittThrowsValidationException(): void {
$data = $this->validData();
unset($data['eintritt']);
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/eintritt/');
$this->service->create($data);
}
public function testEmptyVornameThrowsValidationException(): void {
$data = $this->validData();
$data['vorname'] = '';
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/vorname/');
$this->service->create($data);
}
public function testWhitespaceOnlyVornameThrowsValidationException(): void {
$data = $this->validData();
$data['vorname'] = ' ';
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/vorname/');
$this->service->create($data);
}
public function testMultipleMissingFieldsListedInError(): void {
$data = [];
try {
$this->service->create($data);
$this->fail('Expected ValidationException');
} catch (ValidationException $e) {
$message = $e->getMessage();
$this->assertStringContainsString('vorname', $message);
$this->assertStringContainsString('nachname', $message);
$this->assertStringContainsString('eintritt', $message);
// geburtsdatum is optional since Issue #162
$this->assertStringNotContainsString('geburtsdatum', $message);
}
}
// ── Date plausibility: birthday in future ───────────────────────
public function testBirthdayInFutureThrowsValidationException(): void {
$data = $this->validData();
$data['geburtsdatum'] = '2030-01-01';
$data['eintritt'] = '2030-09-01';
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/Zukunft/');
$this->service->create($data);
}
// ── Date plausibility: birthday > 120 years ago ─────────────────
public function testBirthdayMoreThan120YearsAgoThrowsValidationException(): void {
$data = $this->validData();
$data['geburtsdatum'] = '1850-01-01';
$data['eintritt'] = '1870-01-01';
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/120/');
$this->service->create($data);
}
// ── Date plausibility: eintritt before birthday ─────────────────
public function testEintrittBeforeBirthdayThrowsValidationException(): void {
$data = $this->validData();
$data['geburtsdatum'] = '2010-06-15';
$data['eintritt'] = '2005-09-01';
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/Eintrittsdatum.*Geburtsdatum/');
$this->service->create($data);
}
// ── Date plausibility: valid dates pass ─────────────────────────
public function testValidDatesPassValidation(): void {
$data = $this->validData();
$insertedMember = new Member();
$insertedMember->setId(1);
$insertedMember->setVorname('Max');
$insertedMember->setNachname('Mustermann');
$insertedMember->setGeburtsdatum('2010-06-15');
$insertedMember->setEintritt('2020-09-01');
$insertedMember->setRolle('mitglied');
$insertedMember->setStatus('aktiv');
$insertedMember->setCreatedAt('2026-01-01 00:00:00');
$insertedMember->setUpdatedAt('2026-01-01 00:00:00');
$this->memberMapper->method('insert')->willReturn($insertedMember);
$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']);
$this->assertSame('Mustermann', $result['nachname']);
}
// ── Duplicate detection ─────────────────────────────────────────
public function testDuplicateDetectionByNameAndBirthdayThrows(): void {
$data = $this->validData();
$existingMember = new Member();
$existingMember->setId(42);
$existingMember->setVorname('Max');
$existingMember->setNachname('Mustermann');
$existingMember->setGeburtsdatum('2010-06-15');
$existingMember->setEintritt('2020-09-01');
$existingMember->setRolle('mitglied');
$existingMember->setStatus('aktiv');
$existingMember->setCreatedAt('2026-01-01 00:00:00');
$existingMember->setUpdatedAt('2026-01-01 00:00:00');
$this->memberMapper = $this->createMock(MemberMapper::class);
$this->memberMapper->method('search')->willReturn([$existingMember]);
// Rebuild service with new mapper
$this->service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
null,
$this->db
);
$this->expectException(DuplicateMemberException::class);
$this->expectExceptionMessageMatches('/ID: 42/');
$this->service->create($data);
}
public function testDuplicateDetectionCaseInsensitive(): void {
$data = $this->validData();
$data['vorname'] = 'MAX';
$data['nachname'] = 'mustermann';
$existingMember = new Member();
$existingMember->setId(42);
$existingMember->setVorname('max');
$existingMember->setNachname('Mustermann');
$existingMember->setGeburtsdatum('2010-06-15');
$existingMember->setEintritt('2020-09-01');
$existingMember->setRolle('mitglied');
$existingMember->setStatus('aktiv');
$existingMember->setCreatedAt('2026-01-01 00:00:00');
$existingMember->setUpdatedAt('2026-01-01 00:00:00');
$this->memberMapper = $this->createMock(MemberMapper::class);
$this->memberMapper->method('search')->willReturn([$existingMember]);
$this->service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
null,
$this->db
);
$this->expectException(DuplicateMemberException::class);
$this->service->create($data);
}
public function testSameNameDifferentBirthdayNoDuplicate(): void {
$data = $this->validData();
$existingMember = new Member();
$existingMember->setId(42);
$existingMember->setVorname('Max');
$existingMember->setNachname('Mustermann');
$existingMember->setGeburtsdatum('2012-03-20'); // Different birthday
$existingMember->setEintritt('2020-09-01');
$existingMember->setRolle('mitglied');
$existingMember->setStatus('aktiv');
$existingMember->setCreatedAt('2026-01-01 00:00:00');
$existingMember->setUpdatedAt('2026-01-01 00:00:00');
$this->memberMapper = $this->createMock(MemberMapper::class);
$this->memberMapper->method('search')->willReturn([$existingMember]);
$insertedMember = new Member();
$insertedMember->setId(43);
$insertedMember->setVorname('Max');
$insertedMember->setNachname('Mustermann');
$insertedMember->setGeburtsdatum('2010-06-15');
$insertedMember->setEintritt('2020-09-01');
$insertedMember->setRolle('mitglied');
$insertedMember->setStatus('aktiv');
$insertedMember->setCreatedAt('2026-01-01 00:00:00');
$insertedMember->setUpdatedAt('2026-01-01 00:00:00');
$this->memberMapper->method('insert')->willReturn($insertedMember);
$this->service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
null,
$this->db
);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// Should not throw — different birthday means not a duplicate
$result = $this->service->create($data);
$this->assertSame(43, $result['id']);
}
public function testDuplicateDetectionTrimsWhitespace(): void {
$data = $this->validData();
$data['vorname'] = ' Max ';
$data['nachname'] = ' Mustermann ';
$existingMember = new Member();
$existingMember->setId(42);
$existingMember->setVorname('Max');
$existingMember->setNachname('Mustermann');
$existingMember->setGeburtsdatum('2010-06-15');
$existingMember->setEintritt('2020-09-01');
$existingMember->setRolle('mitglied');
$existingMember->setStatus('aktiv');
$existingMember->setCreatedAt('2026-01-01 00:00:00');
$existingMember->setUpdatedAt('2026-01-01 00:00:00');
$this->memberMapper = $this->createMock(MemberMapper::class);
$this->memberMapper->method('search')->willReturn([$existingMember]);
$this->service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
null,
$this->db
);
$this->expectException(DuplicateMemberException::class);
$this->service->create($data);
}
}