Files
Mitgliederverwaltung/tests/Unit/QueryServiceTest.php
T
shahondin1624 139f014c29
Database Portability Tests / Unit Tests (PlatformHelper) (pull_request) Failing after 42s
Database Portability Tests / Integration (mysql) (pull_request) Has been skipped
Database Portability Tests / Integration (postgres) (pull_request) Has been skipped
Database Portability Tests / Integration (sqlite) (pull_request) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (pull_request) Successful in 4s
feat: extend Abfrage-Builder with NOT, visual brackets, and full field coverage (Closes #194)
Backend (QueryService):
- AST supports optional `not: true` flag on groups and leaves, translated
  to SQL `NOT (...)` wrappers; existing queries without `not` unchanged
- New member fields: notizen, zusatz_notizen, einwilligung_datum,
  juleica_nummer, juleica_ablaufdatum
- New related-entity fields via EXISTS subqueries: telefon, email,
  familie_name, beitrag_bezahlt_jahr, beitrag_offen_jahr, beitrag_betrag,
  lager_teilnahme, lager_name, verletzung_vorhanden
- Allergien (verschlüsselt): server-side decrypt-and-filter for content
  operators; pure SQL null/empty check for is_empty/is_not_empty; audit
  log records field+op+user but never the value
- Fields now carry a `group` label for grouped rendering in the UI
- Depth cap kept at 10 (Backend-Validierung) - no hard frontend limit

Frontend (QueryBuilder.vue):
- NICHT toggle on each leaf condition (¬ button) and on each group header
- Visual brackets `(` `)` rendered around nested groups
- "Klammer auflösen" button that lifts children into the parent when
  logic is compatible and the group isn't negated
- Fields dropdown grouped by category (Mitglied, Adresse, Kontakt,
  Familie, Beiträge, Lager, Gesundheit)
- Encrypted fields flagged with 🔒 badge
- Removed hard `depth < 3` nesting cap

Tests: 25 new QueryService tests covering NOT semantics, EXISTS joins,
allergien decrypt path, audit-log value masking, and backwards
compatibility with legacy ASTs. All 1118 unit + 13 integration tests pass.

Version bumped to 0.2.8 in all three locations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:16:43 +02:00

894 lines
35 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Unit;
use OCA\Mitgliederverwaltung\Service\EncryptionService;
use OCA\Mitgliederverwaltung\Service\QueryService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Unit tests for QueryService AST validation and field handling.
*
* Part of Issue #53.
*/
class QueryServiceTest extends TestCase {
private QueryService $service;
private IDBConnection|MockObject $db;
private LoggerInterface|MockObject $logger;
protected function setUp(): void {
$this->db = $this->createMock(IDBConnection::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new QueryService($this->db, $this->logger);
}
// ── Available fields ────────────────────────────────────────────
public function testGetAvailableFieldsReturnsAllFields(): void {
$fields = $this->service->getAvailableFields();
$this->assertIsArray($fields);
$this->assertNotEmpty($fields);
$keys = array_column($fields, 'key');
$this->assertContains('vorname', $keys);
$this->assertContains('nachname', $keys);
$this->assertContains('alter', $keys);
$this->assertContains('status', $keys);
$this->assertContains('adresse_plz', $keys);
$this->assertContains('mitgliedsdauer', $keys);
}
public function testGetAvailableFieldsHaveLabelsAndTypes(): void {
$fields = $this->service->getAvailableFields();
foreach ($fields as $field) {
$this->assertArrayHasKey('key', $field);
$this->assertArrayHasKey('label', $field);
$this->assertArrayHasKey('type', $field);
$this->assertContains($field['type'], ['string', 'number', 'date']);
}
}
// ── AST validation (via reflection to test private method) ──────
public function testValidateAstRejectsUnknownField(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Unbekanntes Feld');
// Execute with unknown field should throw
$ast = ['and' => [['field' => 'nonexistent_field', 'op' => '=', 'value' => 'test']]];
$this->invokeValidateAst($ast);
}
public function testValidateAstRejectsUnknownOperator(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Unbekannter Operator');
$ast = ['and' => [['field' => 'vorname', 'op' => 'LIKE_INJECTION', 'value' => 'test']]];
$this->invokeValidateAst($ast);
}
public function testValidateAstRejectsTooDeepNesting(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('zu tief verschachtelt');
// Build deeply nested AST (12 levels)
$ast = ['field' => 'vorname', 'op' => '=', 'value' => 'test'];
for ($i = 0; $i < 12; $i++) {
$ast = ['and' => [$ast]];
}
$this->invokeValidateAst($ast);
}
public function testValidateAstAcceptsValidSimpleQuery(): void {
$ast = [
'and' => [
['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
],
];
// Should not throw
$this->invokeValidateAst($ast);
$this->assertTrue(true); // If we get here, validation passed
}
public function testValidateAstAcceptsNestedAndOr(): void {
$ast = [
'or' => [
[
'and' => [
['field' => 'alter', 'op' => '<', 'value' => 14],
['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
],
],
['field' => 'rolle', 'op' => '=', 'value' => 'leiter'],
],
];
$this->invokeValidateAst($ast);
$this->assertTrue(true);
}
public function testValidateAstAcceptsIsEmptyWithoutValue(): void {
$ast = ['and' => [['field' => 'austritt', 'op' => 'is_empty']]];
$this->invokeValidateAst($ast);
$this->assertTrue(true);
}
public function testValidateAstRejectsMissingValueForEqualOp(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Wert fehlt');
$ast = ['and' => [['field' => 'vorname', 'op' => '=']]];
$this->invokeValidateAst($ast);
}
public function testValidateAstRejectsInvalidNode(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Ungueltiger AST-Knoten');
$ast = ['invalid_key' => 'something'];
$this->invokeValidateAst($ast);
}
public function testValidateAstRejectsNonArrayAndGroup(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('AND-Gruppe muss ein Array sein');
$ast = ['and' => 'not_an_array'];
$this->invokeValidateAst($ast);
}
public function testValidateAstRejectsNonArrayOrGroup(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('OR-Gruppe muss ein Array sein');
$ast = ['or' => 'not_an_array'];
$this->invokeValidateAst($ast);
}
public function testValidateAstAcceptsIsNotEmptyWithoutValue(): void {
$ast = ['and' => [['field' => 'austritt', 'op' => 'is_not_empty']]];
$this->invokeValidateAst($ast);
$this->assertTrue(true);
}
public function testValidateAstAcceptsContainsOp(): void {
$ast = ['and' => [['field' => 'vorname', 'op' => 'contains', 'value' => 'Ma']]];
$this->invokeValidateAst($ast);
$this->assertTrue(true);
}
public function testValidateAstAcceptsStartsWithOp(): void {
$ast = ['and' => [['field' => 'nachname', 'op' => 'starts_with', 'value' => 'M']]];
$this->invokeValidateAst($ast);
$this->assertTrue(true);
}
public function testValidateAstAcceptsAllComparisonOps(): void {
$ops = ['=', '!=', '<', '>', '<=', '>='];
foreach ($ops as $op) {
$ast = ['and' => [['field' => 'vorname', 'op' => $op, 'value' => 'test']]];
$this->invokeValidateAst($ast);
}
$this->assertTrue(true);
}
// ── execute() with mocked query builder ───────────────────────
public function testExecuteWithSimpleAst(): void {
$qb = $this->createFullQueryBuilderMock([
['id' => 1, 'vorname' => 'Max', 'nachname' => 'Mueller', 'geburtsdatum' => '2010-01-01',
'geschlecht' => 'm', 'rolle' => 'mitglied', 'stufe_id' => null,
'eintritt' => '2020-01-01', 'austritt' => null, 'status' => 'aktiv'],
]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
$this->assertArrayHasKey('total', $result);
$this->assertEquals(1, $result['total']);
$this->assertCount(1, $result['data']);
$this->assertEquals('Max', $result['data'][0]['vorname']);
}
public function testExecuteWithSorting(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
$sort = [['field' => 'nachname', 'dir' => 'desc']];
$result = $this->service->execute($ast, $sort);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithDefaultSorting(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
$result = $this->service->execute($ast, null);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithComputedFieldSortingIsSkipped(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
// Sort by computed field 'alter' should be skipped for ORDER BY
$sort = [['field' => 'alter', 'dir' => 'asc']];
$result = $this->service->execute($ast, $sort);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithAddressField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$qb->method('leftJoin')->willReturnSelf();
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'adresse_plz', 'op' => '=', 'value' => '12345']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithOrGroup(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['or' => [
['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
['field' => 'vorname', 'op' => '=', 'value' => 'Anna'],
]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithContainsOperator(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'vorname', 'op' => 'contains', 'value' => 'Ma']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithStartsWithOperator(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'nachname', 'op' => 'starts_with', 'value' => 'M']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithIsEmptyOperator(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'austritt', 'op' => 'is_empty']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithIsNotEmptyOperator(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'austritt', 'op' => 'is_not_empty']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithAgeComparison(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'alter', 'op' => '<', 'value' => 14]]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithMembershipDuration(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'mitgliedsdauer', 'op' => '>=', 'value' => 5]]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithAllComparisonOperators(): void {
$ops = ['=', '!=', '<', '>', '<=', '>='];
foreach ($ops as $op) {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'stufe_id', 'op' => $op, 'value' => 1]]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
// Re-create service for fresh db mock state
$this->db = $this->createMock(IDBConnection::class);
$this->service = new QueryService($this->db, $this->logger);
}
}
public function testExecuteWithAllAgeOps(): void {
$ops = ['=', '!=', '<', '>', '<=', '>='];
foreach ($ops as $op) {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'alter', 'op' => $op, 'value' => 10]]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
$this->db = $this->createMock(IDBConnection::class);
$this->service = new QueryService($this->db, $this->logger);
}
}
public function testExecuteWithEmptyAndGroup(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => []];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithEmptyOrGroup(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['or' => []];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithPagination(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
$result = $this->service->execute($ast, null, 10, 5);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithInvalidFieldThrows(): void {
$this->expectException(ValidationException::class);
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'nonexistent', 'op' => '=', 'value' => 'x']]];
$this->service->execute($ast);
}
public function testExecuteWithNestedGroups(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [
['or' => [
['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
['field' => 'vorname', 'op' => '=', 'value' => 'Anna'],
]],
['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
// ── astReferencesAddress via reflection ────────────────────────
public function testAstReferencesAddressDetectsAddressField(): void {
$method = new \ReflectionMethod(QueryService::class, 'astReferencesAddress');
$method->setAccessible(true);
$ast = ['and' => [['field' => 'adresse_plz', 'op' => '=', 'value' => '12345']]];
$this->assertTrue($method->invoke($this->service, $ast));
}
public function testAstReferencesAddressReturnsFalseForNonAddress(): void {
$method = new \ReflectionMethod(QueryService::class, 'astReferencesAddress');
$method->setAccessible(true);
$ast = ['and' => [['field' => 'vorname', 'op' => '=', 'value' => 'Max']]];
$this->assertFalse($method->invoke($this->service, $ast));
}
public function testAstReferencesAddressInOrGroup(): void {
$method = new \ReflectionMethod(QueryService::class, 'astReferencesAddress');
$method->setAccessible(true);
$ast = ['or' => [['field' => 'adresse_ort', 'op' => '=', 'value' => 'Berlin']]];
$this->assertTrue($method->invoke($this->service, $ast));
}
// ── NOT operator ─────────────────────────────────────────────────
public function testValidateAstAcceptsNotOnLeaf(): void {
$ast = ['and' => [['not' => true, 'field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
$this->invokeValidateAst($ast);
$this->assertTrue(true);
}
public function testValidateAstAcceptsNotOnGroup(): void {
$ast = [
'and' => [
['not' => true, 'or' => [
['field' => 'juleica_nummer', 'op' => 'is_not_empty'],
]],
],
];
$this->invokeValidateAst($ast);
$this->assertTrue(true);
}
public function testValidateAstRejectsNonBooleanNotFlag(): void {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Flag "not"');
$ast = ['and' => [['not' => 'yes', 'field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
$this->invokeValidateAst($ast);
}
public function testExecuteWithNegatedLeaf(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['not' => true, 'field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithNegatedGroup(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = [
'not' => true,
'and' => [
['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
],
];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithDoubleNegationNested(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = [
'not' => true,
'and' => [
['not' => true, 'field' => 'status', 'op' => '=', 'value' => 'aktiv'],
],
];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testBackwardCompatibleAstWithoutNotStillExecutes(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
// Legacy saved query (no `not` keys anywhere)
$ast = [
'and' => [
['field' => 'vorname', 'op' => 'contains', 'value' => 'M'],
['or' => [
['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
['field' => 'rolle', 'op' => '=', 'value' => 'leiter'],
]],
],
];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
// ── New fields ───────────────────────────────────────────────────
public function testGetAvailableFieldsIncludesNewMemberFields(): void {
$keys = array_column($this->service->getAvailableFields(), 'key');
$this->assertContains('notizen', $keys);
$this->assertContains('zusatz_notizen', $keys);
$this->assertContains('einwilligung_datum', $keys);
$this->assertContains('juleica_nummer', $keys);
$this->assertContains('juleica_ablaufdatum', $keys);
}
public function testGetAvailableFieldsIncludesAllergienFlaggedEncrypted(): void {
$fields = $this->service->getAvailableFields();
$allergien = null;
foreach ($fields as $f) {
if ($f['key'] === 'allergien') {
$allergien = $f;
break;
}
}
$this->assertNotNull($allergien);
$this->assertTrue($allergien['encrypted'] ?? false);
}
public function testGetAvailableFieldsIncludesRelatedEntityFields(): void {
$keys = array_column($this->service->getAvailableFields(), 'key');
$this->assertContains('telefon', $keys);
$this->assertContains('email', $keys);
$this->assertContains('familie_name', $keys);
$this->assertContains('beitrag_bezahlt_jahr', $keys);
$this->assertContains('beitrag_offen_jahr', $keys);
$this->assertContains('lager_teilnahme', $keys);
$this->assertContains('lager_name', $keys);
$this->assertContains('verletzung_vorhanden', $keys);
}
public function testGetAvailableFieldsHaveGroupProperty(): void {
foreach ($this->service->getAvailableFields() as $field) {
$this->assertArrayHasKey('group', $field);
$this->assertNotEmpty($field['group']);
}
}
public function testExecuteWithTelefonField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'telefon', 'op' => 'contains', 'value' => '+49']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithEmailField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'email', 'op' => 'contains', 'value' => '@']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithFamilyNameField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'familie_name', 'op' => '=', 'value' => 'Müller']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithBeitragPaidYearField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'beitrag_bezahlt_jahr', 'op' => '=', 'value' => 2026]]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithBeitragUnpaidYearField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'beitrag_offen_jahr', 'op' => '=', 'value' => 2026]]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithLagerTeilnahmeExistsOp(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'lager_teilnahme', 'op' => 'is_not_empty']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithLagerNameField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'lager_name', 'op' => 'contains', 'value' => 'Sommer']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithInjuryAnyField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'verletzung_vorhanden', 'op' => 'is_not_empty']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithMemberNotizenField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'notizen', 'op' => 'contains', 'value' => 'wichtig']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteWithJuleicaAblaufdatumField(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$ast = ['and' => [['field' => 'juleica_ablaufdatum', 'op' => '<', 'value' => '2027-01-01']]];
$result = $this->service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
// ── Allergien: encrypted field handling ─────────────────────────
public function testExecuteAllergienIsEmptyUsesSqlNoDecrypt(): void {
$qb = $this->createFullQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
$encryption = $this->createMock(EncryptionService::class);
$encryption->expects($this->never())->method('decrypt');
$service = new QueryService($this->db, $this->logger, $encryption, null);
$ast = ['and' => [['field' => 'allergien', 'op' => 'is_empty']]];
$result = $service->execute($ast);
$this->assertArrayHasKey('data', $result);
}
public function testExecuteAllergienContainsDecryptsAndFilters(): void {
$rows = [
['id' => 1, 'vorname' => 'Max', 'nachname' => 'Mueller', 'geburtsdatum' => '2010-01-01',
'geschlecht' => 'm', 'rolle' => 'mitglied', 'stufe_id' => null,
'eintritt' => '2020-01-01', 'austritt' => null, 'status' => 'aktiv',
'allergien_encrypted' => 'CT_nuss'],
['id' => 2, 'vorname' => 'Anna', 'nachname' => 'Schmidt', 'geburtsdatum' => '2012-05-05',
'geschlecht' => 'w', 'rolle' => 'mitglied', 'stufe_id' => null,
'eintritt' => '2021-01-01', 'austritt' => null, 'status' => 'aktiv',
'allergien_encrypted' => 'CT_laktose'],
];
$qb = $this->createAllergienQueryBuilderMock($rows);
$this->db->method('getQueryBuilder')->willReturn($qb);
$encryption = $this->createMock(EncryptionService::class);
$encryption->method('decrypt')->willReturnCallback(fn($ct) => match ($ct) {
'CT_nuss' => 'Nussallergie',
'CT_laktose' => 'Laktoseintoleranz',
default => null,
});
$service = new QueryService($this->db, $this->logger, $encryption, null);
$ast = ['and' => [['field' => 'allergien', 'op' => 'contains', 'value' => 'Nuss']]];
$result = $service->execute($ast);
$this->assertEquals(1, $result['total']);
$this->assertEquals('Max', $result['data'][0]['vorname']);
}
public function testExecuteAllergienContentOpWithoutEncryptionServiceThrows(): void {
$this->expectException(ValidationException::class);
$qb = $this->createAllergienQueryBuilderMock([]);
$this->db->method('getQueryBuilder')->willReturn($qb);
// No encryption service
$ast = ['and' => [['field' => 'allergien', 'op' => 'contains', 'value' => 'Nuss']]];
$this->service->execute($ast);
}
public function testExecuteAllergienQueryAuditsWithoutValue(): void {
$rows = [];
$qb = $this->createAllergienQueryBuilderMock($rows);
$this->db->method('getQueryBuilder')->willReturn($qb);
$encryption = $this->createMock(EncryptionService::class);
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('alice');
$userSession = $this->createMock(IUserSession::class);
$userSession->method('getUser')->willReturn($user);
$this->logger->expects($this->atLeastOnce())
->method('info')
->with(
$this->stringContains('allergien'),
$this->callback(function ($ctx) {
// Must NOT contain the value
$flat = json_encode($ctx);
return !str_contains($flat, 'Nussallergie')
&& !str_contains($flat, 'Nuss');
})
);
$service = new QueryService($this->db, $this->logger, $encryption, $userSession);
$ast = ['and' => [['field' => 'allergien', 'op' => 'contains', 'value' => 'Nuss']]];
$service->execute($ast);
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Invoke the private validateAst method via reflection.
*/
private function invokeValidateAst(array $ast): void {
$method = new \ReflectionMethod(QueryService::class, 'validateAst');
$method->setAccessible(true);
$method->invoke($this->service, $ast, 0);
}
/**
* Create a fully configured query builder mock for execute() tests.
*/
private function createFullQueryBuilderMock(array $rows): MockObject {
$qb = $this->createMock(\OCP\DB\QueryBuilder\IQueryBuilder::class);
$expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class);
$expr->method('isNull')->willReturn('expr_isNull');
$expr->method('isNotNull')->willReturn('expr_isNotNull');
$expr->method('eq')->willReturn('expr_eq');
$expr->method('neq')->willReturn('expr_neq');
$expr->method('lt')->willReturn('expr_lt');
$expr->method('gt')->willReturn('expr_gt');
$expr->method('lte')->willReturn('expr_lte');
$expr->method('gte')->willReturn('expr_gte');
$expr->method('like')->willReturn('expr_like');
// Use anonymous class implementing ICompositeExpression + Stringable
$compositeExpr = new class implements \OCP\DB\QueryBuilder\ICompositeExpression, \Stringable {
public function addMultiple(array $parts = []): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
public function add($part): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
public function count(): int { return 1; }
public function getType(): string { return 'AND'; }
public function __toString(): string { return '(composite_expr)'; }
};
$expr->method('andX')->willReturn($compositeExpr);
$expr->method('orX')->willReturn($compositeExpr);
$qb->method('expr')->willReturn($expr);
$qb->method('select')->willReturnSelf();
$qb->method('from')->willReturnSelf();
$qb->method('where')->willReturnSelf();
$qb->method('andWhere')->willReturnSelf();
$qb->method('leftJoin')->willReturnSelf();
$qb->method('orderBy')->willReturnSelf();
$qb->method('addOrderBy')->willReturnSelf();
$qb->method('setFirstResult')->willReturnSelf();
$qb->method('setMaxResults')->willReturnSelf();
$qb->method('groupBy')->willReturnSelf();
$qb->method('createNamedParameter')->willReturn('?');
$qb->method('createFunction')->willReturn('COUNT(DISTINCT m.id) as cnt');
// Count query result
$countResult = $this->createMock(\OCP\DB\IResult::class);
$countResult->method('fetch')->willReturn(['cnt' => count($rows)]);
$countResult->method('closeCursor')->willReturn(true);
// Data query result
$dataResult = $this->createMock(\OCP\DB\IResult::class);
$callCount = 0;
$dataResult->method('fetch')->willReturnCallback(function () use (&$callCount, $rows) {
if ($callCount < count($rows)) {
return $rows[$callCount++];
}
return false;
});
$dataResult->method('closeCursor')->willReturn(true);
// First executeQuery is for count, second for data
$queryCount = 0;
$qb->method('executeQuery')->willReturnCallback(function () use (&$queryCount, $countResult, $dataResult) {
return ($queryCount++ === 0) ? $countResult : $dataResult;
});
return $qb;
}
/**
* Create a query-builder mock for the allergien path (no separate count query).
*
* Rows should include an `allergien_encrypted` key; the service decrypts
* and filters them in PHP.
*/
private function createAllergienQueryBuilderMock(array $rows): MockObject {
$qb = $this->createMock(\OCP\DB\QueryBuilder\IQueryBuilder::class);
$expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class);
$expr->method('isNull')->willReturn('expr_isNull');
$expr->method('isNotNull')->willReturn('expr_isNotNull');
$expr->method('eq')->willReturn('expr_eq');
$expr->method('neq')->willReturn('expr_neq');
$expr->method('lt')->willReturn('expr_lt');
$expr->method('gt')->willReturn('expr_gt');
$expr->method('lte')->willReturn('expr_lte');
$expr->method('gte')->willReturn('expr_gte');
$expr->method('like')->willReturn('expr_like');
$compositeExpr = new class implements \OCP\DB\QueryBuilder\ICompositeExpression, \Stringable {
public function addMultiple(array $parts = []): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
public function add($part): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
public function count(): int { return 1; }
public function getType(): string { return 'AND'; }
public function __toString(): string { return '(composite_expr)'; }
};
$expr->method('andX')->willReturn($compositeExpr);
$expr->method('orX')->willReturn($compositeExpr);
$qb->method('expr')->willReturn($expr);
$qb->method('select')->willReturnSelf();
$qb->method('from')->willReturnSelf();
$qb->method('where')->willReturnSelf();
$qb->method('andWhere')->willReturnSelf();
$qb->method('leftJoin')->willReturnSelf();
$qb->method('orderBy')->willReturnSelf();
$qb->method('addOrderBy')->willReturnSelf();
$qb->method('setFirstResult')->willReturnSelf();
$qb->method('setMaxResults')->willReturnSelf();
$qb->method('groupBy')->willReturnSelf();
$qb->method('createNamedParameter')->willReturn('?');
$qb->method('createFunction')->willReturn('NOT (expr)');
$dataResult = $this->createMock(\OCP\DB\IResult::class);
$callCount = 0;
$dataResult->method('fetch')->willReturnCallback(function () use (&$callCount, $rows) {
if ($callCount < count($rows)) {
return $rows[$callCount++];
}
return false;
});
$dataResult->method('closeCursor')->willReturn(true);
$qb->method('executeQuery')->willReturn($dataResult);
return $qb;
}
}