e1d24d4d54
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)
401 lines
15 KiB
PHP
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);
|
|
}
|
|
}
|