diff --git a/composer.json b/composer.json
index 0d4773e31..42b141ca4 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,7 @@
"phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/",
"psalm": "if [ -f vendor/bin/psalm ]; then ./vendor/bin/psalm --threads=1 --no-cache --memory-limit=2G; else echo 'Psalm not installed, skipping...'; fi",
"phpstan": "if [ -f vendor/bin/phpstan ]; then ./vendor/bin/phpstan analyse --memory-limit=1G; else echo 'PHPStan not installed, skipping...'; fi",
- "test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky",
+ "test:unit": "phpunit -c phpunit-unit.xml --colors=always --fail-on-warning --fail-on-risky",
"test:all": "if [ ! -f vendor/bin/phpunit ]; then echo 'SKIPPED: phpunit not installed - run composer install'; elif [ ! -f ../../lib/base.php ]; then echo 'SKIPPED: tests/bootstrap.php requires a Nextcloud server tree (../../lib/base.php not found) - run from inside a Nextcloud checkout or in CI'; else ./vendor/bin/phpunit --colors=always; fi",
"check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
"check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
diff --git a/src/utils/organisationStatus.js b/src/utils/organisationStatus.js
new file mode 100644
index 000000000..21fa3c19c
--- /dev/null
+++ b/src/utils/organisationStatus.js
@@ -0,0 +1,38 @@
+/**
+ * Organisation status values the concept-organisations dashboard widget reads
+ * and writes. They must be members of the `status` enum of the organization
+ * schema in lib/Settings/softwarecatalogus_register.json, or OpenRegister
+ * refuses the accept and the widget never lists anything.
+ *
+ * @spec openspec/specs/fe-organizations/spec.md
+ */
+
+/** Status of an organisation still waiting for review. */
+export const CONCEPT_STATUS = 'Draft'
+
+/** Status an organisation gets once it is accepted. */
+export const ACCEPTED_STATUS = 'Active'
+
+/**
+ * Whether an organisation is still a concept, compared case-insensitively.
+ *
+ * @param {object} organisation The organisation object from the store
+ * @return {boolean} True when its status is the concept status
+ * @spec openspec/specs/fe-organizations/spec.md
+ */
+export function isConceptOrganisation(organisation) {
+ return (
+ String(organisation?.status ?? '').toLowerCase()
+ === CONCEPT_STATUS.toLowerCase()
+ )
+}
+
+/**
+ * The patch payload that accepts an organisation.
+ *
+ * @return {{status: string}} The payload for objectStore.patchObject()
+ * @spec openspec/specs/fe-organizations/spec.md
+ */
+export function acceptPayload() {
+ return { status: ACCEPTED_STATUS }
+}
diff --git a/src/views/widgets/ConceptOrganisatiesWidget.vue b/src/views/widgets/ConceptOrganisatiesWidget.vue
index 82acc1fda..086852eff 100644
--- a/src/views/widgets/ConceptOrganisatiesWidget.vue
+++ b/src/views/widgets/ConceptOrganisatiesWidget.vue
@@ -1,6 +1,10 @@
@@ -97,7 +101,7 @@ export default {
items() {
return objectStore
.getCollection('organization')
- .results.filter((item) => item.status?.toLowerCase() === 'concept')
+ .results.filter(isConceptOrganisation)
.map((item) => ({
id: item.id,
mainText:
@@ -116,7 +120,7 @@ export default {
methods: {
/**
- * Handle accepting an organisatie (change status to actief)
+ * Handle accepting an organisatie (change its status to Active)
*
* @param {object} item - The organisatie item to accept
* @return {void}
@@ -125,9 +129,11 @@ export default {
async onAccept(item) {
this.processingIds.push(item.id)
try {
- await objectStore.patchObject('organization', item.id, {
- status: 'actief',
- })
+ await objectStore.patchObject(
+ 'organization',
+ item.id,
+ acceptPayload(),
+ )
await this.fetchData()
} catch (error) {
console.error('Error accepting organization:', error)
diff --git a/tests/vitest/organisationStatus.spec.js b/tests/vitest/organisationStatus.spec.js
new file mode 100644
index 000000000..41d8edfdd
--- /dev/null
+++ b/tests/vitest/organisationStatus.spec.js
@@ -0,0 +1,79 @@
+/**
+ * The concept-organisations widget filters on one status and writes another.
+ * Both must be members of the real organization schema's status enum, and the
+ * accept payload must validate against the real status property, or the widget
+ * lists nothing and every accept is refused.
+ *
+ * @spec openspec/specs/fe-organizations/spec.md
+ */
+
+import Ajv2020 from 'ajv/dist/2020.js'
+import { describe, expect, it } from 'vitest'
+import register from '../../lib/Settings/softwarecatalogus_register.json'
+import {
+ ACCEPTED_STATUS,
+ acceptPayload,
+ CONCEPT_STATUS,
+ isConceptOrganisation,
+} from '../../src/utils/organisationStatus.js'
+
+const statusProperty = register.components.schemas.organization.properties.status
+
+/**
+ * Compile a JSON-schema validator for a patch that only carries `status`,
+ * using the type and enum of the real register property.
+ *
+ * @return {Function} The compiled Ajv validator
+ */
+function compileStatusPatch() {
+ const ajv = new Ajv2020({ allErrors: true, strict: false })
+ return ajv.compile({
+ type: 'object',
+ properties: {
+ status: { type: statusProperty.type, enum: statusProperty.enum },
+ },
+ required: ['status'],
+ additionalProperties: false,
+ })
+}
+
+describe('organisation status values of the concept-organisations widget', () => {
+ it('filters on a status that is in the real enum', () => {
+ expect(statusProperty.enum).toContain(CONCEPT_STATUS)
+ })
+
+ it('writes an accept payload the real status property accepts', () => {
+ const validate = compileStatusPatch()
+ const payload = acceptPayload()
+ expect(validate(payload), JSON.stringify(validate.errors)).toBe(true)
+ expect(payload.status).toBe(ACCEPTED_STATUS)
+ })
+
+ it('accepts into a status that is not the concept status', () => {
+ expect(ACCEPTED_STATUS).not.toBe(CONCEPT_STATUS)
+ })
+
+ it('lists an organisation on the schema default and not an accepted one', () => {
+ expect(isConceptOrganisation({ status: statusProperty.default })).toBe(true)
+ expect(isConceptOrganisation({ status: ACCEPTED_STATUS })).toBe(false)
+ expect(isConceptOrganisation({})).toBe(false)
+ })
+})
+
+describe('the concept-organisations widget uses these values', () => {
+ it('filters and accepts through the shared status helpers', async () => {
+ const fs = await import('fs')
+ const source = fs.readFileSync(
+ new URL(
+ '../../src/views/widgets/ConceptOrganisatiesWidget.vue',
+ import.meta.url,
+ ),
+ 'utf8',
+ )
+ expect(source).toContain('.filter(isConceptOrganisation)')
+ expect(source).toMatch(
+ /patchObject\(\s*'organization',\s*item\.id,\s*acceptPayload\(\),?\s*\)/,
+ )
+ expect(source).not.toMatch(/'concept'|'actief'/)
+ })
+})