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
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:
+1
-1
@@ -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>
|
||||
|
||||
+698
-138
File diff suppressed because it is too large
Load Diff
+222
-11
@@ -1,19 +1,35 @@
|
||||
<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__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">
|
||||
<NcButton type="secondary" @click="addCondition">
|
||||
<template #icon>
|
||||
@@ -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">
|
||||
<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
@@ -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')
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user