Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions src/utils/organisationStatus.js
Original file line number Diff line number Diff line change
@@ -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 }
}
16 changes: 11 additions & 5 deletions src/views/widgets/ConceptOrganisatiesWidget.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<script setup>
import { translate as t } from '@nextcloud/l10n'
import { objectStore } from '../../store/store.js'
import {
acceptPayload,
isConceptOrganisation,
} from '../../utils/organisationStatus.js'
</script>

<template>
Expand Down Expand Up @@ -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:
Expand All @@ -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}
Expand All @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions tests/vitest/organisationStatus.spec.js
Original file line number Diff line number Diff line change
@@ -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'/)
})
})
Loading