feat: extend Abfrage-Builder with NOT, visual brackets, and full field coverage (Closes #194)
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>
This commit is contained in:
shahondin1624
2026-04-17 20:16:43 +02:00
parent eb555a2fbd
commit 139f014c29
6 changed files with 1297 additions and 163 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<name>Mitgliederverwaltung</name>
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
<version>0.2.7</version>
<version>0.2.8</version>
<licence>agpl</licence>
<author>shahondin1624</author>
<namespace>Mitgliederverwaltung</namespace>
File diff suppressed because it is too large Load Diff
+232 -21
View File
@@ -1,17 +1,33 @@
<template>
<div class="query-builder">
<!-- Condition Group -->
<div class="query-builder__group" :class="'query-builder__group--depth-' + depth">
<!-- Group header with AND/OR toggle -->
<div class="query-builder__group"
:class="[
'query-builder__group--depth-' + depth,
{ 'query-builder__group--negated': localNot },
]">
<!-- Open bracket (visible for nested groups) -->
<span v-if="depth > 0" class="query-builder__bracket query-builder__bracket--open">(</span>
<!-- Group header -->
<div class="query-builder__group-header">
<div class="query-builder__logic-toggle">
<button :class="['query-builder__logic-btn', { 'query-builder__logic-btn--active': localLogic === 'and' }]"
@click="setLogic('and')">
UND
</button>
<button :class="['query-builder__logic-btn', { 'query-builder__logic-btn--active': localLogic === 'or' }]"
@click="setLogic('or')">
ODER
<div class="query-builder__group-toggles">
<div class="query-builder__logic-toggle">
<button :class="['query-builder__logic-btn', { 'query-builder__logic-btn--active': localLogic === 'and' }]"
type="button"
@click="setLogic('and')">
UND
</button>
<button :class="['query-builder__logic-btn', { 'query-builder__logic-btn--active': localLogic === 'or' }]"
type="button"
@click="setLogic('or')">
ODER
</button>
</div>
<button :class="['query-builder__not-btn', { 'query-builder__not-btn--active': localNot }]"
type="button"
:title="localNot ? 'Negation aktiv — Klick zum Aufheben' : 'Gesamte Gruppe negieren'"
@click="toggleNot">
NICHT
</button>
</div>
<div class="query-builder__group-actions">
@@ -21,12 +37,22 @@
</template>
Bedingung
</NcButton>
<NcButton v-if="depth < 3" type="tertiary" @click="addGroup">
<NcButton type="tertiary" @click="addGroup">
<template #icon>
<CodeBrackets :size="20" />
</template>
Gruppe
</NcButton>
<NcButton v-if="depth > 0"
type="tertiary"
:disabled="!canUnwrap"
:title="unwrapHint"
@click="unwrapGroup">
<template #icon>
<CloseBrackets :size="20" />
</template>
Klammer auflösen
</NcButton>
<NcButton v-if="depth > 0" type="error" @click="$emit('remove')">
<template #icon>
<Delete :size="20" />
@@ -41,22 +67,33 @@
:key="index"
class="query-builder__condition-row">
<!-- Nested group -->
<QueryBuilder v-if="item.and || item.or"
<QueryBuilder v-if="isGroupNode(item)"
:model-value="item"
:fields="fields"
:depth="depth + 1"
:parent-logic="localLogic"
@update:model-value="updateCondition(index, $event)"
@remove="removeCondition(index)" />
<!-- Leaf condition -->
<div v-else class="query-builder__leaf">
<div v-else class="query-builder__leaf" :class="{ 'query-builder__leaf--negated': !!item.not }">
<button :class="['query-builder__not-leaf-btn', { 'query-builder__not-leaf-btn--active': !!item.not }]"
type="button"
:title="item.not ? 'Bedingung ist negiert' : 'Bedingung negieren'"
@click="toggleLeafNot(index)">
¬
</button>
<select :value="item.field"
class="query-builder__select"
@change="updateLeafField(index, $event.target.value)">
<option value="" disabled>Feld...</option>
<option v-for="f in fields" :key="f.key" :value="f.key">
{{ f.label }}
</option>
<optgroup v-for="(group, gName) in groupedFields" :key="gName" :label="gName">
<option v-for="f in group"
:key="f.key"
:value="f.key">
{{ f.label }}
</option>
</optgroup>
</select>
<select :value="item.op"
@@ -74,6 +111,12 @@
placeholder="Wert..."
@input="updateLeafValue(index, $event.target.value)">
<span v-if="isEncryptedField(item.field) && !['is_empty', 'is_not_empty'].includes(item.op)"
class="query-builder__encrypted-badge"
title="Feld ist verschlüsselt. Abfrage entschlüsselt serverseitig.">
🔒
</span>
<NcButton type="error"
class="query-builder__remove-btn"
@click="removeCondition(index)">
@@ -90,6 +133,9 @@
</div>
</div>
</div>
<!-- Close bracket (visible for nested groups) -->
<span v-if="depth > 0" class="query-builder__bracket query-builder__bracket--close">)</span>
</div>
</template>
@@ -100,6 +146,7 @@ import Plus from 'vue-material-design-icons/Plus.vue'
import Close from 'vue-material-design-icons/Close.vue'
import Delete from 'vue-material-design-icons/Delete.vue'
import CodeBrackets from 'vue-material-design-icons/CodeBrackets.vue'
import CloseBrackets from 'vue-material-design-icons/CodeBracesBox.vue'
const props = defineProps({
modelValue: {
@@ -114,21 +161,30 @@ const props = defineProps({
type: Number,
default: 0,
},
parentLogic: {
type: String,
default: null,
},
})
const emit = defineEmits(['update:modelValue', 'remove'])
const localLogic = ref(props.modelValue.and ? 'and' : 'or')
const localLogic = ref(props.modelValue.or ? 'or' : 'and')
const localConditions = ref([...(props.modelValue.and || props.modelValue.or || [])])
const localNot = ref(!!props.modelValue.not)
watch(() => props.modelValue, (newVal) => {
localLogic.value = newVal.and ? 'and' : 'or'
localLogic.value = newVal.or ? 'or' : 'and'
localConditions.value = [...(newVal.and || newVal.or || [])]
localNot.value = !!newVal.not
}, { deep: true })
function emitUpdate() {
const result = {}
result[localLogic.value] = [...localConditions.value]
if (localNot.value) {
result.not = true
}
emit('update:modelValue', result)
}
@@ -137,6 +193,11 @@ function setLogic(logic) {
emitUpdate()
}
function toggleNot() {
localNot.value = !localNot.value
emitUpdate()
}
function addCondition() {
localConditions.value.push({
field: props.fields[0]?.key || 'vorname',
@@ -157,13 +218,19 @@ function removeCondition(index) {
}
function updateCondition(index, newValue) {
if (newValue && newValue.__unwrap === true && Array.isArray(newValue.children)) {
localConditions.value.splice(index, 1, ...newValue.children)
emitUpdate()
return
}
localConditions.value[index] = newValue
emitUpdate()
}
function updateLeafField(index, field) {
const existing = localConditions.value[index]
localConditions.value[index] = {
...localConditions.value[index],
...(existing.not ? { not: true } : {}),
field,
op: '=',
value: '',
@@ -192,6 +259,59 @@ function updateLeafValue(index, value) {
emitUpdate()
}
function toggleLeafNot(index) {
const cur = localConditions.value[index]
localConditions.value[index] = { ...cur, not: !cur.not }
if (!localConditions.value[index].not) {
delete localConditions.value[index].not
}
emitUpdate()
}
function isGroupNode(item) {
return item && (Array.isArray(item.and) || Array.isArray(item.or))
}
function isEncryptedField(fieldKey) {
const f = props.fields.find(x => x.key === fieldKey)
return !!(f && f.encrypted)
}
// Unwrap: replace this group with its children in the parent, only if the
// parent's logic matches ours (semantically equivalent) and this group is
// not negated. Emitted to parent via a special sentinel.
const canUnwrap = computed(() => {
if (props.depth === 0) return false
if (localNot.value) return false
if (props.parentLogic && props.parentLogic !== localLogic.value) return false
return localConditions.value.length > 0
})
const unwrapHint = computed(() => {
if (props.depth === 0) return ''
if (localNot.value) return 'Negierte Gruppen können nicht aufgelöst werden'
if (props.parentLogic && props.parentLogic !== localLogic.value) {
return 'Logik der Gruppe (' + localLogic.value.toUpperCase() + ') passt nicht zur Eltern-Logik ('
+ props.parentLogic.toUpperCase() + ')'
}
return 'Klammer auflösen und Bedingungen in Eltern-Gruppe übernehmen'
})
function unwrapGroup() {
if (!canUnwrap.value) return
emit('update:modelValue', { __unwrap: true, children: [...localConditions.value] })
}
const groupedFields = computed(() => {
const out = {}
for (const f of props.fields) {
const g = f.group || 'Sonstiges'
if (!out[g]) out[g] = []
out[g].push(f)
}
return out
})
/**
* Get appropriate operators for a field type.
*/
@@ -235,6 +355,10 @@ function inputTypeForField(fieldKey) {
</script>
<style scoped>
.query-builder {
position: relative;
}
.query-builder__group {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
@@ -247,16 +371,53 @@ function inputTypeForField(fieldKey) {
margin: 8px 0;
}
.query-builder__group--depth-2 {
.query-builder__group--depth-2,
.query-builder__group--depth-3,
.query-builder__group--depth-4,
.query-builder__group--depth-5 {
background: var(--color-background-darker, var(--color-background-dark));
margin: 8px 0;
}
.query-builder__group--negated {
border-color: var(--color-warning, #e9a23b);
box-shadow: inset 0 0 0 1px var(--color-warning, #e9a23b);
}
.query-builder__bracket {
display: inline-block;
font-family: monospace;
font-size: 1.6em;
font-weight: 700;
color: var(--color-text-lighter);
line-height: 1;
vertical-align: top;
padding: 0 4px;
}
.query-builder__bracket--open {
float: left;
margin-right: 2px;
}
.query-builder__bracket--close {
display: block;
margin-top: -10px;
}
.query-builder__group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 8px;
flex-wrap: wrap;
}
.query-builder__group-toggles {
display: flex;
gap: 8px;
align-items: center;
}
.query-builder__logic-toggle {
@@ -281,9 +442,28 @@ function inputTypeForField(fieldKey) {
color: white;
}
.query-builder__not-btn {
padding: 4px 10px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background: none;
cursor: pointer;
font-size: 0.8em;
font-weight: 700;
color: var(--color-text-lighter);
letter-spacing: 0.05em;
}
.query-builder__not-btn--active {
background: var(--color-warning, #e9a23b);
color: white;
border-color: var(--color-warning, #e9a23b);
}
.query-builder__group-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.query-builder__conditions {
@@ -296,6 +476,32 @@ function inputTypeForField(fieldKey) {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.query-builder__leaf--negated {
padding: 4px;
border-radius: var(--border-radius);
background: color-mix(in srgb, var(--color-warning, #e9a23b) 10%, transparent);
}
.query-builder__not-leaf-btn {
width: 28px;
height: 28px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background: none;
cursor: pointer;
font-size: 1em;
font-weight: 700;
color: var(--color-text-lighter);
flex-shrink: 0;
}
.query-builder__not-leaf-btn--active {
background: var(--color-warning, #e9a23b);
color: white;
border-color: var(--color-warning, #e9a23b);
}
.query-builder__select {
@@ -320,6 +526,11 @@ function inputTypeForField(fieldKey) {
min-width: 120px;
}
.query-builder__encrypted-badge {
font-size: 1em;
cursor: help;
}
.query-builder__remove-btn {
flex-shrink: 0;
}
+1 -1
View File
@@ -31,7 +31,7 @@ app.use(router)
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
// not via webpack DefinePlugin globals.
app.provide('appName', 'mitgliederverwaltung')
app.provide('appVersion', '0.2.7')
app.provide('appVersion', '0.2.8')
app.mount('#mitgliederverwaltung')
+363
View File
@@ -4,9 +4,12 @@ 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;
@@ -451,6 +454,307 @@ class QueryServiceTest extends TestCase {
$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 ──────────────────────────────────────────────────────
/**
@@ -527,4 +831,63 @@ class QueryServiceTest extends TestCase {
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;
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ module.exports = {
new VueLoaderPlugin(),
new webpack.DefinePlugin({
appName: JSON.stringify('mitgliederverwaltung'),
appVersion: JSON.stringify('0.2.7'),
appVersion: JSON.stringify('0.2.8'),
}),
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,