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
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>
894 lines
35 KiB
PHP
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;
|
|
}
|
|
}
|