+
-
+
@@ -256,7 +282,7 @@
@takeOver="forceReleaseDialogOpen = true" />
+ @add="addWidget"
+ @addApp="addDiscoveredApp" />
+
{{ t('launchpad', 'No dashboards available') }}
@@ -108,6 +114,7 @@ import MenuIcon from 'vue-material-design-icons/Menu.vue'
import DashboardFooter from '../components/DashboardFooter.vue'
import OrgNavigationPanel from '../components/OrgNavigationPanel.vue'
import SidebarBackdrop from '../components/Workspace/SidebarBackdrop.vue'
+import StartPageMenu from '../components/Workspace/StartPageMenu.vue'
import Views from './Views.vue'
import { useDashboardStore } from '../stores/dashboard.js'
import { useOrgNavigationStore } from '../stores/orgNavigation.js'
@@ -148,6 +155,7 @@ export default {
DashboardFooter,
OrgNavigationPanel,
SidebarBackdrop,
+ StartPageMenu,
},
inject: {
@@ -566,6 +574,14 @@ export default {
gap: 12px;
}
+/* runtime-shell REQ-SHELL-009: the start-page menu sits where the grid's
+ top-right controls would be, so a member finds it in the same place with
+ or without a dashboard. Empty when the panel is there. */
+.workspace-shell__empty-menu {
+ align-self: flex-end;
+ margin-inline-end: 36px;
+}
+
.workspace-shell__empty-title {
font-size: 1.3em;
font-weight: 600;
diff --git a/src/views/__tests__/Views.loadingState.spec.js b/src/views/__tests__/Views.loadingState.spec.js
index e9717761d..8a2bbf585 100644
--- a/src/views/__tests__/Views.loadingState.spec.js
+++ b/src/views/__tests__/Views.loadingState.spec.js
@@ -138,19 +138,19 @@ async function mountViews({ activeDashboard = null, loading = false } = {}) {
describe('Views — initial-load shim', () => {
// Mounting the full Views tree pulls in the heavy CnWidgetWrapper widget
// chrome; give these a generous timeout so they stay green under parallel
- // full-suite load (they run ~4.5s in isolation).
+ // full-suite load (the first one pays the dynamic import of the library barrel, ~12s in isolation on 2.81 and over 20s on a loaded machine).
it('renders the loading shim, NOT the empty-state CTA, while loading=true and activeDashboard is null', async () => {
const wrapper = await mountViews({ activeDashboard: null, loading: true })
expect(wrapper.find('.launchpad-loading').exists()).toBe(true)
expect(wrapper.findComponent({ name: 'NcLoadingIcon' }).exists()).toBe(true)
expect(wrapper.find('.launchpad-empty').exists()).toBe(false)
- }, 20000)
+ }, 90000)
it('renders the empty-state CTA once loading=false and activeDashboard is still null', async () => {
const wrapper = await mountViews({ activeDashboard: null, loading: false })
expect(wrapper.find('.launchpad-loading').exists()).toBe(false)
expect(wrapper.find('.launchpad-empty').exists()).toBe(true)
- }, 20000)
+ }, 90000)
})
diff --git a/src/views/__tests__/ViewsCompulsoryRemove.spec.js b/src/views/__tests__/ViewsCompulsoryRemove.spec.js
new file mode 100644
index 000000000..1938b0061
--- /dev/null
+++ b/src/views/__tests__/ViewsCompulsoryRemove.spec.js
@@ -0,0 +1,84 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Deleting a compulsory widget from a template copy says why it cannot be
+ * done and sends nothing; any other widget is removed as before
+ * (admin-templates REQ-TMPL-023). Both menus end in `removeWidget`, which is
+ * what is called here, the way the menus call it.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn(), showSuccess: vi.fn() }))
+vi.mock('@nextcloud/l10n', async (importOriginal) => ({
+ ...(await importOriginal()),
+ t: (_app, text, vars = {}) =>
+ text.replace(/\{(\w+)\}/g, (_m, k) => vars[k] ?? `{${k}}`),
+}))
+
+let Views
+let showError
+
+/**
+ * @param {object[]} placements The dashboard's placements.
+ * @param {string} permissionLevel The copy's permission level.
+ * @return {object} host with the real Views methods.
+ */
+function makeHost(placements, permissionLevel = 'add_only') {
+ const host = {
+ widgetPlacements: placements,
+ permissionLevel,
+ }
+ for (const [name, fn] of Object.entries(Views.methods)) {
+ host[name] = fn.bind(host)
+ }
+ // The store action is mapped into `methods`; the double goes on after
+ // the bind, as ViewsPersonalHide.spec.js does with `switchDashboard`.
+ host.removeWidgetFromDashboard = vi.fn().mockResolvedValue()
+ return host
+}
+
+const attention = {
+ id: 7,
+ widgetId: 'attention',
+ customTitle: 'Vandaag eerst',
+ isCompulsory: 1,
+}
+const cases = {
+ id: 8,
+ widgetId: 'object-list',
+ customTitle: 'Mijn zaken',
+ isCompulsory: 0,
+}
+
+beforeEach(async () => {
+ vi.clearAllMocks()
+ Views = (await import('../Views.vue')).default
+ showError = (await import('@nextcloud/dialogs')).showError
+})
+
+describe('Views removes a widget', () => {
+ it('explains a compulsory widget and sends nothing', async () => {
+ const host = makeHost([attention, cases])
+ await host.removeWidget(7)
+ expect(showError).toHaveBeenCalledWith(
+ '"Vandaag eerst" is part of the start page your administrator set for your group and cannot be removed.',
+ )
+ expect(host.removeWidgetFromDashboard).not.toHaveBeenCalled()
+ })
+
+ it('removes a widget that is not compulsory, with no message', async () => {
+ const host = makeHost([attention, cases])
+ await host.removeWidget(8)
+ expect(showError).not.toHaveBeenCalled()
+ expect(host.removeWidgetFromDashboard).toHaveBeenCalledWith(8)
+ })
+
+ it('lets a full-permission copy remove a compulsory widget', async () => {
+ const host = makeHost([attention], 'full')
+ await host.removeWidget(7)
+ expect(showError).not.toHaveBeenCalled()
+ expect(host.removeWidgetFromDashboard).toHaveBeenCalledWith(7)
+ })
+})
diff --git a/src/views/__tests__/ViewsHideUnavailableSource.spec.js b/src/views/__tests__/ViewsHideUnavailableSource.spec.js
new file mode 100644
index 000000000..a478400b4
--- /dev/null
+++ b/src/views/__tests__/ViewsHideUnavailableSource.spec.js
@@ -0,0 +1,158 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * A list that asked to be hidden when its register is not on this instance
+ * is left out of the grid outside edit mode, after OpenRegister answered
+ * 404 for its source; every other failure keeps the list on the page
+ * (admin-templates REQ-TMPL-022). Asked the way the page asks: through the
+ * page's own watcher, computed and probe, with the probe's HTTP call the
+ * only thing replaced.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn(), showSuccess: vi.fn() }))
+vi.mock('@nextcloud/router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ generateUrl: (path, vars) => path.replace(/\{(\w+)\}/g, (_m, k) => vars[k]),
+}))
+const { get } = vi.hoisted(() => ({ get: vi.fn() }))
+vi.mock('@nextcloud/axios', () => ({ default: { get } }))
+
+let Views
+let resetSourceAvailability
+
+/**
+ * @param {object[]} placements The dashboard's placements.
+ * @param {boolean} isEditMode Whether the page is in edit mode.
+ * @return {object} host with the real Views methods and computed.
+ */
+function makeHost(placements, isEditMode = false) {
+ const host = { widgetPlacements: placements, isEditMode, unavailableSources: {} }
+ for (const [name, fn] of Object.entries(Views.methods)) {
+ host[name] = fn.bind(host)
+ }
+ Object.defineProperty(host, 'shownPlacements', {
+ get: () => Views.computed.shownPlacements.call(host),
+ })
+ return host
+}
+
+const header = { id: 1, widgetId: 'header', content: { title: 'Mijn werkdag' } }
+const cases = {
+ id: 2,
+ widgetId: 'object-list',
+ content: { register: 'dossiq', schema: 'case', hideWhenUnavailable: true },
+}
+const tickets = {
+ id: 3,
+ widgetId: 'object-list',
+ content: { register: 'pipelinq', schema: 'ticket', hideWhenUnavailable: true },
+}
+const plainList = {
+ id: 4,
+ widgetId: 'object-list',
+ content: { register: 'pipelinq', schema: 'lead' },
+}
+
+function answer(register) {
+ if (register === 'dossiq') {
+ return Promise.resolve({ data: { results: [], total: 0 } })
+ }
+ if (register === 'pipelinq') {
+ return Promise.reject({ response: { status: 404 } })
+ }
+ return Promise.reject({ response: { status: 500 } })
+}
+
+beforeEach(async () => {
+ vi.clearAllMocks()
+ get.mockImplementation((url) => answer(url.split('/')[5]))
+ ;({ resetSourceAvailability } =
+ await import('../../services/sourceAvailability.js'))
+ resetSourceAvailability()
+ Views = (await import('../Views.vue')).default
+})
+
+describe('Views hides a list whose register is not here', () => {
+ it('leaves out the list OpenRegister answered 404 for, and keeps the others', async () => {
+ const host = makeHost([header, cases, tickets, plainList])
+ expect(host.shownPlacements.map((p) => p.id)).toEqual([1, 2, 3, 4])
+
+ await host.probeHideableSources()
+
+ expect(host.shownPlacements.map((p) => p.id)).toEqual([1, 2, 4])
+ // The list without the flag was not asked about: it renders as before.
+ expect(get).toHaveBeenCalledTimes(2)
+ expect(get).toHaveBeenCalledWith(
+ '/apps/openregister/api/objects/pipelinq/ticket',
+ { params: { _limit: 1 } },
+ )
+ })
+
+ it('keeps every list in edit mode', async () => {
+ const host = makeHost([header, cases, tickets], true)
+ await host.probeHideableSources()
+ expect(host.shownPlacements.map((p) => p.id)).toEqual([1, 2, 3])
+ })
+
+ it('keeps a list whose source could not be asked', async () => {
+ const broken = {
+ id: 5,
+ widgetId: 'object-list',
+ content: {
+ register: 'learniq',
+ schema: 'pupil',
+ hideWhenUnavailable: true,
+ },
+ }
+ const host = makeHost([header, broken])
+ await host.probeHideableSources()
+ expect(host.shownPlacements.map((p) => p.id)).toEqual([1, 5])
+ })
+
+ it('asks once per source, not once per list', async () => {
+ const second = { ...tickets, id: 6 }
+ const host = makeHost([tickets, second])
+ await host.probeHideableSources()
+ await host.probeHideableSources()
+ expect(get).toHaveBeenCalledTimes(1)
+ expect(host.shownPlacements).toEqual([])
+ })
+})
+
+describe('Views hides an app tile whose register is not here', () => {
+ it('leaves out a stat that names its source under `source`, and keeps one whose source is here', async () => {
+ // Mijn werkdag 4: one number per app, each reading its own register.
+ const caseTile = {
+ id: 5,
+ widgetId: 'stat',
+ content: {
+ source: { register: 'dossiq', schema: 'case', metric: 'count' },
+ hideWhenUnavailable: true,
+ },
+ }
+ const ticketTile = {
+ id: 6,
+ widgetId: 'stat',
+ content: {
+ source: { register: 'pipelinq', schema: 'ticket', metric: 'count' },
+ hideWhenUnavailable: true,
+ },
+ }
+ const plainTile = {
+ id: 7,
+ widgetId: 'stat',
+ content: {
+ source: { register: 'pipelinq', schema: 'lead', metric: 'count' },
+ },
+ }
+ const host = makeHost([header, caseTile, ticketTile, plainTile])
+
+ await host.probeHideableSources()
+
+ expect(host.shownPlacements.map((p) => p.id)).toEqual([1, 5, 7])
+ expect(get).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/views/settings/ProfileFieldsSettings.vue b/src/views/settings/ProfileFieldsSettings.vue
new file mode 100644
index 000000000..585d59ce6
--- /dev/null
+++ b/src/views/settings/ProfileFieldsSettings.vue
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+ {{
+ t(
+ 'launchpad',
+ 'Fields your organisation adds to your profile. Colleagues see them in profile widgets.',
+ )
+ }}
+
+
+ {{ t('launchpad', 'Loading…') }}
+
+
+ {{ t('launchpad', 'Your profile fields could not be loaded.') }}
+
+
+
+
+
+
+
+
diff --git a/src/views/settings/__tests__/ProfileFieldsSettings.spec.js b/src/views/settings/__tests__/ProfileFieldsSettings.spec.js
new file mode 100644
index 000000000..2c13d27b5
--- /dev/null
+++ b/src/views/settings/__tests__/ProfileFieldsSettings.spec.js
@@ -0,0 +1,126 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Vitest unit tests for `ProfileFieldsSettings.vue` (REQ-PEX-002): the
+ * person's own fields load from the API, an LDAP field shows read-only, and
+ * saving sends only the editable fields.
+ */
+
+import axios from '@nextcloud/axios'
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ProfileFieldsSettings from '../ProfileFieldsSettings.vue'
+
+vi.mock('@nextcloud/axios', () => ({ default: { get: vi.fn(), put: vi.fn() } }))
+vi.mock('@nextcloud/router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ generateUrl: (path) => path,
+}))
+
+const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
+
+const FIELDS = [
+ {
+ key: 'office',
+ label: 'Kantoorlocatie',
+ type: 'text',
+ source: 'ldap',
+ values: ['Stadhuis'],
+ readOnly: true,
+ },
+ {
+ key: 'expertise',
+ label: 'Expertise',
+ type: 'tags',
+ source: 'self',
+ values: ['subsidies'],
+ readOnly: false,
+ },
+ {
+ key: 'room',
+ label: 'Kamer',
+ type: 'text',
+ source: 'self',
+ values: [],
+ readOnly: false,
+ },
+]
+
+const stubs = {
+ NcAppSettingsSection: { template: '' },
+ NcNoteCard: { template: '
' },
+ NcButton: { template: ' ' },
+ NcSelect: {
+ props: ['modelValue', 'inputLabel'],
+ emits: ['update:modelValue'],
+ template:
+ '{{ (modelValue || []).join("|") }}
',
+ },
+ NcTextField: {
+ props: ['modelValue', 'label'],
+ emits: ['update:modelValue'],
+ template:
+ ' ',
+ },
+}
+
+describe('ProfileFieldsSettings', () => {
+ beforeEach(() => {
+ axios.get.mockReset()
+ axios.put.mockReset()
+ axios.get.mockResolvedValue({ data: { fields: FIELDS } })
+ })
+
+ it('shows an LDAP field read-only and the own fields as inputs', async () => {
+ const wrapper = mount(ProfileFieldsSettings, { global: { stubs } })
+ await settle()
+ await settle()
+
+ expect(axios.get).toHaveBeenCalledWith(
+ '/apps/launchpad/api/profile-fields/me',
+ )
+ expect(wrapper.text()).toContain('Kantoorlocatie')
+ expect(wrapper.text()).toContain('Stadhuis')
+ expect(wrapper.find('.nc-select').attributes('data-label')).toBe('Expertise')
+ expect(wrapper.find('.nc-select').text()).toBe('subsidies')
+ expect(wrapper.find('input.nc-text').attributes('aria-label')).toBe('Kamer')
+ })
+
+ it('saves only the editable fields', async () => {
+ axios.put.mockResolvedValue({ data: { fields: FIELDS } })
+ const wrapper = mount(ProfileFieldsSettings, { global: { stubs } })
+ await settle()
+ await settle()
+
+ await wrapper.find('input.nc-text').setValue('B-204')
+ await wrapper.find('form').trigger('submit')
+ await settle()
+
+ expect(axios.put).toHaveBeenCalledWith(
+ '/apps/launchpad/api/profile-fields/me',
+ {
+ values: { expertise: ['subsidies'], room: 'B-204' },
+ },
+ )
+ expect(wrapper.text()).toContain('Your profile fields are saved.')
+ })
+
+ it('shows the server message when a save is refused', async () => {
+ axios.put.mockRejectedValue({
+ response: {
+ data: { error: 'A value is longer than 255 characters: room' },
+ },
+ })
+ const wrapper = mount(ProfileFieldsSettings, { global: { stubs } })
+ await settle()
+ await settle()
+
+ await wrapper.find('form').trigger('submit')
+ await settle()
+
+ expect(wrapper.text()).toContain(
+ 'A value is longer than 255 characters: room',
+ )
+ })
+})
diff --git a/stylelint.config.js b/stylelint.config.js
index dbe67ece4..8cc0ba193 100644
--- a/stylelint.config.js
+++ b/stylelint.config.js
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
module.exports = {
extends: '@nextcloud/stylelint-config',
rules: {
diff --git a/tests/Unit/Activity/ActivityCallSitesGoldenTest.php b/tests/Unit/Activity/ActivityCallSitesGoldenTest.php
new file mode 100644
index 000000000..65e44ec54
--- /dev/null
+++ b/tests/Unit/Activity/ActivityCallSitesGoldenTest.php
@@ -0,0 +1,190 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Activity;
+
+use OCA\LaunchPad\Activity\PublisherTools;
+use Closure;
+use OCA\LaunchPad\Activity\ActivityPublisher;
+use OCA\LaunchPad\Activity\DebounceHelper;
+use OCA\LaunchPad\Db\Acknowledgement;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\AcknowledgementService;
+use OCA\LaunchPad\Service\BulkOperationService;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Service\TemplateResyncService;
+use OCP\Activity\IEvent;
+use OCP\Activity\IManager;
+use OCP\IGroupManager;
+use OCP\IUserManager;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use ReflectionClass;
+
+/**
+ * The audit rows of three services, case by case.
+ *
+ * @covers \OCA\LaunchPad\Service\AcknowledgementService
+ * @covers \OCA\LaunchPad\Service\BulkOperationService
+ * @covers \OCA\LaunchPad\Service\TemplateResyncService
+ * @uses \OCA\LaunchPad\Activity\ActivityPublisher
+ * @uses \OCA\LaunchPad\Db\Acknowledgement
+ * @uses \OCA\LaunchPad\Activity\ActivityRow
+ * @uses \OCA\LaunchPad\Activity\ActivitySubject
+ * @uses \OCA\LaunchPad\Activity\DebounceHelper
+ * @uses \OCA\LaunchPad\Activity\PublisherTools
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ */
+class ActivityCallSitesGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/activity-call-sites.json';
+
+ /**
+ * The events written in this test.
+ *
+ * @var list>
+ */
+ private array $rows = [];
+
+ /**
+ * A service without its constructor, with the named collaborators set.
+ *
+ * @param class-string $class The service class.
+ * @param array $properties Property values by name.
+ *
+ * @return object The service.
+ */
+ private function service(string $class, array $properties): object {
+ $service = (new ReflectionClass($class))->newInstanceWithoutConstructor();
+ Closure::bind(
+ function (array $values): void {
+ foreach ($values as $name => $value) {
+ $this->$name = $value;
+ }
+ },
+ $service,
+ $class
+ )($properties);
+ return $service;
+ }//end service()
+
+ /**
+ * A real publisher over a recording activity manager.
+ *
+ * @return ActivityPublisher
+ */
+ private function publisher(): ActivityPublisher {
+ $manager = $this->createMock(IManager::class);
+ $manager->method('generateEvent')->willReturnCallback(static fn (): IEvent => new RecordingEvent());
+ $manager->method('publish')->willReturnCallback(
+ function (IEvent $event): void {
+ $this->rows[] = [
+ 'type' => $event->getType(),
+ 'author' => $event->getAuthor(),
+ 'affected' => $event->getAffectedUser(),
+ 'params' => $event->getSubjectParameters(),
+ 'object' => $event->getObjectName(),
+ 'link' => $event->getLink(),
+ ];
+ }
+ );
+
+ return new ActivityPublisher(
+ manager: $manager,
+ groupManager: $this->createMock(IGroupManager::class),
+ userManager: $this->createMock(IUserManager::class),
+ tools: new PublisherTools(
+ debounce: new DebounceHelper(clock: static fn (): int => 1000),
+ logger: new NullLogger()
+ )
+ );
+ }//end publisher()
+
+ /**
+ * Call a private method.
+ *
+ * @param object $service The service.
+ * @param string $method The method name.
+ * @param list $args Positional arguments.
+ *
+ * @return void
+ */
+ private static function invoke(object $service, string $method, array $args): void {
+ (new ReflectionClass($service))->getMethod($method)->invoke($service, ...$args);
+ }//end invoke()
+
+ /**
+ * Each helper writes the recorded rows.
+ *
+ * @return void
+ */
+ public function testRowsMatchTheRecordedFixture(): void {
+ $actual = [];
+
+ $bulk = $this->service(BulkOperationService::class, ['activityPublisher' => $this->publisher(), 'logger' => new NullLogger()]);
+ $run = static fn (int $count, string $user, int $ms, bool $dryRun): array => [
+ 'dashboardUuids' => array_fill(0, $count, 'u'),
+ 'userId' => $user,
+ 'start' => (microtime(true) - ($ms / 1000)),
+ 'dryRun' => $dryRun,
+ ];
+ self::invoke($bulk, 'emitAuditEvent', ['delete', $run(3, 'admin', 12, false)]);
+ self::invoke($bulk, 'emitAuditEvent', ['status', $run(2, 'root', 5, true) + ['extra' => ['publicationStatus' => 'published', 'dryRun' => 'x']]]);
+ // The duration is measured from the run's start: at least the time
+ // that had passed, and pinned to it for the comparison.
+ foreach ([12, 5] as $i => $ms) {
+ $measured = $this->rows[$i]['params']['durationMs'];
+ $this->assertGreaterThanOrEqual($ms, $measured);
+ $this->assertLessThan($ms + 1000, $measured);
+ $this->rows[$i]['params']['durationMs'] = $ms;
+ }
+
+ $actual['bulk'] = $this->rows;
+
+ $this->rows = [];
+ $resync = $this->service(TemplateResyncService::class, ['activityPublisher' => $this->publisher(), 'logger' => new NullLogger()]);
+ $template = new Dashboard();
+ $template->setName('Intranet');
+ self::invoke($resync, 'emitAuditEvent', [$template, ['templateId' => 7, 'strategy' => 'merge', 'affectedCount' => 4, 'actingAdminId' => 'admin']]);
+ $actual['resync'] = $this->rows;
+
+ $this->rows = [];
+ $placements = $this->createMock(WidgetPlacementMapper::class);
+ $placements->method('findByAnnouncementKey')->willReturn([]);
+ $ack = $this->service(
+ AcknowledgementService::class,
+ ['activityPublisher' => $this->publisher(), 'placementMapper' => $placements]
+ );
+ $receipt = new Acknowledgement();
+ $receipt->setAnnouncementKey('ak-1');
+ $receipt->setUserId('alice');
+ $receipt->setContentVersion(1);
+ self::invoke($ack, 'emitAcknowledgedActivity', [$receipt]);
+ $actual['acknowledged'] = $this->rows;
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testRowsMatchTheRecordedFixture()
+}//end class
diff --git a/tests/Unit/Activity/ActivityPublisherGoldenTest.php b/tests/Unit/Activity/ActivityPublisherGoldenTest.php
new file mode 100644
index 000000000..cbe067f54
--- /dev/null
+++ b/tests/Unit/Activity/ActivityPublisherGoldenTest.php
@@ -0,0 +1,234 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Activity;
+
+use OCA\LaunchPad\Activity\PublisherTools;
+use OCA\LaunchPad\Activity\ActivityPublisher;
+use OCA\LaunchPad\Activity\ActivityRow;
+use OCA\LaunchPad\Activity\ActivitySubject;
+use OCA\LaunchPad\Activity\DebounceHelper;
+use OCA\LaunchPad\Activity\Extension;
+use OCP\Activity\IEvent;
+use OCP\Activity\IManager;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * The publisher's rows and counts, case by case.
+ *
+ * @covers \OCA\LaunchPad\Activity\ActivityPublisher
+ * @uses \OCA\LaunchPad\Activity\DebounceHelper
+ * @uses \OCA\LaunchPad\Activity\ActivityRow
+ * @uses \OCA\LaunchPad\Activity\ActivitySubject
+ * @uses \OCA\LaunchPad\Activity\PublisherTools
+ */
+class ActivityPublisherGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/activity-publisher.json';
+
+ /**
+ * The calls of each case, in order: [method, named arguments].
+ *
+ * @return array}>>
+ */
+ private static function cases(): array {
+ $dash = [
+ 'dashboardUuid' => 'uuid-1',
+ 'dashboardName' => 'Team',
+ 'dashboardLink' => 'https://nc.example/d/1',
+ ];
+ $long = str_repeat('abcdefghij', 25);
+
+ return [
+ 'publish to other' => [['publish', ['type' => Extension::EVENT_SHARED, 'actorUserId' => 'alice', 'recipientUserId' => 'bob'] + $dash]],
+ 'publish to self' => [['publish', ['type' => Extension::EVENT_CREATED, 'actorUserId' => 'alice', 'recipientUserId' => 'alice'] + $dash]],
+ 'publish with params' => [['publish', ['type' => Extension::EVENT_SHARED, 'actorUserId' => 'alice', 'recipientUserId' => 'bob', 'extraParams' => ['recipient' => 'bob', 'role' => 'view', 'self' => true, 'actor' => 'mallory', 'dashboard' => 'Other']] + $dash]],
+ 'publish long message' => [['publish', ['type' => Extension::EVENT_COMMENTED, 'actorUserId' => 'alice', 'recipientUserId' => 'bob', 'extraParams' => ['message' => $long]] + $dash]],
+ 'publish empty message' => [['publish', ['type' => Extension::EVENT_COMMENTED, 'actorUserId' => 'alice', 'recipientUserId' => 'bob', 'extraParams' => ['message' => '']] + $dash]],
+ 'publish numeric message' => [['publish', ['type' => Extension::EVENT_COMMENTED, 'actorUserId' => 'alice', 'recipientUserId' => 'bob', 'extraParams' => ['message' => 42]] + $dash]],
+ 'publish unknown type' => [['publish', ['type' => 'dashboard_viewed', 'actorUserId' => 'alice', 'recipientUserId' => 'bob'] + $dash]],
+ 'publish failing manager' => [['publish', ['type' => Extension::EVENT_DELETED, 'actorUserId' => 'alice', 'recipientUserId' => 'boom'] + $dash]],
+ 'reaction debounced' => [
+ ['publish', ['type' => Extension::EVENT_REACTED, 'actorUserId' => 'alice', 'recipientUserId' => 'bob'] + $dash],
+ ['publish', ['type' => Extension::EVENT_REACTED, 'actorUserId' => 'alice', 'recipientUserId' => 'carol'] + $dash],
+ ['publish', ['type' => Extension::EVENT_REACTED, 'actorUserId' => 'dave', 'recipientUserId' => 'bob'] + $dash],
+ ],
+ 'recipients' => [['publishToRecipients', ['type' => Extension::EVENT_SHARED, 'actorUserId' => 'alice', 'recipientUserIds' => ['bob', 'carol', 'alice', 'bob'], 'extraParams' => ['role' => 'edit', 'self' => 'x']] + $dash]],
+ 'recipients none' => [['publishToRecipients', ['type' => Extension::EVENT_SHARED, 'actorUserId' => 'alice', 'recipientUserIds' => []] + $dash]],
+ 'recipients one fails' => [['publishToRecipients', ['type' => Extension::EVENT_UPDATED, 'actorUserId' => 'alice', 'recipientUserIds' => ['boom', 'bob']] + $dash]],
+ 'group' => [['publishToGroup', ['type' => Extension::EVENT_UPDATED, 'actorUserId' => 'alice', 'groupId' => 'team', 'extraParams' => ['k' => 'v']] + $dash]],
+ 'group unknown' => [['publishToGroup', ['type' => Extension::EVENT_UPDATED, 'actorUserId' => 'alice', 'groupId' => 'nobody'] + $dash]],
+ 'global' => [
+ ['publishGlobal', ['type' => Extension::EVENT_PUBLISHED, 'actorUserId' => 'bob', 'extraParams' => ['k' => 'v', 'self' => 'x']] + $dash],
+ ['publishGlobal', ['type' => Extension::EVENT_PUBLISHED, 'actorUserId' => 'bob'] + $dash],
+ ['publishGlobal', ['type' => Extension::EVENT_UPDATED, 'actorUserId' => 'bob'] + $dash],
+ ['publishGlobal', ['type' => Extension::EVENT_PUBLISHED, 'actorUserId' => 'bob', 'dashboardUuid' => 'uuid-2'] + $dash],
+ ],
+ ];
+ }//end cases()
+
+ /**
+ * Call one publisher method with the case's arguments.
+ *
+ * @param ActivityPublisher $publisher The publisher under test.
+ * @param string $method The publisher method.
+ * @param array $args The case's named arguments.
+ *
+ * @return int|bool What the publisher returned.
+ */
+ private static function call(ActivityPublisher $publisher, string $method, array $args): int|bool {
+ $row = new ActivityRow(
+ type: $args['type'],
+ actorUserId: $args['actorUserId'],
+ dashboard: new ActivitySubject(uuid: $args['dashboardUuid'], name: $args['dashboardName'], link: $args['dashboardLink']),
+ extraParams: $args['extraParams'] ?? []
+ );
+
+ return match ($method) {
+ 'publish' => $publisher->publish(row: $row, recipientUserId: $args['recipientUserId']),
+ 'publishToRecipients' => $publisher->publishToRecipients(row: $row, recipientUserIds: $args['recipientUserIds']),
+ 'publishToGroup' => $publisher->publishToGroup(row: $row, groupId: $args['groupId']),
+ 'publishGlobal' => $publisher->publishGlobal(row: $row),
+ };
+ }//end call()
+
+ /**
+ * Run one case on a fresh publisher and record what it did.
+ *
+ * @param list}> $calls The case's calls.
+ *
+ * @return array The returns, rows and log lines.
+ */
+ private function record(array $calls): array {
+ $rows = [];
+ $manager = $this->createMock(IManager::class);
+ $manager->method('generateEvent')->willReturnCallback(static fn (): IEvent => new RecordingEvent());
+ $manager->method('publish')->willReturnCallback(
+ static function (IEvent $event) use (&$rows): void {
+ if ($event->getAffectedUser() === 'boom') {
+ throw new RuntimeException('down');
+ }
+
+ $rows[] = [
+ 'app' => $event->getApp(),
+ 'type' => $event->getType(),
+ 'author' => $event->getAuthor(),
+ 'affected' => $event->getAffectedUser(),
+ 'subject' => $event->getSubject(),
+ 'params' => $event->getSubjectParameters(),
+ 'object' => [$event->getObjectType(), $event->getObjectId(), $event->getObjectName()],
+ 'link' => $event->getLink(),
+ 'message' => $event->getMessage(),
+ 'stamped' => $event->getTimestamp() > 0,
+ ];
+ }
+ );
+
+ $group = $this->createMock(IGroup::class);
+ $group->method('getUsers')->willReturn([$this->user(uid: 'carol'), $this->user(uid: 'alice'), $this->user(uid: 'erin')]);
+ $groups = $this->createMock(IGroupManager::class);
+ $groups->method('get')->willReturnCallback(static fn (string $gid): ?IGroup => $gid === 'team' ? $group : null);
+
+ $users = $this->createMock(IUserManager::class);
+ $users->method('callForAllUsers')->willReturnCallback(
+ function (callable $callback): void {
+ foreach (['alice', 'bob', 'boom'] as $uid) {
+ $callback($this->user(uid: $uid));
+ }
+ }
+ );
+
+ $log = [];
+ $logger = $this->createMock(LoggerInterface::class);
+ foreach (['warning', 'error', 'debug'] as $level) {
+ $logger->method($level)->willReturnCallback(
+ static function (string $message, array $context = []) use (&$log, $level): void {
+ unset($context['exception']);
+ $log[] = [$level, $message, $context];
+ }
+ );
+ }
+
+ $publisher = new ActivityPublisher(
+ manager: $manager,
+ groupManager: $groups,
+ userManager: $users,
+ tools: new PublisherTools(
+ debounce: new DebounceHelper(clock: static fn (): int => 1000),
+ logger: $logger
+ )
+ );
+
+ $returns = [];
+ foreach ($calls as [$method, $args]) {
+ $returns[] = self::call(publisher: $publisher, method: $method, args: $args);
+ }
+
+ return ['returns' => $returns, 'rows' => $rows, 'log' => $log];
+ }//end record()
+
+ /**
+ * Build a user double.
+ *
+ * @param string $uid The user id.
+ *
+ * @return IUser
+ */
+ private function user(string $uid): IUser {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ return $user;
+ }//end user()
+
+ /**
+ * Every case writes the recorded rows and returns the recorded counts.
+ *
+ * @return void
+ */
+ public function testRowsMatchTheRecordedFixture(): void {
+ if (function_exists('apcu_clear_cache') === true) {
+ apcu_clear_cache();
+ }
+
+ $actual = [];
+ foreach (self::cases() as $case => $calls) {
+ $actual[$case] = $this->record(calls: $calls);
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $expected = json_decode((string) file_get_contents(self::FIXTURE), true);
+ foreach ($expected as $case => $result) {
+ $this->assertSame($result, $actual[$case], $case);
+ }
+
+ $this->assertSame(array_keys($expected), array_keys($actual));
+ }//end testRowsMatchTheRecordedFixture()
+}//end class
diff --git a/tests/Unit/Activity/DashboardActivityEmissionTest.php b/tests/Unit/Activity/DashboardActivityEmissionTest.php
index 774f841b0..a78d60bf1 100644
--- a/tests/Unit/Activity/DashboardActivityEmissionTest.php
+++ b/tests/Unit/Activity/DashboardActivityEmissionTest.php
@@ -26,7 +26,14 @@
namespace Unit\Activity;
+use OCA\LaunchPad\Service\DashboardOutlets;
+use OCA\LaunchPad\Service\DashboardPlatform;
+use OCA\LaunchPad\Service\DashboardStructure;
+use OCA\LaunchPad\Service\DashboardBuilders;
+use OCA\LaunchPad\Service\DashboardStores;
use OCA\LaunchPad\Activity\ActivityPublisher;
+use OCA\LaunchPad\Activity\ActivitySubject;
+use OCA\LaunchPad\Activity\ActivityRow;
use OCA\LaunchPad\Activity\DashboardActivityEmitter;
use OCA\LaunchPad\Activity\DebounceHelper;
use OCA\LaunchPad\Activity\Extension;
@@ -40,7 +47,9 @@
use OCA\LaunchPad\Service\DashboardFactory;
use OCA\LaunchPad\Service\DashboardResolver;
use OCA\LaunchPad\Service\DashboardService;
+use OCA\LaunchPad\Service\ShareNotifier;
use OCA\LaunchPad\Service\DashboardShareService;
+use OCA\LaunchPad\Service\ShareGrant;
use OCA\LaunchPad\Service\DashboardTreeService;
use OCA\LaunchPad\Service\PersonalLayerService;
use OCA\LaunchPad\Service\TemplateService;
@@ -119,10 +128,13 @@ private function shareService(Dashboard $dashboard): DashboardShareService {
return new DashboardShareService(
shareMapper: $this->shareMapper,
dashboardMapper: $this->dashboardMapper,
- groupManager: $this->groupManager,
- notificationManager: $notifications,
db: $this->createMock(IDBConnection::class),
- activity: $this->emitter,
+ notifier: new ShareNotifier(
+ notificationManager: $notifications,
+ groupManager: $this->groupManager,
+ logger: $this->createMock(LoggerInterface::class),
+ activity: $this->emitter
+ ),
);
}
@@ -130,43 +142,52 @@ private function dashboardService(): DashboardService {
$this->groupManager->method('isAdmin')->willReturn(true);
return new DashboardService(
- dashboardMapper: $this->dashboardMapper,
- placementMapper: $this->createMock(WidgetPlacementMapper::class),
- settingMapper: $this->createMock(AdminSettingMapper::class),
- templateService: $this->createMock(TemplateService::class),
- dashboardFactory: $this->createMock(DashboardFactory::class),
- dashResolver: $this->createMock(DashboardResolver::class),
- treeService: $this->createMock(DashboardTreeService::class),
- groupManager: $this->groupManager,
- adminTemplateService: $this->createMock(AdminTemplateService::class),
- db: $this->createMock(IDBConnection::class),
- config: $this->createMock(IConfig::class),
- l10nFactory: $this->createMock(IFactory::class),
- logger: $this->createMock(LoggerInterface::class),
- personalLayers: $this->createMock(PersonalLayerService::class),
- footerService: $this->createMock(\OCA\LaunchPad\Service\FooterService::class),
- activity: $this->emitter,
+ stores: new DashboardStores(
+ dashboardMapper: $this->dashboardMapper,
+ placementMapper: $this->createMock(WidgetPlacementMapper::class),
+ settingMapper: $this->createMock(AdminSettingMapper::class)
+ ),
+ builders: new DashboardBuilders(
+ templateService: $this->createMock(TemplateService::class),
+ dashboardFactory: $this->createMock(DashboardFactory::class),
+ dashResolver: $this->createMock(DashboardResolver::class),
+ adminTemplateService: $this->createMock(AdminTemplateService::class)
+ ),
+ structure: new DashboardStructure(
+ treeService: $this->createMock(DashboardTreeService::class),
+ personalLayers: $this->createMock(PersonalLayerService::class),
+ footerService: $this->createMock(\OCA\LaunchPad\Service\FooterService::class)
+ ),
+ platform: new DashboardPlatform(
+ groupManager: $this->groupManager,
+ db: $this->createMock(IDBConnection::class),
+ config: $this->createMock(IConfig::class),
+ l10nFactory: $this->createMock(IFactory::class)
+ ),
+ outlets: new DashboardOutlets(
+ logger: $this->createMock(LoggerInterface::class),
+ activity: $this->emitter
+ )
);
}
public function testSharingWithAUserSendsDashboardSharedToThatUser(): void {
$this->publisher->expects($this->once())->method('publishToRecipients')
->with(
- Extension::EVENT_SHARED,
- 'alice',
- 'dash-7',
- 'Team board',
- '',
- ['bob'],
- $this->callback(static fn (array $p): bool => ($p['recipient'] ?? '') === 'bob')
+ $this->callback(
+ static fn (ActivityRow $row): bool => $row->type === Extension::EVENT_SHARED
+ && $row->actorUserId === 'alice'
+ && $row->dashboard == new ActivitySubject(uuid: 'dash-7', name: 'Team board')
+ && ($row->extraParams['recipient'] ?? '') === 'bob'
+ && ($row->extraParams['role'] ?? '') === Dashboard::PERMISSION_VIEW_ONLY
+ ),
+ ['bob']
)
->willReturn(2);
$this->shareService($this->dashboard())->addShare(
dashboardId: 7,
- shareType: DashboardShare::SHARE_TYPE_USER,
- shareWith: 'bob',
- permissionLevel: Dashboard::PERMISSION_VIEW_ONLY,
+ grant: new ShareGrant(shareType: DashboardShare::SHARE_TYPE_USER, shareWith: 'bob', permissionLevel: Dashboard::PERMISSION_VIEW_ONLY),
callerId: 'alice'
);
}
@@ -177,7 +198,7 @@ public function testPublishingAGroupDashboardTellsTheGroupOnce(): void {
$this->shareMapper->method('findByDashboardId')->willReturn([]);
$this->publisher->expects($this->once())->method('publishToGroup')
- ->with(Extension::EVENT_PUBLISHED, 'alice', 'staff', 'dash-7', 'Team board', '')
+ ->with(new ActivityRow(Extension::EVENT_PUBLISHED, 'alice', new ActivitySubject('dash-7', 'Team board')), 'staff')
->willReturn(3);
$service = $this->dashboardService();
@@ -193,7 +214,10 @@ public function testAScheduledDashboardThatSurfacesIsAnnouncedByItsOwner(): void
$this->shareMapper->method('findByDashboardId')->willReturn([]);
$this->publisher->expects($this->once())->method('publishToGroup')
- ->with(Extension::EVENT_PUBLISHED, 'alice', 'staff');
+ ->with(
+ $this->callback(static fn (ActivityRow $row): bool => $row->type === Extension::EVENT_PUBLISHED && $row->actorUserId === 'alice'),
+ 'staff'
+ );
$this->assertSame(1, $this->dashboardService()->materialiseScheduledDashboards());
}
@@ -205,8 +229,8 @@ public function testSavingAGroupDashboardIsAnnouncedAtMostOnceADay(): void {
$calls = 0;
$this->publisher->method('publishToGroup')->willReturnCallback(
- function (string $type) use (&$calls): int {
- $this->assertSame(Extension::EVENT_UPDATED, $type);
+ function (ActivityRow $row) use (&$calls): int {
+ $this->assertSame(Extension::EVENT_UPDATED, $row->type);
$calls++;
return 1;
}
@@ -232,6 +256,20 @@ public function testSavingAPrivateDashboardSendsNothing(): void {
$this->dashboardService()->updateDashboard(dashboardId: 7, userId: 'alice', data: ['name' => 'Mine']);
}
+ public function testSavingADashboardSharedWithAGroupTellsThatGroup(): void {
+ $this->dashboardMapper->method('find')->willReturn($this->dashboard());
+ $share = new DashboardShare();
+ $share->setShareType(DashboardShare::SHARE_TYPE_GROUP);
+ $share->setShareWith('sales');
+ $this->shareMapper->method('findByDashboardId')->willReturn([$share]);
+
+ $this->publisher->expects($this->never())->method('publishToRecipients');
+ $this->publisher->expects($this->once())->method('publishToGroup')
+ ->with(new ActivityRow(Extension::EVENT_UPDATED, 'alice', new ActivitySubject('dash-7', 'Ours')), 'sales');
+
+ $this->dashboardService()->updateDashboard(dashboardId: 7, userId: 'alice', data: ['name' => 'Ours']);
+ }
+
public function testSavingADashboardSharedWithAUserTellsThatUser(): void {
$this->dashboardMapper->method('find')->willReturn($this->dashboard());
$share = new DashboardShare();
@@ -240,7 +278,7 @@ public function testSavingADashboardSharedWithAUserTellsThatUser(): void {
$this->shareMapper->method('findByDashboardId')->willReturn([$share]);
$this->publisher->expects($this->once())->method('publishToRecipients')
- ->with(Extension::EVENT_UPDATED, 'alice', 'dash-7', 'Ours', '', ['bob']);
+ ->with(new ActivityRow(Extension::EVENT_UPDATED, 'alice', new ActivitySubject('dash-7', 'Ours')), ['bob']);
$this->dashboardService()->updateDashboard(dashboardId: 7, userId: 'alice', data: ['name' => 'Ours']);
}
diff --git a/tests/Unit/Activity/ExtensionTest.php b/tests/Unit/Activity/ExtensionTest.php
index 2e4fb890f..8cd5313b0 100644
--- a/tests/Unit/Activity/ExtensionTest.php
+++ b/tests/Unit/Activity/ExtensionTest.php
@@ -32,7 +32,10 @@
namespace Unit\Activity;
+use OCA\LaunchPad\Activity\PublisherTools;
use OCA\LaunchPad\Activity\ActivityPublisher;
+use OCA\LaunchPad\Activity\ActivitySubject;
+use OCA\LaunchPad\Activity\ActivityRow;
use OCA\LaunchPad\Activity\DebounceHelper;
use OCA\LaunchPad\Activity\Extension;
use OCP\Activity\Exceptions\UnknownActivityException;
@@ -201,12 +204,12 @@ public function testPublishPopulatesCanonicalFields(): void {
foreach (Extension::ALL_EVENTS as $type) {
$publisher->publish(
- type: $type,
- actorUserId: 'alice',
- recipientUserId: 'alice',
- dashboardUuid: 'uuid-1',
- dashboardName: 'Dashboard A',
- dashboardLink: 'https://nc.example/apps/launchpad#uuid-1'
+ row: new ActivityRow(
+ type: $type,
+ actorUserId: 'alice',
+ dashboard: new ActivitySubject(uuid: 'uuid-1', name: 'Dashboard A', link: 'https://nc.example/apps/launchpad#uuid-1')
+ ),
+ recipientUserId: 'alice'
);
}
@@ -257,12 +260,12 @@ public function testUnknownTypeDropped(): void {
$publisher = $this->createPublisher();
$result = $publisher->publish(
- type: 'totally_unknown_type',
- actorUserId: 'alice',
- recipientUserId: 'alice',
- dashboardUuid: 'uuid-1',
- dashboardName: 'Dashboard A',
- dashboardLink: 'https://nc.example/apps/launchpad#uuid-1'
+ row: new ActivityRow(
+ type: 'totally_unknown_type',
+ actorUserId: 'alice',
+ dashboard: new ActivitySubject(uuid: 'uuid-1', name: 'Dashboard A', link: 'https://nc.example/apps/launchpad#uuid-1')
+ ),
+ recipientUserId: 'alice'
);
$this->assertFalse(condition: $result);
@@ -278,20 +281,20 @@ public function testReactionDebounceSuppressesSecondEmission(): void {
$publisher = $this->createPublisher();
$first = $publisher->publish(
- type: Extension::EVENT_REACTED,
- actorUserId: 'bob',
- recipientUserId: 'bob',
- dashboardUuid: 'uuid-1',
- dashboardName: 'Marketing',
- dashboardLink: 'https://nc.example/apps/launchpad#uuid-1'
+ row: new ActivityRow(
+ type: Extension::EVENT_REACTED,
+ actorUserId: 'bob',
+ dashboard: new ActivitySubject(uuid: 'uuid-1', name: 'Marketing', link: 'https://nc.example/apps/launchpad#uuid-1')
+ ),
+ recipientUserId: 'bob'
);
$second = $publisher->publish(
- type: Extension::EVENT_REACTED,
- actorUserId: 'bob',
- recipientUserId: 'bob',
- dashboardUuid: 'uuid-1',
- dashboardName: 'Marketing',
- dashboardLink: 'https://nc.example/apps/launchpad#uuid-1'
+ row: new ActivityRow(
+ type: Extension::EVENT_REACTED,
+ actorUserId: 'bob',
+ dashboard: new ActivitySubject(uuid: 'uuid-1', name: 'Marketing', link: 'https://nc.example/apps/launchpad#uuid-1')
+ ),
+ recipientUserId: 'bob'
);
$this->assertTrue(condition: $first);
@@ -304,12 +307,12 @@ public function testReactionDebounceSuppressesSecondEmission(): void {
// Advance the clock past the 900 s window — third emission allowed.
$this->now += 901;
$third = $publisher->publish(
- type: Extension::EVENT_REACTED,
- actorUserId: 'bob',
- recipientUserId: 'bob',
- dashboardUuid: 'uuid-1',
- dashboardName: 'Marketing',
- dashboardLink: 'https://nc.example/apps/launchpad#uuid-1'
+ row: new ActivityRow(
+ type: Extension::EVENT_REACTED,
+ actorUserId: 'bob',
+ dashboard: new ActivitySubject(uuid: 'uuid-1', name: 'Marketing', link: 'https://nc.example/apps/launchpad#uuid-1')
+ ),
+ recipientUserId: 'bob'
);
$this->assertTrue(condition: $third);
$this->assertCount(
@@ -328,20 +331,20 @@ public function testReactionDebounceIsPerActorPerDashboard(): void {
$publisher = $this->createPublisher();
$publisher->publish(
- type: Extension::EVENT_REACTED,
- actorUserId: 'bob',
- recipientUserId: 'bob',
- dashboardUuid: 'uuid-1',
- dashboardName: 'Marketing',
- dashboardLink: 'link'
+ row: new ActivityRow(
+ type: Extension::EVENT_REACTED,
+ actorUserId: 'bob',
+ dashboard: new ActivitySubject(uuid: 'uuid-1', name: 'Marketing', link: 'link')
+ ),
+ recipientUserId: 'bob'
);
$publisher->publish(
- type: Extension::EVENT_REACTED,
- actorUserId: 'carol',
- recipientUserId: 'carol',
- dashboardUuid: 'uuid-1',
- dashboardName: 'Marketing',
- dashboardLink: 'link'
+ row: new ActivityRow(
+ type: Extension::EVENT_REACTED,
+ actorUserId: 'carol',
+ dashboard: new ActivitySubject(uuid: 'uuid-1', name: 'Marketing', link: 'link')
+ ),
+ recipientUserId: 'carol'
);
$this->assertCount(
@@ -370,11 +373,11 @@ public function testGlobalFanoutIsDebounced(): void {
);
$count = $publisher->publishGlobal(
- type: Extension::EVENT_UPDATED,
- actorUserId: 'root',
- dashboardUuid: 'uuid-default',
- dashboardName: 'Company News',
- dashboardLink: 'https://nc.example/'
+ row: new ActivityRow(
+ type: Extension::EVENT_UPDATED,
+ actorUserId: 'root',
+ dashboard: new ActivitySubject(uuid: 'uuid-default', name: 'Company News', link: 'https://nc.example/')
+ )
);
$this->assertSame(expected: 2, actual: $count);
$this->assertCount(
@@ -384,11 +387,11 @@ public function testGlobalFanoutIsDebounced(): void {
// Second call within window MUST NOT enumerate users.
$countSuppressed = $publisher->publishGlobal(
- type: Extension::EVENT_UPDATED,
- actorUserId: 'root',
- dashboardUuid: 'uuid-default',
- dashboardName: 'Company News',
- dashboardLink: 'https://nc.example/'
+ row: new ActivityRow(
+ type: Extension::EVENT_UPDATED,
+ actorUserId: 'root',
+ dashboard: new ActivitySubject(uuid: 'uuid-default', name: 'Company News', link: 'https://nc.example/')
+ )
);
$this->assertSame(expected: 0, actual: $countSuppressed);
$this->assertCount(
@@ -408,11 +411,11 @@ public function testPublishToRecipientsDeduplicatesActor(): void {
$publisher = $this->createPublisher();
$count = $publisher->publishToRecipients(
- type: Extension::EVENT_SHARED,
- actorUserId: 'alice',
- dashboardUuid: 'uuid-2',
- dashboardName: 'Marketing',
- dashboardLink: 'link',
+ row: new ActivityRow(
+ type: Extension::EVENT_SHARED,
+ actorUserId: 'alice',
+ dashboard: new ActivitySubject(uuid: 'uuid-2', name: 'Marketing', link: 'link')
+ ),
recipientUserIds: ['bob', 'carol', 'alice']
);
@@ -455,12 +458,12 @@ public function testPublishToGroupResolvesMembers(): void {
$publisher = $this->createPublisher();
$count = $publisher->publishToGroup(
- type: Extension::EVENT_UPDATED,
- actorUserId: 'alice',
- groupId: 'marketing',
- dashboardUuid: 'uuid-3',
- dashboardName: 'Campaigns',
- dashboardLink: 'link'
+ row: new ActivityRow(
+ type: Extension::EVENT_UPDATED,
+ actorUserId: 'alice',
+ dashboard: new ActivitySubject(uuid: 'uuid-3', name: 'Campaigns', link: 'link')
+ ),
+ groupId: 'marketing'
);
$this->assertSame(expected: 3, actual: $count);
@@ -541,6 +544,38 @@ public function testParseSetsRichSubjectForAllTypes(): void {
}//end foreach
}//end testParseSetsRichSubjectForAllTypes()
+ /**
+ * REQ-ACK-006: an acknowledgement line names the acknowledged version;
+ * a line stored without one keeps its old text.
+ *
+ * @return void
+ */
+ public function testParseNamesTheAcknowledgedVersion(): void {
+ $extension = $this->createExtension();
+ $cases = [
+ 'You acknowledged version 2 of Dashboard A' => ['self' => true, 'version' => 2],
+ 'alice acknowledged version 2 of Dashboard A' => ['self' => false, 'version' => 2],
+ 'You acknowledged Dashboard A' => ['self' => true],
+ 'alice acknowledged Dashboard A' => ['self' => false],
+ ];
+ foreach ($cases as $expected => $params) {
+ $event = new RecordingEvent();
+ $event->setApp(app: Extension::APP_ID);
+ $event->setType(type: Extension::EVENT_ACKNOWLEDGED);
+ $event->setSubject(subject: Extension::EVENT_ACKNOWLEDGED, parameters: $params + ['actor' => 'alice', 'dashboard' => 'Dashboard A']);
+ $extension->parse(language: 'en', event: $event);
+
+ $this->assertSame(expected: $expected, actual: $event->getParsedSubject());
+ }
+
+ $event = new RecordingEvent();
+ $event->setApp(app: Extension::APP_ID);
+ $event->setType(type: Extension::EVENT_SHARED);
+ $event->setSubject(subject: Extension::EVENT_SHARED, parameters: ['self' => false, 'version' => 2, 'actor' => 'alice', 'dashboard' => 'Dashboard A']);
+ $extension->parse(language: 'en', event: $event);
+ $this->assertStringNotContainsString(needle: 'version', haystack: (string)$event->getParsedSubject());
+ }//end testParseNamesTheAcknowledgedVersion()
+
/**
* REQ-ACT-001: parse() throws UnknownActivityException for unknown
* subjects so NC's provider chain can fall through.
@@ -557,6 +592,53 @@ public function testParseThrowsForUnknownType(): void {
$extension->parse(language: 'en', event: $event);
}//end testParseThrowsForUnknownType()
+ /**
+ * Every event type renders the recorded subject and icon, for self and
+ * other, with every placeholder, and with the author / object name
+ * fallbacks when the parameters lack them.
+ *
+ * @return void
+ */
+ public function testParseRendersTheRecordedSubjects(): void {
+ $extension = $this->createExtension();
+ $actual = [];
+ foreach (Extension::ALL_EVENTS as $type) {
+ foreach (['self' => true, 'other' => false] as $variant => $isSelf) {
+ $event = new RecordingEvent();
+ $event->setApp(app: Extension::APP_ID);
+ $event->setType(type: $type);
+ $event->setSubject(
+ subject: $type,
+ parameters: ['self' => $isSelf, 'actor' => 'alice', 'dashboard' => 'Dashboard A', 'recipient' => 'bob', 'role' => 'editor', 'target' => 'carol', 'version' => 3]
+ );
+ $extension->parse(language: 'en', event: $event);
+ $actual[$type.' / '.$variant] = [$event->getRichSubject(), $event->getParsedSubject(), $event->getIcon()];
+ }
+
+ $event = new RecordingEvent();
+ $event->setApp(app: Extension::APP_ID);
+ $event->setType(type: $type);
+ $event->setAuthor(author: 'dave');
+ $event->setObject(objectType: 'dashboard', objectId: 9, objectName: 'Board 9');
+ $event->setSubject(subject: $type, parameters: []);
+ $extension->parse(language: 'en', event: $event);
+ $actual[$type.' / fallbacks'] = [$event->getRichSubject(), $event->getParsedSubject(), $event->getIcon()];
+ }
+
+ $fixture = __DIR__.'/fixtures/extension-parse.json';
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents($fixture, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n");
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents($fixture), true), $actual);
+
+ $foreign = new RecordingEvent();
+ $foreign->setApp(app: 'files');
+ $this->expectException(exception: UnknownActivityException::class);
+ $this->expectExceptionMessage('Unknown app: files');
+ $extension->parse(language: 'en', event: $foreign);
+ }//end testParseRendersTheRecordedSubjects()
+
/**
* Build a publisher wired to the test mocks and clock.
*
@@ -567,8 +649,10 @@ private function createPublisher(): ActivityPublisher {
manager: $this->manager,
groupManager: $this->groupManager,
userManager: $this->userManager,
- debounce: $this->debounce,
- logger: new NullLogger()
+ tools: new PublisherTools(
+ debounce: $this->debounce,
+ logger: new NullLogger()
+ )
);
}//end createPublisher()
diff --git a/tests/Unit/Activity/Settings/ActivitySettingsTest.php b/tests/Unit/Activity/Settings/ActivitySettingsTest.php
new file mode 100644
index 000000000..440b9b731
--- /dev/null
+++ b/tests/Unit/Activity/Settings/ActivitySettingsTest.php
@@ -0,0 +1,99 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Tests\Unit\Activity\Settings;
+
+use OCA\LaunchPad\Activity\Extension;
+use OCA\LaunchPad\Activity\Settings\DashboardAcknowledgedSetting;
+use OCA\LaunchPad\Activity\Settings\DashboardPublishedSetting;
+use OCA\LaunchPad\Activity\Settings\DashboardSharedSetting;
+use OCA\LaunchPad\Activity\Settings\DashboardUpdatedSetting;
+use OCP\Activity\ActivitySettings;
+use OCP\IL10N;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class ActivitySettingsTest extends TestCase {
+ /**
+ * @return array, string, bool}>
+ */
+ public static function settings(): array {
+ return [
+ 'shared' => [DashboardSharedSetting::class, Extension::EVENT_SHARED, true],
+ 'published' => [DashboardPublishedSetting::class, Extension::EVENT_PUBLISHED, true],
+ 'updated' => [DashboardUpdatedSetting::class, Extension::EVENT_UPDATED, false],
+ 'acknowledged' => [DashboardAcknowledgedSetting::class, Extension::EVENT_ACKNOWLEDGED, false],
+ ];
+ }
+
+ private function l10n(): IL10N {
+ $l = $this->createMock(IL10N::class);
+ $l->method('t')->willReturnCallback(static fn (string $text): string => $text);
+ return $l;
+ }
+
+ /**
+ * @param class-string $class
+ */
+ #[DataProvider('settings')]
+ public function testDefaultsAndGroup(string $class, string $type, bool $mail): void {
+ $setting = new $class($this->l10n());
+
+ $this->assertInstanceOf(ActivitySettings::class, $setting);
+ $this->assertSame($type, $setting->getIdentifier());
+ $this->assertSame('launchpad', $setting->getGroupIdentifier());
+ $this->assertSame('LaunchPad', $setting->getGroupName());
+ $this->assertNotSame('', $setting->getName());
+ $this->assertTrue($setting->canChangeStream());
+ $this->assertTrue($setting->isDefaultEnabledStream());
+ $this->assertTrue($setting->canChangeMail());
+ $this->assertSame($mail, $setting->isDefaultEnabledMail());
+ $this->assertGreaterThanOrEqual(0, $setting->getPriority());
+ $this->assertLessThanOrEqual(100, $setting->getPriority());
+ }
+
+ public function testInfoXmlRegistersEverySetting(): void {
+ // Read the file ourselves: under the Nextcloud bootstrap (CI) the
+ // external entity loader is disabled, so simplexml_load_file() on a
+ // path returns false. Loading the string needs no entity loader.
+ $xml = simplexml_load_string((string)file_get_contents(__DIR__ . '/../../../../appinfo/info.xml'));
+ $this->assertNotFalse($xml);
+ $registered = [];
+ foreach ($xml->activity->settings->setting ?? [] as $node) {
+ $registered[] = (string)$node;
+ }
+
+ $expected = array_map(static fn (array $row): string => $row[0], self::settings());
+ sort($expected);
+ sort($registered);
+ $this->assertSame($expected, $registered);
+ foreach ($registered as $class) {
+ $this->assertTrue(class_exists($class), $class . ' is registered in info.xml but does not exist');
+ }
+ }
+
+ public function testIdentifiersAreInTheProviderCatalogue(): void {
+ foreach (self::settings() as [$class, $type]) {
+ $this->assertContains($type, Extension::ALL_EVENTS);
+ }
+ }
+}
diff --git a/tests/Unit/Activity/fixtures/activity-call-sites.json b/tests/Unit/Activity/fixtures/activity-call-sites.json
new file mode 100644
index 000000000..12a01efbb
--- /dev/null
+++ b/tests/Unit/Activity/fixtures/activity-call-sites.json
@@ -0,0 +1,70 @@
+{
+ "bulk": [
+ {
+ "type": "dashboard_updated",
+ "author": "admin",
+ "affected": "admin",
+ "params": {
+ "self": true,
+ "actor": "admin",
+ "dashboard": "Bulk delete (3 dashboards)",
+ "bulkOperation": "delete",
+ "dashboardCount": 3,
+ "durationMs": 12,
+ "dryRun": false
+ },
+ "object": "bulk-delete",
+ "link": ""
+ },
+ {
+ "type": "dashboard_updated",
+ "author": "root",
+ "affected": "root",
+ "params": {
+ "self": true,
+ "actor": "root",
+ "dashboard": "Bulk status (2 dashboards)",
+ "bulkOperation": "status",
+ "dashboardCount": 2,
+ "durationMs": 5,
+ "dryRun": "x",
+ "publicationStatus": "published"
+ },
+ "object": "bulk-status",
+ "link": ""
+ }
+ ],
+ "resync": [
+ {
+ "type": "dashboard_updated",
+ "author": "admin",
+ "affected": "admin",
+ "params": {
+ "self": true,
+ "actor": "admin",
+ "dashboard": "Template resync: Intranet (merge, 4 copies)",
+ "templateResync": true,
+ "templateId": 7,
+ "strategy": "merge",
+ "affectedCount": 4
+ },
+ "object": "template-resync-7",
+ "link": ""
+ }
+ ],
+ "acknowledged": [
+ {
+ "type": "dashboard_acknowledged",
+ "author": "alice",
+ "affected": "alice",
+ "params": {
+ "self": true,
+ "actor": "alice",
+ "dashboard": "ak-1",
+ "version": 1
+ },
+ "object": "ak-1",
+ "link": ""
+ }
+ ]
+}
diff --git a/tests/Unit/Activity/fixtures/activity-publisher.json b/tests/Unit/Activity/fixtures/activity-publisher.json
new file mode 100644
index 000000000..3e5be3e8a
--- /dev/null
+++ b/tests/Unit/Activity/fixtures/activity-publisher.json
@@ -0,0 +1,662 @@
+{
+ "publish to other": {
+ "returns": [
+ true
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_shared",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_shared",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "publish to self": {
+ "returns": [
+ true
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_created",
+ "author": "alice",
+ "affected": "alice",
+ "subject": "dashboard_created",
+ "params": {
+ "self": true,
+ "actor": "alice",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "publish with params": {
+ "returns": [
+ true
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_shared",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_shared",
+ "params": {
+ "self": true,
+ "actor": "mallory",
+ "dashboard": "Other",
+ "recipient": "bob",
+ "role": "view"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "publish long message": {
+ "returns": [
+ true
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_commented",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_commented",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team",
+ "message": "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "publish empty message": {
+ "returns": [
+ true
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_commented",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_commented",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team",
+ "message": ""
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "publish numeric message": {
+ "returns": [
+ true
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_commented",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_commented",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team",
+ "message": 42
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "42",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "publish unknown type": {
+ "returns": [
+ false
+ ],
+ "rows": [],
+ "log": [
+ [
+ "warning",
+ "Unknown LaunchPad activity type rejected",
+ {
+ "type": "dashboard_viewed",
+ "dashboard": "uuid-1"
+ }
+ ]
+ ]
+ },
+ "publish failing manager": {
+ "returns": [
+ false
+ ],
+ "rows": [],
+ "log": [
+ [
+ "error",
+ "LaunchPad Activity publish failed",
+ {
+ "type": "dashboard_deleted",
+ "dashboard": "uuid-1",
+ "recipient": "boom"
+ }
+ ]
+ ]
+ },
+ "reaction debounced": {
+ "returns": [
+ true,
+ false,
+ true
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_reacted",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_reacted",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_reacted",
+ "author": "dave",
+ "affected": "bob",
+ "subject": "dashboard_reacted",
+ "params": {
+ "self": false,
+ "actor": "dave",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "recipients": {
+ "returns": [
+ 3
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_shared",
+ "author": "alice",
+ "affected": "alice",
+ "subject": "dashboard_shared",
+ "params": {
+ "self": true,
+ "actor": "alice",
+ "dashboard": "Team",
+ "role": "edit"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_shared",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_shared",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team",
+ "role": "edit"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_shared",
+ "author": "alice",
+ "affected": "carol",
+ "subject": "dashboard_shared",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team",
+ "role": "edit"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "recipients none": {
+ "returns": [
+ 1
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_shared",
+ "author": "alice",
+ "affected": "alice",
+ "subject": "dashboard_shared",
+ "params": {
+ "self": true,
+ "actor": "alice",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "recipients one fails": {
+ "returns": [
+ 2
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_updated",
+ "author": "alice",
+ "affected": "alice",
+ "subject": "dashboard_updated",
+ "params": {
+ "self": true,
+ "actor": "alice",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_updated",
+ "author": "alice",
+ "affected": "bob",
+ "subject": "dashboard_updated",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": [
+ [
+ "error",
+ "LaunchPad Activity publish failed",
+ {
+ "type": "dashboard_updated",
+ "dashboard": "uuid-1",
+ "recipient": "boom"
+ }
+ ]
+ ]
+ },
+ "group": {
+ "returns": [
+ 3
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_updated",
+ "author": "alice",
+ "affected": "alice",
+ "subject": "dashboard_updated",
+ "params": {
+ "self": true,
+ "actor": "alice",
+ "dashboard": "Team",
+ "k": "v"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_updated",
+ "author": "alice",
+ "affected": "carol",
+ "subject": "dashboard_updated",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team",
+ "k": "v"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_updated",
+ "author": "alice",
+ "affected": "erin",
+ "subject": "dashboard_updated",
+ "params": {
+ "self": false,
+ "actor": "alice",
+ "dashboard": "Team",
+ "k": "v"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": []
+ },
+ "group unknown": {
+ "returns": [
+ 0
+ ],
+ "rows": [],
+ "log": []
+ },
+ "global": {
+ "returns": [
+ 2,
+ 0,
+ 2,
+ 2
+ ],
+ "rows": [
+ {
+ "app": "launchpad",
+ "type": "dashboard_published",
+ "author": "bob",
+ "affected": "alice",
+ "subject": "dashboard_published",
+ "params": {
+ "self": false,
+ "actor": "bob",
+ "dashboard": "Team",
+ "k": "v"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_published",
+ "author": "bob",
+ "affected": "bob",
+ "subject": "dashboard_published",
+ "params": {
+ "self": true,
+ "actor": "bob",
+ "dashboard": "Team",
+ "k": "v"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_updated",
+ "author": "bob",
+ "affected": "alice",
+ "subject": "dashboard_updated",
+ "params": {
+ "self": false,
+ "actor": "bob",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_updated",
+ "author": "bob",
+ "affected": "bob",
+ "subject": "dashboard_updated",
+ "params": {
+ "self": true,
+ "actor": "bob",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-1"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_published",
+ "author": "bob",
+ "affected": "alice",
+ "subject": "dashboard_published",
+ "params": {
+ "self": false,
+ "actor": "bob",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-2"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ },
+ {
+ "app": "launchpad",
+ "type": "dashboard_published",
+ "author": "bob",
+ "affected": "bob",
+ "subject": "dashboard_published",
+ "params": {
+ "self": true,
+ "actor": "bob",
+ "dashboard": "Team"
+ },
+ "object": [
+ "launchpad_dashboard",
+ 0,
+ "uuid-2"
+ ],
+ "link": "https://nc.example/d/1",
+ "message": "",
+ "stamped": true
+ }
+ ],
+ "log": [
+ [
+ "error",
+ "LaunchPad Activity publish failed",
+ {
+ "type": "dashboard_published",
+ "dashboard": "uuid-1",
+ "recipient": "boom"
+ }
+ ],
+ [
+ "debug",
+ "LaunchPad global activity fan-out debounced",
+ {
+ "type": "dashboard_published",
+ "dashboard": "uuid-1"
+ }
+ ],
+ [
+ "error",
+ "LaunchPad Activity publish failed",
+ {
+ "type": "dashboard_updated",
+ "dashboard": "uuid-1",
+ "recipient": "boom"
+ }
+ ],
+ [
+ "error",
+ "LaunchPad Activity publish failed",
+ {
+ "type": "dashboard_published",
+ "dashboard": "uuid-2",
+ "recipient": "boom"
+ }
+ ]
+ ]
+ }
+}
diff --git a/tests/Unit/Activity/fixtures/extension-parse.json b/tests/Unit/Activity/fixtures/extension-parse.json
new file mode 100644
index 000000000..37a593543
--- /dev/null
+++ b/tests/Unit/Activity/fixtures/extension-parse.json
@@ -0,0 +1,212 @@
+{
+ "dashboard_created / self": [
+ "You created dashboard Dashboard A",
+ "You created dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_created.svg"
+ ],
+ "dashboard_created / other": [
+ "alice created dashboard Dashboard A",
+ "alice created dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_created.svg"
+ ],
+ "dashboard_created / fallbacks": [
+ "dave created dashboard Board 9",
+ "dave created dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_created.svg"
+ ],
+ "dashboard_updated / self": [
+ "You updated dashboard Dashboard A",
+ "You updated dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_updated.svg"
+ ],
+ "dashboard_updated / other": [
+ "alice updated dashboard Dashboard A",
+ "alice updated dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_updated.svg"
+ ],
+ "dashboard_updated / fallbacks": [
+ "dave updated dashboard Board 9",
+ "dave updated dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_updated.svg"
+ ],
+ "dashboard_deleted / self": [
+ "You deleted dashboard Dashboard A",
+ "You deleted dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_deleted.svg"
+ ],
+ "dashboard_deleted / other": [
+ "alice deleted dashboard Dashboard A",
+ "alice deleted dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_deleted.svg"
+ ],
+ "dashboard_deleted / fallbacks": [
+ "dave deleted dashboard Board 9",
+ "dave deleted dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_deleted.svg"
+ ],
+ "dashboard_published / self": [
+ "You published dashboard Dashboard A",
+ "You published dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_published.svg"
+ ],
+ "dashboard_published / other": [
+ "alice published dashboard Dashboard A",
+ "alice published dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_published.svg"
+ ],
+ "dashboard_published / fallbacks": [
+ "dave published dashboard Board 9",
+ "dave published dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_published.svg"
+ ],
+ "dashboard_unpublished / self": [
+ "You unpublished dashboard Dashboard A",
+ "You unpublished dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_unpublished.svg"
+ ],
+ "dashboard_unpublished / other": [
+ "alice unpublished dashboard Dashboard A",
+ "alice unpublished dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_unpublished.svg"
+ ],
+ "dashboard_unpublished / fallbacks": [
+ "dave unpublished dashboard Board 9",
+ "dave unpublished dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_unpublished.svg"
+ ],
+ "dashboard_scheduled / self": [
+ "You scheduled dashboard Dashboard A",
+ "You scheduled dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_scheduled.svg"
+ ],
+ "dashboard_scheduled / other": [
+ "alice scheduled dashboard Dashboard A",
+ "alice scheduled dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_scheduled.svg"
+ ],
+ "dashboard_scheduled / fallbacks": [
+ "dave scheduled dashboard Board 9",
+ "dave scheduled dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_scheduled.svg"
+ ],
+ "dashboard_shared / self": [
+ "You shared dashboard Dashboard A with bob",
+ "You shared dashboard Dashboard A with bob",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_shared.svg"
+ ],
+ "dashboard_shared / other": [
+ "alice shared dashboard Dashboard A with bob",
+ "alice shared dashboard Dashboard A with bob",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_shared.svg"
+ ],
+ "dashboard_shared / fallbacks": [
+ "dave shared dashboard Board 9 with ",
+ "dave shared dashboard Board 9 with ",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_shared.svg"
+ ],
+ "dashboard_public_share_created / self": [
+ "You created a public link for dashboard Dashboard A",
+ "You created a public link for dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_public_share_created.svg"
+ ],
+ "dashboard_public_share_created / other": [
+ "alice created a public link for dashboard Dashboard A",
+ "alice created a public link for dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_public_share_created.svg"
+ ],
+ "dashboard_public_share_created / fallbacks": [
+ "dave created a public link for dashboard Board 9",
+ "dave created a public link for dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_public_share_created.svg"
+ ],
+ "dashboard_commented / self": [
+ "You commented on dashboard Dashboard A",
+ "You commented on dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_commented.svg"
+ ],
+ "dashboard_commented / other": [
+ "alice commented on dashboard Dashboard A",
+ "alice commented on dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_commented.svg"
+ ],
+ "dashboard_commented / fallbacks": [
+ "dave commented on dashboard Board 9",
+ "dave commented on dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_commented.svg"
+ ],
+ "dashboard_reacted / self": [
+ "You reacted to dashboard Dashboard A",
+ "You reacted to dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_reacted.svg"
+ ],
+ "dashboard_reacted / other": [
+ "alice reacted to dashboard Dashboard A",
+ "alice reacted to dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_reacted.svg"
+ ],
+ "dashboard_reacted / fallbacks": [
+ "dave reacted to dashboard Board 9",
+ "dave reacted to dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_reacted.svg"
+ ],
+ "dashboard_restored / self": [
+ "You restored dashboard Dashboard A to an earlier version",
+ "You restored dashboard Dashboard A to an earlier version",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_restored.svg"
+ ],
+ "dashboard_restored / other": [
+ "alice restored dashboard Dashboard A to an earlier version",
+ "alice restored dashboard Dashboard A to an earlier version",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_restored.svg"
+ ],
+ "dashboard_restored / fallbacks": [
+ "dave restored dashboard Board 9 to an earlier version",
+ "dave restored dashboard Board 9 to an earlier version",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_restored.svg"
+ ],
+ "dashboard_lock_overridden / self": [
+ "You overrode the lock on dashboard Dashboard A",
+ "You overrode the lock on dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_lock_overridden.svg"
+ ],
+ "dashboard_lock_overridden / other": [
+ "alice overrode the lock on dashboard Dashboard A",
+ "alice overrode the lock on dashboard Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_lock_overridden.svg"
+ ],
+ "dashboard_lock_overridden / fallbacks": [
+ "dave overrode the lock on dashboard Board 9",
+ "dave overrode the lock on dashboard Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_lock_overridden.svg"
+ ],
+ "dashboard_role_changed / self": [
+ "Your role in Dashboard A was changed to editor",
+ "Your role in Dashboard A was changed to editor",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_role_changed.svg"
+ ],
+ "dashboard_role_changed / other": [
+ "alice changed carol's role in Dashboard A to editor",
+ "alice changed carol's role in Dashboard A to editor",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_role_changed.svg"
+ ],
+ "dashboard_role_changed / fallbacks": [
+ "dave changed 's role in Board 9 to ",
+ "dave changed 's role in Board 9 to ",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_role_changed.svg"
+ ],
+ "dashboard_acknowledged / self": [
+ "You acknowledged version 3 of Dashboard A",
+ "You acknowledged version 3 of Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_acknowledged.svg"
+ ],
+ "dashboard_acknowledged / other": [
+ "alice acknowledged version 3 of Dashboard A",
+ "alice acknowledged version 3 of Dashboard A",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_acknowledged.svg"
+ ],
+ "dashboard_acknowledged / fallbacks": [
+ "dave acknowledged Board 9",
+ "dave acknowledged Board 9",
+ "https://nc.example/apps/launchpad/img/activity/dashboard_acknowledged.svg"
+ ]
+}
diff --git a/tests/Unit/BackgroundJob/OrphanedDataCleanupJobTest.php b/tests/Unit/BackgroundJob/OrphanedDataCleanupJobTest.php
new file mode 100644
index 000000000..0d6b8acbd
--- /dev/null
+++ b/tests/Unit/BackgroundJob/OrphanedDataCleanupJobTest.php
@@ -0,0 +1,93 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\BackgroundJob;
+
+use OCA\LaunchPad\BackgroundJob\CleanupJobTools;
+use OCA\LaunchPad\BackgroundJob\OrphanedDataCleanupJob;
+use OCA\LaunchPad\Db\CleanupResult;
+use OCA\LaunchPad\Service\Cleanup\CategoryRegistryService;
+use OCA\LaunchPad\Service\OrphanedDataCleanupService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IAppConfig;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class OrphanedDataCleanupJobTest extends TestCase {
+
+ /**
+ * Run the job with the given stored config; record the purge and log calls.
+ *
+ * @param string $stored The stored categories config.
+ *
+ * @return list
+ */
+ private function runWith(string $stored): array {
+ $log = [];
+ $config = $this->createMock(IAppConfig::class);
+ $config->method('getValueString')->willReturn($stored);
+ $logger = $this->createMock(LoggerInterface::class);
+ foreach (['info', 'warning'] as $level) {
+ $logger->method($level)->willReturnCallback(function (string $message) use ($level, &$log): void {
+ $log[] = $level . ' ' . $message;
+ });
+ }
+
+ $registry = $this->createMock(CategoryRegistryService::class);
+ $registry->method('getAutoSafeCategoryNames')->willReturn(['locks']);
+ $registry->method('getCategoryNames')->willReturn(['locks', 'shares', 'rules']);
+
+ $result = $this->createMock(CleanupResult::class);
+ $result->method('getTotalRows')->willReturn(4);
+ $result->method('getDurationMs')->willReturn(9);
+ $service = $this->createMock(OrphanedDataCleanupService::class);
+ $service->method('purge')->willReturnCallback(function (array $categoryNames) use ($result, &$log): CleanupResult {
+ $log[] = 'purge ' . implode(',', $categoryNames);
+ return $result;
+ });
+
+ $job = new OrphanedDataCleanupJob(
+ time: $this->createMock(ITimeFactory::class),
+ cleanupService: $service,
+ registry: $registry,
+ tools: new CleanupJobTools(appConfig: $config, logger: $logger)
+ );
+ $run = new \ReflectionMethod($job, 'run');
+ $run->setAccessible(true);
+ $run->invoke($job, null);
+
+ return $log;
+ }
+
+ public function testCategoriesThePurgeRuns(): void {
+ $this->assertSame(['purge locks', 'info launchpad.cleanup.job_run rows=4 duration_ms=9 categories=locks'], $this->runWith(''));
+ $this->assertSame(
+ ['warning launchpad.cleanup.job_config_invalid raw="x"', 'purge locks', 'info launchpad.cleanup.job_run rows=4 duration_ms=9 categories=locks'],
+ $this->runWith('"x"')
+ );
+ $this->assertSame(
+ ['purge rules,shares', 'info launchpad.cleanup.job_run rows=4 duration_ms=9 categories=rules,shares'],
+ $this->runWith('["rules","",5,"nope","shares"]')
+ );
+ $this->assertSame(['info launchpad.cleanup.job_skipped reason=no_categories_enabled'], $this->runWith('["nope"]'));
+ }
+}
diff --git a/tests/Unit/BackgroundJob/PurgeViewsJobTest.php b/tests/Unit/BackgroundJob/PurgeViewsJobTest.php
index f206c53e6..9f99deede 100644
--- a/tests/Unit/BackgroundJob/PurgeViewsJobTest.php
+++ b/tests/Unit/BackgroundJob/PurgeViewsJobTest.php
@@ -23,6 +23,7 @@
namespace Unit\BackgroundJob;
+use OCA\LaunchPad\BackgroundJob\PurgeMappers;
use OCA\LaunchPad\BackgroundJob\PurgeViewsJob;
use OCA\LaunchPad\Db\DashboardViewMapper;
use OCA\LaunchPad\Db\TileClickMapper;
@@ -60,9 +61,11 @@ protected function setUp(): void {
$this->job = new PurgeViewsJob(
time: $this->time,
analyticsService: $this->analyticsService,
- viewMapper: $this->viewMapper,
- tileClickMapper: $this->tileClickMapper,
- logger: $this->logger,
+ mappers: new PurgeMappers(
+ viewMapper: $this->viewMapper,
+ tileClickMapper: $this->tileClickMapper
+ ),
+ logger: $this->logger
);
}
diff --git a/tests/Unit/BackgroundJob/TemplateResyncJobTest.php b/tests/Unit/BackgroundJob/TemplateResyncJobTest.php
index cb743a12b..3b8488a19 100644
--- a/tests/Unit/BackgroundJob/TemplateResyncJobTest.php
+++ b/tests/Unit/BackgroundJob/TemplateResyncJobTest.php
@@ -111,4 +111,37 @@ private function invokeRun(array $argument): void {
$reflection->setAccessible(true);
$reflection->invoke($this->job, $argument);
}
+
+ /**
+ * The exact log line of every outcome: skipped, completed and failed.
+ *
+ * @return void
+ */
+ public function testTheLogLineOfEveryOutcome(): void {
+ $logged = [];
+ foreach (['warning', 'info', 'error'] as $level) {
+ $this->logger->method($level)->willReturnCallback(function (string $message, array $context = []) use ($level, &$logged): void {
+ $logged[] = [$level, $message, $context];
+ });
+ }
+
+ $failure = new \RuntimeException('boom');
+ $this->resyncService->method('applyResync')->willReturnOnConsecutiveCalls(
+ ['affectedCount' => 3, 'totalCopies' => 5],
+ $this->throwException($failure)
+ );
+
+ $this->invokeRun(['templateId' => 2, 'strategy' => '', 'actingAdminId' => 'a']);
+ $this->invokeRun(['templateId' => 2, 'strategy' => 'merge', 'actingAdminId' => 'a']);
+ $this->invokeRun(['templateId' => '7', 'strategy' => 'overwrite', 'actingAdminId' => 'a']);
+
+ $this->assertSame(
+ [
+ ['warning', 'launchpad.template_resync.job_skipped reason=invalid_arguments', ['argument' => ['templateId' => 2, 'strategy' => '', 'actingAdminId' => 'a']]],
+ ['info', 'launchpad.template_resync.job_completed template=2 strategy=merge affected=3 total=5', []],
+ ['error', 'launchpad.template_resync.job_failed', ['templateId' => 7, 'strategy' => 'overwrite', 'exception' => $failure]],
+ ],
+ $logged
+ );
+ }
}
diff --git a/tests/Unit/Command/CleanupPurgeCommandTest.php b/tests/Unit/Command/CleanupPurgeCommandTest.php
new file mode 100644
index 000000000..e9025f7d1
--- /dev/null
+++ b/tests/Unit/Command/CleanupPurgeCommandTest.php
@@ -0,0 +1,86 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\CleanupPurgeCommand;
+use OCA\LaunchPad\Db\CleanupResult;
+use OCA\LaunchPad\Service\Cleanup\CategoryRegistryService;
+use OCA\LaunchPad\Service\OrphanedDataCleanupService;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The purge command, case by case.
+ */
+class CleanupPurgeCommandTest extends TestCase {
+
+ /**
+ * Every case exits, purges and prints as recorded.
+ *
+ * @return void
+ */
+ public function testRunsMatchTheRecording(): void {
+ $cases = [
+ 'unknown category' => [['--category' => 'nope', '--yes' => true], []],
+ 'one category' => [['--category' => 'placements', '--yes' => true], []],
+ 'all categories dry run' => [['--dry-run' => true, '--yes' => true], []],
+ 'confirmed' => [[], ['y']],
+ 'declined' => [[], ['n']],
+ 'with skipped' => [['--yes' => true], [], ['reactions' => 'table missing']],
+ ];
+
+ $actual = [];
+ foreach ($cases as $name => $case) {
+ [$options, $answers] = $case;
+ $skipped = ($case[2] ?? []);
+ $purges = [];
+
+ $registry = $this->createMock(CategoryRegistryService::class);
+ $registry->method('getCategoryNames')->willReturn(['placements', 'shares', 'reactions']);
+ $registry->method('getCategoryByName')->willReturnCallback(fn (string $name) => in_array($name, ['placements', 'shares', 'reactions'], true) ? $this->createMock(\OCA\LaunchPad\Service\Cleanup\CleanupCategoryInterface::class) : null);
+
+ $service = $this->createMock(OrphanedDataCleanupService::class);
+ $service->method('purge')->willReturnCallback(
+ static function (array $categoryNames, bool $dryRun, ?string $userId, string $source) use (&$purges, $skipped): CleanupResult {
+ $purges[] = [$categoryNames, $dryRun, $userId, $source];
+ return new CleanupResult(byCategory: ['placements' => 2, 'shares' => 1], totalRows: 3, durationMs: 5, dryRun: $dryRun, scannedAt: '2026-01-01T00:00:00Z', skipped: $skipped);
+ }
+ );
+
+ $command = new CleanupPurgeCommand(cleanupService: $service, registry: $registry);
+ $command->setApplication(new Application());
+ $tester = new CommandTester($command);
+ $tester->setInputs($answers);
+ $exit = $tester->execute($options, ['interactive' => $answers !== []]);
+
+ $actual[$name] = ['exit' => $exit, 'purges' => $purges, 'output' => explode("\n", trim($tester->getDisplay()))];
+ }
+
+ $fixture = __DIR__.'/fixtures/cleanup-purge-command.json';
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents($fixture, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents($fixture), true), $actual);
+ }//end testRunsMatchTheRecording()
+}//end class
diff --git a/tests/Unit/Command/CleanupScanCommandTest.php b/tests/Unit/Command/CleanupScanCommandTest.php
new file mode 100644
index 000000000..f2850f09d
--- /dev/null
+++ b/tests/Unit/Command/CleanupScanCommandTest.php
@@ -0,0 +1,65 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\CleanupScanCommand;
+use OCA\LaunchPad\Db\CleanupResult;
+use OCA\LaunchPad\Service\OrphanedDataCleanupService;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+class CleanupScanCommandTest extends TestCase {
+
+ /**
+ * @param array $counts Rows per category.
+ * @param list $skipped Skipped categories.
+ *
+ * @return array{0: int, 1: string}
+ */
+ private function scan(array $counts, array $skipped): array {
+ $result = $this->createMock(CleanupResult::class);
+ $result->method('getByCategory')->willReturn($counts);
+ $result->method('getTotalRows')->willReturn(array_sum($counts));
+ $result->method('getSkipped')->willReturn($skipped);
+ $result->method('getDurationMs')->willReturn(12);
+ $service = $this->createMock(OrphanedDataCleanupService::class);
+ $service->method('scan')->willReturn($result);
+
+ $tester = new CommandTester(new CleanupScanCommand($service));
+ $code = $tester->execute([]);
+ return [$code, $tester->getDisplay()];
+ }
+
+ public function testScanOutput(): void {
+ [$code, $out] = $this->scan(['locks' => 2, 'shares' => 0], ['tiles']);
+ $this->assertSame(1, $code);
+ $this->assertSame(
+ "+----------+-------+\n| Category | Count |\n+----------+-------+\n| locks | 2 |\n| shares | 0 |\n| TOTAL | 2 |\n+----------+-------+\n"
+ . "Skipped categories (feature unavailable): tiles\nScan completed in 12ms.\n",
+ $out
+ );
+
+ [$code, $out] = $this->scan(['locks' => 0], []);
+ $this->assertSame(0, $code);
+ $this->assertStringEndsWith("| TOTAL | 0 |\n+----------+-------+\nScan completed in 12ms.\n", $out);
+ }
+}
diff --git a/tests/Unit/Command/CommandAuditTrailTest.php b/tests/Unit/Command/CommandAuditTrailTest.php
new file mode 100644
index 000000000..7b9c59433
--- /dev/null
+++ b/tests/Unit/Command/CommandAuditTrailTest.php
@@ -0,0 +1,76 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\I18nExportStringsCommand;
+use OCA\LaunchPad\Service\CommandService;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The audit line of one command run.
+ */
+class CommandAuditTrailTest extends TestCase {
+
+ /**
+ * A run is audited under its own name, with the argv tail, exit code and user.
+ *
+ * @return void
+ */
+ public function testARunIsAuditedUnderItsOwnName(): void {
+ $logged = [];
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->method('info')->willReturnCallback(
+ static function (string $message, array $context=[]) use (&$logged): void {
+ $logged[] = [$message, $context];
+ }
+ );
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('admin');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+
+ $argv = $_SERVER['argv'] ?? null;
+ $_SERVER['argv'] = ['occ', 'launchpad:i18n:export-strings', '--dry', 'x y'];
+ try {
+ $root = sys_get_temp_dir().'/launchpad-audit-'.bin2hex(random_bytes(4));
+ mkdir($root.'/appinfo', 0777, true);
+ file_put_contents($root.'/appinfo/signature.json', '{"hashes":{}}');
+ $tester = new CommandTester(new I18nExportStringsCommand(new CommandService($logger), $session, $root));
+ $tester->execute([]);
+ exec('rm -rf '.escapeshellarg($root));
+ } finally {
+ $_SERVER['argv'] = $argv;
+ }
+
+ $this->assertCount(1, $logged);
+ [$line, $context] = $logged[0];
+ $this->assertMatchesRegularExpression(
+ '/^\[launchpad\] cli i18n:export-strings --dry x y exitCode='.CommandService::EXIT_INVALID_ARGS.' durationMs=\d+ byUser=admin$/',
+ $line
+ );
+ $this->assertSame(['app' => 'launchpad', 'command' => 'i18n:export-strings', 'exitCode' => CommandService::EXIT_INVALID_ARGS, 'byUser' => 'admin'], array_diff_key($context, ['durationMs' => true]));
+ }//end testARunIsAuditedUnderItsOwnName()
+}//end class
diff --git a/tests/Unit/Command/CommandErrorGoldenTest.php b/tests/Unit/Command/CommandErrorGoldenTest.php
new file mode 100644
index 000000000..d9dc7bcfa
--- /dev/null
+++ b/tests/Unit/Command/CommandErrorGoldenTest.php
@@ -0,0 +1,192 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\CommandBase;
+use OCA\LaunchPad\Command\DashboardDeleteCommand;
+use OCA\LaunchPad\Command\DeleteCommandStores;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\CommandService;
+use OCA\LaunchPad\Service\DashboardTreeService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\BufferedOutput;
+use Symfony\Component\Console\Output\ConsoleOutput;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Output\StreamOutput;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The printed failures, case by case.
+ *
+ * @covers \OCA\LaunchPad\Command\CommandBase
+ * @covers \OCA\LaunchPad\Command\DashboardDeleteCommand
+ * @uses \OCA\LaunchPad\Command\DeleteCommandStores
+ * @uses \OCA\LaunchPad\Service\CommandService
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ */
+class CommandErrorGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/command-error.json';
+
+ /**
+ * A command that fails with the error it is given.
+ *
+ * @param array $error The exit code, code, message and context.
+ *
+ * @return CommandBase
+ */
+ private function probe(array $error): CommandBase {
+ return new class (new CommandService(new NullLogger()), $this->createMock(IUserSession::class), $error) extends CommandBase {
+ /**
+ * @param CommandService $commandService The service.
+ * @param IUserSession $userSession The session.
+ * @param array $error The error to emit.
+ */
+ public function __construct(CommandService $commandService, IUserSession $userSession, private array $error) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }
+
+ /**
+ * @return void
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:probe');
+ }
+
+ /**
+ * @param InputInterface $input The input.
+ * @param OutputInterface $output The output.
+ *
+ * @return int
+ */
+ protected function handle(InputInterface $input, OutputInterface $output): int {
+ return $this->emitError(input: $input, output: $output, error: $this->error);
+ }
+ };
+ }//end probe()
+
+ /**
+ * Run a command and capture stdout, stderr and the exit code.
+ *
+ * @param CommandBase $command The command.
+ * @param array $input The input.
+ *
+ * @return array
+ */
+ private static function runCommand(CommandBase $command, array $input): array {
+ $stdout = new StreamOutput(fopen('php://memory', 'w+'));
+ $output = new class ($stdout) extends ConsoleOutput {
+ private BufferedOutput $err;
+
+ /**
+ * @param StreamOutput $stdout The stdout stream.
+ */
+ public function __construct(private StreamOutput $stdout) {
+ parent::__construct();
+ $this->err = new BufferedOutput();
+ }
+
+ /**
+ * @return OutputInterface
+ */
+ public function getErrorOutput(): OutputInterface {
+ return $this->err;
+ }
+
+ /**
+ * @param string $message The message.
+ * @param bool $newline Newline.
+ *
+ * @return void
+ */
+ protected function doWrite(string $message, bool $newline): void {
+ $this->stdout->doWrite($message, $newline);
+ }
+ };
+ $command->setApplication(null);
+ $exit = $command->run(new ArrayInput($input), $output);
+ rewind($stdout->getStream());
+
+ return ['exit' => $exit, 'stdout' => stream_get_contents($stdout->getStream()), 'stderr' => $output->getErrorOutput()->fetch()];
+ }//end runCommand()
+
+ /**
+ * Every case prints the recorded output.
+ *
+ * @return void
+ */
+ public function testPrintedFailuresMatchTheRecordedFixture(): void {
+ $errors = [
+ 'with context' => ['exitCode' => CommandService::EXIT_NOT_FOUND, 'code' => 'NOT_FOUND', 'message' => 'Dashboard not found', 'context' => ['uuid' => 'u-1']],
+ 'without context' => ['exitCode' => CommandService::EXIT_INVALID_ARGS, 'code' => 'ABORTED', 'message' => 'Deletion aborted by user.'],
+ 'null context' => ['exitCode' => 3, 'code' => 'X', 'message' => 'm', 'context' => null],
+ ];
+ $actual = [];
+ foreach ($errors as $name => $error) {
+ foreach (['plain' => [], 'json' => ['--json' => true], 'quiet' => ['--quiet' => true]] as $mode => $flags) {
+ $actual['probe '.$name.' '.$mode] = self::runCommand($this->probe($error), $flags);
+ }
+ }
+
+ $mapper = $this->createMock(DashboardMapper::class);
+ $mapper->method('findByUuid')->willReturnCallback(
+ static function (string $uuid): Dashboard {
+ if ($uuid === 'missing') {
+ throw new DoesNotExistException('no');
+ }
+ $dashboard = new Dashboard();
+ $dashboard->setUuid($uuid);
+ return $dashboard;
+ }
+ );
+ $mapper->method('countChildrenByParent')->willReturn(2);
+ $delete = new DashboardDeleteCommand(
+ commandService: new CommandService(new NullLogger()),
+ userSession: $this->createMock(IUserSession::class),
+ stores: new DeleteCommandStores(
+ dashboardMapper: $mapper,
+ placementMapper: $this->createMock(WidgetPlacementMapper::class),
+ treeService: $this->createMock(DashboardTreeService::class)
+ )
+ );
+ foreach (['missing', 'parent'] as $uuid) {
+ foreach (['plain' => [], 'json' => ['--json' => true]] as $mode => $flags) {
+ $actual['delete '.$uuid.' '.$mode] = self::runCommand($delete, ['uuid' => $uuid, '--no-interaction' => true] + $flags);
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testPrintedFailuresMatchTheRecordedFixture()
+}//end class
diff --git a/tests/Unit/Command/DashboardDeleteCommandTest.php b/tests/Unit/Command/DashboardDeleteCommandTest.php
new file mode 100644
index 000000000..9e5ff5736
--- /dev/null
+++ b/tests/Unit/Command/DashboardDeleteCommandTest.php
@@ -0,0 +1,107 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\DashboardDeleteCommand;
+use OCA\LaunchPad\Command\DeleteCommandStores;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\CommandService;
+use OCA\LaunchPad\Service\DashboardTreeService;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The delete command's successful paths.
+ */
+class DashboardDeleteCommandTest extends TestCase {
+
+ /**
+ * Every case exits, deletes and prints as recorded.
+ *
+ * @return void
+ */
+ public function testDeletionsMatchTheRecording(): void {
+ $cases = [
+ 'leaf' => ['leaf', []],
+ 'leaf json' => ['leaf', ['--json' => true]],
+ 'parent cascade' => ['parent', ['--cascade' => true]],
+ 'parent cascade json' => ['parent', ['--cascade' => true, '--json' => true]],
+ 'parent without cascade' => ['parent', []],
+ ];
+
+ $actual = [];
+ foreach ($cases as $name => [$uuid, $flags]) {
+ $writes = [];
+ $mapper = $this->createMock(DashboardMapper::class);
+ $mapper->method('findByUuid')->willReturnCallback(
+ static function (string $uuid): Dashboard {
+ $dashboard = new Dashboard();
+ $dashboard->setId(4);
+ $dashboard->setUuid($uuid);
+ $dashboard->setName('Board');
+ return $dashboard;
+ }
+ );
+ $mapper->method('countChildrenByParent')->willReturnCallback(static fn (string $uuid): int => ($uuid === 'parent') ? 2 : 0);
+ $mapper->method('delete')->willReturnCallback(
+ static function (Dashboard $dashboard) use (&$writes): Dashboard {
+ $writes[] = 'delete '.$dashboard->getUuid();
+ return $dashboard;
+ }
+ );
+ $placements = $this->createMock(WidgetPlacementMapper::class);
+ $placements->method('deleteByDashboardId')->willReturnCallback(
+ static function (int $id) use (&$writes): int {
+ $writes[] = 'placements '.$id;
+ return 1;
+ }
+ );
+ $tree = $this->createMock(DashboardTreeService::class);
+ $tree->method('deleteSubtree')->willReturnCallback(
+ static function (Dashboard $dashboard) use (&$writes): int {
+ $writes[] = 'subtree '.$dashboard->getUuid();
+ return 3;
+ }
+ );
+
+ $command = new DashboardDeleteCommand(
+ commandService: new CommandService(new NullLogger()),
+ userSession: $this->createMock(IUserSession::class),
+ stores: new DeleteCommandStores(dashboardMapper: $mapper, placementMapper: $placements, treeService: $tree)
+ );
+ $tester = new CommandTester($command);
+ $exit = $tester->execute(['uuid' => $uuid, '--no-interaction' => true] + $flags, ['interactive' => false]);
+
+ $actual[$name] = ['exit' => $exit, 'writes' => $writes, 'output' => trim($tester->getDisplay())];
+ }
+
+ $fixture = __DIR__.'/fixtures/dashboard-delete-command.json';
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents($fixture, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents($fixture), true), $actual);
+ }//end testDeletionsMatchTheRecording()
+}//end class
diff --git a/tests/Unit/Command/DashboardDumpCommandsGoldenTest.php b/tests/Unit/Command/DashboardDumpCommandsGoldenTest.php
new file mode 100644
index 000000000..0bebdbd55
--- /dev/null
+++ b/tests/Unit/Command/DashboardDumpCommandsGoldenTest.php
@@ -0,0 +1,147 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\DashboardDebugShareCommand;
+use OCA\LaunchPad\Command\DashboardShowCommand;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\DashboardShare;
+use OCA\LaunchPad\Db\DashboardShareMapper;
+use OCA\LaunchPad\Db\WidgetPlacement;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\CommandService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The printed output of the two dump commands, case by case.
+ */
+class DashboardDumpCommandsGoldenTest extends TestCase {
+
+ private const UUID = 'a1b2c3d4-e5f6-4789-abcd-ef1234567890';
+
+ /**
+ * A dashboard mapper that knows one dashboard.
+ *
+ * @return DashboardMapper
+ */
+ private function dashboards(): DashboardMapper {
+ $dashboard = $this->getMockBuilder(Dashboard::class)->onlyMethods(['jsonSerialize'])->getMock();
+ $dashboard->setId(7);
+ $dashboard->method('jsonSerialize')->willReturn(['id' => 7, 'name' => 'Home']);
+
+ $mapper = $this->createMock(DashboardMapper::class);
+ $mapper->method('findByUuid')->willReturnCallback(
+ static function (string $uuid) use ($dashboard): Dashboard {
+ if ($uuid !== self::UUID) {
+ throw new DoesNotExistException('missing');
+ }
+
+ return $dashboard;
+ }
+ );
+
+ return $mapper;
+ }//end dashboards()
+
+ /**
+ * Run a command and capture exit code and display.
+ *
+ * @param object $command The command.
+ * @param array $input The input.
+ *
+ * @return array{0: int, 1: string}
+ */
+ private static function runCommand(object $command, array $input): array {
+ $tester = new CommandTester($command);
+ $code = $tester->execute($input);
+ return [$code, $tester->getDisplay()];
+ }//end runCommand()
+
+ public function testShowCommandOutput(): void {
+ $placement = $this->createMock(WidgetPlacement::class);
+ $placement->method('jsonSerialize')->willReturn(['id' => 1, 'widgetId' => 'w/x']);
+ $placements = $this->createMock(WidgetPlacementMapper::class);
+ $placements->method('findByDashboardId')->with(7)->willReturn([$placement]);
+
+ $command = new DashboardShowCommand(
+ new CommandService(new NullLogger()),
+ $this->createMock(IUserSession::class),
+ $this->dashboards(),
+ $placements
+ );
+
+ $pretty = "{\n \"dashboard\": {\n \"id\": 7,\n \"name\": \"Home\"\n },\n"
+ ." \"widgets\": [\n {\n \"id\": 1,\n \"widgetId\": \"w/x\"\n }\n ]\n}\n";
+ $this->assertSame([0, $pretty], self::runCommand($command, ['uuid' => self::UUID]));
+ $this->assertSame([0, ''], self::runCommand($command, ['uuid' => self::UUID, '--quiet' => true]));
+
+ [$code, $json] = self::runCommand($command, ['uuid' => self::UUID, '--json' => true]);
+ $this->assertSame(0, $code);
+ $this->assertSame(
+ ['data' => ['dashboard' => ['id' => 7, 'name' => 'Home'], 'widgets' => [['id' => 1, 'widgetId' => 'w/x']]]],
+ array_intersect_key(json_decode($json, true), ['data' => 1])
+ );
+
+ [$code, $invalid] = self::runCommand($command, ['uuid' => 'nope', '--json' => true]);
+ $this->assertSame(CommandService::EXIT_INVALID_ARGS, $code);
+ $this->assertStringContainsString('INVALID_ARGUMENT', $invalid);
+ $this->assertStringContainsString("Invalid UUID format: 'nope'", $invalid);
+ $this->assertStringContainsString('"providedValue":"nope"', $invalid);
+
+ [$code, $missing] = self::runCommand($command, ['uuid' => 'b1b2c3d4-e5f6-4789-abcd-ef1234567890', '--json' => true]);
+ $this->assertNotSame(0, $code);
+ $this->assertStringContainsString('b1b2c3d4-e5f6-4789-abcd-ef1234567890', $missing);
+ }
+
+ public function testDebugShareCommandOutput(): void {
+ $share = $this->createMock(DashboardShare::class);
+ $share->method('jsonSerialize')->willReturn(['shareType' => 'user', 'shareWith' => 'bob']);
+ $shares = $this->createMock(DashboardShareMapper::class);
+ $shares->method('findByDashboardId')->with(7)->willReturn([$share]);
+
+ $command = new DashboardDebugShareCommand(
+ new CommandService(new NullLogger()),
+ $this->createMock(IUserSession::class),
+ $this->dashboards(),
+ $shares
+ );
+
+ $pretty = "{\n \"uuid\": \"".self::UUID."\",\n \"shares\": [\n {\n \"shareType\": \"user\",\n"
+ ." \"shareWith\": \"bob\"\n }\n ],\n \"locked\": false,\n \"lockedBy\": null,\n"
+ ." \"lockedAt\": null,\n \"versionCount\": 0,\n \"viewCount\": 0\n}\n";
+ $this->assertSame([0, $pretty], self::runCommand($command, ['uuid' => self::UUID]));
+ $this->assertSame([0, ''], self::runCommand($command, ['uuid' => self::UUID, '--quiet' => true]));
+
+ [$code, $json] = self::runCommand($command, ['uuid' => self::UUID, '--json' => true]);
+ $this->assertSame(0, $code);
+ $this->assertSame(['shareType' => 'user', 'shareWith' => 'bob'], json_decode($json, true)['data']['shares'][0]);
+
+ [$code, $missing] = self::runCommand($command, ['uuid' => 'other', '--json' => true]);
+ $this->assertNotSame(0, $code);
+ $this->assertStringContainsString('other', $missing);
+ }
+}
diff --git a/tests/Unit/Command/DashboardListCommandGoldenTest.php b/tests/Unit/Command/DashboardListCommandGoldenTest.php
new file mode 100644
index 000000000..273913c1f
--- /dev/null
+++ b/tests/Unit/Command/DashboardListCommandGoldenTest.php
@@ -0,0 +1,125 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\DashboardListCommand;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Service\CommandService;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The printed output of the dashboard list command, case by case.
+ */
+class DashboardListCommandGoldenTest extends TestCase {
+
+ /** @var list */
+ private array $log = [];
+
+ private static function dashboard(string $uuid, string $status, ?string $owner = null): Dashboard {
+ $dashboard = new Dashboard();
+ $dashboard->setUuid($uuid);
+ $dashboard->setName('A dashboard with a very long name ' . $uuid);
+ $dashboard->setPublicationStatus($status);
+ $dashboard->setUserId($owner);
+ return $dashboard;
+ }
+
+ /**
+ * @param array $input The input.
+ *
+ * @return array{0: int, 1: string, 2: list}
+ */
+ private function runList(array $input): array {
+ $this->log = [];
+ $mapper = $this->createMock(DashboardMapper::class);
+ $mapper->method('findByUserId')->willReturnCallback(function (string $userId): array {
+ $this->log[] = 'byUser ' . $userId;
+ return ['k' => self::dashboard('u1', 'draft', $userId)];
+ });
+ $mapper->method('findByGroup')->willReturnCallback(function (string $groupId): array {
+ $this->log[] = 'byGroup ' . $groupId;
+ return [];
+ });
+ $mapper->method('findAdminTemplates')->willReturnCallback(function (): array {
+ $this->log[] = 'templates';
+ return [self::dashboard('t1', 'published')];
+ });
+ $mapper->method('findByParent')->willReturnCallback(function (?string $parentUuid): array {
+ $this->log[] = 'roots';
+ return [self::dashboard('r1', 'draft', 'bob'), self::dashboard('', 'published')];
+ });
+ $mapper->method('findDescendants')->willReturnCallback(function (string $ancestorUuid): array {
+ $this->log[] = 'descendants ' . $ancestorUuid;
+ return [self::dashboard('c1', 'published', 'bob')];
+ });
+ $users = $this->createMock(IUserManager::class);
+ $users->method('userExists')->willReturnCallback(fn (string $uid): bool => $uid === 'alice');
+
+ $tester = new CommandTester(new DashboardListCommand(
+ new CommandService(new NullLogger()),
+ $this->createMock(IUserSession::class),
+ $mapper,
+ $users
+ ));
+ $code = $tester->execute($input);
+ return [$code, $tester->getDisplay(), $this->log];
+ }
+
+ public function testRefusedFilters(): void {
+ [$code, $out, $log] = $this->runList(['--status' => 'gone', '--user' => 'nobody', '--json' => true]);
+ $this->assertSame(CommandService::EXIT_INVALID_ARGS, $code);
+ $this->assertStringContainsString('"message":"Invalid --status value: gone"', $out);
+ $this->assertStringContainsString('"context":{"allowed":["draft","published","scheduled"]}', $out);
+ $this->assertSame([], $log);
+
+ [$code, $out] = $this->runList(['--user' => 'nobody', '--json' => true]);
+ $this->assertSame(CommandService::EXIT_NOT_FOUND, $code);
+ $this->assertStringContainsString('"message":"User not found: nobody"', $out);
+ $this->assertStringContainsString('"context":{"userId":"nobody"}', $out);
+ }
+
+ public function testScopesAndOutput(): void {
+ $header = sprintf('%-36s %-20s %-14s %-12s %s', 'UUID', 'NAME', 'TYPE', 'STATUS', 'OWNER');
+ $this->assertSame(
+ [0, $header . "\n"
+ . sprintf('%-36s %-20s %-14s %-12s %s', 't1', 'A dashboard with a..', 'user', 'published', '') . "\n"
+ . sprintf('%-36s %-20s %-14s %-12s %s', 'c1', 'A dashboard with a..', 'user', 'published', 'bob') . "\n"
+ . sprintf('%-36s %-20s %-14s %-12s %s', '', 'A dashboard with a..', 'user', 'published', '') . "\n",
+ ['templates', 'roots', 'descendants r1']],
+ $this->runList(['--status' => 'published'])
+ );
+ $this->assertSame([0, "No dashboards match the supplied filters.\n", ['byGroup staff']], $this->runList(['--group' => 'staff']));
+ $this->assertSame([0, '', ['byUser alice']], $this->runList(['--user' => 'alice', '--quiet' => true]));
+
+ [$code, $out, $log] = $this->runList(['--user' => 'alice', '--group' => 'staff', '--json' => true]);
+ $this->assertSame([0, ['byUser alice']], [$code, $log]);
+ $this->assertStringContainsString(
+ '"data":{"dashboards":[{"uuid":"u1","name":"A dashboard with a very long name u1","type":"user","owner":"alice","group":"","publicationStatus":"draft"}],"count":1}',
+ $out
+ );
+ }
+}
diff --git a/tests/Unit/Command/DemoShowcasesCommandsGoldenTest.php b/tests/Unit/Command/DemoShowcasesCommandsGoldenTest.php
new file mode 100644
index 000000000..0141b3006
--- /dev/null
+++ b/tests/Unit/Command/DemoShowcasesCommandsGoldenTest.php
@@ -0,0 +1,89 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\DemoShowcasesInstallCommand;
+use OCA\LaunchPad\Command\DemoShowcasesListCommand;
+use OCA\LaunchPad\Exception\ShowcaseNotFoundException;
+use OCA\LaunchPad\Service\DemoShowcasesService;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The printed output of the two showcase commands, case by case.
+ */
+class DemoShowcasesCommandsGoldenTest extends TestCase {
+
+ public function testInstallOutput(): void {
+ $service = $this->createMock(DemoShowcasesService::class);
+ $service->method('installShowcase')->willReturnCallback(static function (string $showcaseId, string $lang, bool $force): array {
+ if ($showcaseId === 'nope') {
+ throw new ShowcaseNotFoundException(message: 'gone');
+ }
+
+ if ($showcaseId === 'broken') {
+ throw new \RuntimeException('disk');
+ }
+
+ return [
+ 'installedDashboardUuid' => 'u-' . $lang,
+ 'skippedWidgets' => ($force === true) ? ['w1', 'w2'] : [],
+ 'alreadyInstalled' => ($showcaseId === 'old'),
+ ];
+ });
+ $tester = new CommandTester(new DemoShowcasesInstallCommand($service));
+
+ $outputs = [];
+ foreach ([['id' => 'nope'], ['id' => 'broken'], ['id' => 'old'], ['id' => 'new', '--lang' => 'en', '--force' => true], ['id' => 'new']] as $input) {
+ $code = $tester->execute($input);
+ $outputs[] = [$code, $tester->getDisplay()];
+ }
+
+ $this->assertSame(
+ [
+ [1, "Showcase not found: nope\n"],
+ [1, "Installation failed: disk\n"],
+ [0, "Showcase old is already installed (UUID: u-nl).\nUse --force to reinstall.\n"],
+ [0, "Installed dashboard u-en\nSkipped unknown widgets: w1, w2\n"],
+ [0, "Installed dashboard u-nl\n"],
+ ],
+ $outputs
+ );
+ }
+
+ public function testListOutput(): void {
+ $service = $this->createMock(DemoShowcasesService::class);
+ $service->method('getAvailableShowcases')->willReturn([
+ ['id' => 'de-bron', 'name' => 'De Bron', 'language' => 'nl', 'isInstalled' => true, 'installedDashboardUuid' => 'u1'],
+ ['id' => 'harbour', 'name' => 'Harbour', 'language' => 'en', 'isInstalled' => false, 'installedDashboardUuid' => null],
+ ]);
+ $tester = new CommandTester(new DemoShowcasesListCommand($service));
+
+ $this->assertSame(0, $tester->execute([]));
+ $table = $tester->getDisplay();
+ $this->assertStringContainsString('| de-bron | De Bron | nl | Installed | u1 |', $table);
+ $this->assertStringContainsString('| harbour | Harbour | en | Not installed | - |', $table);
+
+ $this->assertSame(0, $tester->execute(['--json' => true]));
+ $this->assertStringStartsWith("[\n {\n \"id\": \"de-bron\",", $tester->getDisplay());
+ }
+}
diff --git a/tests/Unit/Command/ExportCommandGoldenTest.php b/tests/Unit/Command/ExportCommandGoldenTest.php
new file mode 100644
index 000000000..250a54696
--- /dev/null
+++ b/tests/Unit/Command/ExportCommandGoldenTest.php
@@ -0,0 +1,176 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\ExportCommand;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Service\ExportService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+use ZipArchive;
+
+/**
+ * The printed output and archive of the export command, case by case.
+ */
+class ExportCommandGoldenTest extends TestCase {
+
+ private string $zipPath = '';
+
+ protected function setUp(): void {
+ $this->zipPath = sys_get_temp_dir() . '/launchpad-export-golden-' . getmypid() . '.zip';
+ }
+
+ protected function tearDown(): void {
+ if (file_exists($this->zipPath) === true) {
+ unlink($this->zipPath);
+ }
+ }
+
+ private static function dashboard(string $uuid): Dashboard {
+ $dashboard = new Dashboard();
+ $dashboard->setUuid($uuid);
+ return $dashboard;
+ }
+
+ /**
+ * Run the export command.
+ *
+ * @param array $input The input.
+ *
+ * @return array{0: int, 1: string}
+ */
+ private function runExport(array $input): array {
+ $mapper = $this->createMock(DashboardMapper::class);
+ $mapper->method('findByUuid')->willReturnCallback(
+ static function (string $uuid): Dashboard {
+ if ($uuid === 'missing') {
+ throw new DoesNotExistException('missing');
+ }
+
+ return self::dashboard($uuid);
+ }
+ );
+ $mapper->method('findAdminTemplates')->willReturn([self::dashboard('tpl-1')]);
+ $mapper->method('findByParent')->with(null)->willReturn([self::dashboard('root-1'), self::dashboard('')]);
+ $mapper->method('findDescendants')->with('root-1')->willReturn([self::dashboard('child-1'), self::dashboard('')]);
+
+ $service = $this->createMock(ExportService::class);
+ $service->method('buildManifest')->willReturnCallback(
+ static fn (string $scope, int $dashboardCount, string $currentUserId): array => [
+ 'scope' => $scope,
+ 'count' => $dashboardCount,
+ 'by' => $currentUserId,
+ ]
+ );
+ $service->method('serializeDashboard')->willReturnCallback(
+ static function (Dashboard $dashboard): array {
+ if ($dashboard->getUuid() === 'boom') {
+ throw new \RuntimeException('cannot serialise');
+ }
+
+ return ['uuid' => $dashboard->getUuid(), 'url' => 'a/b'];
+ }
+ );
+
+ $tester = new CommandTester(new ExportCommand($service, $mapper));
+ $code = $tester->execute($input);
+ return [$code, $tester->getDisplay()];
+ }//end runExport()
+
+ /**
+ * The entries of the written archive with their contents.
+ *
+ * @return array
+ */
+ private function entries(): array {
+ $zip = new ZipArchive();
+ $zip->open($this->zipPath);
+ $entries = [];
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = (string)$zip->getNameIndex($i);
+ $entries[$name] = (string)$zip->getFromIndex($i);
+ }
+
+ $zip->close();
+ return $entries;
+ }
+
+ public function testRefusals(): void {
+ $this->assertSame([1, "--output parameter is required\n"], $this->runExport([]));
+ $this->assertSame(
+ [1, "Unsupported scope: x. Use \"site\" or \"dashboard\".\n"],
+ $this->runExport(['--output' => $this->zipPath, '--scope' => 'x'])
+ );
+ $this->assertSame(
+ [1, "--dashboard-uuid is required when --scope=dashboard\n"],
+ $this->runExport(['--output' => $this->zipPath, '--scope' => 'dashboard'])
+ );
+ $this->assertSame(
+ [1, "Dashboard not found: missing\n"],
+ $this->runExport(['--output' => $this->zipPath, '--scope' => 'dashboard', '--dashboard-uuid' => 'missing'])
+ );
+ $this->assertSame(
+ [1, "Export failed: cannot serialise\n"],
+ $this->runExport(['--output' => $this->zipPath, '--scope' => 'dashboard', '--dashboard-uuid' => 'boom'])
+ );
+ }
+
+ public function testSingleDashboardArchive(): void {
+ $this->assertSame(
+ [0, 'Exported 1 dashboard to ' . $this->zipPath . "\n"],
+ $this->runExport(['--output' => $this->zipPath, '--scope' => 'dashboard', '--dashboard-uuid' => 'd-1'])
+ );
+ $this->assertSame(
+ [
+ 'manifest.json' => "{\n \"scope\": \"dashboard\",\n \"count\": 1,\n \"by\": \"cli\"\n}",
+ 'dashboards/d-1.json' => "{\n \"uuid\": \"d-1\",\n \"url\": \"a/b\"\n}",
+ 'assets/widgets/d-1/' => '',
+ 'metadata-fields.json' => '[]',
+ 'assets/icons/' => '',
+ ],
+ $this->entries()
+ );
+ }
+
+ public function testSiteArchive(): void {
+ $this->assertSame(
+ [0, 'Exported 5 dashboards to ' . $this->zipPath . "\n"],
+ $this->runExport(['--output' => $this->zipPath])
+ );
+ $this->assertSame(
+ [
+ 'manifest.json',
+ 'dashboards/tpl-1.json',
+ 'assets/widgets/tpl-1/',
+ 'dashboards/root-1.json',
+ 'assets/widgets/root-1/',
+ 'dashboards/child-1.json',
+ 'assets/widgets/child-1/',
+ 'metadata-fields.json',
+ 'assets/icons/',
+ ],
+ array_keys($this->entries())
+ );
+ }
+}
diff --git a/tests/Unit/Command/I18nNavigationCommandsGoldenTest.php b/tests/Unit/Command/I18nNavigationCommandsGoldenTest.php
new file mode 100644
index 000000000..fadd63da9
--- /dev/null
+++ b/tests/Unit/Command/I18nNavigationCommandsGoldenTest.php
@@ -0,0 +1,117 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\I18nCopyNavigationCommand;
+use OCA\LaunchPad\Command\I18nMigrateLanguageStructureCommand;
+use OCA\LaunchPad\Service\CommandService;
+use OCP\IDBConnection;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The printed output of the two i18n navigation commands, case by case.
+ */
+class I18nNavigationCommandsGoldenTest extends TestCase {
+
+ private function db(bool $tableExists): IDBConnection {
+ $db = $this->createMock(IDBConnection::class);
+ $db->method('tableExists')->willReturn($tableExists);
+ return $db;
+ }
+
+ /**
+ * Run a command; strip the volatile envelope fields.
+ *
+ * @param CommandTester $tester The tester.
+ * @param array $input The input.
+ * @param list $answers Interactive answers.
+ *
+ * @return array{0: int, 1: string}
+ */
+ private static function runCommand(CommandTester $tester, array $input, array $answers = []): array {
+ $tester->setInputs($answers);
+ $code = $tester->execute($input, ['interactive' => $answers !== []]);
+ return [$code, preg_replace('/"(durationMs|timestamp|ts)":[^,}]+,?/', '', $tester->getDisplay(true))];
+ }
+
+ private function copy(bool $tableExists): CommandTester {
+ return new CommandTester(new I18nCopyNavigationCommand(
+ new CommandService(new NullLogger()),
+ $this->createMock(IUserSession::class),
+ $this->db($tableExists)
+ ));
+ }
+
+ private function migrate(bool $tableExists): CommandTester {
+ return new CommandTester(new I18nMigrateLanguageStructureCommand(
+ new CommandService(new NullLogger()),
+ $this->createMock(IUserSession::class),
+ $this->db($tableExists)
+ ));
+ }
+
+ public function testCopyNavigation(): void {
+ $outputs = [
+ self::runCommand($this->copy(true), ['--from' => 'nl']),
+ self::runCommand($this->copy(true), ['--from' => '', '--to' => 'en', '--json' => true]),
+ self::runCommand($this->copy(false), ['--from' => 'nl', '--to' => 'en', '--json' => true]),
+ self::runCommand($this->copy(false), ['--from' => 'nl', '--to' => 'en']),
+ self::runCommand($this->copy(true), ['--from' => 'nl', '--to' => 'en']),
+ self::runCommand($this->copy(true), ['--from' => 'nl', '--to' => 'en', '--json' => true]),
+ ];
+ $this->assertSame(
+ [CommandService::EXIT_INVALID_ARGS, CommandService::EXIT_INVALID_ARGS, CommandService::EXIT_NOT_FOUND, CommandService::EXIT_NOT_FOUND, 0, 0],
+ array_column($outputs, 0)
+ );
+ $this->assertStringContainsString('Both --from and --to are required', $outputs[0][1]);
+ $this->assertStringContainsString('"context":{"from":"","to":"en"}', $outputs[1][1]);
+ $this->assertStringContainsString('"code":"NOT_FOUND"', $outputs[2][1]);
+ $this->assertStringContainsString('"context":{"language":"nl"}', $outputs[2][1]);
+ $this->assertStringContainsString("No navigation tree found for language 'nl'", $outputs[3][1]);
+ $this->assertSame("Copied 0 nodes from nl to en\n", $outputs[4][1]);
+ $this->assertStringContainsString('{"from":"nl","to":"en","copied":0}', $outputs[5][1]);
+ }
+
+ public function testMigrateLanguageStructure(): void {
+ $outputs = [
+ self::runCommand($this->migrate(false), ['--json' => true]),
+ self::runCommand($this->migrate(true), [], ['n']),
+ self::runCommand($this->migrate(true), [], ['y']),
+ self::runCommand($this->migrate(true), ['--no-interaction' => true]),
+ self::runCommand($this->migrate(true), ['--json' => true]),
+ ];
+ $this->assertSame(
+ [CommandService::EXIT_ERROR, CommandService::EXIT_INVALID_ARGS, 0, 0, 0],
+ array_column($outputs, 0)
+ );
+ $this->assertStringContainsString('"code":"CAPABILITY_MISSING"', $outputs[0][1]);
+ $this->assertStringContainsString('"context":{"expectedTable":"launchpad_language_content"}', $outputs[0][1]);
+ $this->assertStringContainsString('Migration aborted by user.', $outputs[1][1]);
+ $this->assertStringContainsString('Migration finished — 0 rows migrated.', $outputs[2][1]);
+ $this->assertSame("Migration finished — 0 rows migrated.\n", $outputs[3][1]);
+ $this->assertStringContainsString('{"migrated":0,"idempotent":true}', $outputs[4][1]);
+ }
+}
diff --git a/tests/Unit/Command/ImportCommandGoldenTest.php b/tests/Unit/Command/ImportCommandGoldenTest.php
new file mode 100644
index 000000000..7a92f923b
--- /dev/null
+++ b/tests/Unit/Command/ImportCommandGoldenTest.php
@@ -0,0 +1,83 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\ImportCommand;
+use OCA\LaunchPad\Service\ImportService;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The printed output of the import command, case by case.
+ */
+class ImportCommandGoldenTest extends TestCase {
+
+ /**
+ * Run the command against an import service that answers `$answer`.
+ *
+ * @param array|\Throwable|null $answer The service answer.
+ * @param array $input The input.
+ *
+ * @return array{0: int, 1: string}
+ */
+ private function runImport(array|\Throwable|null $answer, array $input): array {
+ $service = $this->createMock(ImportService::class);
+ $expect = $service->expects($answer === null ? $this->never() : $this->once())->method('import');
+ if ($answer instanceof \Throwable) {
+ $expect->willThrowException($answer);
+ } elseif ($answer !== null) {
+ $expect->with(__FILE__, (bool)($input['--preserve-uuids'] ?? false), (string)($input['--user'] ?? 'cli'))
+ ->willReturn($answer);
+ }
+
+ $tester = new CommandTester(new ImportCommand($service));
+ $code = $tester->execute($input);
+ return [$code, $tester->getDisplay()];
+ }//end runImport()
+
+ public function testImportCommandOutput(): void {
+ $this->assertSame([1, "--file parameter is required\n"], $this->runImport(null, []));
+ $this->assertSame([1, "File not found: /no/such.zip\n"], $this->runImport(null, ['--file' => '/no/such.zip']));
+ $this->assertSame(
+ [1, "Not a launchpad archive\n"],
+ $this->runImport(new \InvalidArgumentException('Not a launchpad archive'), ['--file' => __FILE__])
+ );
+ $this->assertSame(
+ [1, "Import failed: disk full\n"],
+ $this->runImport(new \RuntimeException('disk full'), ['--file' => __FILE__])
+ );
+ $this->assertSame(
+ [1, "UUID collisions detected (--preserve-uuids):\n - uuid a taken\n - collision\n"],
+ $this->runImport(
+ ['status' => ImportService::ERR_UUID_COLLISION, 'errors' => [['message' => 'uuid a taken'], []]],
+ ['--file' => __FILE__, '--preserve-uuids' => true, '--user' => 'alice']
+ )
+ );
+ $this->assertSame(
+ [0, "Imported 3 dashboards, skipped 1, errors: 2\n - bad widget\n"],
+ $this->runImport(
+ ['status' => 'ok', 'importedDashboardCount' => 3, 'skippedDashboardCount' => 1, 'errors' => [['message' => 'bad widget'], ['message' => '']]],
+ ['--file' => __FILE__]
+ )
+ );
+ }
+}
diff --git a/tests/Unit/Command/ImportConfluenceCommandGoldenTest.php b/tests/Unit/Command/ImportConfluenceCommandGoldenTest.php
new file mode 100644
index 000000000..c46af6425
--- /dev/null
+++ b/tests/Unit/Command/ImportConfluenceCommandGoldenTest.php
@@ -0,0 +1,104 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Command;
+
+use OCA\LaunchPad\Command\ImportConfluenceCommand;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Service\ConfluenceImportService;
+use OCA\LaunchPad\Service\DashboardTreeService;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * The printed output of the Confluence import command, case by case.
+ */
+class ImportConfluenceCommandGoldenTest extends TestCase {
+
+ /**
+ * Run the command.
+ *
+ * @param array $input The input.
+ * @param \Throwable|null $fails What the import service throws, if anything.
+ *
+ * @return array{0: int, 1: string}
+ */
+ private function runConfluence(array $input, ?\Throwable $fails = null): array {
+ $service = $this->createMock(ConfluenceImportService::class);
+ $service->method('dryRun')->with(__FILE__)->willReturn(
+ ['pageCount' => 4, 'attachmentCount' => 2, 'estimatedDashboards' => 3, 'assetFolder' => 'Imports/x', 'warnings' => ['w1']]
+ );
+ $import = $service->method('import');
+ if ($fails !== null) {
+ $import->willThrowException($fails);
+ } else {
+ $import->willReturnCallback(
+ static fn (string $zipPath, string $currentUserId, ?string $parentUuid): array => [
+ 'createdDashboardCount' => 2,
+ 'skippedPageCount' => 1,
+ 'errors' => [['pageId' => 'p9', 'reason' => 'empty ' . $currentUserId . ' ' . ($parentUuid ?? 'root')]],
+ 'assetFolder' => 'Imports/y',
+ 'warnings' => ['w2', 'w3'],
+ ]
+ );
+ }
+
+ $parent = new Dashboard();
+ $parent->setUuid('parent-uuid');
+ $tree = $this->createMock(DashboardTreeService::class);
+ $tree->method('resolvePath')->willReturnCallback(
+ static fn (string $path): ?Dashboard => ($path === 'docs/home' ? $parent : null)
+ );
+
+ $tester = new CommandTester(new ImportConfluenceCommand($service, $tree));
+ $code = $tester->execute($input);
+ return [$code, $tester->getDisplay()];
+ }//end runConfluence()
+
+ public function testConfluenceImportOutput(): void {
+ $this->assertSame([1, "--file parameter is required\n"], $this->runConfluence([]));
+ $this->assertSame([1, "File not found: /no/x.zip\n"], $this->runConfluence(['--file' => '/no/x.zip']));
+ $this->assertSame(
+ [1, "Parent path not found: nope\n"],
+ $this->runConfluence(['--file' => __FILE__, '--parent-path' => 'nope'])
+ );
+ $this->assertSame(
+ [0, "Pages: 4, attachments: 2, estimated dashboards: 3, asset folder: Imports/x\nwarning: w1\n"],
+ $this->runConfluence(['--file' => __FILE__, '--dry-run' => true])
+ );
+ $this->assertSame(
+ [0, "Imported 2 dashboards, skipped 1, errors: 1, asset folder: Imports/y\n - p9: empty bob parent-uuid\nwarning: w2\nwarning: w3\n"],
+ $this->runConfluence(['--file' => __FILE__, '--parent-path' => 'docs/home', '--user' => 'bob'])
+ );
+ $this->assertSame(
+ [0, "Imported 2 dashboards, skipped 1, errors: 1, asset folder: Imports/y\n - p9: empty cli root\nwarning: w2\nwarning: w3\n"],
+ $this->runConfluence(['--file' => __FILE__])
+ );
+ $this->assertSame(
+ [1, "Not a Confluence export\n"],
+ $this->runConfluence(['--file' => __FILE__], new \InvalidArgumentException('Not a Confluence export'))
+ );
+ $this->assertSame(
+ [1, "Import failed: zip broken\n"],
+ $this->runConfluence(['--file' => __FILE__], new \RuntimeException('zip broken'))
+ );
+ }
+}
diff --git a/tests/Unit/Command/SetupCommandTest.php b/tests/Unit/Command/SetupCommandTest.php
new file mode 100644
index 000000000..82b10ac7e
--- /dev/null
+++ b/tests/Unit/Command/SetupCommandTest.php
@@ -0,0 +1,155 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Tests\Unit\Command;
+
+use OCA\LaunchPad\Command\SetupCommand;
+use OCA\LaunchPad\Service\AdminSettingsService;
+use OCA\LaunchPad\Service\SetupWizardService;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+class SetupCommandTest extends TestCase {
+ /** @var SetupWizardService&MockObject */
+ private $wizard;
+
+ /** @var AdminSettingsService&MockObject */
+ private $settings;
+
+ /** @var list */
+ private array $files = [];
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->wizard = $this->createMock(SetupWizardService::class);
+ $this->settings = $this->createMock(AdminSettingsService::class);
+ }
+
+ protected function tearDown(): void {
+ foreach ($this->files as $file) {
+ @unlink($file);
+ }
+ parent::tearDown();
+ }
+
+ private function yaml(string $body): string {
+ $file = tempnam(sys_get_temp_dir(), 'lp-setup-');
+ $this->assertIsString($file);
+ file_put_contents($file, $body);
+ $this->files[] = $file;
+ return $file;
+ }
+
+ private function runSetup(string $body): CommandTester {
+ $tester = new CommandTester(new SetupCommand($this->wizard, $this->settings));
+ $tester->execute(['--config' => $this->yaml($body)]);
+ return $tester;
+ }
+
+ public function testAConfigWithoutStorageBackendSucceeds(): void {
+ $this->settings->method('getGroupOrder')->willReturn([]);
+ $this->settings->expects($this->once())->method('setGroupOrder')->with(['engineering', 'sales']);
+ $this->wizard->expects($this->once())->method('markWizardComplete');
+
+ $tester = $this->runSetup("group_priority_order: [\"engineering\", \"sales\"]\n");
+
+ $this->assertSame(0, $tester->getStatusCode());
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('Step 1: Welcome... done', $display);
+ $this->assertStringContainsString('Step 2: Group order... done', $display);
+ $this->assertStringContainsString('Step 3: Demo data... skipped (not in config)', $display);
+ $this->assertStringContainsString('Step 4: Admin roles... skipped (not in config)', $display);
+ $this->assertStringContainsString('Step 5: Footer config... skipped (not in config)', $display);
+ $this->assertStringContainsString('Step 6: Done... done', $display);
+ $this->assertStringNotContainsString('Storage backend', $display);
+ $this->assertStringContainsString('Setup wizard completed successfully.', $display);
+ }
+
+ public function testAnOldConfigWithStorageBackendStillRunsAndSaysTheFieldIsIgnored(): void {
+ $this->settings->method('getGroupOrder')->willReturn(['engineering']);
+ $this->settings->expects($this->never())->method('setGroupOrder');
+ $this->wizard->expects($this->once())->method('markWizardComplete');
+
+ $tester = $this->runSetup("storage_backend: \"groupfolder\"\ngroup_priority_order: [\"engineering\"]\n");
+
+ $this->assertSame(0, $tester->getStatusCode());
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString("'storage_backend' is no longer used and was ignored", $display);
+ $this->assertStringContainsString('Step 2: Group order... already configured, skipping', $display);
+ }
+
+ public function testAnEmptyMapStillCompletesTheWizard(): void {
+ $this->wizard->expects($this->once())->method('markWizardComplete');
+
+ $tester = $this->runSetup("{}\n");
+
+ $this->assertSame(0, $tester->getStatusCode());
+ $this->assertStringContainsString('Step 2: Group order... skipped (not in config)', $tester->getDisplay());
+ }
+
+ public function testTheFullOutputWithPendingStepsInTheConfig(): void {
+ $this->settings->method('getGroupOrder')->willReturn([]);
+ $this->wizard->expects($this->once())->method('markWizardComplete');
+
+ $tester = $this->runSetup("group_priority_order: [\"a\"]\ndemo_packages: [x]\nfooter_config: {}\n");
+
+ $this->assertSame(0, $tester->getStatusCode());
+ $this->assertSame(
+ "Step 1: Welcome... done\n"
+ . "Step 2: Group order... done\n"
+ . "Step 3: Demo data... skipped (capability pending)\n"
+ . "Step 4: Admin roles... skipped (not in config)\n"
+ . "Step 5: Footer config... skipped (capability pending)\n"
+ . "Step 6: Done... done\n"
+ . "Setup wizard completed successfully.\n",
+ $tester->getDisplay()
+ );
+ }
+
+ public function testRefusedConfigs(): void {
+ $this->wizard->expects($this->never())->method('markWizardComplete');
+ $this->settings->method('setGroupOrder')->willThrowException(new \RuntimeException('db down'));
+
+ $tester = new CommandTester(new SetupCommand($this->wizard, $this->settings));
+ $this->assertSame(1, $tester->execute([]));
+ $this->assertSame("--config parameter is required\n", $tester->getDisplay());
+
+ $this->assertSame(1, $tester->execute(['--config' => '/no/such/setup.yaml']));
+ $this->assertSame("File not found: /no/such/setup.yaml\n", $tester->getDisplay());
+
+ $this->assertSame(1, $tester->execute(['--config' => $this->yaml("a: [\n")]));
+ $this->assertStringStartsWith('Invalid setup.yaml: ', $tester->getDisplay());
+
+ $this->assertSame(1, $tester->execute(['--config' => $this->yaml("just a string\n")]));
+ $this->assertSame("Invalid setup.yaml: top-level structure must be a map.\n", $tester->getDisplay());
+
+ $this->assertSame(1, $tester->execute(['--config' => $this->yaml("group_priority_order: nope\n")]));
+ $this->assertSame(
+ "Step 1: Welcome... done\nInvalid setup.yaml: 'group_priority_order' must be a list of strings\n",
+ $tester->getDisplay()
+ );
+
+ $this->assertSame(1, $tester->execute(['--config' => $this->yaml("group_priority_order: [b]\n")]));
+ $this->assertSame("Step 1: Welcome... done\nSetup failed: db down\n", $tester->getDisplay());
+ }
+}
diff --git a/tests/Unit/Command/TemplateInstallCommandTest.php b/tests/Unit/Command/TemplateInstallCommandTest.php
new file mode 100644
index 000000000..948cc5cc2
--- /dev/null
+++ b/tests/Unit/Command/TemplateInstallCommandTest.php
@@ -0,0 +1,312 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Tests\Unit\Command;
+
+use InvalidArgumentException;
+use OCA\LaunchPad\Command\TemplateInstallCommand;
+use OCA\LaunchPad\Exception\TemplateNotInstalledException;
+use OCA\LaunchPad\Service\AdminSettingsService;
+use OCA\LaunchPad\Service\ShippedTemplateService;
+use OCA\LaunchPad\Service\ShippedTemplateUpdateService;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/**
+ * `launchpad:template:install` (REQ-CLI-012): what it passes on, what it
+ * prints and how it exits. The install itself is ShippedTemplateServiceTest.
+ */
+class TemplateInstallCommandTest extends TestCase {
+ /** @var ShippedTemplateService&MockObject */
+ private $templates;
+
+ /** @var ShippedTemplateUpdateService&MockObject */
+ private $updates;
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->templates = $this->createMock(ShippedTemplateService::class);
+ $this->updates = $this->createMock(ShippedTemplateUpdateService::class);
+ }
+
+ private function command(bool $personalDashboards = true): TemplateInstallCommand {
+ return new TemplateInstallCommand($this->templates, $this->settings($personalDashboards), $this->updates);
+ }
+
+ /**
+ * @param array $overrides Result fields to override.
+ *
+ * @return array
+ */
+ private static function updateResult(array $overrides = []): array {
+ return $overrides + [
+ 'templateId' => 'mijn-werkdag',
+ 'uuid' => 'uuid-1',
+ 'id' => 3,
+ 'name' => 'Mijn werkdag',
+ 'installedVersion' => 2,
+ 'version' => 3,
+ 'upToDate' => false,
+ 'dryRun' => false,
+ 'applied' => true,
+ 'added' => ['Mijn tickets (object-list)'],
+ 'removed' => ['Oud (text)'],
+ 'changed' => [['widget' => 'Mijn zaken (object-list)', 'fields' => ['position', 'settings']]],
+ 'unchanged' => 3,
+ 'copies' => 4,
+ 'resync' => ['async' => false, 'affectedCount' => 4, 'totalCopies' => 4],
+ ];
+ }
+
+ /**
+ * @param array $overrides Result fields to override.
+ *
+ * @return array
+ */
+ private static function installResult(array $overrides = []): array {
+ return $overrides + [
+ 'templateId' => 'mijn-werkdag',
+ 'uuid' => 'uuid-1',
+ 'id' => 3,
+ 'version' => 1,
+ 'alreadyInstalled' => false,
+ 'targetGroups' => [],
+ 'isDefault' => false,
+ 'missingWidgets' => [],
+ 'registers' => ['dossiq', 'pipelinq'],
+ ];
+ }
+
+ private function settings(bool $personalDashboards): AdminSettingsService {
+ $settings = $this->createMock(AdminSettingsService::class);
+ $settings->method('getSettings')->willReturn(['allowUserDashboards' => $personalDashboards]);
+ return $settings;
+ }
+
+ public function testInstallsForAGroup(): void {
+ $this->templates->expects(self::once())->method('install')
+ ->with('mijn-werkdag', ['targetGroups' => ['medewerkers', 'bestuur'], 'makeDefault' => true, 'force' => false])
+ ->willReturn(self::installResult(['targetGroups' => ['medewerkers', 'bestuur'], 'isDefault' => true]));
+
+ $tester = new CommandTester($this->command());
+ $exit = $tester->execute([
+ 'id' => 'mijn-werkdag',
+ '--group' => ['medewerkers', 'bestuur'],
+ '--default' => true,
+ ]);
+
+ self::assertSame(0, $exit);
+ $display = $tester->getDisplay();
+ self::assertStringContainsString('Installed template mijn-werkdag version 1 (UUID: uuid-1).', $display);
+ self::assertStringContainsString('Groups: medewerkers, bestuur. Default for everyone: yes.', $display);
+ self::assertStringContainsString('Its lists read these registers: dossiq, pipelinq. A list whose register is not on this instance is hidden for members.', $display);
+ self::assertStringNotContainsString('Personal dashboards are off', $display);
+ }
+
+ public function testASecondRunSaysSoAndPassesForce(): void {
+ $this->templates->expects(self::once())->method('install')
+ ->with('mijn-werkdag', ['targetGroups' => [], 'makeDefault' => false, 'force' => true])
+ ->willReturn(self::installResult(['alreadyInstalled' => true, 'missingWidgets' => ['decidesk']]));
+
+ $tester = new CommandTester($this->command(false));
+ $exit = $tester->execute(['id' => 'mijn-werkdag', '--force' => true]);
+
+ self::assertSame(0, $exit);
+ $display = $tester->getDisplay();
+ self::assertStringContainsString('Already installed: template mijn-werkdag', $display);
+ self::assertStringContainsString('Groups: (none). Default for everyone: no.', $display);
+ self::assertStringContainsString('decidesk', $display);
+ self::assertStringContainsString('Personal dashboards are off. Members will see this template read-only', $display);
+ }
+
+ public function testAnUnknownGroupExitsOne(): void {
+ $this->templates->method('install')
+ ->willThrowException(new InvalidArgumentException('Unknown group: medewerkerz'));
+
+ $tester = new CommandTester($this->command());
+
+ self::assertSame(1, $tester->execute(['id' => 'mijn-werkdag', '--group' => ['medewerkerz']]));
+ self::assertStringContainsString('Unknown group: medewerkerz', $tester->getDisplay());
+ }
+
+ public function testAnUnknownIdExitsOne(): void {
+ $this->templates->method('install')->willThrowException(
+ new InvalidArgumentException('Unknown template: nope. Shipped templates: mijn-werkdag')
+ );
+
+ $tester = new CommandTester($this->command());
+
+ self::assertSame(1, $tester->execute(['id' => 'nope']));
+ self::assertStringContainsString('Shipped templates: mijn-werkdag', $tester->getDisplay());
+ }
+
+ public function testAFailedInstallExitsOne(): void {
+ $this->templates->method('install')->willThrowException(new RuntimeException('disk full'));
+
+ $tester = new CommandTester($this->command());
+
+ self::assertSame(1, $tester->execute(['id' => 'mijn-werkdag']));
+ self::assertStringContainsString('Installation failed: disk full', $tester->getDisplay());
+ }
+
+ /**
+ * REQ-CLI-012: `--update` prints every widget it adds, removes and changes.
+ */
+ public function testUpdatePrintsWhatChanges(): void {
+ $this->templates->expects(self::never())->method('install');
+ $this->updates->expects(self::once())->method('update')
+ ->with('mijn-werkdag', false)
+ ->willReturn(self::updateResult());
+
+ $tester = new CommandTester($this->command());
+ $exit = $tester->execute(['id' => 'mijn-werkdag', '--update' => true]);
+
+ self::assertSame(0, $exit);
+ $display = $tester->getDisplay();
+ self::assertStringContainsString('Updated template mijn-werkdag from version 2 to version 3 (UUID: uuid-1).', $display);
+ self::assertStringContainsString('+ added: Mijn tickets (object-list)', $display);
+ self::assertStringContainsString('- removed: Oud (text)', $display);
+ self::assertStringContainsString('~ changed: Mijn zaken (object-list) (position, settings)', $display);
+ self::assertStringContainsString('= unchanged: 3 widgets', $display);
+ self::assertStringContainsString('Members with a copy of this template: 4. Copies changed: 4.', $display);
+ }
+
+ /**
+ * REQ-CLI-012: `--dry-run` is passed on and the output says nothing was written.
+ */
+ public function testADryRunSaysNothingWasWritten(): void {
+ $this->updates->expects(self::once())->method('update')
+ ->with('mijn-werkdag', true)
+ ->willReturn(self::updateResult(['dryRun' => true, 'applied' => false, 'resync' => null]));
+
+ $tester = new CommandTester($this->command());
+ $exit = $tester->execute(['id' => 'mijn-werkdag', '--update' => true, '--dry-run' => true]);
+
+ self::assertSame(0, $exit);
+ $display = $tester->getDisplay();
+ self::assertStringContainsString('Dry run, nothing written. Would update template mijn-werkdag from version 2 to version 3', $display);
+ self::assertStringContainsString('+ added: Mijn tickets (object-list)', $display);
+ self::assertStringContainsString('Their copies would follow.', $display);
+ self::assertStringNotContainsString('Copies changed', $display);
+ }
+
+ public function testAnUpToDateTemplateSaysSoAndExitsZero(): void {
+ $this->updates->method('update')->willReturn(
+ self::updateResult(['upToDate' => true, 'installedVersion' => 3, 'applied' => false, 'resync' => null])
+ );
+
+ $tester = new CommandTester($this->command());
+
+ self::assertSame(0, $tester->execute(['id' => 'mijn-werkdag', '--update' => true]));
+ self::assertStringContainsString('is at version 3, the version LaunchPad ships. Nothing changed.', $tester->getDisplay());
+ self::assertStringNotContainsString('added', $tester->getDisplay());
+ }
+
+ public function testUpdatingATemplateThatIsNotInstalledExitsOne(): void {
+ $this->updates->method('update')->willThrowException(
+ new TemplateNotInstalledException('Template mijn-werkdag is not installed here, so there is nothing to update.')
+ );
+
+ $tester = new CommandTester($this->command());
+
+ self::assertSame(1, $tester->execute(['id' => 'mijn-werkdag', '--update' => true]));
+ self::assertStringContainsString('is not installed here', $tester->getDisplay());
+ }
+
+ /**
+ * REQ-CLI-012: an update keeps the groups and the default flag, so the
+ * options that change them are refused before anything runs.
+ */
+ public function testUpdateRefusesTheOptionsThatChangeWhoGetsTheTemplate(): void {
+ $this->updates->expects(self::never())->method('update');
+ $this->templates->expects(self::never())->method('install');
+
+ foreach ([['--group' => ['medewerkers']], ['--default' => true], ['--force' => true]] as $extra) {
+ $tester = new CommandTester($this->command());
+ self::assertSame(1, $tester->execute(['id' => 'mijn-werkdag', '--update' => true] + $extra));
+ self::assertStringContainsString('cannot be combined with', $tester->getDisplay());
+ }
+ }
+
+ public function testDryRunWithoutUpdateExitsOne(): void {
+ $this->templates->expects(self::never())->method('install');
+
+ $tester = new CommandTester($this->command());
+
+ self::assertSame(1, $tester->execute(['id' => 'mijn-werkdag', '--dry-run' => true]));
+ self::assertStringContainsString('--dry-run only works together with --update', $tester->getDisplay());
+ }
+
+ /**
+ * The full output of an install with every note, line by line.
+ */
+ public function testTheInstallOutputInFull(): void {
+ $this->templates->method('install')
+ ->willReturn(self::installResult(['missingWidgets' => ['decidesk', 'pipelinq']]));
+
+ $tester = new CommandTester($this->command(false));
+ self::assertSame(0, $tester->execute(['id' => 'mijn-werkdag']));
+ self::assertSame(
+ "Installed template mijn-werkdag version 1 (UUID: uuid-1).\n"
+ . "Groups: (none). Default for everyone: no.\n"
+ . "No app on this instance registers these widgets, so they will show empty: decidesk, pipelinq\n"
+ . "Its lists read these registers: dossiq, pipelinq. A list whose register is not on this instance is hidden for members.\n"
+ . "Personal dashboards are off. Members will see this template read-only and its compulsory widgets have no effect."
+ . " Turn on \"allow personal dashboards\" in the LaunchPad admin settings to give each member their own copy.\n",
+ $tester->getDisplay()
+ );
+ }
+
+ /**
+ * The full output of an update, and of an update re-synced in the background.
+ */
+ public function testTheUpdateOutputInFull(): void {
+ $this->updates->method('update')->willReturnOnConsecutiveCalls(
+ self::updateResult(),
+ self::updateResult(['added' => [], 'removed' => [], 'changed' => [], 'resync' => ['async' => true]]),
+ self::updateResult(['resync' => []])
+ );
+
+ $tester = new CommandTester($this->command());
+ self::assertSame(0, $tester->execute(['id' => 'mijn-werkdag', '--update' => true]));
+ self::assertSame(
+ "Updated template mijn-werkdag from version 2 to version 3 (UUID: uuid-1).\n"
+ . " + added: Mijn tickets (object-list)\n"
+ . " - removed: Oud (text)\n"
+ . " ~ changed: Mijn zaken (object-list) (position, settings)\n"
+ . " = unchanged: 3 widgets\n"
+ . "Members with a copy of this template: 4. Copies changed: 4. A widget a member added stays.\n",
+ $tester->getDisplay()
+ );
+
+ self::assertSame(0, $tester->execute(['id' => 'mijn-werkdag', '--update' => true]));
+ self::assertSame(
+ "Updated template mijn-werkdag from version 2 to version 3 (UUID: uuid-1).\n"
+ . " = unchanged: 3 widgets\n"
+ . "Members with a copy of this template: 4. Their copies are updated in the background. A widget a member added stays.\n",
+ $tester->getDisplay()
+ );
+
+ self::assertSame(0, $tester->execute(['id' => 'mijn-werkdag', '--update' => true]));
+ self::assertStringEndsWith("Copies changed: 0. A widget a member added stays.\n", $tester->getDisplay());
+ }
+
+ /**
+ * An update that fails unexpectedly says so and exits one.
+ */
+ public function testAFailedUpdateExitsOne(): void {
+ $this->updates->method('update')->willThrowException(new RuntimeException('locked'));
+
+ $tester = new CommandTester($this->command());
+ self::assertSame(1, $tester->execute(['id' => 'mijn-werkdag', '--update' => true]));
+ self::assertSame("Update failed: locked\n", $tester->getDisplay());
+ }
+}
diff --git a/tests/Unit/Command/fixtures/cleanup-purge-command.json b/tests/Unit/Command/fixtures/cleanup-purge-command.json
new file mode 100644
index 000000000..e46196f6e
--- /dev/null
+++ b/tests/Unit/Command/fixtures/cleanup-purge-command.json
@@ -0,0 +1,76 @@
+{
+ "unknown category": {
+ "exit": 1,
+ "purges": [],
+ "output": [
+ "Unknown cleanup category: nope",
+ "Valid categories: placements, shares, reactions"
+ ]
+ },
+ "one category": {
+ "exit": 0,
+ "purges": [
+ [
+ [
+ "placements"
+ ],
+ false,
+ null,
+ "cli"
+ ]
+ ],
+ "output": [
+ "Purged 3 items from category 'placements' in 5ms."
+ ]
+ },
+ "all categories dry run": {
+ "exit": 0,
+ "purges": [
+ [
+ [],
+ true,
+ null,
+ "cli"
+ ]
+ ],
+ "output": [
+ "DRY-RUN: Would purge 3 items across 2 categories in 5ms."
+ ]
+ },
+ "confirmed": {
+ "exit": 0,
+ "purges": [
+ [
+ [],
+ false,
+ null,
+ "cli"
+ ]
+ ],
+ "output": [
+ "Delete orphaned data in categories: [placements, shares, reactions]? (y/N) Purged 3 items across 2 categories in 5ms."
+ ]
+ },
+ "declined": {
+ "exit": 0,
+ "purges": [],
+ "output": [
+ "Delete orphaned data in categories: [placements, shares, reactions]? (y/N) Purge cancelled."
+ ]
+ },
+ "with skipped": {
+ "exit": 0,
+ "purges": [
+ [
+ [],
+ false,
+ null,
+ "cli"
+ ]
+ ],
+ "output": [
+ "Purged 3 items across 2 categories in 5ms.",
+ "Skipped categories: table missing"
+ ]
+ }
+}
diff --git a/tests/Unit/Command/fixtures/command-error.json b/tests/Unit/Command/fixtures/command-error.json
new file mode 100644
index 000000000..4947cf170
--- /dev/null
+++ b/tests/Unit/Command/fixtures/command-error.json
@@ -0,0 +1,67 @@
+{
+ "probe with context plain": {
+ "exit": 4,
+ "stdout": "",
+ "stderr": "Dashboard not found\n"
+ },
+ "probe with context json": {
+ "exit": 4,
+ "stdout": "{\"success\":false,\"exitCode\":4,\"data\":null,\"errors\":[{\"code\":\"NOT_FOUND\",\"message\":\"Dashboard not found\",\"context\":{\"uuid\":\"u-1\"}}]}\n",
+ "stderr": ""
+ },
+ "probe with context quiet": {
+ "exit": 4,
+ "stdout": "",
+ "stderr": "Dashboard not found\n"
+ },
+ "probe without context plain": {
+ "exit": 2,
+ "stdout": "",
+ "stderr": "Deletion aborted by user.\n"
+ },
+ "probe without context json": {
+ "exit": 2,
+ "stdout": "{\"success\":false,\"exitCode\":2,\"data\":null,\"errors\":[{\"code\":\"ABORTED\",\"message\":\"Deletion aborted by user.\"}]}\n",
+ "stderr": ""
+ },
+ "probe without context quiet": {
+ "exit": 2,
+ "stdout": "",
+ "stderr": "Deletion aborted by user.\n"
+ },
+ "probe null context plain": {
+ "exit": 3,
+ "stdout": "",
+ "stderr": "m\n"
+ },
+ "probe null context json": {
+ "exit": 3,
+ "stdout": "{\"success\":false,\"exitCode\":3,\"data\":null,\"errors\":[{\"code\":\"X\",\"message\":\"m\"}]}\n",
+ "stderr": ""
+ },
+ "probe null context quiet": {
+ "exit": 3,
+ "stdout": "",
+ "stderr": "m\n"
+ },
+ "delete missing plain": {
+ "exit": 4,
+ "stdout": "",
+ "stderr": "Dashboard not found\n"
+ },
+ "delete missing json": {
+ "exit": 4,
+ "stdout": "{\"success\":false,\"exitCode\":4,\"data\":null,\"errors\":[{\"code\":\"NOT_FOUND\",\"message\":\"Dashboard not found\",\"context\":{\"uuid\":\"missing\"}}]}\n",
+ "stderr": ""
+ },
+ "delete parent plain": {
+ "exit": 2,
+ "stdout": "",
+ "stderr": "Use --cascade to also delete child dashboards\n"
+ },
+ "delete parent json": {
+ "exit": 2,
+ "stdout": "{\"success\":false,\"exitCode\":2,\"data\":null,\"errors\":[{\"code\":\"CHILDREN_EXIST\",\"message\":\"Use --cascade to also delete child dashboards\",\"context\":{\"uuid\":\"parent\",\"childCount\":2}}]}\n",
+ "stderr": ""
+ }
+}
diff --git a/tests/Unit/Command/fixtures/dashboard-delete-command.json b/tests/Unit/Command/fixtures/dashboard-delete-command.json
new file mode 100644
index 000000000..5489ee2d2
--- /dev/null
+++ b/tests/Unit/Command/fixtures/dashboard-delete-command.json
@@ -0,0 +1,37 @@
+{
+ "leaf": {
+ "exit": 0,
+ "writes": [
+ "placements 4",
+ "delete leaf"
+ ],
+ "output": "Deleted dashboard leaf"
+ },
+ "leaf json": {
+ "exit": 0,
+ "writes": [
+ "placements 4",
+ "delete leaf"
+ ],
+ "output": "{\"success\":true,\"exitCode\":0,\"data\":{\"uuid\":\"leaf\",\"cascade\":false,\"childCount\":0},\"errors\":[]}"
+ },
+ "parent cascade": {
+ "exit": 0,
+ "writes": [
+ "subtree parent"
+ ],
+ "output": "Deleted dashboard parent and 2 descendant(s)"
+ },
+ "parent cascade json": {
+ "exit": 0,
+ "writes": [
+ "subtree parent"
+ ],
+ "output": "{\"success\":true,\"exitCode\":0,\"data\":{\"uuid\":\"parent\",\"cascade\":true,\"childCount\":2},\"errors\":[]}"
+ },
+ "parent without cascade": {
+ "exit": 2,
+ "writes": [],
+ "output": "Use --cascade to also delete child dashboards"
+ }
+}
diff --git a/tests/Unit/Controller/AcknowledgementControllerTest.php b/tests/Unit/Controller/AcknowledgementControllerTest.php
index c8accdbf7..a8d28c33d 100644
--- a/tests/Unit/Controller/AcknowledgementControllerTest.php
+++ b/tests/Unit/Controller/AcknowledgementControllerTest.php
@@ -21,7 +21,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\AcknowledgementTools;
use OCA\LaunchPad\Controller\AcknowledgementController;
+use OCA\LaunchPad\Db\Acknowledgement;
use OCA\LaunchPad\Service\AcknowledgementService;
use OCA\LaunchPad\Service\RoleService;
use OCP\AppFramework\Http;
@@ -35,12 +37,46 @@
* Unit tests for AcknowledgementController authorization.
*/
class AcknowledgementControllerTest extends TestCase {
+ /**
+ * Request double.
+ *
+ * @var IRequest&MockObject
+ */
private IRequest&MockObject $request;
+
+ /**
+ * Acknowledgement service double.
+ *
+ * @var AcknowledgementService&MockObject
+ */
private AcknowledgementService&MockObject $service;
+
+ /**
+ * Role service double.
+ *
+ * @var RoleService&MockObject
+ */
private RoleService&MockObject $roleService;
+
+ /**
+ * Group manager double.
+ *
+ * @var IGroupManager&MockObject
+ */
private IGroupManager&MockObject $groupManager;
+
+ /**
+ * Logger double.
+ *
+ * @var LoggerInterface&MockObject
+ */
private LoggerInterface&MockObject $logger;
+ /**
+ * Create fresh doubles.
+ *
+ * @return void
+ */
protected function setUp(): void {
$this->request = $this->createMock(originalClassName: IRequest::class);
$this->service = $this->createMock(originalClassName: AcknowledgementService::class);
@@ -49,14 +85,23 @@ protected function setUp(): void {
$this->logger = $this->createMock(originalClassName: LoggerInterface::class);
}
+ /**
+ * Build the controller for a session user.
+ *
+ * @param string|null $userId The session user.
+ *
+ * @return AcknowledgementController The controller.
+ */
private function makeController(?string $userId = 'alice'): AcknowledgementController {
return new AcknowledgementController(
request: $this->request,
- acknowledgementService: $this->service,
- roleService: $this->roleService,
+ tools: new AcknowledgementTools(
+ acknowledgements: $this->service,
+ roleService: $this->roleService,
+ logger: $this->logger
+ ),
groupManager: $this->groupManager,
- logger: $this->logger,
- userId: $userId,
+ userId: $userId
);
}
@@ -72,6 +117,11 @@ public function testAcknowledgeRejectsCrossUser(): void {
['userId', null, 'bob'],
['contentVersion', 1, 1],
]);
+ // Alice may acknowledge ak-1, so only the body userId check can refuse.
+ $this->service->method('isAllowedToAcknowledge')->willReturnCallback(
+ static fn (string $announcementKey, string $userId): bool => $announcementKey === 'ak-1' && $userId === 'alice'
+ );
+ $this->service->method('currentContentVersion')->willReturnMap([['ak-1', 'alice', 1]]);
$this->service->expects($this->never())->method('acknowledge');
@@ -83,6 +133,49 @@ public function testAcknowledgeRejectsCrossUser(): void {
);
}
+ /**
+ * A user outside the announcement's audience cannot acknowledge it, and
+ * the audience is checked for the session user, not a request value.
+ *
+ * @return void
+ */
+ public function testAcknowledgeRefusesAUserOutsideTheAudience(): void {
+ $this->request->method('getParam')->willReturnMap([
+ ['announcementKey', '', 'ak-1'],
+ ['userId', null, null],
+ ['contentVersion', 1, 1],
+ ]);
+ $this->service->method('isAllowedToAcknowledge')->willReturnCallback(
+ static fn (string $announcementKey, string $userId): bool => $announcementKey === 'ak-1' && $userId === 'alice'
+ );
+ $this->service->expects($this->never())->method('acknowledge');
+
+ $response = $this->makeController(userId: 'mallory')->acknowledge();
+
+ $this->assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus());
+ }
+
+ /**
+ * An announcement key nobody is addressed by cannot be acknowledged.
+ *
+ * @return void
+ */
+ public function testAcknowledgeRefusesAnUnknownAnnouncement(): void {
+ $this->request->method('getParam')->willReturnMap([
+ ['announcementKey', '', 'made-up'],
+ ['userId', null, 'alice'],
+ ['contentVersion', 1, 1],
+ ]);
+ $this->service->method('isAllowedToAcknowledge')->willReturnCallback(
+ static fn (string $announcementKey, string $userId): bool => $announcementKey === 'ak-1' && $userId === 'alice'
+ );
+ $this->service->expects($this->never())->method('acknowledge');
+
+ $response = $this->makeController()->acknowledge();
+
+ $this->assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus());
+ }
+
/**
* REQ-ACK-003: acknowledging with one's own userId succeeds.
*
@@ -95,13 +188,12 @@ public function testAcknowledgeOwnUserSucceeds(): void {
['contentVersion', 1, 1],
]);
- $receipt = new \OCA\LaunchPad\Db\Acknowledgement();
- // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $receipt->setAnnouncementKey('ak-1');
- $receipt->setUserId('alice');
- $receipt->setContentVersion(1);
- // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $receipt = $this->makeReceipt(version: 1);
+ $this->service->method('isAllowedToAcknowledge')->willReturnCallback(
+ static fn (string $announcementKey, string $userId): bool => $announcementKey === 'ak-1' && $userId === 'alice'
+ );
+ $this->service->method('currentContentVersion')->willReturnMap([['ak-1', 'alice', 1]]);
$this->service->expects($this->once())
->method('acknowledge')
->with('ak-1', 'alice', 1)
@@ -115,6 +207,131 @@ public function testAcknowledgeOwnUserSucceeds(): void {
);
}
+ /**
+ * Point the request at ak-1 for alice with the given body version, with
+ * ak-1 addressed to alice at the given current version.
+ *
+ * @param int $bodyVersion The contentVersion in the request.
+ * @param int $currentVersion The announcement's current version.
+ *
+ * @return void
+ */
+ private function acknowledgeVersion(int $bodyVersion, int $currentVersion): void {
+ $this->request->method('getParam')->willReturnMap([
+ ['announcementKey', '', 'ak-1'],
+ ['userId', null, null],
+ ['contentVersion', 1, $bodyVersion],
+ ]);
+ $this->service->method('isAllowedToAcknowledge')->willReturnCallback(
+ static fn (string $announcementKey, string $userId): bool => $announcementKey === 'ak-1' && $userId === 'alice'
+ );
+ $this->service->method('currentContentVersion')->willReturnMap([['ak-1', 'alice', $currentVersion]]);
+ }
+
+ /**
+ * REQ-ACK-005: a receipt cannot be taken in advance for a content
+ * version that has not been published yet.
+ *
+ * @return void
+ */
+ public function testAcknowledgeRefusesAFutureContentVersion(): void {
+ $this->acknowledgeVersion(bodyVersion: 3, currentVersion: 2);
+ $this->service->expects($this->never())->method('acknowledge');
+
+ $response = $this->makeController()->acknowledge();
+
+ $this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus());
+ }
+
+ /**
+ * An announcement without a current version accepts no receipt.
+ *
+ * @return void
+ */
+ public function testAcknowledgeRefusesWhenTheAnnouncementHasNoVersion(): void {
+ $this->request->method('getParam')->willReturnMap([
+ ['announcementKey', '', 'ak-1'],
+ ['userId', null, null],
+ ['contentVersion', 1, 1],
+ ]);
+ $this->service->method('isAllowedToAcknowledge')->willReturn(true);
+ $this->service->method('currentContentVersion')->willReturn(null);
+ $this->service->expects($this->never())->method('acknowledge');
+
+ $response = $this->makeController()->acknowledge();
+
+ $this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus());
+ }
+
+ /**
+ * REQ-ACK-005: the current version is recorded as sent.
+ *
+ * @return void
+ */
+ public function testAcknowledgeAcceptsTheCurrentContentVersion(): void {
+ $this->acknowledgeVersion(bodyVersion: 2, currentVersion: 2);
+ $this->service->expects($this->once())->method('acknowledge')->with('ak-1', 'alice', 2)->willReturn($this->makeReceipt(version: 2));
+
+ $response = $this->makeController()->acknowledge();
+
+ $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus());
+ }
+
+ /**
+ * REQ-ACK-005: an older version is accepted, since a copy that was not
+ * re-synced yet still shows it.
+ *
+ * @return void
+ */
+ public function testAcknowledgeAcceptsAnOlderContentVersion(): void {
+ $this->acknowledgeVersion(bodyVersion: 1, currentVersion: 2);
+ $this->service->expects($this->once())->method('acknowledge')->with('ak-1', 'alice', 1)->willReturn($this->makeReceipt(version: 1));
+
+ $response = $this->makeController()->acknowledge();
+
+ $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus());
+ }
+
+ /**
+ * REQ-ACK-005: the version check uses the announcement being
+ * acknowledged and the session user, not ak-1's version.
+ *
+ * @return void
+ */
+ public function testAcknowledgeChecksTheVersionOfTheAnnouncementItNames(): void {
+ $bodyVersion = 3;
+ $this->request->method('getParam')->willReturnCallback(
+ static function (string $key, mixed $default = null) use (&$bodyVersion): mixed {
+ return ['announcementKey' => 'ak-2', 'userId' => null, 'contentVersion' => $bodyVersion][$key] ?? $default;
+ }
+ );
+ $this->service->method('isAllowedToAcknowledge')->willReturn(true);
+ $this->service->method('currentContentVersion')->willReturnMap([['ak-1', 'alice', 1], ['ak-2', 'alice', 3], ['ak-2', 'bob', 9]]);
+ $this->service->expects($this->once())->method('acknowledge')->with('ak-2', 'alice', 3)->willReturn($this->makeReceipt(version: 3));
+
+ $this->assertSame(expected: Http::STATUS_OK, actual: $this->makeController()->acknowledge()->getStatus());
+ $bodyVersion = 4;
+ $this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $this->makeController()->acknowledge()->getStatus());
+ }
+
+ /**
+ * Build alice's receipt for ak-1.
+ *
+ * @param int $version The content version.
+ *
+ * @return Acknowledgement The receipt.
+ */
+ private function makeReceipt(int $version): Acknowledgement {
+ $receipt = new Acknowledgement();
+ // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $receipt->setAnnouncementKey('ak-1');
+ $receipt->setUserId('alice');
+ $receipt->setContentVersion($version);
+ // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+
+ return $receipt;
+ }
+
/**
* REQ-ACK-004: a non-admin who does not own the template is rejected
* with 403 and the report is never built.
@@ -186,7 +403,7 @@ public function testPendingReturnsOutstandingItems(): void {
expected: Http::STATUS_OK,
actual: $response->getStatus()
);
- $this->assertSame(expected: 1, actual: $response->getData()['count']);
+ $this->assertSame(expected: ['count' => 1, 'items' => [['announcementKey' => 'ak-1']]], actual: $response->getData());
}
/**
@@ -239,4 +456,81 @@ public function testReportAllowsNextcloudAdmin(): void {
actual: $response->getStatus()
);
}
+
+ /**
+ * The 401s, the missing key, the version clamp, and the 404 and
+ * logged 500 answers of every endpoint.
+ *
+ * @return void
+ */
+ public function testEveryRemainingAnswer(): void {
+ $anonymous = $this->makeController(userId: null);
+ foreach ([$anonymous->acknowledge(), $anonymous->pending(), $anonymous->report(announcementKey: 'k'), $anonymous->reportCsv(announcementKey: 'k')] as $response) {
+ $this->assertSame(401, $response->getStatus());
+ }
+
+ $params = ['announcementKey' => '', 'contentVersion' => -4];
+ $this->request->method('getParam')->willReturnCallback(
+ function (string $key, mixed $default = null) use (&$params): mixed {
+ return $params[$key] ?? $default;
+ }
+ );
+ $this->assertSame(
+ [400, ['error' => 'announcementKey is required']],
+ [$this->makeController()->acknowledge()->getStatus(), $this->makeController()->acknowledge()->getData()]
+ );
+
+ $params['announcementKey'] = 'k';
+ $logged = [];
+ $this->logger->method('error')->willReturnCallback(function (string $message) use (&$logged): void {
+ $logged[] = $message;
+ });
+ $this->service->method('isAllowedToAcknowledge')->willReturn(true);
+ $this->service->method('currentContentVersion')->willReturn(1);
+ $this->service->expects($this->once())->method('acknowledge')->with('k', 'alice', 1)
+ ->willThrowException(new \RuntimeException('db down'));
+ $this->service->method('getPending')->willThrowException(new \RuntimeException('db down'));
+ $this->groupManager->method('isAdmin')->willReturn(true);
+ $reports = [
+ new \OCP\AppFramework\Db\DoesNotExistException('none'),
+ new \RuntimeException('db down'),
+ new \OCP\AppFramework\Db\DoesNotExistException('none'),
+ new \RuntimeException('db down'),
+ ];
+ $this->service->method('report')->willReturnCallback(function () use (&$reports) {
+ $next = array_shift($reports);
+ if ($next instanceof \Throwable) {
+ throw $next;
+ }
+
+ return $next;
+ });
+
+ $controller = $this->makeController();
+ $answers = [];
+ foreach ([
+ $controller->acknowledge(),
+ $controller->pending(),
+ $controller->report(announcementKey: 'k'),
+ $controller->report(announcementKey: 'k'),
+ $controller->reportCsv(announcementKey: 'k'),
+ $controller->reportCsv(announcementKey: 'k'),
+ ] as $response) {
+ $answers[] = [$response->getStatus(), $response->getData()];
+ }
+
+ $this->assertSame(
+ [
+ [500, ['error' => 'Operation failed']],
+ [500, ['error' => 'Operation failed']],
+ [404, ['error' => 'Unknown announcement']],
+ [500, ['error' => 'Operation failed']],
+ [404, ['error' => 'Unknown announcement']],
+ [500, ['error' => 'Operation failed']],
+ ],
+ $answers
+ );
+ $this->assertSame(['acknowledge failed: db down', 'pending failed: db down', 'report failed: db down', 'reportCsv failed: db down'], $logged);
+
+ }
}//end class
diff --git a/tests/Unit/Controller/ActionMatrixControllerTest.php b/tests/Unit/Controller/ActionMatrixControllerTest.php
new file mode 100644
index 000000000..a1caf699c
--- /dev/null
+++ b/tests/Unit/Controller/ActionMatrixControllerTest.php
@@ -0,0 +1,198 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use JsonException;
+use OCA\LaunchPad\Controller\ActionMatrixController;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Settings\LaunchPadAdmin;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
+
+class ActionMatrixControllerTest extends TestCase {
+ /**
+ * Controller.
+ *
+ * @var ActionMatrixController
+ */
+ private ActionMatrixController $controller;
+
+ /**
+ * Request.
+ *
+ * @var IRequest
+ */
+ private IRequest&MockObject $request;
+
+ /**
+ * Action auth.
+ *
+ * @var ActionAuthService
+ */
+ private ActionAuthService&MockObject $actionAuth;
+
+ /**
+ * Group manager.
+ *
+ * @var IGroupManager
+ */
+ private IGroupManager&MockObject $groupManager;
+
+ /**
+ * Set up.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ $this->request = $this->createMock(originalClassName: IRequest::class);
+ $this->actionAuth = $this->createMock(originalClassName: ActionAuthService::class);
+ $this->groupManager = $this->createMock(originalClassName: IGroupManager::class);
+
+ $this->controller = new ActionMatrixController(
+ request: $this->request,
+ actionAuth: $this->actionAuth,
+ groupManager: $this->groupManager,
+ );
+ }//end setUp()
+
+ /**
+ * @return array
+ */
+ public static function adminEndpoints(): array {
+ return [
+ 'read' => ['getMatrix'],
+ 'write' => ['setMatrix'],
+ ];
+ }//end adminEndpoints()
+
+ /**
+ * @dataProvider adminEndpoints
+ * @param string $method Method.
+ * @return void
+ */
+ public function testEndpointIsGatedByTheAdminSetting(string $method): void {
+ $attributes = (new ReflectionMethod(ActionMatrixController::class, $method))
+ ->getAttributes(AuthorizedAdminSetting::class);
+
+ $this->assertCount(expectedCount: 1, haystack: $attributes);
+ $this->assertSame(
+ expected: [LaunchPadAdmin::class],
+ actual: $attributes[0]->getArguments()
+ );
+ }//end testEndpointIsGatedByTheAdminSetting()
+
+ /**
+ * Get matrix merges seed actions sorted and lists groups.
+ *
+ * @return void
+ */
+ public function testGetMatrixMergesSeedActionsSortedAndListsGroups(): void {
+ $this->actionAuth->method('getMatrix')->willReturn(value: [
+ 'zz.custom-action' => ['editors'],
+ 'admin.get-my-role' => ['@all'],
+ ]);
+ $admin = $this->createMock(originalClassName: IGroup::class);
+ $admin->method('getGID')->willReturn(value: 'admin');
+ $editors = $this->createMock(originalClassName: IGroup::class);
+ $editors->method('getGID')->willReturn(value: 'editors');
+ $this->groupManager->method('search')->with('')->willReturn(value: [$admin, $editors]);
+
+ $data = $this->controller->getMatrix()->getData();
+
+ $seedKeys = array_keys(
+ json_decode(
+ (string)file_get_contents(__DIR__ . '/../../../lib/actions.seed.json'),
+ associative: true,
+ flags: JSON_THROW_ON_ERROR
+ )['actions']
+ );
+ $expected = array_values(array_unique(array_merge(['zz.custom-action'], $seedKeys)));
+ sort($expected);
+
+ $this->assertSame(expected: $expected, actual: $data['actions']);
+ // A key in both the matrix and the seed appears once.
+ $this->assertSame(
+ expected: 1,
+ actual: count(array_keys($data['actions'], 'admin.get-my-role', true))
+ );
+ $this->assertSame(expected: ['admin', 'editors'], actual: $data['groups']);
+ $this->assertSame(expected: ['editors'], actual: $data['matrix']['zz.custom-action']);
+ }//end testGetMatrixMergesSeedActionsSortedAndListsGroups()
+
+ /**
+ * Set matrix writes the submitted matrix and returns the stored one.
+ *
+ * @return void
+ */
+ public function testSetMatrixWritesTheSubmittedMatrixAndReturnsTheStoredOne(): void {
+ $submitted = ['dashboards.create' => ['staff']];
+ $this->request->method('getParam')->with('matrix')->willReturn(value: $submitted);
+ $this->actionAuth->expects($this->once())->method('setMatrix')->with($submitted);
+ $this->actionAuth->method('getMatrix')->willReturn(value: $submitted);
+
+ $response = $this->controller->setMatrix();
+
+ $this->assertSame(expected: 200, actual: $response->getStatus());
+ $this->assertSame(expected: ['matrix' => $submitted], actual: $response->getData());
+ }//end testSetMatrixWritesTheSubmittedMatrixAndReturnsTheStoredOne()
+
+ /**
+ * Set matrix treats anon array matrix as empty.
+ *
+ * @return void
+ */
+ public function testSetMatrixTreatsANonArrayMatrixAsEmpty(): void {
+ $this->request->method('getParam')->with('matrix')->willReturn(value: 'not-a-matrix');
+ $this->actionAuth->expects($this->once())->method('setMatrix')->with([]);
+ $this->actionAuth->method('getMatrix')->willReturn(value: []);
+
+ $this->assertSame(
+ expected: ['matrix' => []],
+ actual: $this->controller->setMatrix()->getData()
+ );
+ }//end testSetMatrixTreatsANonArrayMatrixAsEmpty()
+
+ /**
+ * Set matrix reports an encoding failure as bad request.
+ *
+ * @return void
+ */
+ public function testSetMatrixReportsAnEncodingFailureAsBadRequest(): void {
+ $this->request->method('getParam')->willReturn(value: ['a' => ['b']]);
+ $this->actionAuth->method('setMatrix')
+ ->willThrowException(exception: new JsonException(message: 'Malformed UTF-8'));
+
+ $response = $this->controller->setMatrix();
+
+ $this->assertSame(expected: 400, actual: $response->getStatus());
+ $this->assertSame(
+ expected: ['error' => 'Could not encode the action matrix: Malformed UTF-8'],
+ actual: $response->getData()
+ );
+ }//end testSetMatrixReportsAnEncodingFailureAsBadRequest()
+}//end class
diff --git a/tests/Unit/Controller/AdminActingUserTest.php b/tests/Unit/Controller/AdminActingUserTest.php
new file mode 100644
index 000000000..60c791a16
--- /dev/null
+++ b/tests/Unit/Controller/AdminActingUserTest.php
@@ -0,0 +1,191 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Tests\Unit\Controller;
+
+use OCA\LaunchPad\Controller\AdminBulkController;
+use OCA\LaunchPad\Controller\AdminCleanupController;
+use OCA\LaunchPad\Controller\AdminOrgNavigationController;
+use OCA\LaunchPad\Controller\CleanupSources;
+use OCA\LaunchPad\Controller\OrgNavigationSources;
+use OCA\LaunchPad\Db\AdminSettingMapper;
+use OCA\LaunchPad\Db\CleanupResult;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\BulkOperationService;
+use OCA\LaunchPad\Service\Cleanup\CategoryRegistryService;
+use OCA\LaunchPad\Service\OrgNavigationService;
+use OCA\LaunchPad\Service\OrphanedDataCleanupService;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * The endpoints that act on the session user's behalf hand the services that
+ * user, never a value from the request. The request answers `mallory` for
+ * every user-shaped parameter.
+ *
+ * @covers \OCA\LaunchPad\Controller\AdminBulkController
+ * @covers \OCA\LaunchPad\Controller\AdminCleanupController
+ * @covers \OCA\LaunchPad\Controller\AdminOrgNavigationController
+ * @uses \OCA\LaunchPad\Controller\CleanupSources
+ * @uses \OCA\LaunchPad\Controller\OrgNavigationSources
+ * @uses \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Controller\RespondsWithErrors
+ * @uses \OCA\LaunchPad\Service\RequestGuard
+ */
+class AdminActingUserTest extends TestCase {
+ /**
+ * A request whose user-shaped parameters are forged.
+ *
+ * @return IRequest The request.
+ */
+ private function forgedRequest(): IRequest {
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')->willReturnCallback(static function (string $key, mixed $default = null): mixed {
+ return in_array($key, ['userId', 'user', 'uid', 'requestedBy'], true) === true ? 'mallory' : $default;
+ });
+ return $request;
+ }
+
+ /**
+ * A guard over the given session user, who is an administrator and holds every action.
+ *
+ * @param string|null $uid The session user.
+ *
+ * @return RequestGuard The guard.
+ */
+ private function guard(?string $uid): RequestGuard {
+ $user = null;
+ if ($uid !== null) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ }
+
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $groups = $this->createMock(IGroupManager::class);
+ $groups->method('isAdmin')->willReturnCallback(static fn (string $userId): bool => $userId === $uid);
+ return new RequestGuard(userSession: $session, groupManager: $groups, actionAuth: $this->createMock(ActionAuthService::class));
+ }
+
+ /**
+ * Bulk delete runs for the session user.
+ *
+ * @return void
+ */
+ public function testBulkDeleteActsAsTheSessionUser(): void {
+ $service = $this->createMock(BulkOperationService::class);
+ $service->expects($this->once())->method('bulkDelete')->with(['uuid-1'], 'alice', false, false)->willReturn([]);
+ $controller = new AdminBulkController(request: $this->forgedRequest(), bulkService: $service, guard: $this->guard(uid: 'alice'));
+
+ $this->assertSame(200, $controller->bulkDelete(dashboardUuids: ['uuid-1'])->getStatus());
+ }
+
+ /**
+ * Bulk move runs for the session user.
+ *
+ * @return void
+ */
+ public function testBulkMoveActsAsTheSessionUser(): void {
+ $service = $this->createMock(BulkOperationService::class);
+ $service->expects($this->once())->method('bulkMove')->with(['uuid-1'], 'parent-1', 'alice', false)->willReturn([]);
+ $controller = new AdminBulkController(request: $this->forgedRequest(), bulkService: $service, guard: $this->guard(uid: 'alice'));
+
+ $this->assertSame(200, $controller->bulkMove(dashboardUuids: ['uuid-1'], parentUuid: 'parent-1')->getStatus());
+ }
+
+ /**
+ * Bulk reindex runs for the session user.
+ *
+ * @return void
+ */
+ public function testBulkReindexActsAsTheSessionUser(): void {
+ $service = $this->createMock(BulkOperationService::class);
+ $service->expects($this->once())->method('bulkReindex')->with(['uuid-1'], 'alice', false)->willReturn([]);
+ $controller = new AdminBulkController(request: $this->forgedRequest(), bulkService: $service, guard: $this->guard(uid: 'alice'));
+
+ $this->assertSame(200, $controller->bulkReindex(dashboardUuids: ['uuid-1'])->getStatus());
+ }
+
+ /**
+ * A cleanup purge is recorded against the session user.
+ *
+ * @return void
+ */
+ public function testCleanupPurgeActsAsTheSessionUser(): void {
+ $service = $this->createMock(OrphanedDataCleanupService::class);
+ $service->expects($this->once())->method('purge')->with([], false, 'alice', 'api')->willReturn($this->createMock(CleanupResult::class));
+ $controller = new AdminCleanupController(
+ request: $this->forgedRequest(),
+ cleanup: new CleanupSources(cleanupService: $service, registry: $this->createMock(CategoryRegistryService::class)),
+ guard: $this->guard(uid: 'alice')
+ );
+
+ $this->assertSame(200, $controller->purge()->getStatus());
+ }
+
+ /**
+ * The org navigation is filtered by the session user's groups.
+ *
+ * @return void
+ */
+ public function testOrgNavigationFiltersForTheSessionUser(): void {
+ $service = $this->createMock(OrgNavigationService::class);
+ $service->method('getTree')->willReturn([['id' => 'n1']]);
+ $service->expects($this->once())->method('filterTreeByUserGroups')->with([['id' => 'n1']], 'alice')->willReturn([]);
+
+ $response = $this->orgNavigation(service: $service, uid: 'alice')->getOrgNavigation();
+
+ $this->assertSame(200, $response->getStatus());
+ }
+
+ /**
+ * An anonymous caller is refused before the language is looked at.
+ *
+ * @return void
+ */
+ public function testAnonymousWithAnInvalidLanguageIsRefusedFirst(): void {
+ $service = $this->createMock(OrgNavigationService::class);
+ $service->expects($this->never())->method('getTree');
+
+ $response = $this->orgNavigation(service: $service, uid: null)->getOrgNavigation(lang: 'not-a-language');
+
+ $this->assertSame(401, $response->getStatus());
+ $this->assertSame(['error' => 'Not authenticated'], $response->getData());
+ }
+
+ /**
+ * The org navigation controller over the given service.
+ *
+ * @param OrgNavigationService $service The navigation service.
+ * @param string|null $uid The session user.
+ *
+ * @return AdminOrgNavigationController The controller.
+ */
+ private function orgNavigation(OrgNavigationService $service, ?string $uid): AdminOrgNavigationController {
+ return new AdminOrgNavigationController(
+ request: $this->forgedRequest(),
+ sources: new OrgNavigationSources(service: $service, settings: $this->createMock(AdminSettingMapper::class)),
+ guard: $this->guard(uid: $uid)
+ );
+ }
+}
diff --git a/tests/Unit/Controller/AdminBulkControllerTest.php b/tests/Unit/Controller/AdminBulkControllerTest.php
index 9761278fe..c02e9dcce 100644
--- a/tests/Unit/Controller/AdminBulkControllerTest.php
+++ b/tests/Unit/Controller/AdminBulkControllerTest.php
@@ -24,6 +24,7 @@
use InvalidArgumentException;
use OCA\LaunchPad\Controller\AdminBulkController;
+use OCA\LaunchPad\Service\RequestGuard;
use OCA\LaunchPad\Service\BulkOperationService;
use OCA\LaunchPad\Service\PermissionDeniedException;
use OCP\AppFramework\Http;
@@ -34,6 +35,7 @@
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use OCA\LaunchPad\Service\ActionAuthService;
class AdminBulkControllerTest extends TestCase {
@@ -68,8 +70,7 @@ protected function setUp(): void {
$this->controller = new AdminBulkController(
request: $this->request,
bulkService: $this->bulkService,
- userSession: $this->userSession,
- groupManager: $this->groupManager,
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->createMock(ActionAuthService::class)),
);
}//end setUp()
@@ -227,4 +228,51 @@ public function testBulkReindexNonAdminForbidden(): void {
self::assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
}//end testBulkReindexNonAdminForbidden()
-}//end class
+
+ /**
+ * Every endpoint maps invalid UUIDs, a denied dashboard and a refused request the same way.
+ *
+ * @return void
+ */
+ public function testEveryEndpointMapsItsFailuresTheSameWay(): void {
+ $this->loginAsAdmin();
+ $failures = [
+ 'denied' => new PermissionDeniedException(message: 'denied', deniedUuids: ['uuid-9']),
+ 'refused' => new InvalidArgumentException(message: 'maximum is 500'),
+ ];
+ $this->bulkService->method('bulkDelete')->willReturnCallback(function () use (&$failure) { throw $failure; });
+ $this->bulkService->method('bulkMove')->willReturnCallback(function () use (&$failure) { throw $failure; });
+ $this->bulkService->method('bulkStatus')->willReturnCallback(function () use (&$failure) { throw $failure; });
+ $this->bulkService->method('bulkReindex')->willReturnCallback(function () use (&$failure) { throw $failure; });
+
+ $calls = [
+ 'delete' => fn (mixed $uuids): JSONResponse => $this->controller->bulkDelete(dashboardUuids: $uuids),
+ 'move' => fn (mixed $uuids): JSONResponse => $this->controller->bulkMove(dashboardUuids: $uuids, parentUuid: 'p'),
+ 'status' => fn (mixed $uuids): JSONResponse => $this->controller->bulkStatus(dashboardUuids: $uuids, publicationStatus: 'published'),
+ 'reindex' => fn (mixed $uuids): JSONResponse => $this->controller->bulkReindex(dashboardUuids: $uuids),
+ ];
+
+ $actual = [];
+ foreach ($calls as $name => $call) {
+ foreach ([['a', 5], ['a', ' '], 'x'] as $i => $bad) {
+ $response = $call($bad);
+ $actual[$name.' bad '.$i] = [$response->getStatus(), $response->getData()];
+ }
+ foreach ($failures as $label => $failure) {
+ $response = $call([' uuid-9 ']);
+ $actual[$name.' '.$label] = [$response->getStatus(), $response->getData()];
+ }
+ }
+
+ $expected = [];
+ foreach (array_keys($calls) as $name) {
+ for ($i = 0; $i < 3; $i++) {
+ $expected[$name.' bad '.$i] = [400, ['error' => 'dashboardUuids must be an array of strings']];
+ }
+ $expected[$name.' denied'] = [403, ['error' => 'denied', 'deniedUuids' => ['uuid-9']]];
+ $expected[$name.' refused'] = [400, ['error' => 'maximum is 500']];
+ }
+
+ self::assertSame($expected, $actual);
+ }//end testEveryEndpointMapsItsFailuresTheSameWay()
+}
diff --git a/tests/Unit/Controller/AdminBulkStatusGoldenTest.php b/tests/Unit/Controller/AdminBulkStatusGoldenTest.php
new file mode 100644
index 000000000..0f00e4391
--- /dev/null
+++ b/tests/Unit/Controller/AdminBulkStatusGoldenTest.php
@@ -0,0 +1,264 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use DateTime;
+use OCA\LaunchPad\Activity\ActivityPublisher;
+use OCA\LaunchPad\Activity\ActivityRow;
+use OCA\LaunchPad\Controller\AdminBulkController;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\BulkDashboardStores;
+use OCA\LaunchPad\Service\BulkOperationAccess;
+use OCA\LaunchPad\Service\BulkOperationService;
+use OCA\LaunchPad\Service\DashboardTreeService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IAppConfig;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCA\LaunchPad\Service\ActionAuthService;
+
+/**
+ * Bulk status changes, request by request.
+ *
+ * @covers \OCA\LaunchPad\Controller\AdminBulkController
+ * @covers \OCA\LaunchPad\Service\BulkOperationService
+ * @uses \OCA\LaunchPad\Service\BulkDashboardStores
+ * @uses \OCA\LaunchPad\Service\BulkOperationAccess
+ * @uses \OCA\LaunchPad\Service\BulkStatusRequest
+ * @uses \OCA\LaunchPad\Service\BulkStatusChange
+ * @uses \OCA\LaunchPad\Service\BulkOutcome
+ * @uses \OCA\LaunchPad\Service\RequestGuard
+ * @uses \OCA\LaunchPad\Service\PermissionDeniedException
+ * @uses \OCA\LaunchPad\Activity\ActivityRow
+ * @uses \OCA\LaunchPad\Activity\ActivitySubject
+ * @uses \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Controller\RespondsWithErrors
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ */
+class AdminBulkStatusGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__ . '/fixtures/admin-bulk-status.json';
+
+ /**
+ * Dashboards by uuid: status, publishAt, publishedAt.
+ */
+ private const ROWS = [
+ 'd-draft' => ['draft', null, null],
+ 'd-pub' => ['published', null, '2020-01-01 00:00:00'],
+ 'd-pubnull' => ['published', null, null],
+ 'd-sched' => ['scheduled', '2099-01-01 10:00:00', null],
+ 'd-sched2' => ['scheduled', '2098-05-05 00:00:00', '2019-02-02 00:00:00'],
+ 'd-fail' => ['draft', null, null],
+ 'd-locked' => ['draft', null, null],
+ ];
+
+ /**
+ * Every request matches the recorded fixture.
+ *
+ * @return void
+ */
+ public function testStatusChangesMatchTheRecordedFixture(): void {
+ $all = ['d-draft', 'd-pub', 'd-pubnull', 'd-sched', 'd-sched2', 'd-missing', 'd-fail'];
+ $actual = [];
+ $statuses = ['draft', 'published', 'scheduled', 'archived', ' published', '', null];
+ $publishAts = [null, '', ' ', '2000-01-01 00:00:00', '2099-01-01 10:00:00', '2099-01-01T10:00:00+02:00', 'garbage', ' 2099-01-01 10:00:00 '];
+ foreach ($statuses as $status) {
+ foreach ($publishAts as $publishAt) {
+ foreach ([false, true] as $dryRun) {
+ $name = 'status ' . json_encode($status) . ' at ' . json_encode($publishAt) . ' dry ' . json_encode($dryRun);
+ $actual[$name] = $this->request(caller: 'alice', request: [$all, $status, $publishAt, $dryRun]);
+ }
+ }
+ }
+
+ $uuidSets = [
+ 'all' => $all,
+ 'locked' => ['d-draft', 'd-locked'],
+ 'locked missing' => ['d-locked', 'd-missing'],
+ 'own' => ['d-draft', 'd-pub', 'd-sched'],
+ 'empty' => [],
+ 'duplicate' => ['d-draft', 'd-draft'],
+ 'padded' => [' d-draft '],
+ 'blank' => ['d-draft', ' '],
+ 'int' => ['d-draft', 7],
+ 'not an array' => 'd-draft',
+ 'null' => null,
+ ];
+ foreach ($uuidSets as $setName => $uuids) {
+ foreach (['alice', 'carol', 'bob', null] as $caller) {
+ $actual['set ' . $setName . ' caller ' . json_encode($caller)] = $this->request(caller: $caller, request: [$uuids, 'published', null, false]);
+ }
+ }
+
+ foreach (['true', '1', 'yes', 'on', 'TRUE', 'no', '0', '', null] as $query) {
+ $actual['dry query ' . json_encode($query)] = $this->request(caller: 'alice', request: [['d-draft'], 'published', null, null], query: $query);
+ }
+
+ $actual['body false wins over query'] = $this->request(caller: 'alice', request: [['d-draft'], 'published', null, false], query: 'true');
+ foreach ([1, 2, 0, -5] as $cap) {
+ $actual['cap ' . $cap] = $this->request(caller: 'alice', request: [['d-draft', 'd-pub'], 'draft', null, false], cap: $cap);
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+ $this->markTestIncomplete(message: 'Fixture recorded.');
+ }
+
+ $expected = json_decode((string)file_get_contents(self::FIXTURE), true);
+ $this->assertSame(expected: array_keys($expected), actual: array_keys($actual));
+ foreach ($expected as $name => $case) {
+ $this->assertSame(expected: $case, actual: $actual[$name], message: $name);
+ }
+ }
+
+ /**
+ * Run one bulk-status request through the controller and record it.
+ *
+ * @param string|null $caller The session user, or none.
+ * @param array $request Uuids, status, publishAt, dryRun.
+ * @param string|null $query The dryRun query parameter.
+ * @param int $cap The configured batch cap.
+ *
+ * @return array
+ */
+ private function request(?string $caller, array $request, ?string $query=null, int $cap=500): array {
+ $log = [];
+ $rows = [];
+ foreach (self::ROWS as $uuid => [$status, $publishAt, $publishedAt]) {
+ $row = new Dashboard();
+ $row->setUuid($uuid);
+ $row->setPublicationStatus($status);
+ $row->setPublishAt($publishAt);
+ $row->setPublishedAt($publishedAt);
+ $rows[$uuid] = $row;
+ }
+
+ $mapper = $this->getMockBuilder(className: DashboardMapper::class)->disableOriginalConstructor()->getMock();
+ $mapper->method('findByUuid')->willReturnCallback(static function (string $uuid) use ($rows): Dashboard {
+ if (isset($rows[$uuid]) === false) {
+ throw new DoesNotExistException('none');
+ }
+
+ return $rows[$uuid];
+ });
+ $mapper->method('update')->willReturnCallback(static function (Dashboard $row) use (&$log): Dashboard {
+ if ($row->getUuid() === 'd-fail') {
+ throw new RuntimeException('disk full');
+ }
+
+ $log[] = ['update', $row->getUuid(), $row->getPublicationStatus(), $row->getPublishAt(), self::clock(value: $row->getPublishedAt()), self::clock(value: $row->getUpdatedAt())];
+ return $row;
+ });
+
+ $admins = $this->getMockBuilder(className: IGroupManager::class)->getMock();
+ $admins->method('isAdmin')->willReturnCallback(static function (string $userId) use (&$log): bool {
+ $log[] = 'isAdmin ' . $userId;
+ return $userId === 'alice' || $userId === 'carol';
+ });
+
+ $permissions = $this->getMockBuilder(className: PermissionService::class)->disableOriginalConstructor()->getMock();
+ $permissions->method('resolveAccessLevel')->willReturnCallback(static function (string $userId, ?int $dashboardId=null, ?Dashboard $dashboard=null) use (&$log): ?string {
+ $log[] = 'resolveAccessLevel ' . $userId . ' ' . $dashboard->getUuid();
+ if ($dashboard->getUuid() === 'd-locked' || ($userId === 'carol' && $dashboard->getUuid() === 'd-sched2')) {
+ return null;
+ }
+
+ return Dashboard::PERMISSION_FULL;
+ });
+
+ $config = $this->getMockBuilder(className: IAppConfig::class)->getMock();
+ $config->method('getValueInt')->willReturn($cap);
+
+ $publisher = $this->getMockBuilder(className: ActivityPublisher::class)->disableOriginalConstructor()->getMock();
+ $publisher->method('publish')->willReturnCallback(static function (ActivityRow $row, string $recipientUserId) use (&$log): bool {
+ $extra = $row->extraParams;
+ $extra['durationMs'] = is_int($extra['durationMs']) && $extra['durationMs'] >= 0 ? '' : $extra['durationMs'];
+ $log[] = ['publish', $row->type, $row->actorUserId, $row->dashboard->uuid, $row->dashboard->name, $extra, $recipientUserId];
+ return true;
+ });
+
+ $logger = $this->getMockBuilder(className: LoggerInterface::class)->getMock();
+ foreach (['error', 'warning'] as $level) {
+ $logger->method($level)->willReturnCallback(static function (string $message, array $context=[]) use (&$log, $level): void {
+ $log[] = [$level, $message, $context['uuid'] ?? null, isset($context['exception']) ? $context['exception']->getMessage() : null];
+ });
+ }
+
+ $session = $this->getMockBuilder(className: IUserSession::class)->getMock();
+ $user = null;
+ if ($caller !== null) {
+ $user = $this->getMockBuilder(className: IUser::class)->getMock();
+ $user->method('getUID')->willReturn($caller);
+ }
+
+ $session->method('getUser')->willReturn($user);
+
+ $httpRequest = $this->getMockBuilder(className: IRequest::class)->getMock();
+ $httpRequest->method('getParam')->willReturnCallback(static fn (string $key): ?string => $key === 'dryRun' ? $query : null);
+
+ $service = new BulkOperationService(
+ stores: new BulkDashboardStores(
+ dashboardMapper: $mapper,
+ placementMapper: $this->getMockBuilder(className: WidgetPlacementMapper::class)->disableOriginalConstructor()->getMock(),
+ treeService: $this->getMockBuilder(className: DashboardTreeService::class)->disableOriginalConstructor()->getMock()
+ ),
+ access: new BulkOperationAccess(permissionService: $permissions, groupManager: $admins, appConfig: $config),
+ activityPublisher: $publisher,
+ logger: $logger
+ );
+ $controller = new AdminBulkController(
+ request: $httpRequest,
+ bulkService: $service,
+ guard: new RequestGuard(userSession: $session, groupManager: $admins, actionAuth: $this->getMockBuilder(className: ActionAuthService::class)->disableOriginalConstructor()->getMock())
+ );
+
+ [$uuids, $status, $publishAt, $dryRun] = $request;
+ $response = $controller->bulkStatus(dashboardUuids: $uuids, publicationStatus: $status, publishAt: $publishAt, dryRun: $dryRun);
+
+ return ['status' => $response->getStatus(), 'body' => $response->getData(), 'log' => $log];
+ }
+
+ /**
+ * Replace a timestamp taken from the clock during the run with a marker.
+ *
+ * @param string|null $value A stored timestamp.
+ *
+ * @return string|null
+ */
+ private static function clock(?string $value): ?string {
+ if ($value === null || preg_match('/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/', $value) !== 1) {
+ return $value;
+ }
+
+ $delta = abs((new DateTime($value))->getTimestamp() - time());
+ return $delta < 600 ? '' : $value;
+ }
+}
diff --git a/tests/Unit/Controller/AdminCleanupControllerTest.php b/tests/Unit/Controller/AdminCleanupControllerTest.php
index e10f4afd9..cab3476e0 100644
--- a/tests/Unit/Controller/AdminCleanupControllerTest.php
+++ b/tests/Unit/Controller/AdminCleanupControllerTest.php
@@ -22,6 +22,7 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\CleanupSources;
use OCA\LaunchPad\Controller\AdminCleanupController;
use OCA\LaunchPad\Db\CleanupResult;
use OCA\LaunchPad\Service\Cleanup\CategoryRegistryService;
@@ -33,6 +34,8 @@
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCA\LaunchPad\Service\ActionAuthService;
/**
* Unit tests for AdminCleanupController.
@@ -94,10 +97,11 @@ protected function setUp(): void {
private function makeController(): AdminCleanupController {
return new AdminCleanupController(
request: $this->request,
- cleanupService: $this->cleanupService,
- registry: $this->registry,
- groupManager: $this->groupManager,
- userSession: $this->userSession,
+ cleanup: new CleanupSources(
+ cleanupService: $this->cleanupService,
+ registry: $this->registry
+ ),
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->createMock(ActionAuthService::class))
);
}
diff --git a/tests/Unit/Controller/AdminControllerExportImportTest.php b/tests/Unit/Controller/AdminControllerExportImportTest.php
index e4a1506d5..4edd92c62 100644
--- a/tests/Unit/Controller/AdminControllerExportImportTest.php
+++ b/tests/Unit/Controller/AdminControllerExportImportTest.php
@@ -21,6 +21,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
use OCA\LaunchPad\Controller\AdminController;
use OCA\LaunchPad\Service\AdminSettingsService;
use OCA\LaunchPad\Service\AdminTemplateService;
@@ -76,18 +79,24 @@ protected function setUp(): void {
$this->controller = new AdminController(
request: $this->request,
- templateService: $this->templateService,
- settingsService: $this->settingsService,
- groupManager: $this->groupManager,
- userSession: $this->userSession,
- exportService: $this->exportService,
- importService: $this->importService,
- roleService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\RoleService::class),
- feedRefresh: $this->createMock(originalClassName: \OCA\LaunchPad\Service\FeedRefreshService::class),
- footerService: $this->footerService,
- setupWizardService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\SetupWizardService::class),
- actionAuth: $this->createMock(originalClassName: \OCA\LaunchPad\Service\ActionAuthService::class),
- resyncService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\TemplateResyncService::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->templateService,
+ exportService: $this->exportService,
+ importService: $this->importService,
+ resyncService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->settingsService,
+ groupManager: $this->groupManager,
+ footerService: $this->footerService,
+ setupWizardService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\RoleService::class),
+ feedRefresh: $this->createMock(originalClassName: \OCA\LaunchPad\Service\FeedRefreshService::class),
+ userSession: $this->userSession,
+ actionAuth: $this->createMock(originalClassName: \OCA\LaunchPad\Service\ActionAuthService::class)
+ )
);
}
diff --git a/tests/Unit/Controller/AdminControllerFooterSettingsTest.php b/tests/Unit/Controller/AdminControllerFooterSettingsTest.php
index f3a3c9705..17593fdca 100644
--- a/tests/Unit/Controller/AdminControllerFooterSettingsTest.php
+++ b/tests/Unit/Controller/AdminControllerFooterSettingsTest.php
@@ -22,6 +22,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
use InvalidArgumentException;
use OCA\LaunchPad\Controller\AdminController;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -61,18 +64,24 @@ protected function setUp(): void {
$this->controller = new AdminController(
request: $this->request,
- templateService: $this->createMock(AdminTemplateService::class),
- settingsService: $this->createMock(AdminSettingsService::class),
- groupManager: $this->groupManager,
- userSession: $this->userSession,
- exportService: $this->createMock(ExportService::class),
- importService: $this->createMock(ImportService::class),
- roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
- feedRefresh: $this->createMock(\OCA\LaunchPad\Service\FeedRefreshService::class),
- footerService: $this->footerService,
- setupWizardService: $this->createMock(\OCA\LaunchPad\Service\SetupWizardService::class),
- actionAuth: $this->createMock(ActionAuthService::class),
- resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->createMock(AdminTemplateService::class),
+ exportService: $this->createMock(ExportService::class),
+ importService: $this->createMock(ImportService::class),
+ resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->createMock(AdminSettingsService::class),
+ groupManager: $this->groupManager,
+ footerService: $this->footerService,
+ setupWizardService: $this->createMock(\OCA\LaunchPad\Service\SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
+ feedRefresh: $this->createMock(\OCA\LaunchPad\Service\FeedRefreshService::class),
+ userSession: $this->userSession,
+ actionAuth: $this->createMock(ActionAuthService::class)
+ )
);
}
diff --git a/tests/Unit/Controller/AdminControllerGroupOrderTest.php b/tests/Unit/Controller/AdminControllerGroupOrderTest.php
index d09c94553..716117809 100644
--- a/tests/Unit/Controller/AdminControllerGroupOrderTest.php
+++ b/tests/Unit/Controller/AdminControllerGroupOrderTest.php
@@ -29,6 +29,8 @@
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCA\LaunchPad\Service\ActionAuthService;
class AdminControllerGroupOrderTest extends TestCase {
private AdminSettingsController $controller;
@@ -51,7 +53,7 @@ protected function setUp(): void {
request: $this->request,
settingsService: $this->settingsService,
groupManager: $this->groupManager,
- userSession: $this->userSession,
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->createMock(ActionAuthService::class)),
);
}
diff --git a/tests/Unit/Controller/AdminControllerRefreshFeedsTest.php b/tests/Unit/Controller/AdminControllerRefreshFeedsTest.php
index 01bc5c2cf..bf49a7514 100644
--- a/tests/Unit/Controller/AdminControllerRefreshFeedsTest.php
+++ b/tests/Unit/Controller/AdminControllerRefreshFeedsTest.php
@@ -20,6 +20,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
use OCA\LaunchPad\Controller\AdminController;
use OCA\LaunchPad\Service\AdminSettingsService;
use OCA\LaunchPad\Service\AdminTemplateService;
@@ -66,18 +69,24 @@ protected function setUp(): void {
$this->controller = new AdminController(
request: $this->request,
- templateService: $this->templateService,
- settingsService: $this->settingsService,
- groupManager: $this->groupManager,
- userSession: $this->userSession,
- exportService: $this->createMock(\OCA\LaunchPad\Service\ExportService::class),
- importService: $this->createMock(\OCA\LaunchPad\Service\ImportService::class),
- roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
- feedRefresh: $this->feedRefresh,
- footerService: $this->createMock(\OCA\LaunchPad\Service\FooterService::class),
- setupWizardService: $this->createMock(\OCA\LaunchPad\Service\SetupWizardService::class),
- actionAuth: $this->createMock(\OCA\LaunchPad\Service\ActionAuthService::class),
- resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->templateService,
+ exportService: $this->createMock(\OCA\LaunchPad\Service\ExportService::class),
+ importService: $this->createMock(\OCA\LaunchPad\Service\ImportService::class),
+ resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->settingsService,
+ groupManager: $this->groupManager,
+ footerService: $this->createMock(\OCA\LaunchPad\Service\FooterService::class),
+ setupWizardService: $this->createMock(\OCA\LaunchPad\Service\SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
+ feedRefresh: $this->feedRefresh,
+ userSession: $this->userSession,
+ actionAuth: $this->createMock(\OCA\LaunchPad\Service\ActionAuthService::class)
+ )
);
}//end setUp()
diff --git a/tests/Unit/Controller/AdminControllerResyncTemplateTest.php b/tests/Unit/Controller/AdminControllerResyncTemplateTest.php
index 780aba525..007692700 100644
--- a/tests/Unit/Controller/AdminControllerResyncTemplateTest.php
+++ b/tests/Unit/Controller/AdminControllerResyncTemplateTest.php
@@ -23,6 +23,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
use InvalidArgumentException;
use OCA\LaunchPad\Controller\AdminController;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -66,18 +69,24 @@ protected function setUp(): void {
$this->controller = new AdminController(
request: $this->request,
- templateService: $this->createMock(AdminTemplateService::class),
- settingsService: $this->createMock(AdminSettingsService::class),
- groupManager: $this->groupManager,
- userSession: $this->userSession,
- exportService: $this->createMock(ExportService::class),
- importService: $this->createMock(ImportService::class),
- roleService: $this->createMock(RoleService::class),
- feedRefresh: $this->createMock(FeedRefreshService::class),
- footerService: $this->createMock(FooterService::class),
- setupWizardService: $this->createMock(SetupWizardService::class),
- actionAuth: $this->createMock(ActionAuthService::class),
- resyncService: $this->resyncService,
+ templates: new AdminTemplateTools(
+ templateService: $this->createMock(AdminTemplateService::class),
+ exportService: $this->createMock(ExportService::class),
+ importService: $this->createMock(ImportService::class),
+ resyncService: $this->resyncService
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->createMock(AdminSettingsService::class),
+ groupManager: $this->groupManager,
+ footerService: $this->createMock(FooterService::class),
+ setupWizardService: $this->createMock(SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->createMock(RoleService::class),
+ feedRefresh: $this->createMock(FeedRefreshService::class),
+ userSession: $this->userSession,
+ actionAuth: $this->createMock(ActionAuthService::class)
+ )
);
}
diff --git a/tests/Unit/Controller/AdminControllerRoleExportGoldenTest.php b/tests/Unit/Controller/AdminControllerRoleExportGoldenTest.php
new file mode 100644
index 000000000..9b5ab8647
--- /dev/null
+++ b/tests/Unit/Controller/AdminControllerRoleExportGoldenTest.php
@@ -0,0 +1,226 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminController;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
+use OCA\LaunchPad\Db\RoleAssignment;
+use OCA\LaunchPad\Exception\DuplicateRoleAssignmentException;
+use OCA\LaunchPad\Exception\InvalidRoleAssignmentException;
+use OCA\LaunchPad\Service\ExportService;
+use OCA\LaunchPad\Service\ImportService;
+use OCA\LaunchPad\Service\RoleService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\StreamResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class AdminControllerRoleExportGoldenTest extends TestCase {
+ /** @var RoleService&MockObject */
+ private $roleService;
+
+ /** @var ExportService&MockObject */
+ private $exportService;
+
+ /** @var ImportService&MockObject */
+ private $importService;
+
+ /** @var IGroupManager&MockObject */
+ private $groupManager;
+
+ private AdminController $controller;
+
+ private bool $callerIsAdmin = true;
+
+ protected function setUp(): void {
+ $this->roleService = $this->createMock(originalClassName: RoleService::class);
+ $this->exportService = $this->createMock(originalClassName: ExportService::class);
+ $this->importService = $this->createMock(originalClassName: ImportService::class);
+ $this->groupManager = $this->createMock(originalClassName: IGroupManager::class);
+
+ $user = $this->createMock(originalClassName: IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $userSession = $this->createMock(originalClassName: IUserSession::class);
+ $userSession->method('getUser')->willReturn($user);
+ $this->groupManager->method('isAdmin')->willReturnCallback(
+ fn (string $uid): bool => ($uid === 'alice' && $this->callerIsAdmin === true)
+ );
+
+ $this->controller = new AdminController(
+ request: $this->createMock(originalClassName: IRequest::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\AdminTemplateService::class),
+ exportService: $this->exportService,
+ importService: $this->importService,
+ resyncService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\AdminSettingsService::class),
+ groupManager: $this->groupManager,
+ footerService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\FooterService::class),
+ setupWizardService: $this->createMock(originalClassName: \OCA\LaunchPad\Service\SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->roleService,
+ feedRefresh: $this->createMock(originalClassName: \OCA\LaunchPad\Service\FeedRefreshService::class),
+ userSession: $userSession,
+ actionAuth: $this->createMock(originalClassName: \OCA\LaunchPad\Service\ActionAuthService::class)
+ )
+ );
+ }
+
+ protected function tearDown(): void {
+ unset($_FILES['file']);
+ }
+
+ /**
+ * Status and body of a JSON answer.
+ *
+ * @param JSONResponse $response The answer.
+ *
+ * @return array{0: int, 1: mixed}
+ */
+ private function answer(JSONResponse $response): array {
+ return [$response->getStatus(), $response->getData()];
+ }
+
+ public function testCreateRoleAnswers(): void {
+ $assignment = $this->createMock(originalClassName: RoleAssignment::class);
+ $assignment->method('jsonSerialize')->willReturn(['id' => 3, 'role' => 'editor']);
+ $this->roleService->expects($this->exactly(3))->method('assignRole')
+ ->willReturnCallback(
+ static function (?string $userId, ?string $groupId, string $role, string $assignedBy) use ($assignment) {
+ self::assertSame('alice', $assignedBy);
+ if ($role === 'dup') {
+ throw new DuplicateRoleAssignmentException();
+ }
+
+ if ($role === '') {
+ throw new InvalidRoleAssignmentException();
+ }
+
+ return $assignment;
+ }
+ );
+
+ $this->assertSame(
+ [201, ['id' => 3, 'role' => 'editor']],
+ $this->answer($this->controller->createRole(userId: 'bob', groupId: null, role: 'editor'))
+ );
+ $this->assertSame(
+ [409, ['error' => 'That target already has the requested role assigned', 'errorCode' => 'duplicate_role_assignment']],
+ $this->answer($this->controller->createRole(userId: 'bob', groupId: null, role: 'dup'))
+ );
+ $this->assertSame(
+ [400, ['error' => 'Role assignment payload is invalid', 'errorCode' => 'invalid_role_assignment']],
+ $this->answer($this->controller->createRole(userId: null, groupId: null, role: null))
+ );
+ }
+
+ public function testExportAnswers(): void {
+ $site = $this->createMock(originalClassName: StreamResponse::class);
+ $one = $this->createMock(originalClassName: StreamResponse::class);
+ $this->exportService->method('exportSite')->with('alice')->willReturn($site);
+ $this->exportService->method('exportDashboard')->willReturnCallback(
+ static function (string $dashboardUuid, string $currentUserId) use ($one) {
+ self::assertSame('alice', $currentUserId);
+ if ($dashboardUuid === 'missing-uuid') {
+ throw new DoesNotExistException('nope');
+ }
+
+ return $one;
+ }
+ );
+
+ $this->assertSame($site, $this->controller->export(scope: 'site', dashboardUuid: null));
+ $this->assertSame($one, $this->controller->export(scope: 'dashboard', dashboardUuid: 'abcd-1234'));
+ $this->assertSame(
+ [404, ['error' => 'Dashboard not found']],
+ $this->answer($this->controller->export(scope: 'dashboard', dashboardUuid: 'missing-uuid'))
+ );
+ $this->assertSame(
+ [400, ['error' => 'dashboardUuid parameter is required when scope=dashboard']],
+ $this->answer($this->controller->export(scope: 'dashboard', dashboardUuid: ''))
+ );
+ $this->assertSame(
+ [400, ['error' => 'Invalid dashboard UUID format']],
+ $this->answer($this->controller->export(scope: 'dashboard', dashboardUuid: 'bad uuid!'))
+ );
+ $this->assertSame(
+ [400, ['error' => 'Unsupported scope: x']],
+ $this->answer($this->controller->export(scope: 'x', dashboardUuid: null))
+ );
+
+ $this->callerIsAdmin = false;
+ $this->assertSame(403, $this->controller->export(scope: 'site', dashboardUuid: null)->getStatus());
+ }
+
+ public function testImportAnswers(): void {
+ $results = [
+ ['status' => 'ok', 'importedDashboardCount' => 2, 'skippedDashboardCount' => 1, 'errors' => ['e']],
+ ['status' => ImportService::ERR_UUID_COLLISION, 'errors' => ['clash']],
+ ];
+ $this->importService->method('import')->willReturnCallback(
+ static function (string $zipPath, bool $preserveUuids, string $currentUserId) use (&$results) {
+ self::assertSame('/tmp/upload.zip', $zipPath);
+ self::assertSame('alice', $currentUserId);
+ if ($preserveUuids === false && $results === []) {
+ throw new \InvalidArgumentException('Not a zip');
+ }
+
+ return array_shift($results);
+ }
+ );
+
+ $_FILES['file'] = ['tmp_name' => '/tmp/upload.zip'];
+ $this->assertSame(
+ [200, ['importedDashboardCount' => 2, 'skippedDashboardCount' => 1, 'errors' => ['e']]],
+ $this->answer($this->controller->import(preserveUuids: true))
+ );
+ $this->assertSame(
+ [409, ['importedDashboardCount' => 0, 'skippedDashboardCount' => 0, 'errors' => ['clash']]],
+ $this->answer($this->controller->import(preserveUuids: true))
+ );
+ $this->assertSame(
+ [400, ['error' => 'Not a zip']],
+ $this->answer($this->controller->import(preserveUuids: false))
+ );
+
+ $_FILES['file'] = ['tmp_name' => ''];
+ $this->assertSame(
+ [400, ['error' => 'No file uploaded under field "file".']],
+ $this->answer($this->controller->import(preserveUuids: false))
+ );
+ $_FILES['file'] = 'not-an-array';
+ $this->assertSame(400, $this->controller->import(preserveUuids: false)->getStatus());
+
+ $this->callerIsAdmin = false;
+ $this->assertSame(403, $this->controller->import(preserveUuids: false)->getStatus());
+ }
+}
diff --git a/tests/Unit/Controller/AdminControllerRolesAndTemplatesTest.php b/tests/Unit/Controller/AdminControllerRolesAndTemplatesTest.php
new file mode 100644
index 000000000..3a829e3bf
--- /dev/null
+++ b/tests/Unit/Controller/AdminControllerRolesAndTemplatesTest.php
@@ -0,0 +1,402 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminController;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\RoleAssignment;
+use OCA\LaunchPad\Exception\DuplicateRoleAssignmentException;
+use OCA\LaunchPad\Exception\InvalidRoleAssignmentException;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\AdminSettingsService;
+use OCA\LaunchPad\Service\AdminTemplateService;
+use OCA\LaunchPad\Service\ExportService;
+use OCA\LaunchPad\Service\FeedRefreshService;
+use OCA\LaunchPad\Service\FooterService;
+use OCA\LaunchPad\Service\ImportService;
+use OCA\LaunchPad\Service\RoleService;
+use OCA\LaunchPad\Service\SetupWizardService;
+use OCA\LaunchPad\Service\TemplateResyncService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+class AdminControllerRolesAndTemplatesTest extends TestCase {
+ /**
+ * Controller.
+ *
+ * @var AdminController
+ */
+ private AdminController $controller;
+
+ /**
+ * Template service.
+ *
+ * @var AdminTemplateService
+ */
+ private AdminTemplateService&MockObject $templateService;
+
+ /**
+ * Group manager.
+ *
+ * @var IGroupManager
+ */
+ private IGroupManager&MockObject $groupManager;
+
+ /**
+ * User session.
+ *
+ * @var IUserSession
+ */
+ private IUserSession&MockObject $userSession;
+
+ /**
+ * Role service.
+ *
+ * @var RoleService
+ */
+ private RoleService&MockObject $roleService;
+
+ /**
+ * Action auth.
+ *
+ * @var ActionAuthService
+ */
+ private ActionAuthService&MockObject $actionAuth;
+
+ /**
+ * Feed refresh.
+ *
+ * @var FeedRefreshService
+ */
+ private FeedRefreshService&MockObject $feedRefresh;
+
+ /**
+ * Set up.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ $this->templateService = $this->createMock(originalClassName: AdminTemplateService::class);
+ $this->groupManager = $this->createMock(originalClassName: IGroupManager::class);
+ $this->userSession = $this->createMock(originalClassName: IUserSession::class);
+ $this->roleService = $this->createMock(originalClassName: RoleService::class);
+ $this->actionAuth = $this->createMock(originalClassName: ActionAuthService::class);
+ $this->feedRefresh = $this->createMock(originalClassName: FeedRefreshService::class);
+
+ $this->controller = new AdminController(
+ request: $this->createMock(originalClassName: IRequest::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->templateService,
+ exportService: $this->createMock(originalClassName: ExportService::class),
+ importService: $this->createMock(originalClassName: ImportService::class),
+ resyncService: $this->createMock(originalClassName: TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->createMock(originalClassName: AdminSettingsService::class),
+ groupManager: $this->groupManager,
+ footerService: $this->createMock(originalClassName: FooterService::class),
+ setupWizardService: $this->createMock(originalClassName: SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->roleService,
+ feedRefresh: $this->feedRefresh,
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ )
+ );
+ }//end setUp()
+
+ /**
+ * Sign in.
+ *
+ * @param string $uid Uid.
+ * @return IUser
+ */
+ private function signIn(string $uid): IUser {
+ $user = $this->createMock(originalClassName: IUser::class);
+ $user->method('getUID')->willReturn(value: $uid);
+ $this->userSession->method('getUser')->willReturn(value: $user);
+ return $user;
+ }//end signIn()
+
+ /**
+ * Admin guard refuses an anonymous caller.
+ *
+ * @return void
+ */
+ public function testAdminGuardRefusesAnAnonymousCaller(): void {
+ $this->userSession->method('getUser')->willReturn(value: null);
+ $this->feedRefresh->expects($this->never())->method('refreshAll');
+
+ $response = $this->controller->refreshFeedsNow();
+
+ $this->assertSame(expected: Http::STATUS_UNAUTHORIZED, actual: $response->getStatus());
+ $this->assertSame(expected: ['error' => 'Not authenticated'], actual: $response->getData());
+ }//end testAdminGuardRefusesAnAnonymousCaller()
+
+ /**
+ * List roles serialises every assignment.
+ *
+ * @return void
+ */
+ public function testListRolesSerialisesEveryAssignment(): void {
+ $row = new RoleAssignment();
+ $row->setUserId('alice');
+ $row->setRole('editor');
+ $this->roleService->method('listAssignments')->willReturn(value: [$row]);
+
+ $response = $this->controller->listRoles();
+
+ $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus());
+ $this->assertSame(expected: 'alice', actual: $response->getData()[0]['userId']);
+ $this->assertSame(expected: 'editor', actual: $response->getData()[0]['role']);
+ }//end testListRolesSerialisesEveryAssignment()
+
+ /**
+ * Create role records the acting admin and answers201.
+ *
+ * @return void
+ */
+ public function testCreateRoleRecordsTheActingAdminAndAnswers201(): void {
+ $this->signIn(uid: 'root');
+ $created = new RoleAssignment();
+ $created->setGroupId('staff');
+ $created->setRole('viewer');
+ $created->setAssignedBy('root');
+ $this->roleService->expects($this->once())->method('assignRole')
+ ->with(null, 'staff', 'viewer', 'root')
+ ->willReturn(value: $created);
+
+ $response = $this->controller->createRole(groupId: 'staff', role: 'viewer');
+
+ $this->assertSame(expected: Http::STATUS_CREATED, actual: $response->getStatus());
+ $this->assertSame(expected: 'root', actual: $response->getData()['assignedBy']);
+ }//end testCreateRoleRecordsTheActingAdminAndAnswers201()
+
+ /**
+ * Create role answers aduplicate with409.
+ *
+ * @return void
+ */
+ public function testCreateRoleAnswersADuplicateWith409(): void {
+ $this->signIn(uid: 'root');
+ $this->roleService->method('assignRole')
+ ->willThrowException(exception: new DuplicateRoleAssignmentException());
+
+ $response = $this->controller->createRole(userId: 'alice', role: 'editor');
+
+ $this->assertSame(expected: Http::STATUS_CONFLICT, actual: $response->getStatus());
+ $this->assertSame(
+ expected: 'duplicate_role_assignment',
+ actual: $response->getData()['errorCode']
+ );
+ }//end testCreateRoleAnswersADuplicateWith409()
+
+ /**
+ * Create role answers an invalid payload with400.
+ *
+ * @return void
+ */
+ public function testCreateRoleAnswersAnInvalidPayloadWith400(): void {
+ $this->signIn(uid: 'root');
+ $this->roleService->method('assignRole')
+ ->willThrowException(exception: new InvalidRoleAssignmentException(message: 'Unknown role'));
+
+ $response = $this->controller->createRole(userId: 'alice', role: 'overlord');
+
+ $this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus());
+ $this->assertSame(
+ expected: ['error' => 'Unknown role', 'errorCode' => 'invalid_role_assignment'],
+ actual: $response->getData()
+ );
+ }//end testCreateRoleAnswersAnInvalidPayloadWith400()
+
+ /**
+ * Delete role answers204.
+ *
+ * @return void
+ */
+ public function testDeleteRoleAnswers204(): void {
+ $this->roleService->expects($this->once())->method('removeRole')->with(12);
+
+ $this->assertSame(
+ expected: Http::STATUS_NO_CONTENT,
+ actual: $this->controller->deleteRole(id: 12)->getStatus()
+ );
+ }//end testDeleteRoleAnswers204()
+
+ /**
+ * Delete role answers amissing row with404.
+ *
+ * @return void
+ */
+ public function testDeleteRoleAnswersAMissingRowWith404(): void {
+ $this->roleService->method('removeRole')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'gone'));
+
+ $this->assertSame(
+ expected: Http::STATUS_NOT_FOUND,
+ actual: $this->controller->deleteRole(id: 12)->getStatus()
+ );
+ }//end testDeleteRoleAnswersAMissingRowWith404()
+
+ /**
+ * Get my role refuses an anonymous caller.
+ *
+ * @return void
+ */
+ public function testGetMyRoleRefusesAnAnonymousCaller(): void {
+ $this->userSession->method('getUser')->willReturn(value: null);
+ $this->roleService->expects($this->never())->method('getEffectiveRole');
+
+ $this->assertSame(
+ expected: Http::STATUS_UNAUTHORIZED,
+ actual: $this->controller->getMyRole()->getStatus()
+ );
+ }//end testGetMyRoleRefusesAnAnonymousCaller()
+
+ /**
+ * Get my role refuses acaller the matrix denies.
+ *
+ * @return void
+ */
+ public function testGetMyRoleRefusesACallerTheMatrixDenies(): void {
+ $this->signIn(uid: 'alice');
+ $this->actionAuth->method('requireAction')
+ ->willThrowException(exception: new OCSForbiddenException());
+ $this->roleService->expects($this->never())->method('getEffectiveRole');
+
+ $response = $this->controller->getMyRole();
+
+ $this->assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus());
+ $this->assertSame(expected: ['error' => 'Forbidden'], actual: $response->getData());
+ }//end testGetMyRoleRefusesACallerTheMatrixDenies()
+
+ /**
+ * Get my role answers the callers own role and source.
+ *
+ * @return void
+ */
+ public function testGetMyRoleAnswersTheCallersOwnRoleAndSource(): void {
+ $user = $this->signIn(uid: 'alice');
+ $this->actionAuth->expects($this->once())->method('requireAction')
+ ->with($user, 'admin.get-my-role');
+ $this->roleService->method('getEffectiveRole')->with('alice')->willReturn(value: 'editor');
+ $this->roleService->method('getRoleSource')->with('alice')->willReturn(value: 'group');
+
+ $this->assertSame(
+ expected: ['role' => 'editor', 'source' => 'group'],
+ actual: $this->controller->getMyRole()->getData()
+ );
+ }//end testGetMyRoleAnswersTheCallersOwnRoleAndSource()
+
+ /**
+ * List templates serialises every template.
+ *
+ * @return void
+ */
+ public function testListTemplatesSerialisesEveryTemplate(): void {
+ $template = new Dashboard();
+ $template->setName('Onboarding');
+ $this->templateService->method('listTemplates')->willReturn(value: [$template]);
+
+ $data = $this->controller->listTemplates()->getData();
+
+ $this->assertCount(expectedCount: 1, haystack: $data);
+ $this->assertSame(expected: 'Onboarding', actual: $data[0]['name']);
+ }//end testListTemplatesSerialisesEveryTemplate()
+
+ /**
+ * Get template answers the template with its placements.
+ *
+ * @return void
+ */
+ public function testGetTemplateAnswersTheTemplateWithItsPlacements(): void {
+ $template = new Dashboard();
+ $template->setName('Onboarding');
+ $this->templateService->method('getTemplateWithPlacements')->with(3)
+ ->willReturn(value: ['template' => $template, 'placements' => []]);
+
+ $response = $this->controller->getTemplate(id: 3);
+
+ $this->assertSame(expected: Http::STATUS_OK, actual: $response->getStatus());
+ $this->assertSame(expected: 'Onboarding', actual: $response->getData()['template']['name']);
+ $this->assertSame(expected: [], actual: $response->getData()['placements']);
+ }//end testGetTemplateAnswersTheTemplateWithItsPlacements()
+
+ /**
+ * Get template answers amissing template with404.
+ *
+ * @return void
+ */
+ public function testGetTemplateAnswersAMissingTemplateWith404(): void {
+ $this->templateService->method('getTemplateWithPlacements')
+ ->willThrowException(exception: new DoesNotExistException(msg: 'gone'));
+
+ $response = $this->controller->getTemplate(id: 3);
+
+ $this->assertSame(expected: Http::STATUS_NOT_FOUND, actual: $response->getStatus());
+ // The internal message is not echoed to the client.
+ $this->assertSame(expected: ['error' => 'Operation failed'], actual: $response->getData());
+ }//end testGetTemplateAnswersAMissingTemplateWith404()
+
+ /**
+ * Delete template confirms the deletion.
+ *
+ * @return void
+ */
+ public function testDeleteTemplateConfirmsTheDeletion(): void {
+ $this->templateService->expects($this->once())->method('deleteTemplate')->with(3);
+
+ $this->assertSame(
+ expected: ['status' => 'ok'],
+ actual: $this->controller->deleteTemplate(id: 3)->getData()
+ );
+ }//end testDeleteTemplateConfirmsTheDeletion()
+
+ /**
+ * Delete template answers afailure with400.
+ *
+ * @return void
+ */
+ public function testDeleteTemplateAnswersAFailureWith400(): void {
+ $this->templateService->method('deleteTemplate')
+ ->willThrowException(exception: new RuntimeException(message: 'db down'));
+
+ $response = $this->controller->deleteTemplate(id: 3);
+
+ $this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus());
+ $this->assertSame(expected: ['error' => 'Operation failed'], actual: $response->getData());
+ }//end testDeleteTemplateAnswersAFailureWith400()
+}//end class
diff --git a/tests/Unit/Controller/AdminControllerSettingsAliasTest.php b/tests/Unit/Controller/AdminControllerSettingsAliasTest.php
index 0d62bc564..8287db6e5 100644
--- a/tests/Unit/Controller/AdminControllerSettingsAliasTest.php
+++ b/tests/Unit/Controller/AdminControllerSettingsAliasTest.php
@@ -28,6 +28,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
use OCA\LaunchPad\Controller\AdminController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\AdminSettingsService;
@@ -39,6 +42,7 @@
use OCP\IRequest;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\Constraint\Callback;
use PHPUnit\Framework\TestCase;
class AdminControllerSettingsAliasTest extends TestCase {
@@ -52,21 +56,40 @@ protected function setUp(): void {
$this->controller = new AdminController(
request: $this->createMock(IRequest::class),
- templateService: $this->createMock(AdminTemplateService::class),
- settingsService: $this->settingsService,
- groupManager: $this->createMock(IGroupManager::class),
- userSession: $this->createMock(IUserSession::class),
- exportService: $this->createMock(ExportService::class),
- importService: $this->createMock(ImportService::class),
- roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
- feedRefresh: $this->createMock(\OCA\LaunchPad\Service\FeedRefreshService::class),
- footerService: $this->createMock(FooterService::class),
- setupWizardService: $this->createMock(\OCA\LaunchPad\Service\SetupWizardService::class),
- actionAuth: $this->createMock(ActionAuthService::class),
- resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->createMock(AdminTemplateService::class),
+ exportService: $this->createMock(ExportService::class),
+ importService: $this->createMock(ImportService::class),
+ resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->settingsService,
+ groupManager: $this->createMock(IGroupManager::class),
+ footerService: $this->createMock(FooterService::class),
+ setupWizardService: $this->createMock(\OCA\LaunchPad\Service\SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
+ feedRefresh: $this->createMock(\OCA\LaunchPad\Service\FeedRefreshService::class),
+ userSession: $this->createMock(IUserSession::class),
+ actionAuth: $this->createMock(ActionAuthService::class)
+ )
);
}//end setUp()
+ /**
+ * A matcher for a full settings map that holds the expected entries.
+ *
+ * @param array $expected The entries the map must hold.
+ *
+ * @return Callback>
+ */
+ private function settingsMatching(array $expected): Callback {
+ return $this->callback(
+ static fn (array $settings): bool => count($settings) === 12 && array_intersect_key($settings, $expected) === $expected
+ );
+ }//end settingsMatching()
+
/**
* A payload spelled the way GET spells it must reach the service.
*
@@ -76,11 +99,15 @@ public function testReadSideNamesAreAccepted(): void {
$this->settingsService->expects($this->once())
->method('updateSettings')
->with(
- $this->equalTo('full'),
- $this->isTrue(),
- $this->isFalse(),
- $this->equalTo(8),
- $this->equalTo(['md']),
+ $this->settingsMatching(
+ [
+ 'defaultPermLevel' => 'full',
+ 'allowUserDash' => true,
+ 'allowMultiDash' => false,
+ 'defaultGridCols' => 8,
+ 'linkCreateFileExts' => ['md'],
+ ]
+ )
);
$this->controller->updateSettings(
@@ -101,10 +128,14 @@ public function testWriteSideNamesStillWork(): void {
$this->settingsService->expects($this->once())
->method('updateSettings')
->with(
- $this->equalTo('view_only'),
- $this->isTrue(),
- $this->isNull(),
- $this->equalTo(12),
+ $this->settingsMatching(
+ [
+ 'defaultPermLevel' => 'view_only',
+ 'allowUserDash' => true,
+ 'allowMultiDash' => null,
+ 'defaultGridCols' => 12,
+ ]
+ )
);
$this->controller->updateSettings(
@@ -126,7 +157,7 @@ public function testWriteSideNamesStillWork(): void {
public function testShortNameWinsWhenBothAreSupplied(): void {
$this->settingsService->expects($this->once())
->method('updateSettings')
- ->with($this->anything(), $this->isFalse());
+ ->with($this->settingsMatching(['allowUserDash' => false]));
$this->controller->updateSettings(
allowUserDash: false,
@@ -146,13 +177,48 @@ public function testUnsuppliedSettingsStayNull(): void {
$this->settingsService->expects($this->once())
->method('updateSettings')
->with(
- $this->isNull(),
- $this->isNull(),
- $this->isNull(),
- $this->isNull(),
- $this->isNull(),
+ $this->settingsMatching(
+ [
+ 'defaultPermLevel' => null,
+ 'allowUserDash' => null,
+ 'allowMultiDash' => null,
+ 'defaultGridCols' => null,
+ 'linkCreateFileExts' => null,
+ ]
+ )
);
$this->controller->updateSettings(allowUserDash: null);
}//end testUnsuppliedSettingsStayNull()
+
+ /**
+ * runtime-shell REQ-SHELL-009: the start-page option reaches the service
+ * under its own name, and only when it was sent.
+ *
+ * @return void
+ */
+ public function testStartPageWithoutNavigationIsPassedThrough(): void {
+ $this->settingsService->expects($this->once())
+ ->method('updateSettings')
+ ->with(
+ $this->settingsMatching(
+ [
+ 'defaultPermLevel' => null,
+ 'allowUserDash' => null,
+ 'allowMultiDash' => null,
+ 'defaultGridCols' => null,
+ 'linkCreateFileExts' => null,
+ 'defaultSharePermissionLevel' => null,
+ 'forcedShareGroups' => null,
+ 'legacyWidgetBridgeEnabled' => null,
+ 'maxDashboardsPerUser' => null,
+ 'maxWidgetsPerDashboard' => null,
+ 'quicksearchFallbackTarget' => null,
+ 'startPageWithoutNavigation' => true,
+ ]
+ )
+ );
+
+ $this->controller->updateSettings(startPageWithoutNavigation: true);
+ }//end testStartPageWithoutNavigationIsPassedThrough()
}//end class
diff --git a/tests/Unit/Controller/AdminControllerSetupWizardTest.php b/tests/Unit/Controller/AdminControllerSetupWizardTest.php
index 94c2a8afb..48547b745 100644
--- a/tests/Unit/Controller/AdminControllerSetupWizardTest.php
+++ b/tests/Unit/Controller/AdminControllerSetupWizardTest.php
@@ -3,9 +3,9 @@
/**
* AdminController Setup-Wizard Test
*
- * Covers the `getWizardState`, `completeWizard`, and `setWizardStorage`
- * endpoints added by the `setup-wizard` change (REQ-WIZ-008, REQ-WIZ-009,
- * REQ-WIZ-003).
+ * Covers the `getWizardState` and `completeWizard` endpoints added by the
+ * `setup-wizard` change (REQ-WIZ-008, REQ-WIZ-009), and pins that the
+ * retired storage-step endpoint stays gone (decision 131).
*
* @category Test
* @package OCA\LaunchPad\Tests\Unit\Controller
@@ -21,6 +21,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
use OCA\LaunchPad\Controller\AdminController;
use OCA\LaunchPad\Service\AdminSettingsService;
use OCA\LaunchPad\Service\AdminTemplateService;
@@ -55,18 +58,24 @@ protected function setUp(): void {
$this->controller = new AdminController(
request: $this->request,
- templateService: $this->createMock(AdminTemplateService::class),
- settingsService: $this->createMock(AdminSettingsService::class),
- groupManager: $this->groupManager,
- userSession: $this->userSession,
- exportService: $this->createMock(ExportService::class),
- importService: $this->createMock(ImportService::class),
- roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
- feedRefresh: $this->createMock(\OCA\LaunchPad\Service\FeedRefreshService::class),
- footerService: $this->createMock(\OCA\LaunchPad\Service\FooterService::class),
- setupWizardService: $this->wizardService,
- actionAuth: $this->createMock(\OCA\LaunchPad\Service\ActionAuthService::class),
- resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class),
+ templates: new AdminTemplateTools(
+ templateService: $this->createMock(AdminTemplateService::class),
+ exportService: $this->createMock(ExportService::class),
+ importService: $this->createMock(ImportService::class),
+ resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->createMock(AdminSettingsService::class),
+ groupManager: $this->groupManager,
+ footerService: $this->createMock(\OCA\LaunchPad\Service\FooterService::class),
+ setupWizardService: $this->wizardService
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
+ feedRefresh: $this->createMock(\OCA\LaunchPad\Service\FeedRefreshService::class),
+ userSession: $this->userSession,
+ actionAuth: $this->createMock(\OCA\LaunchPad\Service\ActionAuthService::class)
+ )
);
}
@@ -136,52 +145,13 @@ public function testCompleteWizardRejectsNonAdminWith403(): void {
$this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
}
- public function testSetWizardStorageRejectsEmptyValue(): void {
- $this->loginAsAdmin();
- $this->wizardService->expects($this->never())->method('setContentStorage');
-
- $response = $this->controller->setWizardStorage(storage: '');
-
- $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
- }
-
- public function testSetWizardStorageRejectsGroupfolderWhenAppMissing(): void {
- $this->loginAsAdmin();
- $this->wizardService->method('hasGroupfolderApp')->willReturn(false);
- $this->wizardService->expects($this->never())->method('setContentStorage');
-
- $response = $this->controller->setWizardStorage(storage: 'groupfolder');
-
- $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
- }
-
- public function testSetWizardStorageWritesAndReturnsState(): void {
- $this->loginAsAdmin();
- $this->wizardService->method('hasGroupfolderApp')->willReturn(true);
-
- $this->wizardService->expects($this->once())
- ->method('setContentStorage')
- ->with('groupfolder');
-
- $payload = [
- 'complete' => false,
- 'currentRecommendedStep' => 3,
- 'stepStatuses' => ['2' => 'done'],
- ];
- $this->wizardService->method('getWizardState')->willReturn($payload);
-
- $response = $this->controller->setWizardStorage(storage: 'groupfolder');
-
- $this->assertSame(Http::STATUS_OK, $response->getStatus());
- $this->assertSame($payload, $response->getData());
- }
-
- public function testSetWizardStorageReturns401WhenLoggedOut(): void {
- $this->userSession->method('getUser')->willReturn(null);
- $this->wizardService->expects($this->never())->method('setContentStorage');
-
- $response = $this->controller->setWizardStorage(storage: 'database');
+ public function testTheStorageStepEndpointIsRetired(): void {
+ // Decision 131: the wizard no longer asks where content is stored.
+ $this->assertFalse(method_exists(AdminController::class, 'setWizardStorage'));
- $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus());
+ $routes = file_get_contents(__DIR__ . '/../../../appinfo/routes.php');
+ $this->assertIsString($routes);
+ $this->assertStringNotContainsString('setup-wizard/storage', $routes);
+ $this->assertStringNotContainsString('setWizardStorage', $routes);
}
}
diff --git a/tests/Unit/Controller/AdminControllerUpdateTemplateGoldenTest.php b/tests/Unit/Controller/AdminControllerUpdateTemplateGoldenTest.php
new file mode 100644
index 000000000..213f3576d
--- /dev/null
+++ b/tests/Unit/Controller/AdminControllerUpdateTemplateGoldenTest.php
@@ -0,0 +1,228 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\AdminCallerAndRoles;
+use OCA\LaunchPad\Controller\AdminSettingsTools;
+use OCA\LaunchPad\Controller\AdminTemplateTools;
+use OCA\LaunchPad\Controller\AdminController;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Service\AdminSettingsService;
+use OCA\LaunchPad\Service\AdminTemplateService;
+use OCA\LaunchPad\Service\ExportService;
+use OCA\LaunchPad\Service\ImportService;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+/**
+ * The template patch of updateTemplate(), case by case.
+ *
+ * @covers \OCA\LaunchPad\Controller\AdminController
+ * @uses \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Controller\RespondsWithErrors
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ * @covers \OCA\LaunchPad\Service\AdminTemplateService
+ * @uses \OCA\LaunchPad\Service\AdminTemplateStores
+ * @uses \OCA\LaunchPad\Controller\AdminTemplateTools
+ * @uses \OCA\LaunchPad\Controller\AdminSettingsTools
+ * @uses \OCA\LaunchPad\Controller\AdminCallerAndRoles
+ */
+class AdminControllerUpdateTemplateGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/admin-update-template.json';
+
+ /**
+ * Build the controller over the given template service.
+ *
+ * @param AdminTemplateService $templates The template service double.
+ *
+ * @return AdminController
+ */
+ private function controller(AdminTemplateService $templates): AdminController {
+ return new AdminController(
+ request: $this->createMock(IRequest::class),
+ templates: new AdminTemplateTools(
+ templateService: $templates,
+ exportService: $this->createMock(ExportService::class),
+ importService: $this->createMock(ImportService::class),
+ resyncService: $this->createMock(\OCA\LaunchPad\Service\TemplateResyncService::class)
+ ),
+ settings: new AdminSettingsTools(
+ settingsService: $this->createMock(AdminSettingsService::class),
+ groupManager: $this->createMock(IGroupManager::class),
+ footerService: $this->createMock(\OCA\LaunchPad\Service\FooterService::class),
+ setupWizardService: $this->createMock(\OCA\LaunchPad\Service\SetupWizardService::class)
+ ),
+ caller: new AdminCallerAndRoles(
+ roleService: $this->createMock(\OCA\LaunchPad\Service\RoleService::class),
+ feedRefresh: $this->createMock(\OCA\LaunchPad\Service\FeedRefreshService::class),
+ userSession: $this->createMock(IUserSession::class),
+ actionAuth: $this->createMock(\OCA\LaunchPad\Service\ActionAuthService::class)
+ )
+ );
+ }//end controller()
+
+ /**
+ * Every case sends the recorded patch and answers the recorded status.
+ *
+ * @return void
+ */
+ public function testPatchMatchesTheRecordedFixture(): void {
+ $cases = [
+ 'nothing' => [],
+ 'name' => ['name' => 'T'],
+ 'empty name' => ['name' => ''],
+ 'description' => ['description' => 'd'],
+ 'groups' => ['targetGroups' => ['staff', 'hr']],
+ 'no groups' => ['targetGroups' => []],
+ 'level' => ['permissionLevel' => Dashboard::PERMISSION_VIEW_ONLY],
+ 'default' => ['isDefault' => true],
+ 'not default' => ['isDefault' => false],
+ 'grid' => ['gridColumns' => 6],
+ 'everything' => ['gridColumns' => 8, 'isDefault' => false, 'permissionLevel' => 'full', 'targetGroups' => ['a'], 'description' => 'x', 'name' => 'y'],
+ 'failure' => ['name' => 'boom'],
+ ];
+
+ $actual = [];
+ foreach ($cases as $case => $args) {
+ $patch = null;
+ $templates = $this->createMock(AdminTemplateService::class);
+ $templates->method('updateTemplate')->willReturnCallback(
+ static function (int $id, array $data) use (&$patch): Dashboard {
+ $patch = ['id' => $id, 'data' => $data];
+ if (($data['name'] ?? null) === 'boom') {
+ throw new RuntimeException('down');
+ }
+
+ $dashboard = new Dashboard();
+ $dashboard->setName((string) ($data['name'] ?? 'kept'));
+ return $dashboard;
+ }
+ );
+
+ $response = $this->controller(templates: $templates)->updateTemplate(4, ...$args);
+ $actual[$case] = ['status' => $response->getStatus(), 'patch' => $patch, 'name' => $response->getData()['name'] ?? null];
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ $lines = [];
+ foreach ($actual as $case => $row) {
+ $lines[] = json_encode($case).': '.json_encode($row, JSON_UNESCAPED_SLASHES);
+ }
+
+ file_put_contents(self::FIXTURE, "{\n".implode(",\n", $lines)."\n}\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_encode(json_decode((string) file_get_contents(self::FIXTURE))), json_encode($actual));
+ }//end testPatchMatchesTheRecordedFixture()
+
+ /**
+ * Every create case writes the recorded template row.
+ *
+ * @return void
+ */
+ public function testCreateTemplateRowsMatchTheRecordedFixture(): void {
+ $cases = [
+ 'minimal' => ['name' => 'T'],
+ 'description' => ['name' => 'T', 'description' => 'd'],
+ 'groups' => ['name' => 'T', 'targetGroups' => ['staff']],
+ 'no groups' => ['name' => 'T', 'targetGroups' => []],
+ 'level' => ['name' => 'T', 'permissionLevel' => Dashboard::PERMISSION_FULL],
+ 'default' => ['name' => 'T', 'isDefault' => true],
+ 'everything' => ['isDefault' => false, 'permissionLevel' => 'view_only', 'targetGroups' => ['a', 'b'], 'description' => 'x', 'name' => 'y'],
+ ];
+
+ $actual = [];
+ foreach ($cases as $case => $args) {
+ $log = [];
+ $mapper = $this->createMock(\OCA\LaunchPad\Db\DashboardMapper::class);
+ $mapper->method('clearDefaultTemplates')->willReturnCallback(
+ static function () use (&$log): int {
+ $log[] = 'clearDefaultTemplates';
+ return 0;
+ }
+ );
+ $mapper->method('insert')->willReturnCallback(
+ static function (Dashboard $dashboard) use (&$log): Dashboard {
+ $row = $dashboard->jsonSerialize();
+ unset($row['uuid'], $row['createdAt'], $row['updatedAt']);
+ $log[] = ['insert' => $row, 'uuid' => strlen((string) $dashboard->getUuid()), 'stamped' => $dashboard->getCreatedAt() === $dashboard->getUpdatedAt()];
+ return $dashboard;
+ }
+ );
+ $service = new AdminTemplateService(
+ stores: new \OCA\LaunchPad\Service\AdminTemplateStores(
+ dashboardMapper: $mapper,
+ placementMapper: $this->createMock(\OCA\LaunchPad\Db\WidgetPlacementMapper::class)
+ ),
+ settingsService: $this->createMock(AdminSettingsService::class),
+ groupManager: $this->createMock(IGroupManager::class),
+ userManager: $this->createMock(\OCP\IUserManager::class),
+ );
+
+ $response = $this->controller(templates: $service)->createTemplate(...$args);
+ $actual[$case] = ['status' => $response->getStatus(), 'log' => $log];
+ }
+
+ $fixture = __DIR__.'/fixtures/admin-create-template.json';
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ $lines = [];
+ foreach ($actual as $case => $row) {
+ $lines[] = json_encode($case).': '.json_encode($row, JSON_UNESCAPED_SLASHES);
+ }
+
+ file_put_contents($fixture, "{\n".implode(",\n", $lines)."\n}\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_encode(json_decode((string) file_get_contents($fixture))), json_encode($actual));
+ }//end testCreateTemplateRowsMatchTheRecordedFixture()
+
+ /**
+ * A template created with only a name gets the add-only level, no
+ * groups and is not the default.
+ *
+ * @return void
+ */
+ public function testServiceDefaults(): void {
+ $mapper = $this->createMock(\OCA\LaunchPad\Db\DashboardMapper::class);
+ $mapper->expects($this->never())->method('clearDefaultTemplates');
+ $mapper->method('insert')->willReturnArgument(0);
+ $service = new AdminTemplateService(
+ stores: new \OCA\LaunchPad\Service\AdminTemplateStores(
+ dashboardMapper: $mapper,
+ placementMapper: $this->createMock(\OCA\LaunchPad\Db\WidgetPlacementMapper::class)
+ ),
+ settingsService: $this->createMock(AdminSettingsService::class),
+ groupManager: $this->createMock(IGroupManager::class),
+ userManager: $this->createMock(\OCP\IUserManager::class),
+ );
+
+ $template = $service->createTemplate(name: 'T');
+
+ $this->assertSame([Dashboard::PERMISSION_ADD_ONLY, [], 0, null], [$template->getPermissionLevel(), $template->getTargetGroupsArray(), $template->getIsDefault(), $template->getDescription()]);
+ }//end testServiceDefaults()
+}//end class
diff --git a/tests/Unit/Controller/AdminDemoShowcasesControllerTest.php b/tests/Unit/Controller/AdminDemoShowcasesControllerTest.php
index 0a809cebe..54a0045a0 100644
--- a/tests/Unit/Controller/AdminDemoShowcasesControllerTest.php
+++ b/tests/Unit/Controller/AdminDemoShowcasesControllerTest.php
@@ -21,7 +21,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\ShowcaseTools;
use OCA\LaunchPad\Controller\AdminDemoShowcasesController;
+use OCA\LaunchPad\Service\RequestGuard;
use OCA\LaunchPad\Exception\ShowcaseNotFoundException;
use OCA\LaunchPad\Service\DemoShowcasesService;
use OCP\AppFramework\Http;
@@ -32,6 +34,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
+use OCA\LaunchPad\Service\ActionAuthService;
class AdminDemoShowcasesControllerTest extends TestCase {
/** @var IRequest&MockObject */
@@ -56,10 +59,11 @@ protected function setUp(): void {
$this->controller = new AdminDemoShowcasesController(
request: $this->request,
- showcasesSvc: $this->service,
- groupManager: $this->groupManager,
- userSession: $this->userSession,
- logger: new NullLogger(),
+ showcases: new ShowcaseTools(
+ showcasesSvc: $this->service,
+ logger: new NullLogger()
+ ),
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->createMock(ActionAuthService::class)),
);
}
@@ -172,4 +176,33 @@ public function testDestroyForbiddenForNonAdmin(): void {
$response = $this->controller->destroy(id: 'de-bron');
$this->assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus());
}
+
+ /**
+ * An unexpected install failure is logged with the showcase id and
+ * answered with a 500; a missing showcase answers its 404 body.
+ *
+ * @return void
+ */
+ public function testInstallFailureAnswers(): void {
+ $this->loginAsAdmin();
+ $failure = new \RuntimeException('disk');
+ $this->service->method('installShowcase')->willReturnOnConsecutiveCalls(
+ $this->throwException(new ShowcaseNotFoundException(message: 'gone')),
+ $this->throwException($failure)
+ );
+ $logger = $this->createMock(\Psr\Log\LoggerInterface::class);
+ $logger->expects($this->once())->method('error')->with('Showcase install failed', ['showcaseId' => 'de-bron', 'exception' => $failure]);
+ $controller = new AdminDemoShowcasesController(
+ request: $this->request,
+ showcases: new ShowcaseTools(showcasesSvc: $this->service, logger: $logger),
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->createMock(ActionAuthService::class)),
+ );
+
+ $missing = $controller->install(id: 'de-bron');
+ $failed = $controller->install(id: 'de-bron');
+ $this->assertSame(
+ [[404, ['error' => 'Showcase not found']], [500, ['error' => 'Showcase installation failed']]],
+ [[$missing->getStatus(), $missing->getData()], [$failed->getStatus(), $failed->getData()]]
+ );
+ }
}
diff --git a/tests/Unit/Controller/AdminOrgNavigationControllerTest.php b/tests/Unit/Controller/AdminOrgNavigationControllerTest.php
index 5a1be6858..7d977a386 100644
--- a/tests/Unit/Controller/AdminOrgNavigationControllerTest.php
+++ b/tests/Unit/Controller/AdminOrgNavigationControllerTest.php
@@ -20,8 +20,10 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\OrgNavigationSources;
use InvalidArgumentException;
use OCA\LaunchPad\Controller\AdminOrgNavigationController;
+use OCA\LaunchPad\Service\RequestGuard;
use OCA\LaunchPad\Db\AdminSettingMapper;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\OrgNavigationService;
@@ -65,11 +67,11 @@ protected function setUp(): void {
$this->controller = new AdminOrgNavigationController(
request: $this->request,
- service: $this->service,
- settings: $this->settings,
- userSession: $this->userSession,
- groupManager: $this->groupManager,
- actionAuth: $this->actionAuth,
+ sources: new OrgNavigationSources(
+ service: $this->service,
+ settings: $this->settings
+ ),
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->actionAuth),
);
}//end setUp()
diff --git a/tests/Unit/Controller/AdminSettingsControllerTest.php b/tests/Unit/Controller/AdminSettingsControllerTest.php
index e9ac150d9..1f7905592 100644
--- a/tests/Unit/Controller/AdminSettingsControllerTest.php
+++ b/tests/Unit/Controller/AdminSettingsControllerTest.php
@@ -29,6 +29,8 @@
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCA\LaunchPad\Service\ActionAuthService;
class AdminSettingsControllerTest extends TestCase {
private AdminSettingsController $controller;
@@ -52,7 +54,7 @@ protected function setUp(): void {
request: $this->request,
settingsService: $this->settingsService,
groupManager: $this->groupManager,
- userSession: $this->userSession,
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->createMock(ActionAuthService::class)),
);
}
diff --git a/tests/Unit/Controller/AdminShippedTemplateControllerTest.php b/tests/Unit/Controller/AdminShippedTemplateControllerTest.php
new file mode 100644
index 000000000..ed954480b
--- /dev/null
+++ b/tests/Unit/Controller/AdminShippedTemplateControllerTest.php
@@ -0,0 +1,184 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\ShippedTemplateTools;
+use InvalidArgumentException;
+use OCA\LaunchPad\Controller\AdminShippedTemplateController;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCA\LaunchPad\Exception\TemplateNotInstalledException;
+use OCA\LaunchPad\Service\ShippedTemplateService;
+use OCA\LaunchPad\Service\ShippedTemplateUpdateService;
+use OCP\AppFramework\Http;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use RuntimeException;
+use OCA\LaunchPad\Service\ActionAuthService;
+
+/**
+ * The shipped template endpoints (REQ-TMPL-018): who may call them and what
+ * each outcome answers.
+ */
+class AdminShippedTemplateControllerTest extends TestCase {
+ /** @var ShippedTemplateService&MockObject */
+ private $service;
+
+ /** @var ShippedTemplateUpdateService&MockObject */
+ private $updates;
+
+ /** @var IGroupManager&MockObject */
+ private $groupManager;
+
+ /** @var IUserSession&MockObject */
+ private $userSession;
+
+ private AdminShippedTemplateController $controller;
+
+ protected function setUp(): void {
+ $this->service = $this->createMock(ShippedTemplateService::class);
+ $this->updates = $this->createMock(ShippedTemplateUpdateService::class);
+ $this->groupManager = $this->createMock(IGroupManager::class);
+ $this->userSession = $this->createMock(IUserSession::class);
+
+ $this->controller = new AdminShippedTemplateController(
+ request: $this->createMock(IRequest::class),
+ tools: new ShippedTemplateTools(
+ templates: $this->service,
+ updates: $this->updates,
+ logger: new NullLogger()
+ ),
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->createMock(ActionAuthService::class)),
+ );
+ }
+
+ private function loginAs(string $uid, bool $admin): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ $this->userSession->method('getUser')->willReturn($user);
+ $this->groupManager->method('isAdmin')->with($uid)->willReturn($admin);
+ }
+
+ public function testNobodySignedInGets401AndNothingIsInstalled(): void {
+ $this->userSession->method('getUser')->willReturn(null);
+ $this->service->expects(self::never())->method('install');
+ $this->service->expects(self::never())->method('listTemplates');
+
+ self::assertSame(Http::STATUS_UNAUTHORIZED, $this->controller->index()->getStatus());
+ self::assertSame(Http::STATUS_UNAUTHORIZED, $this->controller->install(id: 'mijn-werkdag')->getStatus());
+ }
+
+ public function testANonAdminGets403AndNothingIsInstalled(): void {
+ $this->loginAs('bob', false);
+ $this->service->expects(self::never())->method('install');
+ $this->service->expects(self::never())->method('listTemplates');
+
+ self::assertSame(Http::STATUS_FORBIDDEN, $this->controller->index()->getStatus());
+ self::assertSame(Http::STATUS_FORBIDDEN, $this->controller->install(id: 'mijn-werkdag')->getStatus());
+ }
+
+ public function testAnAdminGetsTheListing(): void {
+ $this->loginAs('alice', true);
+ $this->service->method('listTemplates')->willReturn([['id' => 'mijn-werkdag']]);
+
+ $response = $this->controller->index();
+
+ self::assertSame(Http::STATUS_OK, $response->getStatus());
+ self::assertSame([['id' => 'mijn-werkdag']], $response->getData());
+ }
+
+ public function testInstallAnswers201ThenThe200OfASecondRun(): void {
+ $this->loginAs('alice', true);
+ $this->service->expects(self::exactly(2))->method('install')
+ ->with('mijn-werkdag', ['force' => false, 'userId' => 'alice'])
+ ->willReturnOnConsecutiveCalls(
+ ['uuid' => 'u1', 'alreadyInstalled' => false],
+ ['uuid' => 'u1', 'alreadyInstalled' => true],
+ );
+
+ $first = $this->controller->install(id: 'mijn-werkdag');
+ $second = $this->controller->install(id: 'mijn-werkdag');
+
+ self::assertSame(Http::STATUS_CREATED, $first->getStatus());
+ self::assertSame('u1', $first->getData()['uuid']);
+ self::assertSame(Http::STATUS_OK, $second->getStatus());
+ }
+
+ public function testAnUnknownTemplateIs404AndAFailureIs500WithoutTheReason(): void {
+ $this->loginAs('alice', true);
+ $this->service->method('install')->willReturnCallback(
+ static function (string $templateId): array {
+ if ($templateId === 'nope') {
+ throw new InvalidArgumentException('Unknown template: nope');
+ }
+ throw new RuntimeException('SQLSTATE secret detail');
+ }
+ );
+
+ self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->install(id: 'nope')->getStatus());
+ $failed = $this->controller->install(id: 'mijn-werkdag');
+ self::assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $failed->getStatus());
+ self::assertSame(['error' => 'Template installation failed'], $failed->getData());
+ }
+
+ /**
+ * REQ-TMPL-020: only an administrator updates, and a dry run is passed on.
+ */
+ public function testOnlyAnAdminUpdatesAndADryRunIsPassedOn(): void {
+ $this->loginAs('alice', true);
+ $this->updates->expects(self::exactly(2))->method('update')
+ ->willReturnCallback(
+ static fn (string $templateId, bool $dryRun, string $userId): array => [
+ 'templateId' => $templateId,
+ 'dryRun' => $dryRun,
+ 'by' => $userId,
+ ]
+ );
+
+ $dry = $this->controller->update(id: 'mijn-werkdag', dryRun: true);
+ $real = $this->controller->update(id: 'mijn-werkdag');
+
+ self::assertSame(Http::STATUS_OK, $dry->getStatus());
+ self::assertSame(['templateId' => 'mijn-werkdag', 'dryRun' => true, 'by' => 'alice'], $dry->getData());
+ self::assertFalse($real->getData()['dryRun']);
+ }
+
+ public function testANonAdminCannotUpdate(): void {
+ $this->loginAs('bob', false);
+ $this->updates->expects(self::never())->method('update');
+
+ self::assertSame(Http::STATUS_FORBIDDEN, $this->controller->update(id: 'mijn-werkdag')->getStatus());
+ }
+
+ public function testUpdateAnswers404409And500(): void {
+ $this->loginAs('alice', true);
+ $this->updates->method('update')->willReturnCallback(
+ static function (string $templateId): array {
+ if ($templateId === 'nope') {
+ throw new InvalidArgumentException('Unknown template: nope');
+ }
+ if ($templateId === 'not-installed') {
+ throw new TemplateNotInstalledException('not installed');
+ }
+ throw new RuntimeException('SQLSTATE secret detail');
+ }
+ );
+
+ self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->update(id: 'nope')->getStatus());
+ self::assertSame(Http::STATUS_CONFLICT, $this->controller->update(id: 'not-installed')->getStatus());
+ $failed = $this->controller->update(id: 'mijn-werkdag');
+ self::assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $failed->getStatus());
+ self::assertSame(['error' => 'Template update failed'], $failed->getData());
+ }
+}
diff --git a/tests/Unit/Controller/AnalyticsCallerGuardGoldenTest.php b/tests/Unit/Controller/AnalyticsCallerGuardGoldenTest.php
new file mode 100644
index 000000000..747751423
--- /dev/null
+++ b/tests/Unit/Controller/AnalyticsCallerGuardGoldenTest.php
@@ -0,0 +1,208 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\ActionCaller;
+use OCA\LaunchPad\Controller\AnalyticsController;
+use OCA\LaunchPad\Controller\TileAnalyticsController;
+use OCA\LaunchPad\Controller\MetadataApiCaller;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCP\AppFramework\Http\Response;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionNamedType;
+use Throwable;
+
+/**
+ * Refused and allowed outcomes of the caller guard, endpoint by endpoint.
+ */
+class AnalyticsCallerGuardGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/analytics-caller-guard.json';
+
+ private const CONTROLLERS = [
+ AnalyticsController::class,
+ TileAnalyticsController::class,
+ ];
+
+ private const SCENARIOS = ['anonymous', 'no session user', 'action denied', 'allowed'];
+
+ /**
+ * Actions asked of the action guard in the current call.
+ *
+ * @var string[]
+ */
+ private array $asked = [];
+
+ /**
+ * Every gated endpoint in every scenario gives the recorded outcome.
+ *
+ * @return void
+ */
+ public function testGuardOutcomes(): void {
+ $actual = [];
+ foreach (self::CONTROLLERS as $class) {
+ foreach ($this->gatedEndpoints(class: $class) as $method) {
+ foreach (self::SCENARIOS as $scenario) {
+ $short = (new ReflectionClass($class))->getShortName();
+ $actual[$short.'::'.$method->getName().' / '.$scenario] = $this->outcome(method: $method, scenario: $scenario);
+ }
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testGuardOutcomes()
+
+ /**
+ * The public endpoints whose body asks the action guard.
+ *
+ * @param class-string $class The controller.
+ *
+ * @return ReflectionMethod[]
+ */
+ private function gatedEndpoints(string $class): array {
+ $lines = file((string) (new ReflectionClass($class))->getFileName());
+ $methods = [];
+ foreach ((new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->getDeclaringClass()->getName() !== $class || $method->isConstructor() === true) {
+ continue;
+ }
+ $body = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
+ if (preg_match('/requireAction|Not authenticated|actionRefusal\(/', $body) === 1) {
+ $methods[] = $method;
+ }
+ }
+
+ return $methods;
+ }//end gatedEndpoints()
+
+ /**
+ * Call one endpoint in one scenario.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ * @param string $scenario The caller situation.
+ *
+ * @return array Status and body (or the thrown class) plus the asked actions.
+ */
+ private function outcome(ReflectionMethod $method, string $scenario): array {
+ $this->asked = [];
+ $controller = $this->controller(class: $method->getDeclaringClass()->getName(), scenario: $scenario);
+ try {
+ $response = $method->invokeArgs($controller, $this->arguments(method: $method));
+ $result = ['status' => $response->getStatus()];
+ if ($scenario !== 'allowed' && method_exists($response, 'getData') === true) {
+ $result['body'] = $response->getData();
+ }
+ } catch (Throwable $e) {
+ $result = ['thrown' => get_class($e)];
+ }
+
+ $result['asked'] = $this->asked;
+ return $result;
+ }//end outcome()
+
+ /**
+ * Argument values for an endpoint: defaults where declared, else a plain value per type.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ *
+ * @return array
+ */
+ private function arguments(ReflectionMethod $method): array {
+ $args = [];
+ foreach ($method->getParameters() as $param) {
+ if ($param->isDefaultValueAvailable() === true) {
+ $args[] = $param->getDefaultValue();
+ continue;
+ }
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match ($name) {
+ 'int' => 7,
+ 'string' => 'uuid-a',
+ 'array' => [],
+ 'bool' => true,
+ default => null,
+ };
+ }
+
+ return $args;
+ }//end arguments()
+
+ /**
+ * The controller with every collaborator mocked and the caller set up for the scenario.
+ *
+ * @param class-string $class The controller.
+ * @param string $scenario The caller situation.
+ *
+ * @return object
+ */
+ private function controller(string $class, string $scenario): object {
+ $session = $this->createMock(IUserSession::class);
+ $user = null;
+ if ($scenario !== 'no session user') {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ }
+ $session->method('getUser')->willReturn($user);
+
+ $auth = $this->createMock(ActionAuthService::class);
+ $auth->method('requireAction')->willReturnCallback(
+ function (IUser $user, string $action) use ($scenario): void {
+ $this->asked[] = $action;
+ if ($scenario === 'action denied') {
+ throw new OCSForbiddenException('denied');
+ }
+ }
+ );
+
+ $args = [];
+ foreach ((new ReflectionClass($class))->getConstructor()->getParameters() as $param) {
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match (true) {
+ $param->getName() === 'userId' => ($scenario === 'anonymous') ? null : 'alice',
+ $name === ActionAuthService::class => $auth,
+ $name === IUserSession::class => $session,
+ $name === ActionCaller::class => new ActionCaller(actionAuth: $auth, userSession: $session),
+ $name === MetadataApiCaller::class => new MetadataApiCaller(
+ sources: $this->createMock(\OCA\LaunchPad\Controller\MetadataSources::class),
+ actionAuth: $auth,
+ userSession: $session
+ ),
+ default => $this->createMock($name),
+ };
+ }
+
+ return new $class(...$args);
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/AttentionControllerTest.php b/tests/Unit/Controller/AttentionControllerTest.php
new file mode 100644
index 000000000..2c8685491
--- /dev/null
+++ b/tests/Unit/Controller/AttentionControllerTest.php
@@ -0,0 +1,69 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\AttentionController;
+use OCA\LaunchPad\Service\AttentionSourceService;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCP\IGroupManager;
+
+/**
+ * `GET /api/attention/sources` (REQ-ATT-002).
+ */
+class AttentionControllerTest extends TestCase {
+ public function testASignedInUserGetsTheSources(): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('pieter');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $service = $this->createMock(AttentionSourceService::class);
+ $service->expects(self::once())->method('collect')->with($user)
+ ->willReturn(['sources' => [['appId' => 'dossiq']], 'invalid' => []]);
+
+ $response = (new AttentionController($this->createMock(IRequest::class), $service, new RequestGuard(userSession: $session, groupManager: $this->createMock(IGroupManager::class), actionAuth: $this->createMock(ActionAuthService::class))))->sources();
+
+ self::assertSame(Http::STATUS_OK, $response->getStatus());
+ self::assertSame(
+ ['userId' => 'pieter', 'sources' => [['appId' => 'dossiq']], 'invalid' => []],
+ $response->getData()
+ );
+ }
+
+ public function testNobodySignedInGets401(): void {
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn(null);
+ $service = $this->createMock(AttentionSourceService::class);
+ $service->expects(self::never())->method('collect');
+
+ $response = (new AttentionController($this->createMock(IRequest::class), $service, new RequestGuard(userSession: $session, groupManager: $this->createMock(IGroupManager::class), actionAuth: $this->createMock(ActionAuthService::class))))->sources();
+
+ self::assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus());
+ }
+
+ /**
+ * Without this attribute Nextcloud answers every non-admin with 403
+ * before the method runs, and the widget would show nothing for exactly
+ * the people it is for.
+ */
+ public function testAnEmployeeNeedsNoAdminRights(): void {
+ $attributes = (new ReflectionMethod(AttentionController::class, 'sources'))
+ ->getAttributes(NoAdminRequired::class);
+
+ self::assertCount(1, $attributes);
+ }
+}
diff --git a/tests/Unit/Controller/CallerGuardGoldenTest.php b/tests/Unit/Controller/CallerGuardGoldenTest.php
new file mode 100644
index 000000000..aa93c94da
--- /dev/null
+++ b/tests/Unit/Controller/CallerGuardGoldenTest.php
@@ -0,0 +1,217 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\ActionCaller;
+use OCA\LaunchPad\Controller\DashboardMetadataController;
+use OCA\LaunchPad\Controller\DashboardVersionApiController;
+use OCA\LaunchPad\Controller\ManifestController;
+use OCA\LaunchPad\Controller\MetadataApiCaller;
+use OCA\LaunchPad\Controller\PeopleWidgetController;
+use OCA\LaunchPad\Controller\RuleApiController;
+use OCA\LaunchPad\Controller\TileApiController;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCP\AppFramework\Http\Response;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionNamedType;
+use Throwable;
+
+/**
+ * Refused and allowed outcomes of the caller guard, endpoint by endpoint.
+ */
+class CallerGuardGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/caller-guard.json';
+
+ private const CONTROLLERS = [
+ DashboardMetadataController::class,
+ DashboardVersionApiController::class,
+ ManifestController::class,
+ PeopleWidgetController::class,
+ RuleApiController::class,
+ TileApiController::class,
+ ];
+
+ private const SCENARIOS = ['anonymous', 'no session user', 'action denied', 'allowed'];
+
+ /**
+ * Actions asked of the action guard in the current call.
+ *
+ * @var string[]
+ */
+ private array $asked = [];
+
+ /**
+ * Every gated endpoint in every scenario gives the recorded outcome.
+ *
+ * @return void
+ */
+ public function testGuardOutcomes(): void {
+ $actual = [];
+ foreach (self::CONTROLLERS as $class) {
+ foreach ($this->gatedEndpoints(class: $class) as $method) {
+ foreach (self::SCENARIOS as $scenario) {
+ $short = (new ReflectionClass($class))->getShortName();
+ $actual[$short.'::'.$method->getName().' / '.$scenario] = $this->outcome(method: $method, scenario: $scenario);
+ }
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testGuardOutcomes()
+
+ /**
+ * The public endpoints whose body asks the action guard.
+ *
+ * @param class-string $class The controller.
+ *
+ * @return ReflectionMethod[]
+ */
+ private function gatedEndpoints(string $class): array {
+ $lines = file((string) (new ReflectionClass($class))->getFileName());
+ $methods = [];
+ foreach ((new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->getDeclaringClass()->getName() !== $class || $method->isConstructor() === true) {
+ continue;
+ }
+ $body = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
+ if (preg_match('/requireAction|Not authenticated|actionRefusal\(/', $body) === 1) {
+ $methods[] = $method;
+ }
+ }
+
+ return $methods;
+ }//end gatedEndpoints()
+
+ /**
+ * Call one endpoint in one scenario.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ * @param string $scenario The caller situation.
+ *
+ * @return array Status and body (or the thrown class) plus the asked actions.
+ */
+ private function outcome(ReflectionMethod $method, string $scenario): array {
+ $this->asked = [];
+ $controller = $this->controller(class: $method->getDeclaringClass()->getName(), scenario: $scenario);
+ try {
+ $response = $method->invokeArgs($controller, $this->arguments(method: $method));
+ $result = ['status' => $response->getStatus()];
+ if ($scenario !== 'allowed' && method_exists($response, 'getData') === true) {
+ $result['body'] = $response->getData();
+ }
+ } catch (Throwable $e) {
+ $result = ['thrown' => get_class($e)];
+ }
+
+ $result['asked'] = $this->asked;
+ return $result;
+ }//end outcome()
+
+ /**
+ * Argument values for an endpoint: defaults where declared, else a plain value per type.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ *
+ * @return array
+ */
+ private function arguments(ReflectionMethod $method): array {
+ $args = [];
+ foreach ($method->getParameters() as $param) {
+ if ($param->isDefaultValueAvailable() === true) {
+ $args[] = $param->getDefaultValue();
+ continue;
+ }
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match ($name) {
+ 'int' => 7,
+ 'string' => 'uuid-a',
+ 'array' => [],
+ 'bool' => true,
+ default => null,
+ };
+ }
+
+ return $args;
+ }//end arguments()
+
+ /**
+ * The controller with every collaborator mocked and the caller set up for the scenario.
+ *
+ * @param class-string $class The controller.
+ * @param string $scenario The caller situation.
+ *
+ * @return object
+ */
+ private function controller(string $class, string $scenario): object {
+ $session = $this->createMock(IUserSession::class);
+ $user = null;
+ if ($scenario !== 'no session user') {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ }
+ $session->method('getUser')->willReturn($user);
+
+ $auth = $this->createMock(ActionAuthService::class);
+ $auth->method('requireAction')->willReturnCallback(
+ function (IUser $user, string $action) use ($scenario): void {
+ $this->asked[] = $action;
+ if ($scenario === 'action denied') {
+ throw new OCSForbiddenException('denied');
+ }
+ }
+ );
+
+ $args = [];
+ foreach ((new ReflectionClass($class))->getConstructor()->getParameters() as $param) {
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match (true) {
+ $param->getName() === 'userId' => ($scenario === 'anonymous') ? null : 'alice',
+ $name === ActionAuthService::class => $auth,
+ $name === IUserSession::class => $session,
+ $name === ActionCaller::class => new ActionCaller(actionAuth: $auth, userSession: $session),
+ $name === MetadataApiCaller::class => new MetadataApiCaller(
+ sources: $this->createMock(\OCA\LaunchPad\Controller\MetadataSources::class),
+ actionAuth: $auth,
+ userSession: $session
+ ),
+ default => $this->createMock($name),
+ };
+ }
+
+ return new $class(...$args);
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/DashboardApiControllerActiveTest.php b/tests/Unit/Controller/DashboardApiControllerActiveTest.php
index aeafd26df..53d675880 100644
--- a/tests/Unit/Controller/DashboardApiControllerActiveTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerActiveTest.php
@@ -20,6 +20,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\AnalyticsService;
@@ -84,15 +87,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->permissionService,
- treeService: $this->treeService,
- versionService: $this->versionService,
- analyticsService: $this->analyticsService,
- logger: $this->logger,
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->treeService,
+ versionService: $this->versionService,
+ analyticsService: $this->analyticsService,
+ logger: $this->logger
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerBreadcrumbsTest.php b/tests/Unit/Controller/DashboardApiControllerBreadcrumbsTest.php
index d528fd636..e8245721c 100644
--- a/tests/Unit/Controller/DashboardApiControllerBreadcrumbsTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerBreadcrumbsTest.php
@@ -19,6 +19,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -52,15 +55,21 @@ private function controller(): DashboardApiController {
return new DashboardApiController(
request: $this->createMock(IRequest::class),
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->createMock(PermissionService::class),
- treeService: $this->treeService,
- versionService: $this->createMock(DashboardVersionService::class),
- analyticsService: $this->createMock(AnalyticsService::class),
- logger: $this->createMock(LoggerInterface::class),
- userSession: $session,
- actionAuth: $this->createMock(ActionAuthService::class),
- userId: 'sanne',
+ history: new DashboardApiHistory(
+ treeService: $this->treeService,
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $session,
+ actionAuth: $this->createMock(ActionAuthService::class)
+ ),
+ userId: 'sanne'
);
}
diff --git a/tests/Unit/Controller/DashboardApiControllerByPathTest.php b/tests/Unit/Controller/DashboardApiControllerByPathTest.php
index 38cb0b34a..6bb792631 100644
--- a/tests/Unit/Controller/DashboardApiControllerByPathTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerByPathTest.php
@@ -28,6 +28,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -126,15 +129,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->permissionService,
- treeService: $this->treeService,
- versionService: $this->createMock(DashboardVersionService::class),
- analyticsService: $this->createMock(AnalyticsService::class),
- logger: $this->createMock(LoggerInterface::class),
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->treeService,
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerComputePathTest.php b/tests/Unit/Controller/DashboardApiControllerComputePathTest.php
index 907d54f21..c1fc007bb 100644
--- a/tests/Unit/Controller/DashboardApiControllerComputePathTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerComputePathTest.php
@@ -21,6 +21,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\AnalyticsService;
@@ -74,15 +77,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->createMock(originalClassName: DashboardService::class),
+ services: new DashboardApiServices(
+ dashboardService: $this->createMock(originalClassName: DashboardService::class)
+ ),
permissionService: $this->createMock(originalClassName: PermissionService::class),
- treeService: $this->treeService,
- versionService: $this->createMock(originalClassName: DashboardVersionService::class),
- analyticsService: $this->createMock(originalClassName: AnalyticsService::class),
- logger: $this->createMock(originalClassName: LoggerInterface::class),
- userSession: $this->userSession,
- actionAuth: $this->createMock(originalClassName: ActionAuthService::class),
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->treeService,
+ versionService: $this->createMock(originalClassName: DashboardVersionService::class),
+ analyticsService: $this->createMock(originalClassName: AnalyticsService::class),
+ logger: $this->createMock(originalClassName: LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->createMock(originalClassName: ActionAuthService::class)
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerDefaultFlagTest.php b/tests/Unit/Controller/DashboardApiControllerDefaultFlagTest.php
index e280195ab..507ef0283 100644
--- a/tests/Unit/Controller/DashboardApiControllerDefaultFlagTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerDefaultFlagTest.php
@@ -21,6 +21,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -89,15 +92,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->permissionService,
- treeService: $this->treeService,
- versionService: $this->versionService,
- analyticsService: $this->analyticsService,
- logger: $this->logger,
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->treeService,
+ versionService: $this->versionService,
+ analyticsService: $this->analyticsService,
+ logger: $this->logger
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerDefaultPreferenceTest.php b/tests/Unit/Controller/DashboardApiControllerDefaultPreferenceTest.php
index 77e865f3d..7b4a597e4 100644
--- a/tests/Unit/Controller/DashboardApiControllerDefaultPreferenceTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerDefaultPreferenceTest.php
@@ -29,6 +29,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\AnalyticsService;
@@ -110,15 +113,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->createMock(PermissionService::class),
- treeService: $this->createMock(DashboardTreeService::class),
- versionService: $this->createMock(DashboardVersionService::class),
- analyticsService: $this->createMock(AnalyticsService::class),
- logger: $this->createMock(LoggerInterface::class),
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->createMock(DashboardTreeService::class),
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerForkTest.php b/tests/Unit/Controller/DashboardApiControllerForkTest.php
index a12a9ccf2..653210140 100644
--- a/tests/Unit/Controller/DashboardApiControllerForkTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerForkTest.php
@@ -24,6 +24,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Exception\PersonalDashboardsDisabledException;
@@ -107,15 +110,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->permissionService,
- treeService: $this->treeService,
- versionService: $this->versionService,
- analyticsService: $this->analyticsService,
- logger: $this->logger,
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->treeService,
+ versionService: $this->versionService,
+ analyticsService: $this->analyticsService,
+ logger: $this->logger
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerGroupSharedTest.php b/tests/Unit/Controller/DashboardApiControllerGroupSharedTest.php
index 573dc07e1..2e413be92 100644
--- a/tests/Unit/Controller/DashboardApiControllerGroupSharedTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerGroupSharedTest.php
@@ -21,6 +21,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -122,15 +125,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->permissionService,
- treeService: $this->treeService,
- versionService: $this->versionService,
- analyticsService: $this->analyticsService,
- logger: $this->logger,
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->treeService,
+ versionService: $this->versionService,
+ analyticsService: $this->analyticsService,
+ logger: $this->logger
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerGuardGoldenTest.php b/tests/Unit/Controller/DashboardApiControllerGuardGoldenTest.php
new file mode 100644
index 000000000..7158dbeba
--- /dev/null
+++ b/tests/Unit/Controller/DashboardApiControllerGuardGoldenTest.php
@@ -0,0 +1,174 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\DashboardApiController;
+use OCA\LaunchPad\Controller\RequestActor;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionNamedType;
+use Throwable;
+
+/**
+ * Refused and allowed outcomes of the dashboard API caller guard.
+ */
+class DashboardApiControllerGuardGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/dashboard-api-guard.json';
+
+ private const SCENARIOS = ['no session user', 'action denied', 'no user id', 'allowed'];
+
+ /**
+ * Actions asked of the action guard in the current call.
+ *
+ * @var string[]
+ */
+ private array $asked = [];
+
+ /**
+ * Every gated endpoint in every scenario gives the recorded outcome.
+ *
+ * @return void
+ */
+ public function testGuardOutcomes(): void {
+ $actual = [];
+ $lines = file((string) (new ReflectionClass(DashboardApiController::class))->getFileName());
+ foreach ((new ReflectionClass(DashboardApiController::class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->getDeclaringClass()->getName() !== DashboardApiController::class || $method->isConstructor() === true) {
+ continue;
+ }
+ $body = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
+ if (preg_match('/requireAction|Not authenticated|actionRefusal\(|denyCreate/', $body) !== 1) {
+ continue;
+ }
+ foreach (self::SCENARIOS as $scenario) {
+ $actual[$method->getName().' / '.$scenario] = $this->outcome(method: $method, scenario: $scenario);
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testGuardOutcomes()
+
+ /**
+ * Call one endpoint in one scenario.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ * @param string $scenario The caller situation.
+ *
+ * @return array Status and body (or the thrown class) plus the asked actions.
+ */
+ private function outcome(ReflectionMethod $method, string $scenario): array {
+ $this->asked = [];
+ $controller = $this->controller(scenario: $scenario);
+ try {
+ $response = $method->invokeArgs($controller, $this->arguments(method: $method));
+ $result = ['status' => $response->getStatus()];
+ if ($scenario !== 'allowed') {
+ $result['body'] = $response->getData();
+ }
+ } catch (Throwable $e) {
+ $result = ['thrown' => get_class($e)];
+ }
+
+ $result['asked'] = $this->asked;
+ return $result;
+ }//end outcome()
+
+ /**
+ * Argument values for an endpoint: defaults where declared, else a plain value per type.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ *
+ * @return array
+ */
+ private function arguments(ReflectionMethod $method): array {
+ $args = [];
+ foreach ($method->getParameters() as $param) {
+ if ($param->isDefaultValueAvailable() === true) {
+ $args[] = $param->getDefaultValue();
+ continue;
+ }
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match ($name) {
+ 'int' => 7,
+ 'string' => 'uuid-a',
+ 'array' => [],
+ 'bool' => true,
+ default => null,
+ };
+ }
+
+ return $args;
+ }//end arguments()
+
+ /**
+ * The controller with every collaborator mocked and the caller set up for the scenario.
+ *
+ * @param string $scenario The caller situation.
+ *
+ * @return DashboardApiController
+ */
+ private function controller(string $scenario): DashboardApiController {
+ $session = $this->createMock(IUserSession::class);
+ $user = null;
+ if ($scenario !== 'no session user') {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ }
+ $session->method('getUser')->willReturn($user);
+
+ $auth = $this->createMock(ActionAuthService::class);
+ $auth->method('requireAction')->willReturnCallback(
+ function (IUser $user, string $action) use ($scenario): void {
+ $this->asked[] = $action;
+ if ($scenario === 'action denied') {
+ throw new OCSForbiddenException('denied');
+ }
+ }
+ );
+
+ $args = [];
+ foreach ((new ReflectionClass(DashboardApiController::class))->getConstructor()->getParameters() as $param) {
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match (true) {
+ $param->getName() === 'userId' => ($scenario === 'no user id') ? null : 'alice',
+ $name === RequestActor::class => new RequestActor(userSession: $session, actionAuth: $auth),
+ default => $this->createMock($name),
+ };
+ }
+
+ return new DashboardApiController(...$args);
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/DashboardApiControllerLanguagesAndDetailsTest.php b/tests/Unit/Controller/DashboardApiControllerLanguagesAndDetailsTest.php
index 76ba7c0b3..401650b00 100644
--- a/tests/Unit/Controller/DashboardApiControllerLanguagesAndDetailsTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerLanguagesAndDetailsTest.php
@@ -20,6 +20,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Db\DashboardTranslation;
@@ -62,17 +65,23 @@ private function controller(): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService,
+ translationService: $this->translations,
+ metadataService: $this->metadata
+ ),
permissionService: $this->createMock(PermissionService::class),
- treeService: $this->createMock(DashboardTreeService::class),
- versionService: $this->createMock(DashboardVersionService::class),
- analyticsService: $this->createMock(AnalyticsService::class),
- logger: $this->createMock(LoggerInterface::class),
- userSession: $session,
- actionAuth: $this->createMock(ActionAuthService::class),
- userId: 'jan',
- translationService: $this->translations,
- metadataService: $this->metadata,
+ history: new DashboardApiHistory(
+ treeService: $this->createMock(DashboardTreeService::class),
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $session,
+ actionAuth: $this->createMock(ActionAuthService::class)
+ ),
+ userId: 'jan'
);
}
diff --git a/tests/Unit/Controller/DashboardApiControllerQuotaTest.php b/tests/Unit/Controller/DashboardApiControllerQuotaTest.php
index c747c2e7e..0ce2f63f3 100644
--- a/tests/Unit/Controller/DashboardApiControllerQuotaTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerQuotaTest.php
@@ -22,6 +22,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\AdminSetting;
use OCA\LaunchPad\Db\AdminSettingMapper;
@@ -90,16 +93,22 @@ private function makeController(?string $userId = 'alice'): DashboardApiControll
return new DashboardApiController(
request: $this->createMock(IRequest::class),
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService,
+ quotaService: $this->quotaService
+ ),
permissionService: $this->createMock(PermissionService::class),
- treeService: $this->createMock(DashboardTreeService::class),
- versionService: $this->createMock(DashboardVersionService::class),
- analyticsService: $this->createMock(AnalyticsService::class),
- logger: $this->createMock(LoggerInterface::class),
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
- quotaService: $this->quotaService,
+ history: new DashboardApiHistory(
+ treeService: $this->createMock(DashboardTreeService::class),
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardApiControllerScheduleTest.php b/tests/Unit/Controller/DashboardApiControllerScheduleTest.php
index 937e6e05d..8707e1f12 100644
--- a/tests/Unit/Controller/DashboardApiControllerScheduleTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerScheduleTest.php
@@ -18,6 +18,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -45,15 +48,21 @@ private function controller(): DashboardApiController {
return new DashboardApiController(
request: $this->createMock(IRequest::class),
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->createMock(PermissionService::class),
- treeService: $this->createMock(DashboardTreeService::class),
- versionService: $this->createMock(DashboardVersionService::class),
- analyticsService: $this->createMock(AnalyticsService::class),
- logger: $this->createMock(LoggerInterface::class),
- userSession: $session,
- actionAuth: $this->createMock(ActionAuthService::class),
- userId: 'sanne',
+ history: new DashboardApiHistory(
+ treeService: $this->createMock(DashboardTreeService::class),
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $session,
+ actionAuth: $this->createMock(ActionAuthService::class)
+ ),
+ userId: 'sanne'
);
}
diff --git a/tests/Unit/Controller/DashboardApiControllerUpdateAccessTest.php b/tests/Unit/Controller/DashboardApiControllerUpdateAccessTest.php
new file mode 100644
index 000000000..04f8c8917
--- /dev/null
+++ b/tests/Unit/Controller/DashboardApiControllerUpdateAccessTest.php
@@ -0,0 +1,235 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\DashboardApiController;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\RequestActor;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\AnalyticsService;
+use OCA\LaunchPad\Service\DashboardService;
+use OCA\LaunchPad\Service\DashboardTreeService;
+use OCA\LaunchPad\Service\DashboardVersionService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * The update() guards, case by case.
+ *
+ * @covers \OCA\LaunchPad\Controller\DashboardApiController
+ * @uses \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Controller\RespondsWithErrors
+ * @uses \OCA\LaunchPad\Controller\DashboardApiHistory
+ * @uses \OCA\LaunchPad\Controller\DashboardApiServices
+ * @uses \OCA\LaunchPad\Controller\RequestActor
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ */
+class DashboardApiControllerUpdateAccessTest extends TestCase {
+
+ /**
+ * Permission calls seen, as [method, userId, dashboardId].
+ *
+ * @var list
+ */
+ private array $asked = [];
+
+ /**
+ * Saves seen, as [dashboardId, userId].
+ *
+ * @var list
+ */
+ private array $saved = [];
+
+ /**
+ * Action checks seen, as [uid, action].
+ *
+ * @var list
+ */
+ private array $actions = [];
+
+ /**
+ * Build the controller.
+ *
+ * @param string|null $sessionUid The logged-in user, or null for none.
+ * @param string|null $userId The injected session user id.
+ * @param string $allowed The only user the permission doubles allow.
+ * @param bool $denyAction Whether the action check refuses.
+ *
+ * @return DashboardApiController
+ */
+ private function controller(?string $sessionUid, ?string $userId, string $allowed='alice', bool $denyAction=false): DashboardApiController {
+ $user = null;
+ if ($sessionUid !== null) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($sessionUid);
+ }
+
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+
+ $actionAuth = $this->createMock(ActionAuthService::class);
+ $actionAuth->method('requireAction')->willReturnCallback(
+ function (IUser $actor, string $action) use ($denyAction): void {
+ $this->actions[] = [$actor->getUID(), $action];
+ if ($denyAction === true) {
+ throw new RuntimeException('action refused');
+ }
+ }
+ );
+
+ $permissions = $this->createMock(PermissionService::class);
+ foreach (['canEditDashboard', 'canEditDashboardMetadata'] as $method) {
+ $permissions->method($method)->willReturnCallback(
+ function (string $userId, int $dashboardId) use ($method, $allowed): bool {
+ $this->asked[] = [$method, $userId, $dashboardId];
+ return $userId === $allowed;
+ }
+ );
+ }
+
+ $service = $this->createMock(DashboardService::class);
+ $service->method('updateDashboard')->willReturnCallback(
+ function (int $dashboardId, string $userId, array $data): Dashboard {
+ $this->saved[] = [$dashboardId, $userId];
+ return new Dashboard();
+ }
+ );
+
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')->willReturnCallback(static fn (string $key) => $key === 'userId' ? 'alice' : null);
+
+ return new DashboardApiController(
+ request: $request,
+ services: new DashboardApiServices(dashboardService: $service),
+ permissionService: $permissions,
+ history: new DashboardApiHistory(
+ treeService: $this->createMock(DashboardTreeService::class),
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(userSession: $session, actionAuth: $actionAuth),
+ userId: $userId
+ );
+ }//end controller()
+
+ /**
+ * Without a session the update answers 401 and asks nothing.
+ *
+ * @return void
+ */
+ public function testAnonymousIsUnauthorised(): void {
+ $response = $this->controller(sessionUid: null, userId: null)->update(7, name: 'x');
+
+ $this->assertSame(401, $response->getStatus());
+ $this->assertSame([[], [], []], [$this->actions, $this->asked, $this->saved]);
+ }//end testAnonymousIsUnauthorised()
+
+ /**
+ * A session without an injected user id answers 401 after the action check.
+ *
+ * @return void
+ */
+ public function testSessionWithoutUserIdIsUnauthorised(): void {
+ $response = $this->controller(sessionUid: 'alice', userId: null)->update(7, name: 'x');
+
+ $this->assertSame(401, $response->getStatus());
+ $this->assertSame([['alice', 'dashboard.update']], $this->actions);
+ $this->assertSame([[], []], [$this->asked, $this->saved]);
+ }//end testSessionWithoutUserIdIsUnauthorised()
+
+ /**
+ * A refused action check stops the update before any permission check.
+ *
+ * @return void
+ */
+ public function testRefusedActionStopsTheUpdate(): void {
+ try {
+ $this->controller(sessionUid: 'alice', userId: 'alice', denyAction: true)->update(7, name: 'x');
+ $this->fail('the action refusal must propagate');
+ } catch (RuntimeException $e) {
+ $this->assertSame('action refused', $e->getMessage());
+ }
+
+ $this->assertSame([[], []], [$this->asked, $this->saved]);
+ }//end testRefusedActionStopsTheUpdate()
+
+ /**
+ * A metadata-only update asks the metadata permission for the acting user.
+ *
+ * @return void
+ */
+ public function testMetadataUpdateAsksTheMetadataPermission(): void {
+ $response = $this->controller(sessionUid: 'alice', userId: 'alice')->update(7, name: 'x');
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame([['canEditDashboardMetadata', 'alice', 7]], $this->asked);
+ $this->assertSame([[7, 'alice']], $this->saved);
+ }//end testMetadataUpdateAsksTheMetadataPermission()
+
+ /**
+ * A placement update asks the full edit permission for the acting user.
+ *
+ * @return void
+ */
+ public function testPlacementUpdateAsksTheEditPermission(): void {
+ $response = $this->controller(sessionUid: 'alice', userId: 'alice')->update(7, placements: []);
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame([['canEditDashboard', 'alice', 7]], $this->asked);
+ $this->assertSame([[7, 'alice']], $this->saved);
+ }//end testPlacementUpdateAsksTheEditPermission()
+
+ /**
+ * Another user is refused 403 for metadata and placements, and nothing is saved,
+ * even when the request names the allowed user.
+ *
+ * @return void
+ */
+ public function testOtherUserIsForbidden(): void {
+ $metadata = $this->controller(sessionUid: 'bob', userId: 'bob')->update(7, name: 'x');
+ $placement = $this->controller(sessionUid: 'bob', userId: 'bob')->update(7, placements: []);
+
+ $this->assertSame([403, 403], [$metadata->getStatus(), $placement->getStatus()]);
+ $this->assertSame([['canEditDashboardMetadata', 'bob', 7], ['canEditDashboard', 'bob', 7]], $this->asked);
+ $this->assertSame([], $this->saved);
+ }//end testOtherUserIsForbidden()
+
+ /**
+ * The permission check runs for the injected user id, not the session's UID.
+ *
+ * @return void
+ */
+ public function testPermissionUsesTheInjectedUserId(): void {
+ $response = $this->controller(sessionUid: 'alice', userId: 'bob')->update(7, name: 'x');
+
+ $this->assertSame(403, $response->getStatus());
+ $this->assertSame([['canEditDashboardMetadata', 'bob', 7]], $this->asked);
+ }//end testPermissionUsesTheInjectedUserId()
+}//end class
diff --git a/tests/Unit/Controller/DashboardApiControllerUpdateDataTest.php b/tests/Unit/Controller/DashboardApiControllerUpdateDataTest.php
new file mode 100644
index 000000000..712797b16
--- /dev/null
+++ b/tests/Unit/Controller/DashboardApiControllerUpdateDataTest.php
@@ -0,0 +1,217 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
+use OCA\LaunchPad\Controller\DashboardApiController;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\AnalyticsService;
+use OCA\LaunchPad\Service\DashboardService;
+use OCA\LaunchPad\Service\DashboardTreeService;
+use OCA\LaunchPad\Service\DashboardVersionService;
+use OCA\LaunchPad\Service\PermissionService;
+use InvalidArgumentException;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * The update action's patch payload, case by case.
+ *
+ * @covers \OCA\LaunchPad\Controller\DashboardApiController
+ * @covers \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ * @uses \OCA\LaunchPad\Controller\RequestActor
+ * @uses \OCA\LaunchPad\Controller\DashboardApiHistory
+ * @uses \OCA\LaunchPad\Controller\DashboardApiServices
+ */
+class DashboardApiControllerUpdateDataTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/dashboard-update-data.json';
+
+ /**
+ * The update() argument sets, by case name.
+ *
+ * @return array>
+ */
+ private static function inputs(): array {
+ return [
+ 'nothing' => [],
+ 'name' => ['name' => 'Team'],
+ 'empty name' => ['name' => ''],
+ 'description' => ['description' => 'About'],
+ 'empty description' => ['description' => ''],
+ 'placements' => ['placements' => [['widgetId' => 'w1']]],
+ 'no placements' => ['placements' => []],
+ 'icon' => ['icon' => 'star'],
+ 'empty icon' => ['icon' => ''],
+ 'parent' => ['parentUuid' => 'p-1'],
+ 'parent sentinel' => ['parentUuid' => '__null__'],
+ 'parent empty' => ['parentUuid' => ''],
+ 'parent zero' => ['parentUuid' => '0'],
+ 'slug' => ['slug' => 'team'],
+ 'empty slug' => ['slug' => ''],
+ 'sort order' => ['sortOrder' => 3],
+ 'sort order zero' => ['sortOrder' => 0],
+ 'everything' => [
+ 'sortOrder' => 2,
+ 'slug' => 's',
+ 'parentUuid' => '__null__',
+ 'icon' => 'i',
+ 'placements' => [],
+ 'description' => 'd',
+ 'name' => 'n',
+ ],
+ 'metadata with parent' => [
+ 'name' => 'n',
+ 'parentUuid' => 'p-2',
+ 'sortOrder' => 1,
+ ],
+ ];
+ }//end inputs()
+
+ /**
+ * Run update() with one argument set and return the patch it sends.
+ *
+ * @param array $args The named update() arguments.
+ *
+ * @return array|null The patch, or null when none was sent.
+ */
+ private function patchFor(array $args): ?array {
+ $patch = null;
+ $dashboard = new Dashboard();
+ $dashboard->setUuid('u-1');
+ $service = $this->createMock(DashboardService::class);
+ $service->method('updateDashboard')->willReturnCallback(
+ function (int $dashboardId, string $userId, array $data) use (&$patch, $dashboard): Dashboard {
+ $patch = ['dashboardId' => $dashboardId, 'userId' => $userId, 'data' => $data];
+ return $dashboard;
+ }
+ );
+
+ $response = $this->controller(service: $service)->update(7, ...$args);
+ $this->assertSame(200, $response->getStatus());
+
+ return $patch;
+ }//end patchFor()
+
+ /**
+ * Build the controller for alice, allowed to edit, over the given service.
+ *
+ * @param DashboardService $service The dashboard service double.
+ *
+ * @return DashboardApiController
+ */
+ private function controller(DashboardService $service): DashboardApiController {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+
+ $permissions = $this->createMock(PermissionService::class);
+ $permissions->method('canEditDashboard')->willReturn(true);
+ $permissions->method('canEditDashboardMetadata')->willReturn(true);
+
+ return new DashboardApiController(
+ request: $this->createMock(IRequest::class),
+ services: new DashboardApiServices(
+ dashboardService: $service
+ ),
+ permissionService: $permissions,
+ history: new DashboardApiHistory(
+ treeService: $this->createMock(DashboardTreeService::class),
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->createMock(AnalyticsService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $session,
+ actionAuth: $this->createMock(ActionAuthService::class)
+ ),
+ userId: 'alice'
+ );
+ }//end controller()
+
+ /**
+ * Every case sends the recorded patch, keys in the recorded order.
+ *
+ * @return void
+ */
+ public function testPatchMatchesTheRecordedFixture(): void {
+ $actual = [];
+ foreach (self::inputs() as $case => $args) {
+ $actual[$case] = $this->patchFor(args: $args);
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $expected = json_decode((string) file_get_contents(self::FIXTURE), true);
+ foreach ($expected as $case => $patch) {
+ $this->assertSame($patch, $actual[$case], $case);
+ }
+
+ $this->assertSame(array_keys($expected), array_keys($actual));
+ }//end testPatchMatchesTheRecordedFixture()
+
+ /**
+ * A validation failure in the service answers 400 with its message.
+ *
+ * @return void
+ */
+ public function testUpdateValidationFailureIsABadRequest(): void {
+ $service = $this->createMock(DashboardService::class);
+ $service->method('updateDashboard')->willThrowException(new InvalidArgumentException('Slug taken'));
+
+ $response = $this->controller(service: $service)->update(7, slug: 'x');
+
+ $this->assertSame(400, $response->getStatus());
+ $this->assertSame(
+ ['status' => 'error', 'error' => 'invalid_argument', 'message' => 'Slug taken'],
+ $response->getData()
+ );
+ }//end testUpdateValidationFailureIsABadRequest()
+
+ /**
+ * A schedule without either time answers 400 with the past-date message.
+ *
+ * @return void
+ */
+ public function testScheduleWithoutTimesIsABadRequest(): void {
+ $service = $this->createMock(DashboardService::class);
+ $service->expects($this->never())->method('schedule');
+
+ $response = $this->controller(service: $service)->schedule('u-1', '', null);
+
+ $this->assertSame(400, $response->getStatus());
+ $this->assertSame(
+ ['status' => 'error', 'error' => 'invalid_argument', 'message' => DashboardService::ERR_SCHEDULE_PAST_DATE],
+ $response->getData()
+ );
+ }//end testScheduleWithoutTimesIsABadRequest()
+}//end class
diff --git a/tests/Unit/Controller/DashboardApiControllerViewEventTest.php b/tests/Unit/Controller/DashboardApiControllerViewEventTest.php
index 6365cc072..a5c528a9c 100644
--- a/tests/Unit/Controller/DashboardApiControllerViewEventTest.php
+++ b/tests/Unit/Controller/DashboardApiControllerViewEventTest.php
@@ -28,6 +28,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\DashboardApiServices;
+use OCA\LaunchPad\Controller\DashboardApiHistory;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\DashboardApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Service\ActionAuthService;
@@ -127,15 +130,21 @@ private function makeController(?string $userId): DashboardApiController {
return new DashboardApiController(
request: $this->request,
- dashboardService: $this->dashboardService,
+ services: new DashboardApiServices(
+ dashboardService: $this->dashboardService
+ ),
permissionService: $this->permissionService,
- treeService: $this->createMock(DashboardTreeService::class),
- versionService: $this->createMock(DashboardVersionService::class),
- analyticsService: $this->analyticsService,
- logger: $this->createMock(LoggerInterface::class),
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- userId: $userId,
+ history: new DashboardApiHistory(
+ treeService: $this->createMock(DashboardTreeService::class),
+ versionService: $this->createMock(DashboardVersionService::class),
+ analyticsService: $this->analyticsService,
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardLockApiControllerGoldenTest.php b/tests/Unit/Controller/DashboardLockApiControllerGoldenTest.php
new file mode 100644
index 000000000..bbb412d70
--- /dev/null
+++ b/tests/Unit/Controller/DashboardLockApiControllerGoldenTest.php
@@ -0,0 +1,146 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\DashboardLockApiController;
+use OCA\LaunchPad\Controller\LockApiCaller;
+use OCA\LaunchPad\Controller\LockSources;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardLock;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Exception\LockConflictException;
+use OCA\LaunchPad\Exception\LockForbiddenException;
+use OCA\LaunchPad\Exception\LockNotFoundException;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\DashboardLockService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * The lock endpoints, case by case.
+ */
+class DashboardLockApiControllerGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/dashboard-lock-api.json';
+
+ /**
+ * Every endpoint × case answers as recorded.
+ *
+ * @return void
+ */
+ public function testEndpointsMatchTheRecording(): void {
+ $cases = ['anonymous', 'action denied', 'granted', 'not found', 'forbidden', 'conflict', 'unknown dashboard', 'cannot view', 'no lock'];
+ $actual = [];
+ foreach (['acquire', 'heartbeat', 'release', 'get', 'forceRelease'] as $endpoint) {
+ foreach ($cases as $case) {
+ try {
+ $response = $this->controller(case: $case)->$endpoint('d-1');
+ $actual[$endpoint.' / '.$case] = [$response->getStatus(), $response->getData()];
+ } catch (\Throwable $e) {
+ $actual[$endpoint.' / '.$case] = ['thrown' => get_class($e), 'message' => $e->getMessage()];
+ }
+ }
+ }
+
+ $actual = json_decode((string) json_encode($actual), true);
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testEndpointsMatchTheRecording()
+
+ /**
+ * The controller for one case.
+ *
+ * @param string $case The situation.
+ *
+ * @return DashboardLockApiController
+ */
+ private function controller(string $case): DashboardLockApiController {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn(($case === 'anonymous') ? null : $user);
+
+ $auth = $this->createMock(ActionAuthService::class);
+ if ($case === 'action denied') {
+ $auth->method('requireAction')->willThrowException(new OCSForbiddenException('denied'));
+ }
+
+ $lock = new DashboardLock();
+ $lock->setDashboardUuid('d-1');
+ $lock->setUserId('alice');
+ $lock->setDisplayName('Alice');
+
+ $failure = match ($case) {
+ 'not found' => new LockNotFoundException('No active lock'),
+ 'forbidden' => new LockForbiddenException('Not yours'),
+ 'conflict' => new LockConflictException('Locked by Bob', $lock),
+ 'unknown dashboard' => new DoesNotExistException('gone'),
+ default => null,
+ };
+ $service = $this->createMock(DashboardLockService::class);
+ foreach (['acquireLock', 'heartbeat', 'releaseLock', 'forceRelease'] as $method) {
+ $service->method($method)->willReturnCallback(
+ static function () use ($failure, $lock, $method) {
+ if ($failure !== null) {
+ throw $failure;
+ }
+ return in_array($method, ['acquireLock', 'heartbeat'], true) ? $lock : null;
+ }
+ );
+ }
+ $service->method('getLockState')->willReturn(($case === 'no lock') ? null : $lock);
+
+ $dashboards = $this->createMock(DashboardMapper::class);
+ $dashboards->method('findByUuid')->willReturnCallback(
+ static function () use ($case): Dashboard {
+ if ($case === 'unknown dashboard') {
+ throw new DoesNotExistException('gone');
+ }
+ $dashboard = new Dashboard();
+ $dashboard->setId(3);
+ return $dashboard;
+ }
+ );
+ $permissions = $this->createMock(PermissionService::class);
+ $permissions->method('canViewDashboard')->willReturn($case !== 'cannot view');
+
+ return new DashboardLockApiController(
+ request: $this->createMock(IRequest::class),
+ caller: new LockApiCaller(
+ sources: new LockSources(lockService: $service, dashboardMapper: $dashboards),
+ guard: new RequestGuard(userSession: $session, groupManager: $this->createMock(IGroupManager::class), actionAuth: $auth)
+ ),
+ permissionService: $permissions,
+ userId: ($case === 'anonymous') ? null : 'alice'
+ );
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/DashboardLockApiControllerTest.php b/tests/Unit/Controller/DashboardLockApiControllerTest.php
index c78872c7c..e669fba77 100644
--- a/tests/Unit/Controller/DashboardLockApiControllerTest.php
+++ b/tests/Unit/Controller/DashboardLockApiControllerTest.php
@@ -20,6 +20,8 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\LockApiCaller;
+use OCA\LaunchPad\Controller\LockSources;
use OCA\LaunchPad\Controller\DashboardLockApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Db\DashboardLock;
@@ -34,6 +36,8 @@
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCP\IGroupManager;
class DashboardLockApiControllerTest extends TestCase {
/** @var IRequest&MockObject */
@@ -69,12 +73,15 @@ private function makeController(?string $userId = 'user1'): DashboardLockApiCont
return new DashboardLockApiController(
request: $this->request,
- lockService: $this->lockService,
+ caller: new LockApiCaller(
+ sources: new LockSources(
+ lockService: $this->lockService,
+ dashboardMapper: $this->dashboardMapper
+ ),
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->createMock(IGroupManager::class), actionAuth: $this->actionAuth)
+ ),
permissionService: $this->permissionService,
- dashboardMapper: $this->dashboardMapper,
- actionAuth: $this->actionAuth,
- userSession: $this->userSession,
- userId: $userId,
+ userId: $userId
);
}
diff --git a/tests/Unit/Controller/DashboardMetadataControllerTest.php b/tests/Unit/Controller/DashboardMetadataControllerTest.php
index 84da7cd14..ac00ec8ad 100644
--- a/tests/Unit/Controller/DashboardMetadataControllerTest.php
+++ b/tests/Unit/Controller/DashboardMetadataControllerTest.php
@@ -21,6 +21,8 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\MetadataApiCaller;
+use OCA\LaunchPad\Controller\MetadataSources;
use OCA\LaunchPad\Controller\DashboardMetadataController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Db\DashboardMapper;
@@ -73,12 +75,16 @@ private function controller(?string $userId): DashboardMetadataController {
return new DashboardMetadataController(
request: $this->request,
- metadataService: $this->metadataService,
- dashboardMapper: $this->dashboardMapper,
+ caller: new MetadataApiCaller(
+ sources: new MetadataSources(
+ metadataService: $this->metadataService,
+ dashboardMapper: $this->dashboardMapper
+ ),
+ actionAuth: $this->actionAuth,
+ userSession: $this->userSession
+ ),
permissionService: $this->permissionService,
- actionAuth: $this->actionAuth,
- userSession: $this->userSession,
- userId: $userId,
+ userId: $userId
);
}
diff --git a/tests/Unit/Controller/DashboardReactionApiControllerTest.php b/tests/Unit/Controller/DashboardReactionApiControllerTest.php
index 28b0d6e90..8f07a9171 100644
--- a/tests/Unit/Controller/DashboardReactionApiControllerTest.php
+++ b/tests/Unit/Controller/DashboardReactionApiControllerTest.php
@@ -19,6 +19,8 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\ReactionTools;
+use OCA\LaunchPad\Controller\ActionCaller;
use OCA\LaunchPad\Controller\DashboardReactionApiController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\ReactionService;
@@ -63,11 +65,15 @@ protected function setUp(): void {
private function makeController(?string $userId = 'user1'): DashboardReactionApiController {
return new DashboardReactionApiController(
request: $this->request,
- reactionService: $this->reactionService,
- actionAuth: $this->actionAuth,
- userSession: $this->userSession,
- logger: $this->logger,
- userId: $userId,
+ reactions: new ReactionTools(
+ reactionService: $this->reactionService,
+ logger: $this->logger
+ ),
+ caller: new ActionCaller(
+ actionAuth: $this->actionAuth,
+ userSession: $this->userSession
+ ),
+ userId: $userId
);
}//end makeController()
@@ -210,14 +216,99 @@ public function testGetReactionsReturns401WhenUnauthenticated(): void {
$controller = new DashboardReactionApiController(
request: $this->request,
- reactionService: $this->reactionService,
- actionAuth: $this->actionAuth,
- userSession: $anonSession,
- logger: $this->logger,
- userId: null,
+ reactions: new ReactionTools(
+ reactionService: $this->reactionService,
+ logger: $this->logger
+ ),
+ caller: new ActionCaller(
+ actionAuth: $this->actionAuth,
+ userSession: $anonSession
+ ),
+ userId: null
);
$response = $controller->getReactions('some-uuid');
$this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus());
}//end testGetReactionsReturns401WhenUnauthenticated()
+
+ /**
+ * Every failure answer of the four endpoints, with the logged message,
+ * and the 401 of each without a session user.
+ *
+ * @return void
+ */
+ public function testFailureAnswersOfEveryEndpoint(): void {
+ $failures = [
+ new \OCP\AppFramework\Db\DoesNotExistException('gone'),
+ new \OCA\LaunchPad\Service\PermissionDeniedException('no view'),
+ new \OCA\LaunchPad\Service\ReactionsDisabledException('disabled'),
+ new \InvalidArgumentException('bad emoji'),
+ new \RuntimeException('db down'),
+ ];
+ $calls = [
+ 'getReactions' => ['getReactionsSummary', fn ($c) => $c->getReactions('u')],
+ 'addReaction' => ['addReaction', fn ($c) => $c->addReaction('u', 'x')],
+ 'removeReaction' => ['removeReaction', fn ($c) => $c->removeReaction('u', 'x')],
+ 'getReactorsByEmoji' => ['getReactorsByEmoji', fn ($c) => $c->getReactorsByEmoji('u', 'x', null)],
+ ];
+ $logged = [];
+ $this->logger->method('error')->willReturnCallback(function (string $message) use (&$logged): void {
+ $logged[] = $message;
+ });
+
+ $answers = [];
+ foreach ($calls as $endpoint => [$method, $call]) {
+ foreach ($failures as $failure) {
+ $service = $this->createMock(ReactionService::class);
+ $service->method($method)->willThrowException($failure);
+ $this->reactionService = $service;
+ $response = $call($this->makeController());
+ $answers[] = $endpoint . ' ' . $response->getStatus() . ' ' . json_encode($response->getData());
+ }
+ }
+
+ $this->assertSame(
+ [
+ 'getReactions 404 {"error":"Dashboard not found"}',
+ 'getReactions 403 {"error":"no view"}',
+ 'getReactions 500 {"error":"Operation failed"}',
+ 'getReactions 500 {"error":"Operation failed"}',
+ 'getReactions 500 {"error":"Operation failed"}',
+ 'addReaction 404 {"error":"Dashboard not found"}',
+ 'addReaction 403 {"error":"no view"}',
+ 'addReaction 403 {"error":"disabled"}',
+ 'addReaction 400 {"error":"bad emoji"}',
+ 'addReaction 500 {"error":"Operation failed"}',
+ 'removeReaction 404 {"error":"Dashboard not found"}',
+ 'removeReaction 403 {"error":"no view"}',
+ 'removeReaction 500 {"error":"Operation failed"}',
+ 'removeReaction 500 {"error":"Operation failed"}',
+ 'removeReaction 500 {"error":"Operation failed"}',
+ 'getReactorsByEmoji 404 {"error":"Dashboard not found"}',
+ 'getReactorsByEmoji 403 {"error":"no view"}',
+ 'getReactorsByEmoji 500 {"error":"Operation failed"}',
+ 'getReactorsByEmoji 500 {"error":"Operation failed"}',
+ 'getReactorsByEmoji 500 {"error":"Operation failed"}',
+ ],
+ $answers
+ );
+ $this->assertSame(
+ [
+ 'getReactions failed: disabled', 'getReactions failed: bad emoji', 'getReactions failed: db down',
+ 'addReaction failed: db down',
+ 'removeReaction failed: disabled', 'removeReaction failed: bad emoji', 'removeReaction failed: db down',
+ 'getReactorsByEmoji failed: disabled', 'getReactorsByEmoji failed: bad emoji', 'getReactorsByEmoji failed: db down',
+ ],
+ $logged
+ );
+
+ $anonymous = $this->createMock(IUserSession::class);
+ $anonymous->method('getUser')->willReturn(null);
+ $this->userSession = $anonymous;
+ $this->actionAuth->expects($this->never())->method('requireAction');
+ foreach ($calls as [$method, $call]) {
+ $response = $call($this->makeController());
+ $this->assertSame([401, ['error' => 'Not authenticated']], [$response->getStatus(), $response->getData()]);
+ }
+ }//end testFailureAnswersOfEveryEndpoint()
}//end class
diff --git a/tests/Unit/Controller/DashboardShareApiControllerFollowupsTest.php b/tests/Unit/Controller/DashboardShareApiControllerFollowupsTest.php
index a05490e0d..3764997fc 100644
--- a/tests/Unit/Controller/DashboardShareApiControllerFollowupsTest.php
+++ b/tests/Unit/Controller/DashboardShareApiControllerFollowupsTest.php
@@ -21,10 +21,11 @@
namespace Unit\Controller;
-use Exception;
use InvalidArgumentException;
use OCA\LaunchPad\Controller\DashboardShareApiController;
+use OCA\LaunchPad\Controller\ShareApiTools;
use OCA\LaunchPad\Db\DashboardShare;
+use OCA\LaunchPad\Exception\ForbiddenException;
use OCA\LaunchPad\Service\DashboardShareService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -33,6 +34,7 @@
use OCP\IUserManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
/**
* Tests for DashboardShareApiController follow-up actions.
@@ -88,8 +90,11 @@ private function makeController(
return new DashboardShareApiController(
request: $this->request,
shareService: $this->shareService,
- userManager: $this->userManager,
- groupManager: $this->groupManager,
+ tools: new ShareApiTools(
+ userManager: $this->userManager,
+ groupManager: $this->groupManager,
+ logger: $this->createMock(LoggerInterface::class),
+ ),
userId: $userId,
);
}//end makeController()
@@ -190,7 +195,7 @@ public function testReplaceReturns403WhenNotOwner(): void {
$controller = $this->makeController(userId: 'bob');
$this->shareService->method('replaceShares')
- ->willThrowException(new Exception('Access denied'));
+ ->willThrowException(new ForbiddenException('Access denied'));
$response = $controller->replace(id: 5, shares: []);
diff --git a/tests/Unit/Controller/DashboardShareFailureGoldenTest.php b/tests/Unit/Controller/DashboardShareFailureGoldenTest.php
new file mode 100644
index 000000000..71cfda7c5
--- /dev/null
+++ b/tests/Unit/Controller/DashboardShareFailureGoldenTest.php
@@ -0,0 +1,294 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Tests\Unit\Controller;
+
+use OCA\LaunchPad\Controller\DashboardShareApiController;
+use OCA\LaunchPad\Controller\ShareApiTools;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\DashboardShare;
+use OCA\LaunchPad\Db\DashboardShareMapper;
+use OCA\LaunchPad\Service\DashboardShareService;
+use OCA\LaunchPad\Service\ShareNotifier;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\DB\Exception as DbException;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserManager;
+use OCP\Notification\IManager as INotificationManager;
+use OCP\Notification\INotification;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Drives the share endpoints (list, add, remove, replace) through the real
+ * DashboardShareService and pins, per endpoint and per caller, the status
+ * and body the client gets, the user the owner check ran for, and what was
+ * written and logged. Dashboard 1 belongs to alice. The fixture was
+ * recorded with LAUNCHPAD_GOLDEN_RECORD=1.
+ *
+ * @covers \OCA\LaunchPad\Controller\DashboardShareApiController
+ * @covers \OCA\LaunchPad\Service\DashboardShareService
+ * @covers \OCA\LaunchPad\Service\ShareNotifier
+ * @covers \OCA\LaunchPad\Controller\ShareApiTools
+ * @uses \OCA\LaunchPad\Exception\ForbiddenException
+ * @uses \OCA\LaunchPad\Exception\ResourceException
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ * @uses \OCA\LaunchPad\Db\DashboardShare
+ * @uses \OCA\LaunchPad\Service\ShareGrant
+ */
+class DashboardShareFailureGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__ . '/fixtures/dashboard-share-failures.json';
+
+ /**
+ * Endpoint calls: [method, arguments].
+ */
+ private const CALLS = [
+ 'index' => ['index', [1]],
+ 'index unknown dashboard' => ['index', [404]],
+ 'create' => ['create', [1, 'user', 'carol', 'view_only']],
+ 'create invalid type' => ['create', [1, 'team', 'carol', 'view_only']],
+ 'create unknown dashboard' => ['create', [404, 'user', 'carol', 'view_only']],
+ 'destroy' => ['destroy', [10]],
+ 'destroy unknown share' => ['destroy', [404]],
+ 'replace' => ['replace', [1, [['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'view_only']]]],
+ 'replace empty' => ['replace', [1, null]],
+ 'replace invalid level' => ['replace', [1, [['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'own']]]],
+ 'replace int recipient' => ['replace', [1, [['shareType' => 'user', 'shareWith' => 42, 'permissionLevel' => 'view_only']]]],
+ 'replace array recipient' => ['replace', [1, [['shareType' => 'user', 'shareWith' => ['carol'], 'permissionLevel' => 'view_only']]]],
+ 'replace int type' => ['replace', [1, [['shareType' => 7, 'shareWith' => 'carol', 'permissionLevel' => 'view_only']]]],
+ 'replace null level' => ['replace', [1, [['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => null]]]],
+ 'replace entry not an array' => ['replace', [1, ['user:carol']]],
+ 'replace unknown dashboard' => ['replace', [404, []]],
+ 'revoke for recipient' => ['revokeForRecipient', ['user', 'carol']],
+ 'revoke for recipient invalid type' => ['revokeForRecipient', ['team', 'carol']],
+ ];
+
+ /**
+ * Session users and database states.
+ */
+ private const CALLERS = ['alice (owner)' => 'alice', 'bob (not owner)' => 'bob', 'anonymous' => null];
+
+ private const DATABASES = ['healthy', 'write fails', 'read fails'];
+
+ /**
+ * Every call for every caller and database state.
+ *
+ * @return array
+ */
+ public static function cases(): array {
+ $cases = [];
+ foreach (array_keys(self::CALLS) as $call) {
+ foreach (array_keys(self::CALLERS) as $caller) {
+ foreach (self::DATABASES as $database) {
+ $cases[$call . ' / ' . $caller . ' / ' . $database] = [$call, $caller, $database];
+ }
+ }
+ }
+
+ return $cases;
+ }
+
+ /**
+ * The endpoint answers, checks and writes what it did when recorded.
+ *
+ * @param string $call The call case.
+ * @param string $caller The caller case.
+ * @param string $database The database state.
+ *
+ * @return void
+ */
+ #[DataProvider('cases')]
+ public function testEndpointMatchesTheRecording(string $call, string $caller, string $database): void {
+ $log = ['ownerLookups' => [], 'writes' => [], 'logged' => []];
+ $controller = $this->controller(userId: self::CALLERS[$caller], database: $database, log: $log);
+ [$method, $args] = self::CALLS[$call];
+ try {
+ $response = $controller->$method(...$args);
+ $actual = ['status' => $response->getStatus(), 'data' => $this->plain(value: $response->getData())];
+ } catch (Throwable $e) {
+ $actual = ['threw' => $e::class];
+ }
+
+ $actual += $log;
+ $key = $call . ' / ' . $caller . ' / ' . $database;
+ if (getenv('LAUNCHPAD_GOLDEN_RECORD') === '1') {
+ $recorded = is_file(self::FIXTURE) ? json_decode((string)file_get_contents(self::FIXTURE), true) : [];
+ $recorded[$key] = $actual;
+ ksort($recorded);
+ file_put_contents(self::FIXTURE, json_encode($recorded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+ }
+
+ $expected = json_decode((string)file_get_contents(self::FIXTURE), true)[$key] ?? null;
+ $this->assertNotNull($expected, 'no recording for ' . $key);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * The controller on a real share service over recording doubles.
+ *
+ * @param string|null $userId The session user.
+ * @param string $database The database state.
+ * @param array $log The log, written by reference.
+ *
+ * @return DashboardShareApiController The controller.
+ */
+ private function controller(?string $userId, string $database, array &$log): DashboardShareApiController {
+ $dashboard = new Dashboard();
+ $dashboard->setId(1);
+ $dashboard->setUserId('alice');
+ $dashboard->setName('Team');
+
+ $dashboards = $this->createMock(DashboardMapper::class);
+
+ $shares = $this->createMock(DashboardShareMapper::class);
+ $readFails = static function () use ($database): void {
+ if ($database === 'read fails') {
+ throw new DbException('select failed: secret table name');
+ }
+ };
+ $writeFails = static function (string $what) use ($database, &$log): void {
+ $log['writes'][] = $what;
+ if ($database === 'write fails') {
+ throw new DbException('insert failed: secret column name');
+ }
+ };
+ $existing = new DashboardShare();
+ $existing->setId(10);
+ $existing->setDashboardId(1);
+ $existing->setShareType('user');
+ $existing->setShareWith('dave');
+ $existing->setPermissionLevel('view_only');
+ $shares->method('find')->willReturnCallback(static function (int $id) use ($existing, $readFails): DashboardShare {
+ $readFails();
+ if ($id !== 10) {
+ throw new DoesNotExistException('no share ' . $id);
+ }
+
+ return $existing;
+ });
+ $shares->method('findByDashboardId')->willReturnCallback(static function () use ($existing, $readFails): array {
+ $readFails();
+ return [$existing];
+ });
+ $shares->method('findShare')->willReturnCallback(static function () use ($readFails): ?DashboardShare {
+ $readFails();
+ return null;
+ });
+ $shares->method('insert')->willReturnCallback(static function (DashboardShare $share) use ($writeFails): DashboardShare {
+ $writeFails('insert ' . $share->getShareType() . ':' . $share->getShareWith() . ':' . $share->getPermissionLevel());
+ $share->setId(11);
+ return $share;
+ });
+ $shares->method('delete')->willReturnCallback(static function (DashboardShare $share) use ($writeFails): DashboardShare {
+ $writeFails('delete ' . $share->getId());
+ return $share;
+ });
+ $shares->method('deleteNotIn')->willReturnCallback(static function (int $dashboardId, array $keepKeys) use ($writeFails): int {
+ $writeFails('deleteNotIn ' . $dashboardId . ' keep ' . implode(',', $keepKeys));
+ return 0;
+ });
+
+ $shares->method('deleteByOwnerAndRecipient')->willReturnCallback(static function (string $shareType, string $shareWith, string $ownerId) use ($writeFails): int {
+ $writeFails('revoke ' . $shareType . ':' . $shareWith . ' owner ' . $ownerId);
+ return 2;
+ });
+
+ $dashboards->method('find')->willReturnCallback(static function (int $id) use ($dashboard, &$log): Dashboard {
+ $log['ownerLookups'][] = $id;
+ if ($id !== 1) {
+ throw new DoesNotExistException('no dashboard ' . $id);
+ }
+
+ return $dashboard;
+ });
+
+ $notification = $this->createMock(INotification::class);
+ foreach (['setApp', 'setUser', 'setDateTime', 'setObject', 'setSubject'] as $setter) {
+ $notification->method($setter)->willReturnSelf();
+ }
+
+ $notifications = $this->createMock(INotificationManager::class);
+ $notifications->method('createNotification')->willReturn($notification);
+
+ $logger = $this->createMock(LoggerInterface::class);
+ foreach (['error', 'warning', 'info', 'critical'] as $level) {
+ $logger->method($level)->willReturnCallback(static function (string|\Stringable $message, array $context = []) use ($level, &$log): void {
+ $log['logged'][] = [$level, (string)$message, $context['exceptionClass'] ?? null];
+ });
+ }
+
+ $service = new DashboardShareService(
+ shareMapper: $shares,
+ dashboardMapper: $dashboards,
+ db: $this->createMock(IDBConnection::class),
+ notifier: new ShareNotifier(notificationManager: $notifications, groupManager: $this->createMock(IGroupManager::class), logger: $logger),
+ );
+
+ return $this->build(service: $service, userId: $userId, logger: $logger);
+ }
+
+ /**
+ * Construct the controller with the session user.
+ *
+ * @param DashboardShareService $service The share service.
+ * @param string|null $userId The session user.
+ * @param LoggerInterface $logger The logger.
+ *
+ * @return DashboardShareApiController The controller.
+ */
+ private function build(DashboardShareService $service, ?string $userId, LoggerInterface $logger): DashboardShareApiController {
+ $userManager = $this->createMock(IUserManager::class);
+ $groupManager = $this->createMock(IGroupManager::class);
+ return new DashboardShareApiController(
+ request: $this->createMock(IRequest::class),
+ shareService: $service,
+ tools: new ShareApiTools(userManager: $userManager, groupManager: $groupManager, logger: $logger),
+ userId: $userId,
+ );
+ }
+
+ /**
+ * Entities as their JSON shape, recursively; a timestamp shows as its format.
+ *
+ * @param mixed $value The response data.
+ *
+ * @return mixed The plain data.
+ */
+ private function plain(mixed $value): mixed {
+ if ($value instanceof \JsonSerializable) {
+ $value = $value->jsonSerialize();
+ }
+
+ if (is_array($value) === true) {
+ return array_map(fn (mixed $item): mixed => $this->plain(value: $item), $value);
+ }
+
+ if (is_string($value) === true && preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value) === 1) {
+ return 'stamp Y-m-d H:i:s';
+ }
+
+ return $value;
+ }
+}
diff --git a/tests/Unit/Controller/DashboardShareWriteGoldenTest.php b/tests/Unit/Controller/DashboardShareWriteGoldenTest.php
new file mode 100644
index 000000000..915b6f743
--- /dev/null
+++ b/tests/Unit/Controller/DashboardShareWriteGoldenTest.php
@@ -0,0 +1,354 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use DateTime;
+use OCA\LaunchPad\Activity\DashboardActivityEmitter;
+use OCA\LaunchPad\Controller\DashboardShareApiController;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\DashboardShare;
+use OCA\LaunchPad\Db\DashboardShareMapper;
+use OCA\LaunchPad\Service\DashboardShareService;
+use OCA\LaunchPad\Service\ShareNotifier;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IDBConnection;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Notification\IManager as INotificationManager;
+use OCP\Notification\INotification;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+use Throwable;
+use OCA\LaunchPad\Controller\ShareApiTools;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Share writes, caller by caller.
+ *
+ * @covers \OCA\LaunchPad\Controller\DashboardShareApiController
+ * @covers \OCA\LaunchPad\Service\DashboardShareService
+ * @covers \OCA\LaunchPad\Service\ShareNotifier
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ * @uses \OCA\LaunchPad\Db\DashboardShare
+ * @uses \OCA\LaunchPad\Service\ShareGrant
+ * @uses \OCA\LaunchPad\Controller\ShareApiTools
+ * @uses \OCA\LaunchPad\Exception\ForbiddenException
+ * @uses \OCA\LaunchPad\Exception\ResourceException
+ */
+class DashboardShareWriteGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__ . '/fixtures/dashboard-share-write.json';
+
+ /**
+ * Every write matches the recorded fixture.
+ *
+ * @return void
+ */
+ public function testWritesMatchTheRecordedFixture(): void {
+ $grants = [
+ 'new user' => ['user', 'carol', 'view_only'],
+ 'same level' => ['user', 'bob', 'view_only'],
+ 'upgrade' => ['user', 'bob', 'full'],
+ 'downgrade group' => ['group', 'staff', 'view_only'],
+ 'upgrade group' => ['group', 'staff', 'full'],
+ 'unknown group' => ['group', 'ghost', 'add_only'],
+ 'self' => ['user', 'alice', 'add_only'],
+ 'empty type' => ['', 'carol', 'view_only'],
+ 'link type' => ['link', 'carol', 'view_only'],
+ 'upper type' => ['USER', 'carol', 'view_only'],
+ 'no recipient' => ['user', '', 'view_only'],
+ 'bad level' => ['user', 'carol', 'admin'],
+ 'nothing' => [null, null, null],
+ 'insert fails' => ['user', 'boom', 'full'],
+ ];
+
+ $actual = [];
+ foreach (['alice', 'bob', null] as $caller) {
+ foreach ([1, 2, 3] as $dashboardId) {
+ foreach ($grants as $name => $grant) {
+ $actual["create $caller $dashboardId $name"] = $this->write(caller: $caller, call: ['create', $dashboardId, $grant]);
+ }
+ }
+ }
+
+ $lists = [
+ 'empty' => [],
+ 'none' => null,
+ 'keep' => [
+ ['shareType' => 'user', 'shareWith' => 'bob', 'permissionLevel' => 'view_only'],
+ ['shareType' => 'group', 'shareWith' => 'staff', 'permissionLevel' => 'add_only'],
+ ],
+ 'change' => [
+ ['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'view_only'],
+ ['shareType' => 'user', 'shareWith' => 'bob', 'permissionLevel' => 'full'],
+ ['shareType' => 'group', 'shareWith' => 'staff', 'permissionLevel' => 'view_only'],
+ ],
+ 'invalid second' => [
+ ['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'view_only'],
+ ['shareType' => 'user', 'shareWith' => 'dave', 'permissionLevel' => 'owner'],
+ ],
+ 'missing keys' => [['shareWith' => 'carol']],
+ 'duplicate' => [
+ ['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'view_only'],
+ ['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'full'],
+ ],
+ 'extra keys' => [['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'add_only', 'id' => 7, 'dashboardId' => 2]],
+ 'insert fails' => [
+ ['shareType' => 'user', 'shareWith' => 'carol', 'permissionLevel' => 'view_only'],
+ ['shareType' => 'user', 'shareWith' => 'boom', 'permissionLevel' => 'view_only'],
+ ],
+ 'commit fails' => [['shareType' => 'user', 'shareWith' => 'late', 'permissionLevel' => 'view_only']],
+ 'numeric recipient' => [['shareType' => 'user', 'shareWith' => 10, 'permissionLevel' => 'view_only']],
+ ];
+ foreach (['alice', 'bob', null] as $caller) {
+ foreach ([1, 2, 3] as $dashboardId) {
+ foreach ($lists as $name => $list) {
+ $actual["replace $caller $dashboardId $name"] = $this->write(caller: $caller, call: ['replace', $dashboardId, $list]);
+ }
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+ $this->markTestIncomplete(message: 'Fixture recorded.');
+ }
+
+ $expected = json_decode((string)file_get_contents(self::FIXTURE), true);
+ $this->assertSame(expected: array_keys($expected), actual: array_keys($actual));
+ foreach ($expected as $name => $case) {
+ $this->assertSame(expected: $case, actual: $actual[$name], message: $name);
+ }
+ }
+
+ /**
+ * Run one controller write for a session caller and record it.
+ *
+ * @param string|null $caller The session user, or none.
+ * @param array $call Action, dashboard id, grant or list.
+ *
+ * @return array
+ */
+ private function write(?string $caller, array $call): array {
+ $log = [];
+ $rows = [];
+ $commitFails = false;
+ foreach ([[11, 1, 'user', 'bob', 'view_only'], [12, 1, 'group', 'staff', 'add_only'], [13, 2, 'user', 'alice', 'full']] as [$id, $dashboardId, $type, $with, $level]) {
+ $row = new DashboardShare();
+ $row->setId($id);
+ $row->setDashboardId($dashboardId);
+ $row->setShareType($type);
+ $row->setShareWith($with);
+ $row->setPermissionLevel($level);
+ $row->setCreatedAt('2020-01-01 00:00:00');
+ $row->setUpdatedAt('2020-01-01 00:00:00');
+ $rows["$dashboardId:$type:$with"] = $row;
+ }
+
+ $shares = $this->getMockBuilder(className: DashboardShareMapper::class)->disableOriginalConstructor()->getMock();
+ $shares->method('findShare')->willReturnCallback(static function (int $dashboardId, string $shareType, string $shareWith) use (&$rows, &$log): ?DashboardShare {
+ $log[] = "findShare $dashboardId $shareType $shareWith";
+ return $rows["$dashboardId:$shareType:$shareWith"] ?? null;
+ });
+ $shares->method('insert')->willReturnCallback(static function (DashboardShare $row) use (&$rows, &$log, &$commitFails): DashboardShare {
+ $log[] = ['insert', self::row(share: $row)];
+ if ($row->getShareWith() === 'boom') {
+ throw new RuntimeException('insert failed');
+ }
+
+ $commitFails = $commitFails || $row->getShareWith() === 'late';
+
+ $row->setId(100 + count($rows));
+ $rows[$row->getDashboardId() . ':' . $row->getShareType() . ':' . $row->getShareWith()] = $row;
+ return $row;
+ });
+ $shares->method('update')->willReturnCallback(static function (DashboardShare $row) use (&$log): DashboardShare {
+ $log[] = ['update', self::row(share: $row)];
+ return $row;
+ });
+ $shares->method('deleteNotIn')->willReturnCallback(static function (int $dashboardId, array $keepKeys) use (&$rows, &$log): int {
+ $log[] = ['deleteNotIn', $dashboardId, $keepKeys];
+ $removed = 0;
+ foreach ($rows as $key => $row) {
+ if ($row->getDashboardId() === $dashboardId && in_array($row->getShareType() . ':' . $row->getShareWith(), $keepKeys, true) === false) {
+ unset($rows[$key]);
+ $removed++;
+ }
+ }
+
+ return $removed;
+ });
+ $shares->method('findByDashboardId')->willReturnCallback(static function (int $dashboardId) use (&$rows): array {
+ return array_values(array_filter($rows, static fn (DashboardShare $row): bool => $row->getDashboardId() === $dashboardId));
+ });
+
+ $dashboards = $this->getMockBuilder(className: DashboardMapper::class)->disableOriginalConstructor()->getMock();
+ $dashboards->method('find')->willReturnCallback(static function (int $id) use (&$log): Dashboard {
+ $log[] = "find $id";
+ if ($id === 3) {
+ throw new DoesNotExistException('none');
+ }
+
+ $dashboard = new Dashboard();
+ $dashboard->setId($id);
+ $dashboard->setUserId([1 => 'alice', 2 => 'bob'][$id]);
+ $dashboard->setName('Board ' . $id);
+ return $dashboard;
+ });
+
+ $groups = $this->getMockBuilder(className: IGroupManager::class)->getMock();
+ $groups->method('get')->willReturnCallback(function (string $gid) use (&$log): ?IGroup {
+ $log[] = "group $gid";
+ if ($gid !== 'staff') {
+ return null;
+ }
+
+ $members = [];
+ foreach (['alice', 'bob', 'carol'] as $uid) {
+ $member = $this->getMockBuilder(className: IUser::class)->getMock();
+ $member->method('getUID')->willReturn($uid);
+ $members[] = $member;
+ }
+
+ $group = $this->getMockBuilder(className: IGroup::class)->getMock();
+ $group->method('getUsers')->willReturn($members);
+ return $group;
+ });
+
+ $db = $this->getMockBuilder(className: IDBConnection::class)->getMock();
+ foreach (['beginTransaction', 'commit', 'rollBack'] as $step) {
+ $db->method($step)->willReturnCallback(static function () use (&$log, $step, &$commitFails): bool {
+ $log[] = $step;
+ if ($step === 'commit' && $commitFails === true) {
+ throw new RuntimeException('commit failed');
+ }
+
+ return true;
+ });
+ }
+
+ $notifications = $this->getMockBuilder(className: INotificationManager::class)->getMock();
+ $notifications->method('createNotification')->willReturnCallback(function () use (&$log): INotification {
+ $entry = count($log);
+ $log[$entry] = ['notification'];
+ $notification = $this->getMockBuilder(className: INotification::class)->getMock();
+ foreach (['setApp', 'setUser', 'setObject', 'setSubject'] as $setter) {
+ $notification->method($setter)->willReturnCallback(static function (...$args) use (&$log, $entry, $setter, &$notification): INotification {
+ $log[$entry][] = [$setter, ...$args];
+ return $notification;
+ });
+ }
+
+ $notification->method('setDateTime')->willReturnSelf();
+ return $notification;
+ });
+ $notifications->method('notify')->willReturnCallback(static function () use (&$log): void {
+ $log[] = 'notify';
+ });
+
+ $activity = $this->getMockBuilder(className: DashboardActivityEmitter::class)->disableOriginalConstructor()->getMock();
+ $activity->method('shared')->willReturnCallback(static function (Dashboard $dashboard, DashboardShare $share, string $actor, array $recipients) use (&$log): int {
+ $log[] = ['activity shared', $dashboard->getId(), self::row(share: $share), $actor, $recipients];
+ return count($recipients);
+ });
+
+ $service = new DashboardShareService(
+ shareMapper: $shares,
+ dashboardMapper: $dashboards,
+ db: $db,
+ notifier: new ShareNotifier(
+ notificationManager: $notifications,
+ groupManager: $groups,
+ logger: $this->getMockBuilder(className: LoggerInterface::class)->getMock(),
+ activity: $activity
+ )
+ );
+ $controller = new DashboardShareApiController(
+ request: $this->getMockBuilder(className: IRequest::class)->getMock(),
+ shareService: $service,
+ tools: new ShareApiTools(
+ userManager: $this->getMockBuilder(className: IUserManager::class)->getMock(),
+ groupManager: $groups,
+ logger: $this->getMockBuilder(className: LoggerInterface::class)->getMock()
+ ),
+ userId: $caller
+ );
+
+ try {
+ if ($call[0] === 'create') {
+ [$type, $with, $level] = $call[2];
+ $response = $controller->create(id: $call[1], shareType: $type, shareWith: $with, permissionLevel: $level);
+ } else {
+ $response = $controller->replace(id: $call[1], shares: $call[2]);
+ }
+ } catch (Throwable $e) {
+ return ['thrown' => get_class($e), 'log' => $log];
+ }
+
+ $data = $response->getData();
+ if (isset($data['createdAt']) === true) {
+ $data['createdAt'] = self::clock(value: $data['createdAt']);
+ $data['updatedAt'] = self::clock(value: $data['updatedAt']);
+ } elseif (isset($data['error']) === false) {
+ foreach ($data as $i => $item) {
+ $data[$i]['createdAt'] = self::clock(value: $item['createdAt']);
+ $data[$i]['updatedAt'] = self::clock(value: $item['updatedAt']);
+ }
+ }
+
+ return ['status' => $response->getStatus(), 'body' => $data, 'log' => $log];
+ }
+
+ /**
+ * A share row as plain values, clock timestamps replaced by a marker.
+ *
+ * @param DashboardShare $share The row.
+ *
+ * @return array
+ */
+ private static function row(DashboardShare $share): array {
+ $values = $share->jsonSerialize();
+ $values['createdAt'] = self::clock(value: $values['createdAt']);
+ $values['updatedAt'] = self::clock(value: $values['updatedAt']);
+ return $values;
+ }
+
+ /**
+ * Replace a timestamp taken from the clock during the run with a marker.
+ *
+ * @param string|null $value A stored timestamp.
+ *
+ * @return string|null
+ */
+ private static function clock(?string $value): ?string {
+ if ($value === null || preg_match('/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/', $value) !== 1) {
+ return $value;
+ }
+
+ $delta = abs((new DateTime($value))->getTimestamp() - time());
+ return $delta < 600 ? '' : $value;
+ }
+}
diff --git a/tests/Unit/Controller/DashboardTranslationApiControllerGuardsTest.php b/tests/Unit/Controller/DashboardTranslationApiControllerGuardsTest.php
new file mode 100644
index 000000000..7b3619829
--- /dev/null
+++ b/tests/Unit/Controller/DashboardTranslationApiControllerGuardsTest.php
@@ -0,0 +1,211 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use Exception;
+use OCA\LaunchPad\Controller\ActionCaller;
+use OCA\LaunchPad\Controller\DashboardTranslationApiController;
+use OCA\LaunchPad\Controller\TranslationSources;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\DashboardTranslation;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\DashboardTranslationService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Allowed and refused outcomes of every translation endpoint.
+ */
+class DashboardTranslationApiControllerGuardsTest extends TestCase {
+
+ private const ENDPOINTS = ['list', 'create', 'createBlankLanguage', 'update', 'destroy', 'setPrimary', 'resolved'];
+
+ private const SCENARIOS = ['anonymous', 'no session user', 'action denied', 'unknown dashboard', 'not the owner', 'owner'];
+
+ /**
+ * Every endpoint × scenario gives the recorded status, action and service reach.
+ *
+ * @return void
+ */
+ public function testGuardMatrix(): void {
+ $actual = [];
+ foreach (self::ENDPOINTS as $endpoint) {
+ foreach (self::SCENARIOS as $scenario) {
+ $actual[$endpoint.' / '.$scenario] = $this->callEndpoint($endpoint, $scenario);
+ }
+ }
+
+ $expected = json_decode((string) file_get_contents(__DIR__.'/fixtures/translation-guards.json'), true);
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(__DIR__.'/fixtures/translation-guards.json', json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame($expected, $actual);
+ }//end testGuardMatrix()
+
+ /**
+ * A failed delete maps the service message to an error code.
+ *
+ * @return void
+ */
+ public function testDestroyFailureCodes(): void {
+ $codes = [];
+ foreach ([DashboardTranslationService::ERR_LAST_VARIANT, DashboardTranslationService::ERR_DELETE_PRIMARY, 'something else'] as $message) {
+ $response = $this->controller(userId: 'alice', sessionUser: true, failWith: new Exception($message))
+ ->destroy('uuid-a', 'nl');
+ $codes[] = [$response->getStatus(), $response->getData()];
+ }
+
+ $this->assertSame(
+ [
+ [400, ['status' => 'error', 'error' => 'last_variant', 'message' => DashboardTranslationService::ERR_LAST_VARIANT]],
+ [400, ['status' => 'error', 'error' => 'primary_variant', 'message' => DashboardTranslationService::ERR_DELETE_PRIMARY]],
+ [400, ['status' => 'error', 'error' => 'invalid_state', 'message' => 'something else']],
+ ],
+ $codes
+ );
+ }//end testDestroyFailureCodes()
+
+ /**
+ * One endpoint call in one scenario.
+ *
+ * @param string $endpoint The controller method.
+ * @param string $scenario The caller situation.
+ *
+ * @return array Status, body, asked action and service calls.
+ */
+ private function callEndpoint(string $endpoint, string $scenario): array {
+ $this->asked = [];
+ $this->reached = [];
+ $controller = $this->controller(
+ userId: ($scenario === 'anonymous') ? null : (($scenario === 'not the owner') ? 'mallory' : 'alice'),
+ sessionUser: ($scenario !== 'no session user'),
+ denied: ($scenario === 'action denied'),
+ missing: ($scenario === 'unknown dashboard')
+ );
+
+ $response = match ($endpoint) {
+ 'list' => $controller->list('uuid-a'),
+ 'create' => $controller->create('uuid-a', 'de', 'Name', null, null, null),
+ 'createBlankLanguage' => $controller->create('uuid-a', ' ', 'Name', null, null, null),
+ 'update' => $controller->update('uuid-a', 'nl', 'Name', null, null),
+ 'destroy' => $controller->destroy('uuid-a', 'nl'),
+ 'setPrimary' => $controller->setPrimary('uuid-a', 'nl'),
+ 'resolved' => $controller->resolved('uuid-a'),
+ };
+
+ return ['status' => $response->getStatus(), 'body' => $response->getData(), 'asked' => $this->asked, 'reached' => $this->reached];
+ }//end callEndpoint()
+
+ /**
+ * Actions asked of the action guard.
+ *
+ * @var string[]
+ */
+ private array $asked = [];
+
+ /**
+ * Service methods reached.
+ *
+ * @var string[]
+ */
+ private array $reached = [];
+
+ /**
+ * The controller wired for one scenario.
+ *
+ * @param string|null $userId The controller's user id.
+ * @param bool $sessionUser Whether the session has a user.
+ * @param bool $denied Whether the action guard refuses.
+ * @param bool $missing Whether the dashboard is unknown.
+ * @param Exception|null $failWith What every service write throws.
+ *
+ * @return DashboardTranslationApiController
+ */
+ private function controller(?string $userId, bool $sessionUser, bool $denied = false, bool $missing = false, ?Exception $failWith = null): DashboardTranslationApiController {
+ $session = $this->createMock(IUserSession::class);
+ $user = null;
+ if ($sessionUser === true) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn((string) $userId);
+ }
+ $session->method('getUser')->willReturn($user);
+
+ $auth = $this->createMock(ActionAuthService::class);
+ $auth->method('requireAction')->willReturnCallback(
+ function (IUser $user, string $action) use ($denied): void {
+ $this->asked[] = $action;
+ if ($denied === true) {
+ throw new OCSForbiddenException('denied');
+ }
+ }
+ );
+
+ $mapper = $this->createMock(DashboardMapper::class);
+ $mapper->method('findByUuid')->willReturnCallback(
+ static function (string $uuid) use ($missing): Dashboard {
+ if ($missing === true) {
+ throw new DoesNotExistException('nope');
+ }
+ $dashboard = new Dashboard();
+ $dashboard->setId(7);
+ $dashboard->setUuid($uuid);
+ $dashboard->setUserId('alice');
+ $dashboard->setName('Board');
+ return $dashboard;
+ }
+ );
+
+ $variant = new DashboardTranslation();
+ $variant->setLanguageCode('nl');
+ $service = $this->createMock(DashboardTranslationService::class);
+ foreach (['listVariants', 'createVariant', 'updateVariant', 'deleteVariant', 'promoteVariantToPrimary'] as $method) {
+ $service->method($method)->willReturnCallback(
+ function () use ($method, $variant, $failWith) {
+ $this->reached[] = $method;
+ if ($failWith !== null) {
+ throw $failWith;
+ }
+ return ($method === 'listVariants') ? [$variant] : $variant;
+ }
+ );
+ }
+
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')->willReturn('nl');
+ $service->method('resolveForLocale')->willReturn(['translation' => $variant, 'isFallback' => false]);
+ $service->method('listAvailableLanguages')->willReturn(['nl']);
+
+ return new DashboardTranslationApiController(
+ request: $request,
+ translations: new TranslationSources(dashboardMapper: $mapper, translationService: $service),
+ caller: new ActionCaller(actionAuth: $auth, userSession: $session),
+ userId: $userId
+ );
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/DashboardTranslationApiControllerSetPrimaryTest.php b/tests/Unit/Controller/DashboardTranslationApiControllerSetPrimaryTest.php
index 7160ebca3..1423fe3e2 100644
--- a/tests/Unit/Controller/DashboardTranslationApiControllerSetPrimaryTest.php
+++ b/tests/Unit/Controller/DashboardTranslationApiControllerSetPrimaryTest.php
@@ -27,6 +27,8 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\TranslationSources;
+use OCA\LaunchPad\Controller\ActionCaller;
use OCA\LaunchPad\Controller\DashboardTranslationApiController;
use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Db\DashboardMapper;
@@ -116,11 +118,15 @@ private function makeController(?string $userId): DashboardTranslationApiControl
return new DashboardTranslationApiController(
request: $this->request,
- dashboardMapper: $this->dashboardMapper,
- translationService: $this->translationService,
- actionAuth: $this->actionAuth,
- userSession: $this->userSession,
- userId: $userId,
+ translations: new TranslationSources(
+ dashboardMapper: $this->dashboardMapper,
+ translationService: $this->translationService
+ ),
+ caller: new ActionCaller(
+ actionAuth: $this->actionAuth,
+ userSession: $this->userSession
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/DashboardTranslationCreateGoldenTest.php b/tests/Unit/Controller/DashboardTranslationCreateGoldenTest.php
new file mode 100644
index 000000000..70d166995
--- /dev/null
+++ b/tests/Unit/Controller/DashboardTranslationCreateGoldenTest.php
@@ -0,0 +1,151 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\TranslationSources;
+use OCA\LaunchPad\Controller\ActionCaller;
+use OCA\LaunchPad\Controller\DashboardTranslationApiController;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\DashboardTranslation;
+use OCA\LaunchPad\Db\DashboardTranslationMapper;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\DashboardTranslationService;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * The create endpoint's variant rows, case by case.
+ *
+ * @covers \OCA\LaunchPad\Controller\DashboardTranslationApiController
+ * @covers \OCA\LaunchPad\Service\DashboardTranslationService
+ * @uses \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Controller\RespondsWithErrors
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ * @uses \OCA\LaunchPad\Db\DashboardTranslation
+ * @uses \OCA\LaunchPad\Db\DashboardTranslationMapper
+ * @uses \OCA\LaunchPad\Controller\ActionCaller
+ * @uses \OCA\LaunchPad\Controller\TranslationSources
+ * @uses \OCA\LaunchPad\Controller\TranslationVariantDraft
+ */
+class DashboardTranslationCreateGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/dashboard-translation-create.json';
+
+ /**
+ * A stored variant.
+ *
+ * @param string $language The language code.
+ * @param string $suffix A marker for its texts.
+ *
+ * @return DashboardTranslation
+ */
+ private static function variant(string $language, string $suffix): DashboardTranslation {
+ $variant = new DashboardTranslation();
+ $variant->setLanguageCode($language);
+ $variant->setName('Name '.$suffix);
+ $variant->setDescription('Desc '.$suffix);
+ $variant->setWidgetTreeJson('{"t":"'.$suffix.'"}');
+ return $variant;
+ }//end variant()
+
+ /**
+ * Every case writes the recorded row or answers the recorded refusal.
+ *
+ * @return void
+ */
+ public function testRowsMatchTheRecordedFixture(): void {
+ $cases = [
+ 'explicit' => ['languageCode' => 'nl_NL', 'name' => 'N', 'description' => 'D', 'widgetTreeJson' => '{}'],
+ 'seed primary' => ['languageCode' => 'de'],
+ 'seed named primary' => ['languageCode' => 'de', 'copyFrom' => 'PRIMARY'],
+ 'seed empty' => ['languageCode' => 'de', 'copyFrom' => ''],
+ 'seed language' => ['languageCode' => 'fr', 'copyFrom' => 'en'],
+ 'seed missing' => ['languageCode' => 'fr', 'copyFrom' => 'xx'],
+ 'partly explicit' => ['languageCode' => 'fr', 'name' => 'Own', 'copyFrom' => 'en'],
+ 'empty name' => ['languageCode' => 'fr', 'name' => ''],
+ 'exists' => ['languageCode' => 'en'],
+ 'bad language' => ['languageCode' => '__'],
+ 'no language' => [],
+ ];
+
+ $actual = [];
+ foreach ($cases as $case => $args) {
+ $rows = [];
+ $mapper = $this->createMock(DashboardTranslationMapper::class);
+ $mapper->method('findPrimaryByDashboardUuid')->willReturn(self::variant(language: 'en', suffix: 'primary'));
+ $mapper->method('findByDashboardUuidAndLanguage')->willReturnCallback(
+ static fn (string $dashboardUuid, string $languageCode): ?DashboardTranslation => $languageCode === 'en' ? self::variant(language: 'en', suffix: 'en') : null
+ );
+ $mapper->method('insert')->willReturnCallback(
+ static function (DashboardTranslation $t) use (&$rows): DashboardTranslation {
+ $rows[] = [$t->getDashboardUuid(), $t->getLanguageCode(), $t->getName(), $t->getDescription(), $t->getWidgetTreeJson(), $t->getIsPrimary(), $t->getCreatedAt() === $t->getUpdatedAt()];
+ return $t;
+ }
+ );
+
+ $dashboard = new Dashboard();
+ $dashboard->setUserId('alice');
+ $dashboards = $this->createMock(DashboardMapper::class);
+ $dashboards->method('findByUuid')->willReturn($dashboard);
+ $user = $this->createMock(IUser::class);
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+
+ $controller = new DashboardTranslationApiController(
+ request: $this->createMock(IRequest::class),
+ translations: new TranslationSources(
+ dashboardMapper: $dashboards,
+ translationService: new DashboardTranslationService(
+ translationMapper: $mapper,
+ db: $this->createMock(IDBConnection::class),
+ config: $this->createMock(IConfig::class),
+ )
+ ),
+ caller: new ActionCaller(
+ actionAuth: $this->createMock(ActionAuthService::class),
+ userSession: $session
+ ),
+ userId: 'alice'
+ );
+
+ $response = $controller->create('uuid-1', ...$args);
+ $actual[$case] = ['status' => $response->getStatus(), 'rows' => $rows, 'error' => $response->getData()['message'] ?? $response->getData()['error'] ?? null];
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ $lines = [];
+ foreach ($actual as $case => $row) {
+ $lines[] = json_encode($case).': '.json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+
+ file_put_contents(self::FIXTURE, "{\n".implode(",\n", $lines)."\n}\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_encode(json_decode((string) file_get_contents(self::FIXTURE))), json_encode($actual));
+ }//end testRowsMatchTheRecordedFixture()
+}//end class
diff --git a/tests/Unit/Controller/DashboardVersionApiControllerTest.php b/tests/Unit/Controller/DashboardVersionApiControllerTest.php
index ff7df9712..40b34cdca 100644
--- a/tests/Unit/Controller/DashboardVersionApiControllerTest.php
+++ b/tests/Unit/Controller/DashboardVersionApiControllerTest.php
@@ -32,6 +32,8 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\ActionCaller;
+use OCA\LaunchPad\Controller\VersionApiSources;
use Exception;
use OCA\LaunchPad\Controller\DashboardVersionApiController;
use OCA\LaunchPad\Db\Dashboard;
@@ -123,12 +125,16 @@ private function makeController(?string $userId): DashboardVersionApiController
return new DashboardVersionApiController(
request: $this->request,
- dashboardMapper: $this->dashboardMapper,
- versionService: $this->versionService,
- actionAuth: $this->actionAuth,
- userSession: $this->userSession,
- logger: $this->createMock(LoggerInterface::class),
- userId: $userId,
+ sources: new VersionApiSources(
+ dashboardMapper: $this->dashboardMapper,
+ versionService: $this->versionService,
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ caller: new ActionCaller(
+ actionAuth: $this->actionAuth,
+ userSession: $this->userSession
+ ),
+ userId: $userId
);
}//end makeController()
diff --git a/tests/Unit/Controller/FileControllerTest.php b/tests/Unit/Controller/FileControllerTest.php
index 00e2897d9..3db962618 100644
--- a/tests/Unit/Controller/FileControllerTest.php
+++ b/tests/Unit/Controller/FileControllerTest.php
@@ -32,6 +32,7 @@
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
+use OCA\LaunchPad\Exception\StorageFailureException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -72,7 +73,7 @@ protected function setUp(): void {
public function testHappyPathReturnsSuccessEnvelope(): void {
$this->fileService->method('createFile')
- ->with('alice', 'hello.txt', '/', 'hi')
+ ->with('alice', 'hello.txt', ['dir' => '/', 'content' => 'hi', 'onExisting' => FileService::ON_EXISTING_REPLACE])
->willReturn([
'status' => 'success',
'fileId' => 42,
@@ -98,7 +99,7 @@ public function testHappyPathReturnsSuccessEnvelope(): void {
public function testOverwriteFalseOnExistingFileMapsToHttp409(): void {
$this->request->method('getParam')->willReturnMap([['overwrite', null, false]]);
$this->fileService->expects($this->once())->method('createFile')
- ->with('alice', 'report.docx', '/', '', FileService::ON_EXISTING_REFUSE)
+ ->with('alice', 'report.docx', ['dir' => '/', 'content' => '', 'onExisting' => FileService::ON_EXISTING_REFUSE])
->willThrowException(new FileAlreadyExistsException());
$response = $this->controller->createFile(
@@ -113,7 +114,7 @@ public function testOverwriteFalseOnExistingFileMapsToHttp409(): void {
public function testOverwriteDefaultsToTrue(): void {
$this->fileService->expects($this->once())->method('createFile')
- ->with('alice', 'report.docx', '/', '', FileService::ON_EXISTING_REPLACE)
+ ->with('alice', 'report.docx', ['dir' => '/', 'content' => '', 'onExisting' => FileService::ON_EXISTING_REPLACE])
->willReturn(['status' => 'success', 'fileId' => 1, 'url' => 'u']);
$response = $this->controller->createFile(filename: 'report.docx');
@@ -186,4 +187,60 @@ public function testUnexpectedThrowableIsWrappedNotLeaked(): void {
$this->assertStringNotContainsString('server crash', $data['message']);
$this->assertSame('error', $data['status']);
}
+ /**
+ * Only storage failures and unexpected throwables are logged, each with
+ * its own message; the envelopes keep their exact status and text.
+ */
+ public function testFailureLogsAndEnvelopes(): void {
+ $logged = [];
+ $this->logger->method('error')->willReturnCallback(
+ static function (string $message, array $context) use (&$logged): void {
+ $logged[] = [$message, $context];
+ }
+ );
+ $failures = [
+ new StorageFailureException(message: 'disk full'),
+ new InvalidFilenameException(),
+ new RuntimeException('boom'),
+ ];
+ $this->fileService->method('createFile')->willReturnCallback(
+ static function () use (&$failures): array {
+ throw array_shift($failures);
+ }
+ );
+
+ $answers = [];
+ for ($i = 0; $i < 3; $i++) {
+ $response = $this->controller->createFile(filename: 'a.txt');
+ $answers[] = [$response->getStatus(), $response->getData()];
+ }
+
+ $this->assertSame(
+ [
+ [500, ['status' => 'error', 'error' => 'storage_failure', 'message' => 'disk full']],
+ [400, ['status' => 'error', 'error' => 'invalid_filename', 'message' => 'Invalid filename']],
+ [500, ['status' => 'error', 'error' => 'storage_failure', 'message' => 'Failed to create file']],
+ ],
+ $answers
+ );
+ $this->assertSame(
+ [
+ ['File create storage failure', ['exception' => 'disk full']],
+ ['Unexpected file create failure', ['exception' => 'boom']],
+ ],
+ $logged
+ );
+
+ $session = $this->createMock(IUserSession::class);
+ $controller = new FileController(
+ request: $this->request,
+ fileService: $this->fileService,
+ userSession: $session,
+ logger: $this->logger,
+ );
+ $this->assertSame(
+ ['status' => 'error', 'error' => 'forbidden', 'message' => 'Authentication required'],
+ $controller->createFile(filename: 'a.txt')->getData()
+ );
+ }
}
diff --git a/tests/Unit/Controller/FilesWidgetControllerContentsGoldenTest.php b/tests/Unit/Controller/FilesWidgetControllerContentsGoldenTest.php
new file mode 100644
index 000000000..cd885e883
--- /dev/null
+++ b/tests/Unit/Controller/FilesWidgetControllerContentsGoldenTest.php
@@ -0,0 +1,173 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\FilesWidgetController;
+use OCA\LaunchPad\Controller\FilesWidgetSources;
+use OCA\LaunchPad\Db\WidgetPlacement;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\FilesWidgetService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\Files\File;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\IPreview;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * The endpoint answers, case by case.
+ *
+ * @covers \OCA\LaunchPad\Controller\FilesWidgetController
+ * @uses \OCA\LaunchPad\Controller\FilesWidgetSources
+ * @uses \OCA\LaunchPad\Controller\FilesWidgetAccess
+ * @uses \OCA\LaunchPad\Service\FilesWidgetService
+ * @uses \OCA\LaunchPad\Db\WidgetPlacement
+ * @uses \OCA\LaunchPad\Exception\FolderNotFoundException
+ * @uses \OCA\LaunchPad\Exception\NoAccessException
+ * @uses \OCA\LaunchPad\Exception\ResourceException
+ */
+class FilesWidgetControllerContentsGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/files-widget-contents.json';
+
+ /**
+ * A controller over a real service, a folder of 30 files and one
+ * sub-folder, for the given session user.
+ *
+ * @param string|null $uid The session user, or null for none.
+ *
+ * @return FilesWidgetController
+ */
+ private function controller(?string $uid): FilesWidgetController {
+ $files = [];
+ for ($i = 1; $i <= 30; $i++) {
+ $file = $this->createMock(File::class);
+ $file->method('getId')->willReturn($i);
+ $file->method('getName')->willReturn(sprintf('f%02d.txt', $i));
+ $file->method('getMimetype')->willReturn('text/plain');
+ $file->method('isReadable')->willReturn(true);
+ $file->method('getInternalPath')->willReturn(sprintf('f%02d.txt', $i));
+ $file->method('getSize')->willReturn(100 + $i);
+ $file->method('getMTime')->willReturn(1700000000 + $i);
+ $file->method('getPermissions')->willReturn(1);
+ $files[] = $file;
+ }
+ $sub = $this->createMock(Folder::class);
+ $sub->method('isReadable')->willReturn(true);
+ $sub->method('getDirectoryListing')->willReturn([]);
+ $folder = $this->createMock(Folder::class);
+ $folder->method('isReadable')->willReturn(true);
+ $folder->method('getDirectoryListing')->willReturn($files);
+ $folder->method('get')->willReturnCallback(
+ static function (string $path) use ($sub): Folder {
+ if ($path === 'sub') {
+ return $sub;
+ }
+ throw new NotFoundException($path);
+ }
+ );
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('getById')->willReturn([$folder]);
+ $root = $this->createMock(IRootFolder::class);
+ $root->method('getUserFolder')->willReturn($userFolder);
+
+ $placements = $this->createMock(WidgetPlacementMapper::class);
+ $placements->method('find')->willReturnCallback(
+ static function (int $id): WidgetPlacement {
+ if ($id === 404) {
+ throw new DoesNotExistException('gone');
+ }
+ $placement = new WidgetPlacement();
+ $placement->setId($id);
+ $placement->setDashboardId($id * 10);
+ $placement->setContentArray(['fileId' => 9]);
+ return $placement;
+ }
+ );
+ $permissions = $this->createMock(PermissionService::class);
+ $permissions->method('canAddWidget')->willReturnCallback(
+ static fn (string $userId, int $dashboardId): bool => $userId === 'alice' && $dashboardId !== 20
+ );
+ $session = $this->createMock(IUserSession::class);
+ if ($uid !== null) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ $session->method('getUser')->willReturn($user);
+ }
+
+ return new FilesWidgetController(
+ request: $this->createMock(IRequest::class),
+ sources: new FilesWidgetSources(
+ service: new FilesWidgetService(
+ rootFolder: $root,
+ urlGenerator: $this->createMock(IURLGenerator::class),
+ previewManager: $this->createMock(IPreview::class)
+ ),
+ placementMapper: $placements,
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ permissionService: $permissions,
+ userSession: $session
+ );
+ }//end controller()
+
+ /**
+ * Every case answers as recorded.
+ *
+ * @return void
+ */
+ public function testAnswersMatchTheRecordedFixture(): void {
+ $cases = [
+ 'no session' => [null, ['placementId' => 1]],
+ 'unknown placement' => ['alice', ['placementId' => 404]],
+ 'no permission' => ['alice', ['placementId' => 2]],
+ 'other user' => ['bob', ['placementId' => 1]],
+ 'defaults' => ['alice', ['placementId' => 1]],
+ 'page and cursor' => ['alice', ['placementId' => 1, 'limit' => 4, 'cursor' => '8']],
+ 'sub folder' => ['alice', ['placementId' => 1, 'currentPath' => '/sub']],
+ 'missing folder' => ['alice', ['placementId' => 1, 'currentPath' => '/none']],
+ 'all of them' => ['alice', ['placementId' => 1, 'currentPath' => '/', 'limit' => 25, 'cursor' => '25']],
+ ];
+ $actual = [];
+ foreach ($cases as $name => [$uid, $args]) {
+ $response = $this->controller($uid)->contents(...$args);
+ $data = $response->getData();
+ $data['items'] = array_map(static fn (array $item): string => $item['name'], $data['items'] ?? []);
+ $actual[$name] = ['status' => $response->getStatus(), 'data' => $data];
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testAnswersMatchTheRecordedFixture()
+}//end class
diff --git a/tests/Unit/Controller/FilesWidgetControllerEndpointsGoldenTest.php b/tests/Unit/Controller/FilesWidgetControllerEndpointsGoldenTest.php
new file mode 100644
index 000000000..459e9154b
--- /dev/null
+++ b/tests/Unit/Controller/FilesWidgetControllerEndpointsGoldenTest.php
@@ -0,0 +1,213 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\FilesWidgetController;
+use OCA\LaunchPad\Controller\FilesWidgetSources;
+use OCA\LaunchPad\Db\WidgetPlacement;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Exception\FolderNotFoundException;
+use OCA\LaunchPad\Exception\NoAccessException;
+use OCA\LaunchPad\Service\FilesWidgetService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * Guards, outcomes and logs of the three files widget endpoints.
+ */
+class FilesWidgetControllerEndpointsGoldenTest extends TestCase {
+
+ /**
+ * Log lines written in the current case.
+ *
+ * @var array>
+ */
+ private array $logged = [];
+
+ /**
+ * Service calls made in the current case.
+ *
+ * @var array>
+ */
+ private array $served = [];
+
+ /**
+ * Every endpoint × case gives the recorded status, body, service call and log line.
+ *
+ * @return void
+ */
+ public function testEveryEndpointAnswersAsRecorded(): void {
+ $cases = [
+ 'no session' => [null, 1, null],
+ 'unknown placement' => ['alice', 404, null],
+ 'no permission' => ['alice', 2, null],
+ 'ok' => ['alice', 1, null],
+ 'legacy content slot' => ['alice', 3, null],
+ 'legacy style config' => ['alice', 4, null],
+ 'folder gone' => ['alice', 1, new FolderNotFoundException()],
+ 'no access' => ['alice', 1, new NoAccessException()],
+ 'crash' => ['alice', 1, new RuntimeException('boom')],
+ ];
+
+ $actual = [];
+ foreach (['contents', 'upload', 'destroy'] as $endpoint) {
+ foreach ($cases as $name => [$uid, $placementId, $failure]) {
+ $this->logged = [];
+ $this->served = [];
+ $controller = $this->controller(uid: $uid, failure: $failure);
+ $response = match ($endpoint) {
+ 'contents' => $controller->contents($placementId, '/sub', 5, 'c'),
+ 'upload' => $controller->upload($placementId, '/sub'),
+ 'destroy' => $controller->destroy($placementId, 77),
+ };
+ $actual[$endpoint.' / '.$name] = [$response->getStatus(), $response->getData(), $this->served, $this->logged];
+ }
+ }
+
+ $actual = json_decode((string) json_encode($actual), true);
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(__DIR__.'/fixtures/files-widget-endpoints.json', json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(__DIR__.'/fixtures/files-widget-endpoints.json'), true), $actual);
+ }//end testEveryEndpointAnswersAsRecorded()
+
+ /**
+ * The uploaded `files[]` entries are normalised from both PHP upload shapes.
+ *
+ * @return void
+ */
+ public function testUploadedFilesAreNormalised(): void {
+ $normalise = new \ReflectionMethod(FilesWidgetController::class, 'normaliseUploadedFiles');
+ $controller = $this->controller(uid: 'alice', failure: null);
+ $cases = [
+ 'none' => null,
+ 'not an array' => 'x',
+ 'no name' => ['tmp_name' => '/t'],
+ 'single' => ['name' => 'a.txt', 'tmp_name' => '/t/a', 'size' => '12', 'error' => '0'],
+ 'single bare' => ['name' => 'a.txt'],
+ 'multiple' => ['name' => ['a', 'b'], 'tmp_name' => ['/t/a'], 'size' => [3, 4], 'error' => 'oops'],
+ 'multiple bare' => ['name' => ['a']],
+ ];
+
+ $actual = [];
+ $saved = $_FILES;
+ foreach ($cases as $name => $files) {
+ $_FILES = ($files === null) ? [] : ['files' => $files];
+ $actual[$name] = $normalise->invoke($controller);
+ }
+ $_FILES = $saved;
+
+ $none = UPLOAD_ERR_NO_FILE;
+ $this->assertSame(
+ [
+ 'none' => [],
+ 'not an array' => [],
+ 'no name' => [],
+ 'single' => [['name' => 'a.txt', 'tmp_name' => '/t/a', 'size' => 12, 'error' => 0]],
+ 'single bare' => [['name' => 'a.txt', 'tmp_name' => '', 'size' => 0, 'error' => $none]],
+ 'multiple' => [
+ ['name' => 'a', 'tmp_name' => '/t/a', 'size' => 3, 'error' => $none],
+ ['name' => 'b', 'tmp_name' => '', 'size' => 4, 'error' => $none],
+ ],
+ 'multiple bare' => [['name' => 'a', 'tmp_name' => '', 'size' => 0, 'error' => $none]],
+ ],
+ $actual
+ );
+ }//end testUploadedFilesAreNormalised()
+
+ /**
+ * The controller over a mocked service.
+ *
+ * @param string|null $uid The session user, or null for none.
+ * @param \Throwable|null $failure What the service throws, or null to succeed.
+ *
+ * @return FilesWidgetController
+ */
+ private function controller(?string $uid, ?\Throwable $failure): FilesWidgetController {
+ $service = $this->createMock(FilesWidgetService::class);
+ foreach (['getContentsForPlacement', 'uploadFiles', 'deleteFile'] as $method) {
+ $service->method($method)->willReturnCallback(
+ function (...$args) use ($method, $failure): array {
+ $this->served[] = [$method, $args];
+ if ($failure !== null) {
+ throw $failure;
+ }
+ return ['done' => $method];
+ }
+ );
+ }
+
+ $placements = $this->createMock(WidgetPlacementMapper::class);
+ $placements->method('find')->willReturnCallback(
+ static function (int $id): WidgetPlacement {
+ if ($id === 404) {
+ throw new DoesNotExistException('gone');
+ }
+ $placement = new WidgetPlacement();
+ $placement->setId($id);
+ $placement->setDashboardId($id * 10);
+ $placement->setContentArray(['fileId' => 9]);
+ if ($id === 3) {
+ $placement->setContentArray([]);
+ $placement->setStyleConfigArray(config: ['color' => 'red', 'content' => ['fileId' => 5]]);
+ } elseif ($id === 4) {
+ $placement->setContentArray([]);
+ $placement->setStyleConfigArray(config: ['fileId' => 6]);
+ }
+ return $placement;
+ }
+ );
+
+ $permissions = $this->createMock(PermissionService::class);
+ $permissions->method('canAddWidget')->willReturnCallback(
+ static fn (string $userId, int $dashboardId): bool => $dashboardId !== 20
+ );
+
+ $session = $this->createMock(IUserSession::class);
+ if ($uid !== null) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ $session->method('getUser')->willReturn($user);
+ }
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->method('error')->willReturnCallback(
+ function (string $message, array $context): void {
+ $this->logged[] = [$message, $context];
+ }
+ );
+
+ return new FilesWidgetController(
+ request: $this->createMock(IRequest::class),
+ sources: new FilesWidgetSources(service: $service, placementMapper: $placements, logger: $logger),
+ permissionService: $permissions,
+ userSession: $session
+ );
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/HealthPingControllerTest.php b/tests/Unit/Controller/HealthPingControllerTest.php
index 19e30860c..beffbe039 100644
--- a/tests/Unit/Controller/HealthPingControllerTest.php
+++ b/tests/Unit/Controller/HealthPingControllerTest.php
@@ -26,6 +26,7 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\SessionLog;
use OCA\LaunchPad\Controller\HealthPingController;
use OCA\LaunchPad\Service\HealthPingService;
use OCA\LaunchPad\Service\PermissionService;
@@ -81,8 +82,10 @@ protected function setUp(): void {
request: $this->request,
healthPingService: $this->healthPingService,
permissionService: $this->permissionService,
- userSession: $this->userSession,
- logger: $this->logger,
+ session: new SessionLog(
+ userSession: $this->userSession,
+ logger: $this->logger
+ )
);
}//end setUp()
diff --git a/tests/Unit/Controller/KioskControllerCrudGoldenTest.php b/tests/Unit/Controller/KioskControllerCrudGoldenTest.php
new file mode 100644
index 000000000..63ec1d9f5
--- /dev/null
+++ b/tests/Unit/Controller/KioskControllerCrudGoldenTest.php
@@ -0,0 +1,149 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\KioskController;
+use OCA\LaunchPad\Db\KioskPlaylist;
+use OCA\LaunchPad\Exception\PlaylistNotFoundException;
+use OCA\LaunchPad\Service\KioskPlaylistDraft;
+use OCA\LaunchPad\Service\KioskService;
+use OCA\LaunchPad\Service\PublicShareContext;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IRequest;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * The playlist CRUD endpoints, case by case.
+ */
+class KioskControllerCrudGoldenTest extends TestCase {
+
+ /**
+ * Service calls and log lines of the current call.
+ *
+ * @var array
+ */
+ private array $trace = [];
+
+ /**
+ * Every endpoint × case answers, calls and logs as recorded.
+ *
+ * @return void
+ */
+ public function testEndpointsMatchTheRecording(): void {
+ $endpoints = [
+ 'create' => static fn (KioskController $c) => $c->create(name: 'Wall', entries: [['dashboardUuid' => 'd1', 'dwellSeconds' => 30]], refreshSeconds: 120),
+ 'create defaults' => static fn (KioskController $c) => $c->create(),
+ 'index' => static fn (KioskController $c) => $c->index(),
+ 'update' => static fn (KioskController $c) => $c->update(id: 7, name: 'Hall', entries: null, refreshSeconds: null),
+ 'destroy' => static fn (KioskController $c) => $c->destroy(id: 7),
+ ];
+ $cases = [
+ 'anonymous' => [null, null],
+ 'ok' => ['alice', null],
+ 'not found' => ['alice', new PlaylistNotFoundException()],
+ 'not authorized' => ['alice', new OCSForbiddenException('no')],
+ 'crash' => ['alice', new RuntimeException('db down')],
+ ];
+
+ $actual = [];
+ foreach ($endpoints as $endpoint => $call) {
+ foreach ($cases as $case => [$userId, $failure]) {
+ if ($case === 'not found' && str_starts_with($endpoint, 'create') === true) {
+ continue;
+ }
+ $this->trace = [];
+ try {
+ $response = $call($this->controller(userId: $userId, failure: $failure));
+ $actual[$endpoint.' / '.$case] = [$response->getStatus(), $response->getData(), $this->trace];
+ } catch (\Throwable $e) {
+ $actual[$endpoint.' / '.$case] = ['thrown' => get_class($e), $this->trace];
+ }
+ }
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(__DIR__.'/fixtures/kiosk-crud.json'), true), $this->record(actual: $actual));
+ }//end testEndpointsMatchTheRecording()
+
+ /**
+ * Normalise and optionally record the outcomes.
+ *
+ * @param array $actual The outcomes.
+ *
+ * @return array
+ */
+ private function record(array $actual): array {
+ $actual = json_decode((string) json_encode($actual), true);
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(__DIR__.'/fixtures/kiosk-crud.json', json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ }
+
+ return $actual;
+ }//end record()
+
+ /**
+ * The controller over a recording service mock.
+ *
+ * @param string|null $userId The signed-in user.
+ * @param \Throwable|null $failure What every service call throws, or null to succeed.
+ *
+ * @return KioskController
+ */
+ private function controller(?string $userId, ?\Throwable $failure): KioskController {
+ $playlist = new KioskPlaylist();
+ $playlist->setName('Wall');
+ $playlist->setToken(str_repeat('a', 64));
+
+ $service = $this->createMock(KioskService::class);
+ $answer = function (string $method, array $args) use ($failure, $playlist) {
+ $this->trace[] = [$method, array_map(static fn ($arg) => ($arg instanceof KioskPlaylistDraft) ? [$arg->name, $arg->entries, $arg->refresh] : $arg, $args)];
+ if ($failure !== null) {
+ throw $failure;
+ }
+ return ($method === 'listPlaylists') ? [$playlist, $playlist] : $playlist;
+ };
+ $service->method('createPlaylist')->willReturnCallback(fn (...$args) => $answer('createPlaylist', $args));
+ $service->method('listPlaylists')->willReturnCallback(fn (...$args) => $answer('listPlaylists', $args));
+ $service->method('updatePlaylist')->willReturnCallback(fn (...$args) => $answer('updatePlaylist', $args));
+ $service->method('revokePlaylist')->willReturnCallback(
+ function (...$args) use ($answer): void {
+ $answer('revokePlaylist', $args);
+ }
+ );
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->method('warning')->willReturnCallback(
+ function (string $message, array $context): void {
+ $this->trace[] = ['log', $message, $context];
+ }
+ );
+
+ return new KioskController(
+ request: $this->createMock(IRequest::class),
+ kioskService: $service,
+ shareContext: $this->createMock(PublicShareContext::class),
+ logger: $logger,
+ userId: $userId
+ );
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/KioskPlaylistWriteGoldenTest.php b/tests/Unit/Controller/KioskPlaylistWriteGoldenTest.php
new file mode 100644
index 000000000..eefaa65b6
--- /dev/null
+++ b/tests/Unit/Controller/KioskPlaylistWriteGoldenTest.php
@@ -0,0 +1,204 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\KioskController;
+use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\KioskPlaylist;
+use OCA\LaunchPad\Db\KioskPlaylistMapper;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCA\LaunchPad\Service\KioskService;
+use OCA\LaunchPad\Service\KioskStores;
+use OCA\LaunchPad\Service\PublicShareContext;
+use OCA\LaunchPad\Service\PublicShareService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\Security\ISecureRandom;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Playlist writes, caller by caller.
+ *
+ * @covers \OCA\LaunchPad\Controller\KioskController
+ * @covers \OCA\LaunchPad\Service\KioskService
+ * @uses \OCA\LaunchPad\Service\KioskStores
+ * @uses \OCA\LaunchPad\Service\KioskPlaylistDraft
+ * @uses \OCA\LaunchPad\Db\KioskPlaylist
+ * @uses \OCA\LaunchPad\Db\Dashboard
+ * @uses \OCA\LaunchPad\Exception\PlaylistNotFoundException
+ */
+class KioskPlaylistWriteGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__ . '/fixtures/kiosk-playlist-write.json';
+
+ /**
+ * Every write matches the recorded fixture.
+ *
+ * @return void
+ */
+ public function testWritesMatchTheRecordedFixture(): void {
+ $own = [['dashboardUuid' => 'd-alice', 'dwellSeconds' => 30]];
+ $entrySets = [
+ 'own' => $own,
+ 'own clamped' => [
+ ['dashboardUuid' => 'd-alice', 'dwellSeconds' => 1],
+ ['dashboardUuid' => 'd-alice', 'dwellSeconds' => 99999],
+ ['dashboardUuid' => 'd-alice'],
+ ],
+ 'foreign' => [['dashboardUuid' => 'd-alice'], ['dashboardUuid' => 'd-bob']],
+ 'missing' => [['dashboardUuid' => 'd-missing']],
+ 'invalid' => [['dwellSeconds' => 5]],
+ 'none' => null,
+ ];
+
+ $actual = [];
+ foreach (['alice', 'bob', 'admin'] as $caller) {
+ foreach ($entrySets as $setName => $entries) {
+ foreach ([null, 0, 45, 999999] as $refresh) {
+ $actual["create $caller $setName $refresh"] = $this->write(caller: $caller, call: ['create', null, 'Lobby', $entries, $refresh]);
+ }
+
+ foreach ([7, 8] as $id) {
+ $actual["update $caller $setName playlist $id"] = $this->write(caller: $caller, call: ['update', $id, 'Hall', $entries, 60]);
+ }
+ }
+ }
+
+ $actual['update without name'] = $this->write(caller: 'alice', call: ['update', 7, null, $own, null]);
+ foreach ([0, 5, 999999] as $refresh) {
+ $actual["update refresh $refresh"] = $this->write(caller: 'alice', call: ['update', 7, 'Hall', $own, $refresh]);
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+ $this->markTestIncomplete(message: 'Fixture recorded.');
+ }
+
+ $this->assertSame(expected: json_decode((string)file_get_contents(self::FIXTURE), true), actual: $actual);
+ }
+
+ /**
+ * Run one controller write for a session caller and record it.
+ *
+ * @param string $caller The session user.
+ * @param array $call Action, playlist id, name, entries, refresh.
+ *
+ * @return array
+ */
+ private function write(string $caller, array $call): array {
+ $log = [];
+ [$action, $id, $name, $entries, $refresh] = $call;
+
+ $dashboards = $this->getMockBuilder(className: DashboardMapper::class)->disableOriginalConstructor()->getMock();
+ $dashboards->method('findByUuid')->willReturnCallback(static function (string $uuid) use (&$log): Dashboard {
+ $log[] = 'findByUuid ' . $uuid;
+ if ($uuid === 'd-missing') {
+ throw new DoesNotExistException('none');
+ }
+
+ $dashboard = new Dashboard();
+ $dashboard->setUuid($uuid);
+ $dashboard->setUserId(substr($uuid, 2));
+ return $dashboard;
+ });
+
+ $admins = $this->getMockBuilder(className: IGroupManager::class)->getMock();
+ $admins->method('isAdmin')->willReturnCallback(static function (string $userId) use (&$log): bool {
+ $log[] = 'isAdmin ' . $userId;
+ return $userId === 'admin';
+ });
+
+ $shares = $this->getMockBuilder(className: PublicShareService::class)->disableOriginalConstructor()->getMock();
+ $shares->method('authorizeShareMutation')->willReturnCallback(static function (Dashboard $dashboard, string $userId) use (&$log): void {
+ $log[] = 'authorizeShareMutation ' . $dashboard->getUuid() . ' ' . $userId;
+ if ($dashboard->getUserId() !== $userId && $userId !== 'admin') {
+ throw new OCSForbiddenException('Not authorized');
+ }
+ });
+
+ $playlists = $this->getMockBuilder(className: KioskPlaylistMapper::class)->disableOriginalConstructor()->getMock();
+ $stored = static function (string $op) use (&$log): \Closure {
+ return static function (KioskPlaylist $playlist) use ($op, &$log): KioskPlaylist {
+ $row = $playlist->jsonSerialize();
+ unset($row['createdAt'], $row['url']);
+ $log[] = $op . ' ' . json_encode($row);
+ $playlist->setId(7);
+ return $playlist;
+ };
+ };
+ $playlists->method('insert')->willReturnCallback($stored('insert'));
+ $playlists->method('update')->willReturnCallback($stored('update'));
+ $playlists->method('findById')->willReturnCallback(static function (int $playlistId) use (&$log): KioskPlaylist {
+ $log[] = 'findById ' . $playlistId;
+ if ($playlistId !== 7) {
+ throw new DoesNotExistException('none');
+ }
+
+ $playlist = new KioskPlaylist();
+ $playlist->setId(7);
+ $playlist->setName('Old');
+ $playlist->setToken('t-7');
+ $playlist->setEntries('[]');
+ $playlist->setRefreshSeconds(30);
+ $playlist->setCreatedBy('alice');
+ return $playlist;
+ });
+
+ $random = $this->getMockBuilder(className: ISecureRandom::class)->getMock();
+ $random->method('generate')->willReturn('token');
+
+ $service = new KioskService(
+ stores: new KioskStores(
+ playlistMapper: $playlists,
+ dashMapper: $dashboards,
+ shareService: $shares,
+ placementMapper: $this->getMockBuilder(className: WidgetPlacementMapper::class)->disableOriginalConstructor()->getMock()
+ ),
+ groupManager: $admins,
+ secureRandom: $random,
+ logger: $this->getMockBuilder(className: LoggerInterface::class)->getMock()
+ );
+ $controller = new KioskController(
+ request: $this->getMockBuilder(className: IRequest::class)->getMock(),
+ kioskService: $service,
+ shareContext: $this->getMockBuilder(className: PublicShareContext::class)->disableOriginalConstructor()->getMock(),
+ logger: $this->getMockBuilder(className: LoggerInterface::class)->getMock(),
+ userId: $caller
+ );
+
+ if ($action === 'create') {
+ $response = $controller->create(name: $name, entries: $entries, refreshSeconds: $refresh);
+ } else {
+ $response = $controller->update(id: $id, name: $name, entries: $entries, refreshSeconds: $refresh);
+ }
+
+ $data = $response->getData();
+ unset($data['createdAt'], $data['url']);
+
+ return ['status' => $response->getStatus(), 'data' => $data, 'calls' => $log];
+ }
+}
diff --git a/tests/Unit/Controller/LiveTileControllerTest.php b/tests/Unit/Controller/LiveTileControllerTest.php
index 5253bb685..1683531a2 100644
--- a/tests/Unit/Controller/LiveTileControllerTest.php
+++ b/tests/Unit/Controller/LiveTileControllerTest.php
@@ -26,6 +26,7 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\SessionLog;
use OCA\LaunchPad\Controller\LiveTileController;
use OCA\LaunchPad\Service\LiveTileService;
use OCA\LaunchPad\Service\PermissionService;
@@ -81,8 +82,10 @@ protected function setUp(): void {
request: $this->request,
liveTileService: $this->liveTileService,
permissionService: $this->permissionService,
- userSession: $this->userSession,
- logger: $this->logger,
+ session: new SessionLog(
+ userSession: $this->userSession,
+ logger: $this->logger
+ )
);
}//end setUp()
diff --git a/tests/Unit/Controller/ManifestControllerGrantsTest.php b/tests/Unit/Controller/ManifestControllerGrantsTest.php
index a1224d41d..e4d45514d 100644
--- a/tests/Unit/Controller/ManifestControllerGrantsTest.php
+++ b/tests/Unit/Controller/ManifestControllerGrantsTest.php
@@ -27,6 +27,8 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\ManifestTools;
+use OCA\LaunchPad\Controller\ActionCaller;
use OCA\LaunchPad\Controller\ManifestController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCP\AppFramework\Http;
@@ -192,10 +194,14 @@ public function grantedObjectUuidsFor(?string $userId, string $action): array {
private function makeController(): ManifestController {
return new ManifestController(
request: $this->request,
- container: $this->container,
- actionAuth: $this->actionAuth,
- userSession: $this->userSession,
- logger: $this->logger,
+ tools: new ManifestTools(
+ container: $this->container,
+ logger: $this->logger
+ ),
+ caller: new ActionCaller(
+ actionAuth: $this->actionAuth,
+ userSession: $this->userSession
+ ),
userId: 'alice'
);
}//end makeController()
diff --git a/tests/Unit/Controller/MetadataAdminControllerTest.php b/tests/Unit/Controller/MetadataAdminControllerTest.php
index 3382f5a15..6b349f403 100644
--- a/tests/Unit/Controller/MetadataAdminControllerTest.php
+++ b/tests/Unit/Controller/MetadataAdminControllerTest.php
@@ -21,6 +21,7 @@
namespace Unit\Controller;
use OCA\LaunchPad\Controller\MetadataAdminController;
+use OCA\LaunchPad\Service\RequestGuard;
use OCA\LaunchPad\Db\MetadataField;
use OCA\LaunchPad\Exception\InvalidMetadataFieldException;
use OCA\LaunchPad\Exception\MetadataFieldHasValuesException;
@@ -58,9 +59,7 @@ protected function setUp(): void {
$this->controller = new MetadataAdminController(
request: $this->request,
metadataService: $this->metadataService,
- groupManager: $this->groupManager,
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
+ guard: new RequestGuard(userSession: $this->userSession, groupManager: $this->groupManager, actionAuth: $this->actionAuth),
);
}
diff --git a/tests/Unit/Controller/MetadataFieldWriteGoldenTest.php b/tests/Unit/Controller/MetadataFieldWriteGoldenTest.php
new file mode 100644
index 000000000..7d0c2ca2c
--- /dev/null
+++ b/tests/Unit/Controller/MetadataFieldWriteGoldenTest.php
@@ -0,0 +1,198 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\MetadataAdminController;
+use OCA\LaunchPad\Service\RequestGuard;
+use OCA\LaunchPad\Db\MetadataField;
+use OCA\LaunchPad\Db\MetadataFieldMapper;
+use OCA\LaunchPad\Db\MetadataValueMapper;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\MetadataService;
+use OCA\LaunchPad\Service\MetadataValidationService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * The metadata field rows of the admin endpoints, case by case.
+ *
+ * @covers \OCA\LaunchPad\Controller\MetadataAdminController
+ * @covers \OCA\LaunchPad\Service\MetadataService
+ * @uses \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Controller\RespondsWithErrors
+ * @uses \OCA\LaunchPad\Db\MetadataField
+ * @uses \OCA\LaunchPad\Service\RequestGuard
+ */
+class MetadataFieldWriteGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/metadata-field-write.json';
+
+ /**
+ * Rows the mapper was asked to write.
+ *
+ * @var list>
+ */
+ private array $rows = [];
+
+ /**
+ * The controller over a real service and a recording mapper.
+ *
+ * @return MetadataAdminController
+ */
+ private function controller(): MetadataAdminController {
+ $existing = new MetadataField();
+ $existing->setId(3);
+ $existing->setFieldKey('dept');
+ $existing->setLabel('Department');
+ $existing->setType('select');
+ $existing->setRequired(0);
+ $existing->setSortOrder(1);
+ $existing->setOptionsArray(options: ['a', 'b']);
+
+ $mapper = $this->createMock(MetadataFieldMapper::class);
+ $mapper->method('findByKey')->willReturnCallback(
+ static function (string $key) use ($existing): MetadataField {
+ if ($key === 'dept') {
+ return $existing;
+ }
+
+ throw new DoesNotExistException('none');
+ }
+ );
+ $mapper->method('findById')->willReturnCallback(
+ static function (int $id) use ($existing): MetadataField {
+ if ($id === 3) {
+ return $existing;
+ }
+
+ throw new DoesNotExistException('none');
+ }
+ );
+ foreach (['insert', 'update'] as $method) {
+ $mapper->method($method)->willReturnCallback(
+ function (MetadataField $field) use ($method): MetadataField {
+ $row = $field->jsonSerialize();
+ unset($row['createdAt'], $row['updatedAt']);
+ $this->rows[] = [$method => $row];
+ return $field;
+ }
+ );
+ }
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('root');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $groups = $this->createMock(IGroupManager::class);
+ $groups->method('isAdmin')->willReturn(true);
+
+ return new MetadataAdminController(
+ request: $this->createMock(IRequest::class),
+ metadataService: new MetadataService(
+ fieldMapper: $mapper,
+ valueMapper: $this->createMock(MetadataValueMapper::class),
+ validationService: $this->createMock(MetadataValidationService::class),
+ logger: $this->createMock(LoggerInterface::class),
+ ),
+ guard: new RequestGuard(userSession: $session, groupManager: $groups, actionAuth: $this->createMock(ActionAuthService::class)),
+ );
+ }//end controller()
+
+ /**
+ * Every case writes the recorded row or answers the recorded refusal.
+ *
+ * @return void
+ */
+ public function testRowsMatchTheRecordedFixture(): void {
+ $create = [
+ 'text' => ['key' => 'room', 'label' => 'Room', 'type' => 'text'],
+ 'select' => ['key' => 'floor', 'label' => 'Floor', 'type' => 'select', 'options' => ['1', '2']],
+ 'required sorted' => ['key' => 'cost', 'label' => 'Cost', 'type' => 'number', 'required' => 1, 'sortOrder' => 4],
+ 'required two' => ['key' => 'cost2', 'label' => 'Cost', 'type' => 'text', 'required' => 2],
+ 'duplicate key' => ['key' => 'dept', 'label' => 'Dept', 'type' => 'text'],
+ 'bad key' => ['key' => 'Bad Key', 'label' => 'X', 'type' => 'text'],
+ 'bad type' => ['key' => 'x', 'label' => 'X', 'type' => 'nope'],
+ 'select no option' => ['key' => 'y', 'label' => 'Y', 'type' => 'select'],
+ 'nothing' => [],
+ ];
+ $update = [
+ 'label' => ['label' => 'Unit'],
+ 'all' => ['label' => 'Unit', 'sortOrder' => 9, 'required' => 1, 'options' => ['c']],
+ 'rename key' => ['key' => 'other'],
+ 'missing' => ['id' => 99, 'label' => 'x'],
+ 'nothing' => [],
+ ];
+
+ $actual = [];
+ foreach ($create as $case => $args) {
+ $this->rows = [];
+ $response = $this->controller()->createField(...$args);
+ $actual['create '.$case] = ['status' => $response->getStatus(), 'rows' => $this->rows, 'error' => $response->getStatus() >= 400 ? $response->getData() : null];
+ }
+
+ foreach ($update as $case => $args) {
+ $this->rows = [];
+ $id = $args['id'] ?? 3;
+ unset($args['id']);
+ $response = $this->controller()->updateField($id, ...$args);
+ $actual['update '.$case] = ['status' => $response->getStatus(), 'rows' => $this->rows, 'error' => $response->getStatus() >= 400 ? $response->getData() : null];
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ $lines = [];
+ foreach ($actual as $case => $row) {
+ $lines[] = json_encode($case).': '.json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+
+ file_put_contents(self::FIXTURE, "{\n".implode(",\n", $lines)."\n}\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_encode(json_decode((string) file_get_contents(self::FIXTURE))), json_encode($actual));
+ }//end testRowsMatchTheRecordedFixture()
+
+ /**
+ * A field created with only key, label and type gets the defaults.
+ *
+ * @return void
+ */
+ public function testServiceDefaults(): void {
+ $mapper = $this->createMock(MetadataFieldMapper::class);
+ $mapper->method('findByKey')->willThrowException(new DoesNotExistException('none'));
+ $mapper->method('insert')->willReturnArgument(0);
+ $service = new MetadataService(
+ fieldMapper: $mapper,
+ valueMapper: $this->createMock(MetadataValueMapper::class),
+ validationService: $this->createMock(MetadataValidationService::class),
+ logger: $this->createMock(LoggerInterface::class),
+ );
+
+ $row = $service->createFieldDefinition(key: 'room', label: 'Room', type: 'text')->jsonSerialize();
+
+ $this->assertSame([null, 0, 0], [$row['options'], $row['required'], $row['sortOrder']]);
+ }//end testServiceDefaults()
+}//end class
diff --git a/tests/Unit/Controller/PageControllerBridgedWidgetsTest.php b/tests/Unit/Controller/PageControllerBridgedWidgetsTest.php
index 129bb9211..4f3b3589c 100644
--- a/tests/Unit/Controller/PageControllerBridgedWidgetsTest.php
+++ b/tests/Unit/Controller/PageControllerBridgedWidgetsTest.php
@@ -18,6 +18,8 @@
namespace OCA\LaunchPad\Tests\Unit\Controller;
+use OCA\LaunchPad\Controller\PageSettingsSources;
+use OCA\LaunchPad\Controller\PageSources;
use OCA\LaunchPad\Controller\PageController;
use OCA\LaunchPad\Service\AdminSettingsService;
use OCA\LaunchPad\Service\AdminTemplateService;
@@ -55,15 +57,19 @@ class PageControllerBridgedWidgetsTest extends TestCase {
private function makeController(WidgetService $widgetService): PageController {
return new PageController(
request: $this->createMock(IRequest::class),
- initialState: $this->createMock(IInitialState::class),
+ sources: new PageSources(
+ initialState: $this->createMock(IInitialState::class),
+ widgetService: $widgetService,
+ dashboardService: $this->createMock(DashboardService::class),
+ adminTemplateService: $this->createMock(AdminTemplateService::class)
+ ),
userSession: $this->createMock(IUserSession::class),
- widgetService: $widgetService,
- dashboardService: $this->createMock(DashboardService::class),
- adminTemplateService: $this->createMock(AdminTemplateService::class),
- roleFeaturePerm: $this->createMock(RoleFeaturePermissionService::class),
- treeService: $this->createMock(DashboardTreeService::class),
- logger: $this->createMock(LoggerInterface::class),
- adminSettingsService: $this->createMock(AdminSettingsService::class),
+ settings: new PageSettingsSources(
+ roleFeaturePerm: $this->createMock(RoleFeaturePermissionService::class),
+ treeService: $this->createMock(DashboardTreeService::class),
+ logger: $this->createMock(LoggerInterface::class),
+ adminSettingsService: $this->createMock(AdminSettingsService::class)
+ )
);
}//end makeController()
diff --git a/tests/Unit/Controller/PeopleDirectoryGoldenTest.php b/tests/Unit/Controller/PeopleDirectoryGoldenTest.php
new file mode 100644
index 000000000..3f13811ef
--- /dev/null
+++ b/tests/Unit/Controller/PeopleDirectoryGoldenTest.php
@@ -0,0 +1,265 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\PeopleWidgetController;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\AdminTemplateService;
+use OCA\LaunchPad\Service\PeopleWidgetService;
+use OCA\LaunchPad\Service\ProfileFieldService;
+use OCP\Accounts\IAccount;
+use OCP\Accounts\IAccountManager;
+use OCP\Accounts\IAccountProperty;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Directory pages, viewer by viewer.
+ *
+ * @covers \OCA\LaunchPad\Controller\PeopleWidgetController
+ * @covers \OCA\LaunchPad\Service\PeopleWidgetService
+ * @uses \OCA\LaunchPad\Controller\ResponseHelper
+ * @uses \OCA\LaunchPad\Controller\RespondsWithErrors
+ * @uses \OCA\LaunchPad\Service\PeopleQuery
+ * @uses \OCA\LaunchPad\Service\PeoplePage
+ */
+class PeopleDirectoryGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__ . '/fixtures/people-directory.json';
+
+ /**
+ * Users: display name, enabled, email, groups, phone scope, birthdate.
+ */
+ private const USERS = [
+ 'anna' => ['Anna', true, 'anna@example.test', ['staff'], 'v2-federated', '1990-05-06'],
+ 'bart' => ['bart', true, '', ['staff', 'hr'], 'v2-private', '06-07-1985'],
+ 'cees' => ['Cees', false, 'cees@example.test', ['hr'], 'v2-local', null],
+ 'dirk' => ['Dirk', true, null, [], 'v2-private', 'not a date'],
+ 'eva' => ['Eva', true, 'eva@example.test', ['Staff'], 'v2-federated', '2000-02-29'],
+ 'fien' => ['Fien', false, 'fien@example.test', ['staff'], 'v2-local', '1970-12-31'],
+ ];
+
+ /**
+ * Every request matches the recorded fixture.
+ *
+ * @return void
+ */
+ public function testPagesMatchTheRecordedFixture(): void {
+ $group = static fn (array $values): string => json_encode([['fieldName' => 'group', 'operator' => 'in', 'values' => $values]]);
+ $filters = [
+ 'none' => null,
+ 'empty' => '',
+ 'empty list' => '[]',
+ 'staff' => $group(['staff']),
+ 'hr and staff' => $group(['hr', 'staff', 'hr']),
+ 'no groups' => $group([]),
+ 'ghost and blank' => $group(['ghost', '', 7]),
+ 'other field' => json_encode([['fieldName' => 'role', 'operator' => 'in', 'values' => ['x']]]),
+ 'malformed' => '{',
+ 'scalar' => '"x"',
+ ];
+
+ $actual = [];
+ foreach ($filters as $filterName => $filter) {
+ foreach (['displayName', 'group', 'recent-activity', null, 'foo'] as $sort) {
+ foreach ([null, 'e', 'ee', 'an', ' '] as $q) {
+ foreach ([1, 0] as $excludeDisabled) {
+ $name = "filter $filterName sort " . json_encode($sort) . ' q ' . json_encode($q) . " excl $excludeDisabled";
+ $actual[$name] = $this->request(viewer: 'anna', params: [$filter, $excludeDisabled, 1, $sort, 2, 1, $q]);
+ }
+ }
+ }
+ }
+
+ foreach (['none' => null, 'staff' => $group(['staff'])] as $filterName => $filter) {
+ foreach ([1, 2, 50, 100, 0, 101, null] as $limit) {
+ foreach ([0, 1, 4, 9, -1, null] as $offset) {
+ $name = "page $filterName limit " . json_encode($limit) . ' offset ' . json_encode($offset);
+ $actual[$name] = $this->request(viewer: 'eva', params: [$filter, null, null, 'displayName', $limit, $offset, null]);
+ }
+ }
+ }
+
+ foreach (['anna', 'bart', 'eva', 'blocked', 'ghost', null] as $viewer) {
+ foreach ([1, 0, null] as $birthdays) {
+ foreach ([null, 'ee'] as $q) {
+ foreach (['group', 'displayName'] as $sort) {
+ $name = 'viewer ' . json_encode($viewer) . " birthdays " . json_encode($birthdays) . ' q ' . json_encode($q) . " sort $sort";
+ $actual[$name] = $this->request(viewer: $viewer, params: [null, 0, $birthdays, $sort, 50, 0, $q]);
+ }
+ }
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+ $this->markTestIncomplete(message: 'Fixture recorded.');
+ }
+
+ $expected = json_decode((string)file_get_contents(self::FIXTURE), true);
+ $this->assertSame(expected: array_keys($expected), actual: array_keys($actual));
+ foreach ($expected as $name => $case) {
+ $this->assertSame(expected: $case, actual: $actual[$name], message: $name);
+ }
+ }
+
+ /**
+ * Run one directory request for a session viewer and record it.
+ *
+ * The viewer "ghost" has a session id but no user object; "blocked" is
+ * refused by the action check.
+ *
+ * @param string|null $viewer The session user, or none.
+ * @param array $params filters, excludeDisabled, showBirthdays, sortBy, limit, offset, q.
+ *
+ * @return array
+ */
+ private function request(?string $viewer, array $params): array {
+ $log = [];
+ $users = [];
+ foreach (self::USERS as $uid => [$name, $enabled, $email]) {
+ $user = $this->getMockBuilder(className: IUser::class)->getMock();
+ $user->method('getUID')->willReturn($uid);
+ $user->method('getDisplayName')->willReturn($name);
+ $user->method('isEnabled')->willReturn($enabled);
+ $user->method('getEMailAddress')->willReturn($email);
+ $users[$uid] = $user;
+ }
+
+ $byName = $users;
+ uasort($byName, static fn (IUser $a, IUser $b): int => strcasecmp($a->getDisplayName(), $b->getDisplayName()));
+
+ $directory = $this->getMockBuilder(className: IUserManager::class)->getMock();
+ $directory->method('search')->willReturnCallback(static function (string $pattern, ?int $limit=null, ?int $offset=null) use (&$log, $users): array {
+ $log[] = ['search', $pattern, $limit, $offset];
+ return array_values(array_filter($users, static fn (IUser $user): bool => $pattern === '' || stripos($user->getDisplayName(), $pattern) !== false));
+ });
+ $directory->method('searchDisplayName')->willReturnCallback(static function (string $pattern, ?int $limit=null, ?int $offset=null) use (&$log, $byName): array {
+ $log[] = ['searchDisplayName', $pattern, $limit, $offset];
+ return array_slice(array_values($byName), (int)$offset, $limit);
+ });
+ $directory->method('get')->willReturnCallback(static fn (string $uid): ?IUser => $users[$uid] ?? null);
+ $directory->method('countUsersTotal')->willReturn(count($users));
+ $directory->method('countDisabledUsers')->willReturn(2);
+
+ $groups = $this->getMockBuilder(className: IGroupManager::class)->getMock();
+ $groups->method('get')->willReturnCallback(function (string $gid) use ($users): ?IGroup {
+ $members = array_values(array_filter($users, static fn (IUser $user): bool => in_array($gid, self::USERS[$user->getUID()][3], true)));
+ if ($members === []) {
+ return null;
+ }
+
+ $group = $this->getMockBuilder(className: IGroup::class)->getMock();
+ $group->method('getUsers')->willReturn($members);
+ return $group;
+ });
+
+ $accounts = $this->getMockBuilder(className: IAccountManager::class)->getMock();
+ $accounts->method('getAccount')->willReturnCallback(function (IUser $user): IAccount {
+ [, , , , $scope, $birthdate] = self::USERS[$user->getUID()];
+ $account = $this->getMockBuilder(className: IAccount::class)->getMock();
+ $account->method('getProperty')->willReturnCallback(function (string $property) use ($scope, $birthdate, $user): IAccountProperty {
+ $values = [IAccountManager::PROPERTY_PHONE => '+31 ' . $user->getUID(), IAccountManager::PROPERTY_BIRTHDATE => $birthdate, IAccountManager::PROPERTY_ROLE => ''];
+ if (array_key_exists($property, $values) === false) {
+ throw new \OCP\Accounts\PropertyDoesNotExistException($property);
+ }
+
+ $prop = $this->getMockBuilder(className: IAccountProperty::class)->getMock();
+ $prop->method('getValue')->willReturn((string)$values[$property]);
+ $prop->method('getScope')->willReturn($scope);
+ return $prop;
+ });
+ return $account;
+ });
+
+ $urls = $this->getMockBuilder(className: IURLGenerator::class)->getMock();
+ $urls->method('linkToRouteAbsolute')->willReturnCallback(static fn (string $route, array $args=[]): string => $route . '?' . http_build_query($args));
+
+ $templates = $this->getMockBuilder(className: AdminTemplateService::class)->disableOriginalConstructor()->getMock();
+ $templates->method('getUserGroupIdsFor')->willReturnCallback(static fn (string $userId): array => self::USERS[$userId][3]);
+
+ $fields = $this->getMockBuilder(className: ProfileFieldService::class)->disableOriginalConstructor()->getMock();
+ $fields->method('scopeAllows')->willReturnCallback(static fn (?string $scope, ?string $viewerId, string $ownerId): bool => $viewerId === $ownerId || ($viewerId !== null && $scope !== 'v2-private'));
+ $fields->method('findMatchingUserIds')->willReturnCallback(static function (?string $viewerId, string $query) use (&$log): array {
+ $log[] = ['findMatchingUserIds', $viewerId, $query];
+ return str_contains($query, 'ee') ? ['fien', 'nobody', 'cees', 'dirk'] : [];
+ });
+ $fields->method('visibleFieldsFor')->willReturnCallback(static function (?string $viewerId, array $userIds) use (&$log): array {
+ $log[] = ['visibleFieldsFor', $viewerId, $userIds];
+ return array_fill_keys(array_slice($userIds, 0, 1), ['team' => 'seen by ' . $viewerId]);
+ });
+
+ $service = new PeopleWidgetService(
+ userManager: $directory,
+ groupManager: $groups,
+ accountManager: $accounts,
+ urlGenerator: $urls,
+ adminTemplateService: $templates,
+ profileFields: $fields
+ );
+
+ $session = $this->getMockBuilder(className: IUserSession::class)->getMock();
+ $session->method('getUser')->willReturn($users[$viewer] ?? ($viewer === 'blocked' ? $this->blockedUser() : null));
+
+ $auth = $this->getMockBuilder(className: ActionAuthService::class)->disableOriginalConstructor()->getMock();
+ $auth->method('requireAction')->willReturnCallback(static function (IUser $user, string $action) use (&$log): void {
+ $log[] = ['requireAction', $user->getUID(), $action];
+ if ($user->getUID() === 'blocked') {
+ throw new OCSForbiddenException('no');
+ }
+ });
+
+ $controller = new PeopleWidgetController(
+ request: $this->getMockBuilder(className: IRequest::class)->getMock(),
+ service: $service,
+ actionAuth: $auth,
+ userSession: $session,
+ logger: $this->getMockBuilder(className: LoggerInterface::class)->getMock(),
+ userId: $viewer
+ );
+
+ [$filters, $excludeDisabled, $showBirthdays, $sortBy, $limit, $offset, $q] = $params;
+ $response = $controller->getUsers(filters: $filters, excludeDisabled: $excludeDisabled, showBirthdays: $showBirthdays, sortBy: $sortBy, limit: $limit, offset: $offset, q: $q);
+
+ return ['status' => $response->getStatus(), 'body' => $response->getData(), 'log' => $log];
+ }
+
+ /**
+ * A signed-in user the action check refuses.
+ *
+ * @return IUser
+ */
+ private function blockedUser(): IUser {
+ $user = $this->getMockBuilder(className: IUser::class)->getMock();
+ $user->method('getUID')->willReturn('blocked');
+ return $user;
+ }
+}
diff --git a/tests/Unit/Controller/PeopleWidgetControllerTest.php b/tests/Unit/Controller/PeopleWidgetControllerTest.php
index 14c599d9f..94f963729 100644
--- a/tests/Unit/Controller/PeopleWidgetControllerTest.php
+++ b/tests/Unit/Controller/PeopleWidgetControllerTest.php
@@ -26,6 +26,7 @@
use InvalidArgumentException;
use OCA\LaunchPad\Controller\PeopleWidgetController;
use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\PeopleQuery;
use OCA\LaunchPad\Service\PeopleWidgetService;
use OCP\AppFramework\Http;
use OCP\IRequest;
@@ -82,12 +83,12 @@ public function testReturns200OnHappyPath(): void {
$this->service->expects($this->once())
->method('listUsers')
->with(
- filters: [],
- excludeDisabled: true,
- showBirthdays: true,
- sortBy: 'displayName',
- limit: 50,
- offset: 0,
+ new PeopleQuery(
+ filters: [],
+ sortBy: 'displayName',
+ search: null,
+ page: PeopleQuery::window(excludeDisabled: true, showBirthdays: true, limit: 50, offset: 0)
+ ),
)
->willReturn(
[
@@ -178,18 +179,18 @@ public function testParsesValidFilterJson(): void {
$this->service->expects($this->once())
->method('listUsers')
->with(
- filters: [
- [
- 'fieldName' => 'group',
- 'operator' => 'in',
- 'values' => ['management'],
+ new PeopleQuery(
+ filters: [
+ [
+ 'fieldName' => 'group',
+ 'operator' => 'in',
+ 'values' => ['management'],
+ ],
],
- ],
- excludeDisabled: true,
- showBirthdays: true,
- sortBy: 'displayName',
- limit: 50,
- offset: 0,
+ sortBy: 'displayName',
+ search: null,
+ page: PeopleQuery::window(excludeDisabled: true, showBirthdays: true, limit: 50, offset: 0)
+ ),
)
->willReturn(['users' => [], 'total' => 0, 'hasMore' => false]);
diff --git a/tests/Unit/Controller/PersonalLayerApiControllerShapeTest.php b/tests/Unit/Controller/PersonalLayerApiControllerShapeTest.php
index 968061330..20dd80d86 100644
--- a/tests/Unit/Controller/PersonalLayerApiControllerShapeTest.php
+++ b/tests/Unit/Controller/PersonalLayerApiControllerShapeTest.php
@@ -234,4 +234,16 @@ public function testBothStatesAnswerTheSameKeys(): void {
self::assertNull($empty['id']);
self::assertSame(self::DASHBOARD, $empty['dashboardId']);
}//end testBothStatesAnswerTheSameKeys()
+
+ /**
+ * The full answer without a layer, key by key.
+ *
+ * @return void
+ */
+ public function testTheAnswerWithoutALayer(): void {
+ $body = $this->showWith(null);
+
+ self::assertSame('{"id":null,"dashboardId":1,"overrides":{},"hidden":[],"updatedAt":null,"hasLayer":false,"hiddenPlacements":[]}', $body);
+ }//end testTheAnswerWithoutALayer()
+
}//end class
diff --git a/tests/Unit/Controller/PlacementReadingControllersGoldenTest.php b/tests/Unit/Controller/PlacementReadingControllersGoldenTest.php
new file mode 100644
index 000000000..6255eecfe
--- /dev/null
+++ b/tests/Unit/Controller/PlacementReadingControllersGoldenTest.php
@@ -0,0 +1,165 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\HealthPingController;
+use OCA\LaunchPad\Controller\LiveTileController;
+use OCA\LaunchPad\Controller\SessionLog;
+use OCA\LaunchPad\Controller\WeatherController;
+use OCA\LaunchPad\Service\HealthPingService;
+use OCA\LaunchPad\Service\LiveTileService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCA\LaunchPad\Service\WeatherService;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * Every endpoint of the three placement-reading controllers, case by case.
+ */
+class PlacementReadingControllersGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/placement-readings.json';
+
+ /**
+ * Service calls and log lines of the current call.
+ *
+ * @var array
+ */
+ private array $trace = [];
+
+ /**
+ * Every endpoint × case answers, calls and logs as recorded.
+ *
+ * @return void
+ */
+ public function testEndpointsMatchTheRecording(): void {
+ $cases = [
+ 'anonymous' => [null, 1, 'ok', 'x'],
+ 'cannot view' => ['alice', 2, 'ok', 'x'],
+ 'resolver throws' => ['alice', 1, 'throws', ['a' => 1]],
+ 'resolver error' => ['alice', 1, 'error', ['a' => 1]],
+ 'ok' => ['alice', 1, 'ok', ['a' => 1]],
+ ];
+ $endpoints = [
+ 'health show' => static fn ($c): object => $c['health']->show(placementId: $c['placement']),
+ 'health validate' => static fn ($c): object => $c['health']->validate(),
+ 'live show' => static fn ($c): object => $c['live']->show(placementId: $c['placement']),
+ 'live connector status' => static fn ($c): object => $c['live']->connectorStatus(),
+ 'live validate source' => static fn ($c): object => $c['live']->validateSource(),
+ 'weather show' => static fn ($c): object => $c['weather']->show(placementId: $c['placement']),
+ ];
+
+ $actual = [];
+ foreach ($endpoints as $endpoint => $call) {
+ foreach ($cases as $case => [$uid, $placement, $mode, $config]) {
+ $this->trace = [];
+ $controllers = $this->controllers(uid: $uid, mode: $mode, config: $config);
+ $controllers['placement'] = $placement;
+ $response = $call($controllers);
+ $actual[$endpoint.' / '.$case] = [$response->getStatus(), $response->getData(), $this->trace];
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), json_decode((string) json_encode($actual), true));
+ }//end testEndpointsMatchTheRecording()
+
+ /**
+ * The three controllers over recording service mocks.
+ *
+ * @param string|null $uid The session user.
+ * @param string $mode What the resolvers do: ok, error or throws.
+ * @param mixed $config The posted `config` parameter.
+ *
+ * @return array
+ */
+ private function controllers(?string $uid, string $mode, mixed $config): array {
+ $session = $this->createMock(IUserSession::class);
+ $user = null;
+ if ($uid !== null) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ }
+ $session->method('getUser')->willReturn($user);
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->method('error')->willReturnCallback(
+ function (string $message, array $context): void {
+ $this->trace[] = ['log', $message, $context];
+ }
+ );
+
+ $permissions = $this->createMock(PermissionService::class);
+ $permissions->method('canViewPlacement')->willReturnCallback(
+ function (string $userId, int $placementId): bool {
+ $this->trace[] = ['canViewPlacement', $userId, $placementId];
+ return $placementId !== 2;
+ }
+ );
+
+ $resolve = function (string $service, array $args) use ($mode): array {
+ $this->trace[] = [$service, $args];
+ if ($mode === 'throws') {
+ throw new RuntimeException('down');
+ }
+ return ($mode === 'error') ? ['error' => 'not_configured'] : ['value' => 42, 'stale' => false];
+ };
+
+ $health = $this->createMock(HealthPingService::class);
+ $health->method('resolveForPlacement')->willReturnCallback(fn (int $id): array => $resolve('health', [$id]));
+ $health->method('validateConfig')->willReturnCallback(
+ function (array $config): array {
+ $this->trace[] = ['validateConfig', $config];
+ return ($config === []) ? [] : ['bad host'];
+ }
+ );
+ $live = $this->createMock(LiveTileService::class);
+ $live->method('resolveForPlacement')->willReturnCallback(fn (int $id): array => $resolve('live', [$id]));
+ $live->method('isConnectorAvailable')->willReturn(true);
+ $live->method('validateSourceConfig')->willReturnCallback(
+ function (array $config): array {
+ $this->trace[] = ['validateSourceConfig', $config];
+ return ($config === []) ? [] : ['bad host'];
+ }
+ );
+ $weather = $this->createMock(WeatherService::class);
+ $weather->method('resolveForPlacement')->willReturnCallback(fn (int $id, string $userId): array => $resolve('weather', [$id, $userId]));
+
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')->willReturnCallback(static fn (string $key): mixed => ($key === 'config') ? $config : null);
+
+ $log = new SessionLog(userSession: $session, logger: $logger);
+ return [
+ 'health' => new HealthPingController(request: $request, healthPingService: $health, permissionService: $permissions, session: $log),
+ 'live' => new LiveTileController(request: $request, liveTileService: $live, permissionService: $permissions, session: $log),
+ 'weather' => new WeatherController(request: $request, weatherService: $weather, permissionService: $permissions, session: $log),
+ ];
+ }//end controllers()
+}//end class
diff --git a/tests/Unit/Controller/ProfileFieldsControllerTest.php b/tests/Unit/Controller/ProfileFieldsControllerTest.php
new file mode 100644
index 000000000..87a1d28d4
--- /dev/null
+++ b/tests/Unit/Controller/ProfileFieldsControllerTest.php
@@ -0,0 +1,128 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\ActionCaller;
+use OCA\LaunchPad\Service\ProfileFieldTools;
+use OCA\LaunchPad\Service\ProfileSources;
+use OCA\LaunchPad\Controller\ProfileFieldsController;
+use OCA\LaunchPad\Db\AdminSetting;
+use OCA\LaunchPad\Db\AdminSettingMapper;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\AdminTemplateService;
+use OCA\LaunchPad\Service\ProfileFieldService;
+use OCP\Accounts\IAccountManager;
+use OCP\AppFramework\Http;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\LDAP\ILDAPProviderFactory;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Unit\Support\InMemoryProfileValues;
+
+class ProfileFieldsControllerTest extends TestCase {
+ /**
+ * Stored admin settings.
+ *
+ * @var array
+ */
+ private array $settings = [];
+
+ private InMemoryProfileValues $values;
+
+ private function controller(string $uid, bool $admin): ProfileFieldsController {
+ $settingMapper = $this->createMock(AdminSettingMapper::class);
+ $settingMapper->method('getValue')->willReturnCallback(
+ fn (string $key, mixed $default = null): mixed => $this->settings[$key] ?? $default
+ );
+ $settingMapper->method('setSetting')->willReturnCallback(
+ function (string $key, mixed $value): AdminSetting {
+ $this->settings[$key] = $value;
+ return new AdminSetting();
+ }
+ );
+ $this->values ??= new InMemoryProfileValues();
+
+ $service = new ProfileFieldService(
+ settingMapper: $settingMapper,
+ valueMapper: $this->values,
+ sources: new ProfileSources(
+ accountManager: $this->createMock(IAccountManager::class),
+ ldapProviderFactory: $this->createMock(ILDAPProviderFactory::class)
+ ),
+ tools: new ProfileFieldTools(
+ adminTemplateService: $this->createMock(AdminTemplateService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ )
+ );
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $groups = $this->createMock(IGroupManager::class);
+ $groups->method('isAdmin')->willReturn($admin);
+
+ return new ProfileFieldsController(
+ request: $this->createMock(IRequest::class),
+ profileFields: $service,
+ caller: new ActionCaller(
+ actionAuth: $this->createMock(ActionAuthService::class),
+ userSession: $session
+ ),
+ groupManager: $groups
+ );
+ }//end controller()
+
+ public function testANonAdminCannotReadOrWriteDefinitions(): void {
+ $controller = $this->controller(uid: 'sanne', admin: false);
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $controller->getDefinitions()->getStatus());
+ $this->assertSame(Http::STATUS_FORBIDDEN, $controller->saveDefinitions(fields: [['key' => 'x', 'label' => 'X']])->getStatus());
+ $this->assertSame([], $this->settings, 'a refused write must not store anything');
+ }//end testANonAdminCannotReadOrWriteDefinitions()
+
+ public function testAnAdminDefinesAndAPersonSavesOnlyTheirOwnValues(): void {
+ $saved = $this->controller(uid: 'noor', admin: true)->saveDefinitions(
+ fields: [['key' => 'expertise', 'label' => 'Expertise', 'type' => 'tags', 'searchable' => true]]
+ );
+ $this->assertSame(Http::STATUS_OK, $saved->getStatus());
+
+ $pieter = $this->controller(uid: 'pieter', admin: false);
+ $response = $pieter->saveOwn(values: ['expertise' => ['subsidies']]);
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $this->assertSame(['subsidies'], $response->getData()['fields'][0]['values']);
+ $this->assertSame('pieter', $this->values->rows[0]->getUserId(), 'the value must land on the caller');
+
+ $sanne = $this->controller(uid: 'sanne', admin: false);
+ $this->assertSame([], $sanne->getOwn()->getData()['fields'][0]['values'], 'Sanne must not read Pieter\'s values as her own');
+ }//end testAnAdminDefinesAndAPersonSavesOnlyTheirOwnValues()
+
+ public function testAnInvalidValueIsABadRequest(): void {
+ $this->controller(uid: 'noor', admin: true)->saveDefinitions(fields: [['key' => 'expertise', 'label' => 'Expertise', 'type' => 'tags']]);
+
+ $response = $this->controller(uid: 'pieter', admin: false)->saveOwn(values: ['expertise' => 'not a list']);
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }//end testAnInvalidValueIsABadRequest()
+}//end class
diff --git a/tests/Unit/Controller/PublicShareControllerCrudGoldenTest.php b/tests/Unit/Controller/PublicShareControllerCrudGoldenTest.php
new file mode 100644
index 000000000..976213caa
--- /dev/null
+++ b/tests/Unit/Controller/PublicShareControllerCrudGoldenTest.php
@@ -0,0 +1,193 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\PublicShareController;
+use OCA\LaunchPad\Db\PublicShare;
+use OCA\LaunchPad\Exception\ShareNotFoundException;
+use OCA\LaunchPad\Service\PublicShareContext;
+use OCA\LaunchPad\Service\PublicShareService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IRequest;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * The public share owner endpoints, case by case.
+ */
+class PublicShareControllerCrudGoldenTest extends TestCase {
+
+ /**
+ * Service calls and log lines of the current call.
+ *
+ * @var array
+ */
+ private array $trace = [];
+
+ /**
+ * Every endpoint × case answers, calls and logs as recorded.
+ *
+ * @return void
+ */
+ public function testEndpointsMatchTheRecording(): void {
+ $endpoints = [
+ 'create' => static fn (PublicShareController $c) => $c->create(uuid: 'd-1', password: 'pw', expiresAt: '2030-01-01'),
+ 'index' => static fn (PublicShareController $c) => $c->index(uuid: 'd-1'),
+ 'destroy' => static fn (PublicShareController $c) => $c->destroy(uuid: 'd-1', id: 7),
+ ];
+ $cases = [
+ 'anonymous' => [null, null],
+ 'ok' => ['alice', null],
+ 'unknown dashboard' => ['alice', new DoesNotExistException('no')],
+ 'not authorized' => ['alice', new OCSForbiddenException('no')],
+ 'crash' => ['alice', new RuntimeException('db down')],
+ ];
+
+ $actual = [];
+ foreach ($endpoints as $endpoint => $call) {
+ foreach ($cases as $case => [$userId, $failure]) {
+ $this->trace = [];
+ $response = $call($this->controller(userId: $userId, failure: $failure));
+ $actual[$endpoint.' / '.$case] = [$response->getStatus(), $response->getData(), $this->trace];
+ }
+ }
+
+ $this->trace = [];
+ $response = $this->controller(userId: 'alice', failure: new ShareNotFoundException())->destroy(uuid: 'd-1', id: 7);
+ $actual['destroy / unknown share'] = [$response->getStatus(), $response->getData(), $this->trace];
+
+ $actual = json_decode((string) json_encode($actual), true);
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(__DIR__.'/fixtures/public-share-crud.json', json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(__DIR__.'/fixtures/public-share-crud.json'), true), $actual);
+ }//end testEndpointsMatchTheRecording()
+
+ /**
+ * The public render takes the header password when the query has none,
+ * marks the bearer and strips the creator from the share.
+ *
+ * @return void
+ */
+ public function testShowUsesTheHeaderPasswordAndHidesTheCreator(): void {
+ $cases = [];
+ foreach (['hdr', ''] as $header) {
+ $share = new PublicShare();
+ $share->setToken('tok');
+ $share->setCreatedBy('alice');
+ $dashboard = new \OCA\LaunchPad\Db\Dashboard();
+ $dashboard->setName('Board');
+ $placement = new \OCA\LaunchPad\Db\WidgetPlacement();
+ $placement->setWidgetId('text');
+
+ $seen = [];
+ $service = $this->createMock(PublicShareService::class);
+ $service->method('renderShareContent')->willReturnCallback(
+ function (string $token, string $ipAddress, ?string $password) use (&$seen, $share, $dashboard, $placement): array {
+ $seen[] = [$token, $ipAddress, $password];
+ return ['share' => $share, 'dashboard' => $dashboard, 'placements' => [$placement]];
+ }
+ );
+ $context = $this->createMock(PublicShareContext::class);
+ $context->method('markBearer')->willReturnCallback(
+ function (string $token) use (&$seen): void {
+ $seen[] = ['bearer', $token];
+ }
+ );
+ $request = $this->createMock(IRequest::class);
+ $request->method('getRemoteAddress')->willReturn('10.0.0.1');
+ $request->method('getHeader')->willReturnCallback(static fn (string $name): string => ($name === 'X-Share-Password') ? $header : '');
+ $request->method('getParam')->willReturn('query');
+
+ $controller = new PublicShareController(request: $request, shareService: $service, shareContext: $context, logger: $this->createMock(LoggerInterface::class), userId: null);
+ $data = $controller->show(token: 'tok')->getData();
+ $cases[] = [$seen, array_key_exists('createdBy', $data['share']), $data['dashboard']['name'], count($data['placements'])];
+ }
+
+ $this->assertSame(
+ [
+ [[['tok', '10.0.0.1', 'hdr'], ['bearer', 'tok']], false, 'Board', 1],
+ [[['tok', '10.0.0.1', null], ['bearer', 'tok']], false, 'Board', 1],
+ ],
+ $cases
+ );
+ }//end testShowUsesTheHeaderPasswordAndHidesTheCreator()
+
+ /**
+ * The share password is never taken from the query string.
+ *
+ * @return void
+ */
+ public function testShowTakesNoPasswordFromTheQuery(): void {
+ $parameters = (new \ReflectionMethod(PublicShareController::class, 'show'))->getParameters();
+
+ $this->assertSame(['token'], array_map(static fn (\ReflectionParameter $p): string => $p->getName(), $parameters));
+ }//end testShowTakesNoPasswordFromTheQuery()
+
+ /**
+ * The controller over a recording service mock.
+ *
+ * @param string|null $userId The signed-in user.
+ * @param \Throwable|null $failure What every service call throws, or null to succeed.
+ *
+ * @return PublicShareController
+ */
+ private function controller(?string $userId, ?\Throwable $failure): PublicShareController {
+ $share = new PublicShare();
+ $share->setDashboardUuid('d-1');
+ $share->setToken(str_repeat('b', 64));
+
+ $service = $this->createMock(PublicShareService::class);
+ $answer = function (string $method, array $args) use ($failure, $share) {
+ $this->trace[] = [$method, $args];
+ if ($failure !== null) {
+ throw $failure;
+ }
+ return ($method === 'listActiveShares') ? [$share, $share] : $share;
+ };
+ $service->method('createPublicShare')->willReturnCallback(fn (...$args) => $answer('createPublicShare', $args));
+ $service->method('listActiveShares')->willReturnCallback(fn (...$args) => $answer('listActiveShares', $args));
+ $service->method('revokeShare')->willReturnCallback(
+ function (...$args) use ($answer): void {
+ $answer('revokeShare', $args);
+ }
+ );
+
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->method('error')->willReturnCallback(
+ function (string $message, array $context): void {
+ $this->trace[] = ['log', $message, $context['exceptionClass']];
+ }
+ );
+
+ return new PublicShareController(
+ request: $this->createMock(IRequest::class),
+ shareService: $service,
+ shareContext: $this->createMock(PublicShareContext::class),
+ logger: $logger,
+ userId: $userId
+ );
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/PublicShareControllerTest.php b/tests/Unit/Controller/PublicShareControllerTest.php
index 7ac1fa53b..5878b2f42 100644
--- a/tests/Unit/Controller/PublicShareControllerTest.php
+++ b/tests/Unit/Controller/PublicShareControllerTest.php
@@ -33,9 +33,11 @@
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\DB\Exception as DbException;
use OCP\IRequest;
use OCP\Security\Bruteforce\MaxDelayReached;
use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -264,4 +266,45 @@ public function testUnlockReturns401WithAccessFalseOnWrongPassword(): void {
$this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus());
$this->assertFalse($response->getData()['access']);
}
-}//end class
+
+ // -------------------------------------------------------------------------
+ // unexpected failures on the owner endpoints
+ // -------------------------------------------------------------------------
+
+ /**
+ * The owner endpoints with the service method each one reaches.
+ *
+ * @return array}>
+ */
+ public static function ownerEndpoints(): array {
+ return [
+ 'create' => ['create', 'createPublicShare', ['uuid' => 'some-uuid']],
+ 'index' => ['index', 'listActiveShares', ['uuid' => 'some-uuid']],
+ 'destroy' => ['destroy', 'revokeShare', ['uuid' => 'some-uuid', 'id' => 1]],
+ ];
+ }
+
+ /**
+ * A database failure answers a neutral 500 and logs the exception.
+ *
+ * @param string $endpoint The controller method.
+ * @param string $service The service method that fails.
+ * @param array $args The endpoint arguments.
+ *
+ * @return void
+ */
+ #[DataProvider('ownerEndpoints')]
+ public function testUnexpectedFailureAnswersNeutrally(string $endpoint, string $service, array $args): void {
+ $failure = new DbException('insert failed: secret column');
+ $this->shareService->method($service)->willThrowException($failure);
+ $this->logger->expects($this->once())->method('error')->with(
+ 'Public share request failed: ' . DbException::class,
+ $this->callback(static fn (array $context): bool => $context['exception'] === $failure && $context['exceptionClass'] === DbException::class)
+ );
+
+ $response = $this->makeController()->$endpoint(...$args);
+
+ $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
+ $this->assertSame(['error' => 'Internal error'], $response->getData());
+ }
+}
diff --git a/tests/Unit/Controller/RequestDataExtractorTest.php b/tests/Unit/Controller/RequestDataExtractorTest.php
new file mode 100644
index 000000000..05df5602c
--- /dev/null
+++ b/tests/Unit/Controller/RequestDataExtractorTest.php
@@ -0,0 +1,109 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\RequestDataExtractor;
+use OCP\IRequest;
+use PHPUnit\Framework\TestCase;
+
+class RequestDataExtractorTest extends TestCase {
+
+ /** @var list */
+ private array $asked = [];
+
+ /**
+ * A request carrying the given parameters, recording every key asked for.
+ *
+ * @param array $params The parameters.
+ *
+ * @return IRequest
+ */
+ private function request(array $params): IRequest {
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')->willReturnCallback(function (string $key, mixed $default = null) use ($params): mixed {
+ $this->asked[] = $key;
+ return array_key_exists($key, $params) ? $params[$key] : $default;
+ });
+ return $request;
+ }
+
+ public function testTileDataDefaults(): void {
+ $this->assertSame(
+ [
+ 'title' => 'New Tile',
+ 'icon' => 'icon-link',
+ 'iconType' => 'class',
+ 'bgColor' => '#0082c9',
+ 'txtColor' => '#ffffff',
+ 'linkType' => 'app',
+ 'linkVal' => '',
+ 'gridX' => 0,
+ 'gridY' => 0,
+ ],
+ RequestDataExtractor::extractTileData($this->request([]))
+ );
+ $this->assertSame(
+ ['title', 'icon', 'iconType', 'bgColor', 'textColor', 'linkType', 'linkValue', 'gridX', 'gridY'],
+ $this->asked
+ );
+ }
+
+ public function testTileDataFromRequest(): void {
+ $this->assertSame(
+ [
+ 'title' => 'Mail',
+ 'icon' => 'mail.svg',
+ 'iconType' => 'url',
+ 'bgColor' => '#000000',
+ 'txtColor' => '#111111',
+ 'linkType' => 'url',
+ 'linkVal' => 'https://x',
+ 'gridX' => 3,
+ 'gridY' => 4,
+ ],
+ RequestDataExtractor::extractTileData($this->request([
+ 'title' => 'Mail', 'icon' => 'mail.svg', 'iconType' => 'url', 'bgColor' => '#000000',
+ 'textColor' => '#111111', 'txtColor' => 'ignored', 'linkType' => 'url', 'linkValue' => 'https://x',
+ 'gridX' => '3', 'gridY' => 4.7,
+ ]))
+ );
+ }
+
+ public function testPlacementDataKeepsOnlyGivenFields(): void {
+ $this->assertSame(
+ ['gridX' => 1, 'isVisible' => false, 'content' => '', 'acknowledgementContentVersion' => 2],
+ RequestDataExtractor::extractPlacementData($this->request([
+ 'acknowledgementContentVersion' => 2, 'content' => '', 'isVisible' => false, 'gridX' => 1,
+ 'gridY' => null, 'announcementKey' => 'k',
+ ]))
+ );
+ $this->assertSame(
+ [
+ 'gridX', 'gridY', 'gridWidth', 'gridHeight', 'isVisible', 'showTitle', 'customTitle', 'customIcon',
+ 'styleConfig', 'content', 'tileTitle', 'tileIcon', 'tileIconType', 'tileBackgroundColor', 'tileTextColor',
+ 'tileLinkType', 'tileLinkValue', 'requiresAcknowledgement', 'acknowledgementPrompt',
+ 'acknowledgementDeadline', 'reacknowledgeOnChange', 'acknowledgementContentVersion',
+ ],
+ $this->asked
+ );
+ }
+}
diff --git a/tests/Unit/Controller/RequestGuardGoldenTest.php b/tests/Unit/Controller/RequestGuardGoldenTest.php
new file mode 100644
index 000000000..f5b0649ac
--- /dev/null
+++ b/tests/Unit/Controller/RequestGuardGoldenTest.php
@@ -0,0 +1,260 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Tests\Unit\Controller;
+
+use OCA\LaunchPad\Controller\AdminBulkController;
+use OCA\LaunchPad\Controller\AdminCleanupController;
+use OCA\LaunchPad\Controller\AdminDemoShowcasesController;
+use OCA\LaunchPad\Controller\AdminOrgNavigationController;
+use OCA\LaunchPad\Controller\AdminSettingsController;
+use OCA\LaunchPad\Controller\AdminShippedTemplateController;
+use OCA\LaunchPad\Controller\AttentionController;
+use OCA\LaunchPad\Controller\DashboardLockApiController;
+use OCA\LaunchPad\Controller\MetadataAdminController;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionNamedType;
+use Throwable;
+
+/**
+ * Calls every endpoint of the admin, attention and lock controllers as an
+ * anonymous visitor,
+ * as bob (signed in, not an administrator) and as alice (administrator),
+ * and pins the response, the users the administrator check ran for, the
+ * user and action key of every action check (only alice is granted), and
+ * whether the call reached the controller's services. Collaborators are
+ * doubles built from the constructor signature (a `userId` parameter gets
+ * the session user), so the harness follows a constructor that changes shape. The fixture was recorded with
+ * LAUNCHPAD_GOLDEN_RECORD=1.
+ *
+ * @coversNothing
+ */
+class RequestGuardGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__ . '/fixtures/request-guard.json';
+
+ private const CONTROLLERS = [
+ AdminBulkController::class,
+ AdminCleanupController::class,
+ AdminDemoShowcasesController::class,
+ AdminOrgNavigationController::class,
+ AdminSettingsController::class,
+ AdminShippedTemplateController::class,
+ AttentionController::class,
+ DashboardLockApiController::class,
+ MetadataAdminController::class,
+ ];
+
+ private const CALLERS = ['anonymous' => null, 'bob (signed in)' => 'bob', 'alice (admin)' => 'alice'];
+
+ /**
+ * Every endpoint for every caller.
+ *
+ * @return array
+ */
+ public static function cases(): array {
+ $cases = [];
+ foreach (self::CONTROLLERS as $class) {
+ foreach ((new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->getDeclaringClass()->getName() !== $class || $method->isConstructor() === true) {
+ continue;
+ }
+
+ foreach (array_keys(self::CALLERS) as $caller) {
+ $short = (new ReflectionClass($class))->getShortName();
+ $cases[$short . '::' . $method->getName() . ' / ' . $caller] = [$class, $method->getName(), $caller];
+ }
+ }
+ }
+
+ return $cases;
+ }
+
+ /**
+ * The endpoint answers and checks what it did when recorded.
+ *
+ * @param class-string $class The controller.
+ * @param string $method The endpoint.
+ * @param string $caller The caller case.
+ *
+ * @return void
+ */
+ #[DataProvider('cases')]
+ public function testEndpointMatchesTheRecording(string $class, string $method, string $caller): void {
+ $log = ['isAdmin' => [], 'actions' => [], 'reachedServices' => false];
+ $controller = $this->build(class: $class, uid: self::CALLERS[$caller], log: $log);
+ try {
+ $response = $controller->$method(...$this->arguments(method: new ReflectionMethod($class, $method)));
+ $actual = ['status' => $response->getStatus()];
+ if ($response->getStatus() === 401 || $response->getStatus() === 403) {
+ $actual['data'] = $response->getData();
+ }
+ } catch (Throwable $e) {
+ $actual = ['threw' => $e::class];
+ }
+
+ $actual += $log;
+ $key = (new ReflectionClass($class))->getShortName() . '::' . $method . ' / ' . $caller;
+ if (getenv('LAUNCHPAD_GOLDEN_RECORD') === '1') {
+ $recorded = is_file(self::FIXTURE) ? json_decode((string)file_get_contents(self::FIXTURE), true) : [];
+ $recorded[$key] = $actual;
+ ksort($recorded);
+ file_put_contents(self::FIXTURE, json_encode($recorded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+ }
+
+ $expected = json_decode((string)file_get_contents(self::FIXTURE), true)[$key] ?? null;
+ $this->assertNotNull($expected, 'no recording for ' . $key);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * Build a controller from its constructor signature.
+ *
+ * @param class-string $class The controller or bundle class.
+ * @param string|null $uid The session user.
+ * @param array $log The log, written by reference.
+ *
+ * @return object The instance.
+ */
+ private function build(string $class, ?string $uid, array &$log): object {
+ $args = [];
+ foreach ((new ReflectionClass($class))->getConstructor()?->getParameters() ?? [] as $param) {
+ $type = $param->getType();
+ $name = $type instanceof ReflectionNamedType ? $type->getName() : 'mixed';
+ if ($param->getName() === 'userId') {
+ $args['userId'] = $uid;
+ continue;
+ }
+
+ $args[$param->getName()] = $this->double(type: $name, uid: $uid, log: $log, nullable: $param->allowsNull());
+ }
+
+ return new $class(...$args);
+ }
+
+ /**
+ * A double for one constructor parameter.
+ *
+ * @param string $type The parameter type.
+ * @param string|null $uid The session user.
+ * @param array $log The log, written by reference.
+ * @param bool $nullable Whether null is accepted.
+ *
+ * @return mixed The double.
+ */
+ private function double(string $type, ?string $uid, array &$log, bool $nullable): mixed {
+ if ($type === IUserSession::class) {
+ $session = $this->createMock(IUserSession::class);
+ $user = null;
+ if ($uid !== null) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ }
+
+ $session->method('getUser')->willReturn($user);
+ return $session;
+ }
+
+ if ($type === IGroupManager::class) {
+ $groups = $this->createMock(IGroupManager::class);
+ $groups->method('isAdmin')->willReturnCallback(static function (string $userId) use (&$log): bool {
+ $log['isAdmin'][] = $userId;
+ return $userId === 'alice';
+ });
+ $groups->method('search')->willReturn([]);
+ return $groups;
+ }
+
+ if (in_array($type, ['string', 'int', 'bool', 'array', 'float', 'mixed'], true) === true) {
+ return $nullable === true ? null : match ($type) {
+ 'string' => 'x',
+ 'int' => 1,
+ 'bool' => false,
+ 'array' => [],
+ 'float' => 1.0,
+ default => null,
+ };
+ }
+
+ if ($type === ActionAuthService::class) {
+ $auth = $this->createMock(ActionAuthService::class);
+ $auth->method('requireAction')->willReturnCallback(static function (IUser $user, string $action) use (&$log): void {
+ $log['actions'][] = [$user->getUID(), $action];
+ if ($user->getUID() !== 'alice') {
+ throw new OCSForbiddenException('denied');
+ }
+ });
+ return $auth;
+ }
+
+ $reflection = new ReflectionClass($type);
+ if ($reflection->isInterface() === false && str_starts_with($type, 'OCA\\LaunchPad\\') === true && str_ends_with($type, 'Service') === false && str_ends_with($type, 'Mapper') === false) {
+ return $this->build(class: $type, uid: $uid, log: $log);
+ }
+
+ $double = $this->createMock($type);
+ foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->isConstructor() === true || $method->isStatic() === true || $method->isFinal() === true || str_starts_with($method->getName(), '__') === true) {
+ continue;
+ }
+
+ $double->method($method->getName())->willReturnCallback(function () use (&$log, $type): never {
+ $log['reachedServices'] = true;
+ throw new \RuntimeException('stop at ' . $type);
+ });
+ }
+
+ return $double;
+ }
+
+ /**
+ * Arguments for an endpoint: a plain value per parameter.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ *
+ * @return array The arguments.
+ */
+ private function arguments(ReflectionMethod $method): array {
+ $args = [];
+ foreach ($method->getParameters() as $param) {
+ if ($param->isDefaultValueAvailable() === true) {
+ continue;
+ }
+
+ $type = $param->getType();
+ $name = $type instanceof ReflectionNamedType ? $type->getName() : 'mixed';
+ $args[$param->getName()] = match ($name) {
+ 'int' => 1,
+ 'bool' => false,
+ 'array' => [],
+ default => 'x',
+ };
+ }
+
+ return $args;
+ }
+}
diff --git a/tests/Unit/Controller/ResourceControllerTest.php b/tests/Unit/Controller/ResourceControllerTest.php
index ee80bf3bf..ac3239adb 100644
--- a/tests/Unit/Controller/ResourceControllerTest.php
+++ b/tests/Unit/Controller/ResourceControllerTest.php
@@ -24,6 +24,7 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\ResourceTools;
use OCA\LaunchPad\Controller\ResourceController;
use OCA\LaunchPad\Controller\ResourceUploadRequestParser;
use OCA\LaunchPad\Exception\CorruptImageException;
@@ -77,11 +78,13 @@ protected function setUp(): void {
$this->controller = new ResourceController(
request: $this->request,
- resourceService: $this->service,
- parser: $this->parser,
+ tools: new ResourceTools(
+ resourceService: $this->service,
+ parser: $this->parser,
+ logger: new NullLogger()
+ ),
userSession: $this->userSession,
- groupManager: $this->groupManager,
- logger: new NullLogger(),
+ groupManager: $this->groupManager
);
}//end setUp()
@@ -102,11 +105,9 @@ public function __construct(
) {
parent::__construct(
request: $request,
- resourceService: $resourceService,
- parser: $parser,
+ tools: new ResourceTools(resourceService: $resourceService, parser: $parser, logger: $logger),
userSession: $userSession,
groupManager: $groupManager,
- logger: $logger,
);
}
@@ -138,11 +139,9 @@ public function __construct(
) {
parent::__construct(
request: $request,
- resourceService: $resourceService,
- parser: $parser,
+ tools: new ResourceTools(resourceService: $resourceService, parser: $parser, logger: $logger),
userSession: $userSession,
groupManager: $groupManager,
- logger: $logger,
);
}
@@ -444,4 +443,41 @@ public function testMultipartUnexpectedThrowableIsMaskedAsStorageFailure(): void
$this->assertSame('storage_failure', $body['error']);
$this->assertStringNotContainsString('SECRET', $body['message']);
}
+ /**
+ * Storage failures and unexpected failures are logged with the endpoint's own message.
+ *
+ * @return void
+ */
+ public function testFailuresAreLoggedPerEndpoint(): void {
+ $this->userSession->method('getUser')->willReturn($this->adminUser());
+ $this->groupManager->method('isAdmin')->willReturn(true);
+ $this->parser->method('extractBase64')->willReturn('data:image/png;base64,xxx');
+ $logged = [];
+ $this->logger->method('error')->willReturnCallback(
+ function (string $message, array $context) use (&$logged): void {
+ $logged[] = [$message, $context];
+ }
+ );
+
+ $failure = null;
+ $this->service->method('upload')->willReturnCallback(function () use (&$failure) { throw $failure; });
+ $this->service->method('uploadRaw')->willReturnCallback(function () use (&$failure) { throw $failure; });
+
+ $statuses = [];
+ foreach ([new \OCA\LaunchPad\Exception\StorageFailureException(message: 'disk full'), new \OCA\LaunchPad\Exception\FileTooLargeException(), new \RuntimeException('boom')] as $failure) {
+ $statuses[] = $this->buildController('{"base64":"x"}')->upload()->getStatus();
+ $statuses[] = $this->buildMultipartController(['name' => 'a.png', 'bytes' => 'x'])->uploadMultipart()->getStatus();
+ }
+
+ $this->assertSame([500, 500, 400, 400, 500, 500], $statuses);
+ $this->assertSame(
+ [
+ ['Resource upload storage failure', ['exception' => 'disk full']],
+ ['Resource multipart upload storage failure', ['exception' => 'disk full']],
+ ['Unexpected resource upload failure', ['exception' => 'boom']],
+ ['Unexpected resource multipart upload failure', ['exception' => 'boom']],
+ ],
+ $logged
+ );
+ }
}//end class
diff --git a/tests/Unit/Controller/ResourceServeControllerTest.php b/tests/Unit/Controller/ResourceServeControllerTest.php
index 772e5d46a..44876c9a8 100644
--- a/tests/Unit/Controller/ResourceServeControllerTest.php
+++ b/tests/Unit/Controller/ResourceServeControllerTest.php
@@ -21,6 +21,7 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\ServeTools;
use OCA\LaunchPad\Controller\ResourceServeController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\ResourceServeService;
@@ -74,10 +75,12 @@ protected function setUp(): void {
$this->controller = new ResourceServeController(
request: $this->request,
- serve: $this->serve,
+ serving: new ServeTools(
+ serve: $this->serve,
+ logger: $this->logger
+ ),
actionAuth: $this->actionAuth,
- logger: $this->logger,
- userSession: $this->userSession,
+ userSession: $this->userSession
);
}
@@ -304,4 +307,43 @@ public function testListWithNoFolderReturnsEmptyArray(): void {
$this->assertSame('success', $body['status']);
$this->assertSame([], $body['resources']);
}
+
+ /**
+ * The 401, the 403 of a refused action and the 404 of an unreadable
+ * file, for both endpoints where they apply.
+ *
+ * @return void
+ */
+ public function testRefusalsAndUnreadableFile(): void {
+ $this->actionAuth->method('requireAction')->willReturnCallback(static function ($user, string $action): void {
+ if ($action === 'resource-serve.list-resources') {
+ throw new \OCP\AppFramework\OCS\OCSForbiddenException('no');
+ }
+ });
+ $file = $this->createMock(ISimpleFile::class);
+ $file->method('getSize')->willReturn(10);
+ $file->method('getContent')->willThrowException(new \RuntimeException('disk'));
+ $this->serve->method('findFile')->willReturn($file);
+ $warnings = [];
+ $this->logger->method('warning')->willReturnCallback(function (string $message, array $context) use (&$warnings): void {
+ $warnings[] = [$message, $context];
+ });
+
+ $unreadable = $this->controller->getResource(filename: 'a.png');
+ $this->assertSame(404, $unreadable->getStatus());
+ $this->assertSame([['Resource serve failed to read bytes', ['exception' => 'disk']]], $warnings);
+ $this->assertSame([403, ['error' => 'Forbidden']], [$this->controller->listResources()->getStatus(), $this->controller->listResources()->getData()]);
+
+ $anonymous = $this->createMock(IUserSession::class);
+ $anonymous->method('getUser')->willReturn(null);
+ $controller = new ResourceServeController(
+ request: $this->request,
+ serving: new ServeTools(serve: $this->serve, logger: $this->logger),
+ actionAuth: $this->actionAuth,
+ userSession: $anonymous
+ );
+ foreach ([$controller->getResource(filename: 'a.png'), $controller->listResources()] as $response) {
+ $this->assertSame([401, ['error' => 'Not authenticated']], [$response->getStatus(), $response->getData()]);
+ }
+ }
}
diff --git a/tests/Unit/Controller/ResponseHelperEnvelopeGoldenTest.php b/tests/Unit/Controller/ResponseHelperEnvelopeGoldenTest.php
new file mode 100644
index 000000000..609b35b91
--- /dev/null
+++ b/tests/Unit/Controller/ResponseHelperEnvelopeGoldenTest.php
@@ -0,0 +1,153 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Tests\Unit\Controller;
+
+use OCA\LaunchPad\Controller\RespondsWithErrors;
+use OCA\LaunchPad\Controller\ResponseHelper;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Pins the error envelopes of RespondsWithErrors and of
+ * ResponseHelper::invalidArgument() to the responses the controllers built
+ * inline before, for every message and status the controllers pass. The
+ * fixture holds the inline responses, recorded with LAUNCHPAD_GOLDEN_RECORD=1.
+ *
+ * @covers \OCA\LaunchPad\Controller\ResponseHelper
+ * @covers \OCA\LaunchPad\Controller\RespondsWithErrors
+ */
+class ResponseHelperEnvelopeGoldenTest extends TestCase {
+ use RespondsWithErrors;
+
+ private const FIXTURE = __DIR__ . '/fixtures/response-helper-envelopes.json';
+
+ private const CASES = __DIR__ . '/fixtures/response-helper-envelope-cases.json';
+
+ /**
+ * The recorded call-site cases.
+ *
+ * @return array
+ */
+ public static function cases(): array {
+ $cases = [];
+ foreach (json_decode((string)file_get_contents(self::CASES), true) as [$kind, $value, $status]) {
+ $cases[$kind . ' ' . $status . ' ' . $value] = [$kind, $value, $status];
+ }
+
+ return $cases;
+ }
+
+ /**
+ * The helper answers exactly what the inline construction answered.
+ *
+ * @param string $kind failure, errorCode, badRequest or invalidArgument.
+ * @param string $value The message, the code, or `code|message`.
+ * @param string $status The Http status constant name.
+ *
+ * @return void
+ */
+ #[DataProvider('cases')]
+ public function testEnvelopeMatchesTheInlineResponse(string $kind, string $value, string $status): void {
+ $key = $kind . ' ' . $status . ' ' . $value;
+ $statusCode = (int)constant(Http::class . '::' . $status);
+ if (getenv('LAUNCHPAD_GOLDEN_RECORD') === '1') {
+ $recorded = is_file(self::FIXTURE) ? json_decode((string)file_get_contents(self::FIXTURE), true) : [];
+ $recorded[$key] = $this->shape($this->inline(kind: $kind, value: $value, statusCode: $statusCode));
+ ksort($recorded);
+ file_put_contents(self::FIXTURE, json_encode($recorded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n");
+ $this->addToAssertionCount(1);
+ return;
+ }
+
+ $expected = json_decode((string)file_get_contents(self::FIXTURE), true)[$key] ?? null;
+ $this->assertNotNull($expected, 'no recording for ' . $key);
+ $this->assertSame($expected, $this->shape($this->helper(kind: $kind, value: $value, statusCode: $statusCode)));
+ }
+
+ /**
+ * The response as the controllers built it inline.
+ *
+ * @param string $kind The helper kind.
+ * @param string $value The case value.
+ * @param int $statusCode The status.
+ *
+ * @return JSONResponse The response.
+ */
+ private function inline(string $kind, string $value, int $statusCode): JSONResponse {
+ if ($kind === 'invalidArgument') {
+ return ResponseHelper::invalidArgument(message: $value);
+ }
+
+ if ($kind === 'failure') {
+ return new JSONResponse(data: ['error' => $value], statusCode: $statusCode);
+ }
+
+ if ($kind === 'errorCode') {
+ return new JSONResponse(data: ['status' => 'error', 'error' => $value], statusCode: $statusCode);
+ }
+
+ [$code, $message] = explode('|', $value, 2);
+ return new JSONResponse(data: ['status' => 'error', 'error' => $code, 'message' => $message], statusCode: $statusCode);
+ }
+
+ /**
+ * The response from the helper.
+ *
+ * @param string $kind The helper kind.
+ * @param string $value The case value.
+ * @param int $statusCode The status.
+ *
+ * @return JSONResponse The response.
+ */
+ private function helper(string $kind, string $value, int $statusCode): JSONResponse {
+ if ($kind === 'invalidArgument') {
+ return ResponseHelper::invalidArgument(message: $value);
+ }
+
+ if ($kind === 'failure') {
+ return $this->failure(message: $value, statusCode: $statusCode);
+ }
+
+ if ($kind === 'errorCode') {
+ return $this->errorCode(code: $value, statusCode: $statusCode);
+ }
+
+ [$code, $message] = explode('|', $value, 2);
+ return $this->badRequestWithCode(code: $code, message: $message);
+ }
+
+ /**
+ * What a client sees of a response: its class, status and body.
+ *
+ * @param JSONResponse $response The response.
+ *
+ * @return array The shape.
+ */
+ private function shape(JSONResponse $response): array {
+ return [
+ 'class' => $response::class,
+ 'status' => $response->getStatus(),
+ 'data' => $response->getData(),
+ 'body' => $response->render(),
+ ];
+ }
+}
diff --git a/tests/Unit/Controller/RoleFeaturePermissionApiControllerTest.php b/tests/Unit/Controller/RoleFeaturePermissionApiControllerTest.php
index 46029532a..8c392ad0c 100644
--- a/tests/Unit/Controller/RoleFeaturePermissionApiControllerTest.php
+++ b/tests/Unit/Controller/RoleFeaturePermissionApiControllerTest.php
@@ -313,4 +313,88 @@ public function testSaveLayoutDefaultReturns400OnMissingFields(): void {
$this->assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus());
}//end testSaveLayoutDefaultReturns400OnMissingFields()
+
+ /**
+ * Every answer of the save and delete endpoints, payload forwarded as
+ * given, for both permission rows and layout defaults.
+ *
+ * @return void
+ */
+ public function testSaveAndDeleteAnswersForBothResources(): void {
+ $this->request->method('getParams')->willReturn(['groupId' => 'g']);
+ $permission = $this->makePermission(groupId: 'g', id: 4);
+ $layout = $this->makeLayoutDefault(groupId: 'g', id: 5);
+ $saves = [$permission, new \InvalidArgumentException('bad'), new \RuntimeException('boom')];
+ $layouts = [$layout, new \InvalidArgumentException('bad'), new \RuntimeException('boom')];
+ $next = static function (array &$queue) {
+ $answer = array_shift($queue);
+ if ($answer instanceof \Throwable) {
+ throw $answer;
+ }
+
+ return $answer;
+ };
+ $this->service->method('savePermission')->with(['groupId' => 'g'])->willReturnCallback(function () use (&$saves, $next) {
+ return $next($saves);
+ });
+ $this->service->method('saveLayoutDefault')->with(['groupId' => 'g'])->willReturnCallback(function () use (&$layouts, $next) {
+ return $next($layouts);
+ });
+ $this->service->method('deletePermission')->willReturnCallback(static function (int $id): void {
+ if ($id === 9) {
+ throw new \OCP\AppFramework\Db\DoesNotExistException('gone');
+ }
+ });
+ $this->service->method('deleteLayoutDefault')->willReturnCallback(static function (int $id): void {
+ if ($id === 9) {
+ throw new \OCP\AppFramework\Db\DoesNotExistException('gone');
+ }
+ });
+
+ $answers = [];
+ foreach (['savePermission', 'saveLayoutDefault'] as $save) {
+ for ($i = 0; $i < 3; $i++) {
+ $response = $this->controller->$save();
+ $answers[] = [$response->getStatus(), $response->getData()];
+ }
+ }
+
+ foreach (['deletePermission', 'deleteLayoutDefault'] as $delete) {
+ foreach ([1, 9] as $id) {
+ $response = $this->controller->$delete(id: $id);
+ $answers[] = [$response->getStatus(), $response->getData()];
+ }
+ }
+
+ $this->assertSame(
+ json_encode([
+ [201, $permission->jsonSerialize()],
+ [400, ResponseHelperProbe::error(new \InvalidArgumentException('bad'), 400)],
+ [\OCA\LaunchPad\Controller\ResponseHelper::error(exception: new \RuntimeException('boom'))->getStatus(), ResponseHelperProbe::error(new \RuntimeException('boom'), 500)],
+ [201, $layout->jsonSerialize()],
+ [400, ResponseHelperProbe::error(new \InvalidArgumentException('bad'), 400)],
+ [\OCA\LaunchPad\Controller\ResponseHelper::error(exception: new \RuntimeException('boom'))->getStatus(), ResponseHelperProbe::error(new \RuntimeException('boom'), 500)],
+ [204, []],
+ [404, ResponseHelperProbe::error(new \OCP\AppFramework\Db\DoesNotExistException('gone'), 404)],
+ [204, []],
+ [404, ResponseHelperProbe::error(new \OCP\AppFramework\Db\DoesNotExistException('gone'), 404)],
+ ]),
+ json_encode($answers)
+ );
+ }//end testSaveAndDeleteAnswersForBothResources()
}//end class
+
+/**
+ * The body ResponseHelper::error answers with, for comparison.
+ */
+final class ResponseHelperProbe {
+ /**
+ * @param \Throwable $e The failure.
+ * @param int $status The status.
+ *
+ * @return mixed
+ */
+ public static function error(\Throwable $e, int $status): mixed {
+ return \OCA\LaunchPad\Controller\ResponseHelper::error(exception: $e, statusCode: $status)->getData();
+ }
+}
diff --git a/tests/Unit/Controller/SetupControllerTest.php b/tests/Unit/Controller/SetupControllerTest.php
index 0b81875af..bb97f5edd 100644
--- a/tests/Unit/Controller/SetupControllerTest.php
+++ b/tests/Unit/Controller/SetupControllerTest.php
@@ -38,23 +38,39 @@ protected function setUp(): void {
);
}
- public function testStatusReportsBothDemoDataSteps(): void {
+ public function testStatusReportsEveryManifestStepId(): void {
$this->appConfig->method('getValueString')->willReturn('');
$this->demoData->method('listChoices')->willReturn([]);
$data = $this->controller->status()->getData();
// Absence is the defect this guards: a step the wizard is never told
- // about cannot be offered and cannot be completed.
- $this->assertArrayHasKey('demo-data', $data['steps']);
- $this->assertArrayHasKey('load-demo-data', $data['steps']);
+ // about stays open, and an open step reopens the wizard on every page.
+ // So the ids are read from the manifest the wizard renders, not listed
+ // here by hand.
+ $manifest = json_decode((string)file_get_contents(__DIR__ . '/../../../src/manifest.json'), true);
+ $stepIds = array_column($manifest['setup']['steps'], 'id');
+ $reported = array_values(array_keys($data['steps']));
+ sort($stepIds);
+ sort($reported);
+ $this->assertSame($stepIds, $reported);
+
$this->assertFalse($data['steps']['demo-data']['done']);
- $this->assertFalse($data['steps']['load-demo-data']['done']);
// This app declares no REQUIRED step, so setup must never gate the app.
$this->assertTrue($data['completed']);
$this->assertSame(1, $data['version']);
}
+ public function testTheDatasetStepLoadsFromItsCards(): void {
+ // One cards step with `loadAction` replaces the choice step plus the
+ // run-action step that loaded the pick (wizard-dataset-card-load).
+ $manifest = json_decode((string)file_get_contents(__DIR__ . '/../../../src/manifest.json'), true);
+ $steps = array_column($manifest['setup']['steps'], null, 'id');
+
+ $this->assertSame('load-demo-data', $steps['demo-data']['loadAction'] ?? null);
+ $this->assertArrayNotHasKey('load-demo-data', $steps);
+ }
+
public function testStatusCarriesTheOptionListTheChoiceStepReads(): void {
// 🔴 THIS RESPONSE *IS* THE OPTION LIST. The step declares
// `optionsSource: datasets` and carries no options of its own, so a
@@ -74,7 +90,7 @@ public function testStatusCarriesTheOptionListTheChoiceStepReads(): void {
$this->assertSame('DatabaseOutline', $data['datasets'][1]['icon']);
}
- public function testChoosingNoneClosesBothStepsWithoutRunningAnything(): void {
+ public function testChoosingNoneClosesTheStepWithoutRunningAnything(): void {
// 🔴 THE DEFECT THIS FIXES. Every app in this fleet implemented
// `skip-demo-data` and NO manifest step could reach it, so declining was
// unsayable: the step stayed `done: false` and CnAppRoot reopened the
@@ -88,7 +104,7 @@ public function testChoosingNoneClosesBothStepsWithoutRunningAnything(): void {
$data = $this->controller->status()->getData();
$this->assertTrue($data['steps']['demo-data']['done']);
- $this->assertTrue($data['steps']['load-demo-data']['done']);
+ $this->assertArrayNotHasKey('load-demo-data', $data['steps']);
}
public function testTheChoiceIsPersisted(): void {
@@ -250,12 +266,18 @@ public function testLoadReportsHowMuchLanded(): void {
$this->demoData->method('install')
->willReturn(['objects' => 30, 'registers' => 1, 'schemas' => 4]);
- $this->appConfig->expects($this->once())
- ->method('setValueString')
- ->with('launchpad', 'demo_data_decided', 'installed');
+ $written = [];
+ $this->appConfig->method('setValueString')
+ ->willReturnCallback(static function (string $app, string $key, string $value) use (&$written): bool {
+ $written[$key] = $value;
+
+ return true;
+ });
$data = $this->controller->runAction('load-demo-data')->getData();
+ $this->assertSame(['demo_dataset' => 'demo', 'demo_data_decided' => 'installed'], $written);
+
$this->assertTrue($data['success']);
// A success message that names no count cannot be told apart from an
// import that wrote nothing — the defect this programme already shipped.
@@ -280,4 +302,75 @@ public function testAFailedLoadIsReportedAndLeavesTheStepUNDECIDED(): void {
$this->assertFalse($response->getData()['success']);
$this->assertStringContainsString('OpenRegister is not installed.', $response->getData()['message']);
}
+
+ /**
+ * Build a controller whose request carries the given body params.
+ *
+ * @param array $params The posted body.
+ *
+ * @return SetupController
+ */
+ private function controllerPosting(array $params): SetupController {
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')
+ ->willReturnCallback(static fn (string $key) => ($params[$key] ?? null));
+
+ return new SetupController($request, $this->appConfig, $this->logger, $this->demoData);
+ }
+
+ public function testTheCardPostsItsDatasetAndTheLoadRecordsTheChoice(): void {
+ // The card's Load button posts `{ dataset }` to the step's
+ // `loadAction`. Nothing was stored before: the card IS the choice.
+ $this->appConfig->method('getValueString')->willReturn('');
+ $this->demoData->method('listChoices')->willReturn([
+ ['id' => 'none', 'label' => 'None', 'description' => '', 'objectCount' => 0, 'icon' => ''],
+ ['id' => 'demo', 'label' => 'Example data', 'description' => '', 'objectCount' => 66, 'icon' => ''],
+ ]);
+ $this->demoData->expects($this->once())->method('install')
+ ->willReturn(['objects' => 66, 'registers' => 1, 'schemas' => 4]);
+
+ $written = [];
+ $this->appConfig->method('setValueString')
+ ->willReturnCallback(static function (string $app, string $key, string $value) use (&$written): bool {
+ $written[$key] = $value;
+
+ return true;
+ });
+
+ $data = $this->controllerPosting(['dataset' => 'demo'])->runAction('load-demo-data')->getData();
+
+ $this->assertTrue($data['success']);
+ $this->assertStringContainsString('66', $data['message']);
+ $this->assertSame(['demo_dataset' => 'demo', 'demo_data_decided' => 'installed'], $written);
+ }
+
+ public function testAnUnknownPostedDatasetIsRefusedAndNothingLoads(): void {
+ $this->appConfig->method('getValueString')->willReturn('demo');
+ $this->demoData->method('listChoices')->willReturn([
+ ['id' => 'none', 'label' => 'None', 'description' => '', 'objectCount' => 0, 'icon' => ''],
+ ['id' => 'demo', 'label' => 'Example data', 'description' => '', 'objectCount' => 66, 'icon' => ''],
+ ]);
+ $this->demoData->expects($this->never())->method('install');
+ $this->appConfig->expects($this->never())->method('setValueString');
+
+ $response = $this->controllerPosting(['dataset' => 'atlantis'])->runAction('load-demo-data');
+
+ $this->assertSame(400, $response->getStatus());
+ $this->assertStringContainsString('atlantis', $response->getData()['message']);
+ }
+
+ public function testAFailedCardLoadStoresNothing(): void {
+ // The pick is recorded only after a successful load, so a failed card
+ // leaves the step open for an operator who asked for data.
+ $this->appConfig->method('getValueString')->willReturn('');
+ $this->demoData->method('listChoices')->willReturn([
+ ['id' => 'demo', 'label' => 'Example data', 'description' => '', 'objectCount' => 66, 'icon' => ''],
+ ]);
+ $this->demoData->method('install')->willThrowException(new RuntimeException('OpenRegister is not installed.'));
+ $this->appConfig->expects($this->never())->method('setValueString');
+
+ $response = $this->controllerPosting(['dataset' => 'demo'])->runAction('load-demo-data');
+
+ $this->assertSame(500, $response->getStatus());
+ }
}
diff --git a/tests/Unit/Controller/TileApiControllerTest.php b/tests/Unit/Controller/TileApiControllerTest.php
index 53547ad78..4ad8149cc 100644
--- a/tests/Unit/Controller/TileApiControllerTest.php
+++ b/tests/Unit/Controller/TileApiControllerTest.php
@@ -21,9 +21,12 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\ActionCaller;
+use OCA\LaunchPad\Controller\TileSources;
use OCA\LaunchPad\Controller\TileApiController;
use OCA\LaunchPad\Db\Tile;
use OCA\LaunchPad\Service\ActionAuthService;
+use OCA\LaunchPad\Service\ServiceDiscoveryService;
use OCA\LaunchPad\Service\TileService;
use OCP\AppFramework\Http;
use OCP\IL10N;
@@ -44,6 +47,8 @@ class TileApiControllerTest extends TestCase {
private ActionAuthService $actionAuth;
/** @var IUserSession&MockObject */
private IUserSession $userSession;
+ /** @var ServiceDiscoveryService&MockObject */
+ private ServiceDiscoveryService $discovery;
protected function setUp(): void {
$this->tileService = $this->createMock(TileService::class);
@@ -51,6 +56,7 @@ protected function setUp(): void {
$this->l10n = $this->createMock(IL10N::class);
$this->actionAuth = $this->createMock(ActionAuthService::class);
$this->userSession = $this->createMock(IUserSession::class);
+ $this->discovery = $this->createMock(ServiceDiscoveryService::class);
$this->l10n->method('t')->willReturnArgument(0);
}
@@ -65,14 +71,64 @@ private function makeController(?string $userId = 'alice'): TileApiController {
return new TileApiController(
request: $this->request,
- tileService: $this->tileService,
- actionAuth: $this->actionAuth,
- userSession: $this->userSession,
- l10n: $this->l10n,
- userId: $userId,
+ sources: new TileSources(
+ tileService: $this->tileService,
+ l10n: $this->l10n,
+ discovery: $this->discovery
+ ),
+ caller: new ActionCaller(
+ actionAuth: $this->actionAuth,
+ userSession: $this->userSession
+ ),
+ userId: $userId
);
}
+ /**
+ * REQ-DISC-001: the caller's own discovered apps, behind tile.discover.
+ *
+ * @spec openspec/changes/launcher-discover-server-services/specs/tiles/spec.md
+ */
+ public function testDiscoverAnswersTheCallersApps(): void {
+ $apps = [['appId' => 'deck', 'name' => 'Deck', 'href' => '/apps/deck/', 'icon' => '/apps/deck/img/deck.svg']];
+ $this->discovery->method('discover')->willReturn($apps);
+ $this->actionAuth->expects($this->once())->method('requireAction')
+ ->with($this->isInstanceOf(IUser::class), 'tile.discover');
+
+ $response = $this->makeController()->discover();
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $this->assertSame(['apps' => $apps], $response->getData());
+ }
+
+ /**
+ * Without the tile.discover action the endpoint refuses before discovering.
+ *
+ * @spec openspec/changes/launcher-discover-server-services/specs/tiles/spec.md
+ */
+ public function testDiscoverRefusesWithoutTheAction(): void {
+ $this->actionAuth->method('requireAction')
+ ->willThrowException(new \OCP\AppFramework\OCS\OCSForbiddenException());
+ $this->discovery->expects($this->never())->method('discover');
+
+ $response = $this->makeController()->discover();
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ /**
+ * An anonymous caller is refused.
+ *
+ * @spec openspec/changes/launcher-discover-server-services/specs/tiles/spec.md
+ */
+ public function testDiscoverRefusesAnonymous(): void {
+ $this->discovery->expects($this->never())->method('discover');
+
+ $response = $this->makeController(userId: null)->discover();
+
+ $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus());
+ }
+
public function testCreateReturns410GoneEnvelope(): void {
// Was: `expects($this->never())->method('createTile')`. The write
// methods no longer exist on TileService — a mock expectation on a
diff --git a/tests/Unit/Controller/VisibilityPreviewControllerTest.php b/tests/Unit/Controller/VisibilityPreviewControllerTest.php
index 1c16992bc..07b93e5fb 100644
--- a/tests/Unit/Controller/VisibilityPreviewControllerTest.php
+++ b/tests/Unit/Controller/VisibilityPreviewControllerTest.php
@@ -297,4 +297,40 @@ public function testPreviewVerdictMatchesRenderTimeVerdictForIdenticalInputs():
$this->assertTrue($renderTimeVisible);
$this->assertSame($renderTimeVisible, $previewVisible);
}//end testPreviewVerdictMatchesRenderTimeVerdictForIdenticalInputs()
+
+ /**
+ * Rule ids default to the entry's position, `isInclude` defaults to
+ * true, and every refusal message of a malformed entry.
+ *
+ * @return void
+ */
+ public function testRuleIdsDefaultsAndRefusals(): void {
+ $controller = $this->makeController();
+ $response = $controller->preview(
+ rules: [
+ ['id' => '7', 'ruleType' => 'group', 'ruleConfig' => ['groups' => ['marketing']]],
+ ['ruleType' => 'group', 'ruleConfig' => ['groups' => ['marketing']]],
+ ['ruleType' => 'group', 'ruleConfig' => ['groups' => ['marketing']], 'isInclude' => false],
+ ],
+ context: ['groups' => ['marketing'], 'datetime' => '2026-07-23T14:30']
+ );
+ $data = $response->getData();
+ $this->assertSame([7, 1], $data['matchedIncludeRuleIds']);
+ $this->assertSame([2], $data['matchedExcludeRuleIds']);
+
+ $refusals = [];
+ foreach ([['x'], [['ruleType' => 7, 'ruleConfig' => []]], [['ruleType' => 'group', 'ruleConfig' => 'x']]] as $rules) {
+ $answer = $controller->preview(rules: $rules, context: []);
+ $refusals[] = [$answer->getStatus(), $answer->getData()];
+ }
+
+ $this->assertSame(
+ [
+ [400, ['error' => 'Each entry in rules must be an object']],
+ [400, ['error' => 'Invalid ruleType: only group, time, date and attribute are accepted']],
+ [400, ['error' => 'ruleConfig must be an object']],
+ ],
+ $refusals
+ );
+ }//end testRuleIdsDefaultsAndRefusals()
}//end class
diff --git a/tests/Unit/Controller/WeatherControllerTest.php b/tests/Unit/Controller/WeatherControllerTest.php
index eaed23f3d..79f903212 100644
--- a/tests/Unit/Controller/WeatherControllerTest.php
+++ b/tests/Unit/Controller/WeatherControllerTest.php
@@ -20,6 +20,7 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\SessionLog;
use OCA\LaunchPad\Controller\WeatherController;
use OCA\LaunchPad\Service\PermissionService;
use OCA\LaunchPad\Service\WeatherService;
@@ -55,8 +56,7 @@ private function controller(): WeatherController {
$this->request,
$this->weatherService,
$this->permissionService,
- $this->userSession,
- $this->logger,
+ new SessionLog($this->userSession, $this->logger),
);
}//end controller()
diff --git a/tests/Unit/Controller/WidgetApiControllerDataEndpointsTest.php b/tests/Unit/Controller/WidgetApiControllerDataEndpointsTest.php
index 306f8bac1..73000b91d 100644
--- a/tests/Unit/Controller/WidgetApiControllerDataEndpointsTest.php
+++ b/tests/Unit/Controller/WidgetApiControllerDataEndpointsTest.php
@@ -40,6 +40,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\WidgetApiPolicy;
+use OCA\LaunchPad\Controller\WidgetApiSources;
+use OCA\LaunchPad\Controller\RequestActor;
use DateTimeImmutable;
use Exception;
use OCA\LaunchPad\Controller\WidgetApiController;
@@ -150,16 +153,22 @@ private function makeController(?string $userId): WidgetApiController {
return new WidgetApiController(
request: $this->request,
- widgetService: $this->widgetService,
+ sources: new WidgetApiSources(
+ widgetService: $this->widgetService,
+ newsWidgetService: $this->newsWidgetService,
+ calendarService: $this->calendarService,
+ placementService: $this->createMock(WidgetPlacementService::class)
+ ),
permissionService: $this->permissionService,
- newsWidgetService: $this->newsWidgetService,
- calendarService: $this->calendarService,
- placementService: $this->createMock(WidgetPlacementService::class),
- roleFeaturePerm: $this->createMock(RoleFeaturePermissionService::class),
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- logger: $this->createMock(LoggerInterface::class),
- userId: $userId,
+ policy: new WidgetApiPolicy(
+ roleFeaturePerm: $this->createMock(RoleFeaturePermissionService::class),
+ logger: $this->createMock(LoggerInterface::class)
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: $userId
);
}//end makeController()
@@ -667,4 +676,53 @@ public function testCalendarsHidesBackendFailureDetail(): void {
}//end testCalendarsHidesBackendFailureDetail()
-}//end class
+ /**
+ * The calendar config read from every placement shape, and the range
+ * refusals with their messages.
+ *
+ * @return void
+ */
+ public function testCalendarConfigAndRangeParsing(): void {
+ $controller = $this->makeController('alice');
+ $extract = new \ReflectionMethod(WidgetApiController::class, 'extractCalendarConfig');
+ $withJson = $this->makePlacement(content: ['internalCalendars' => ['a', '', 7], 'externalIcsUrls' => ['', 'https://x.test/a.ics'], 'viewMode' => 'week', 'daysAhead' => '9', 'colorByCalendar' => 0]);
+ $badJson = new WidgetPlacement();
+ $badJson->setContent('{oops');
+ $plain = new class {
+ public string $content = 'ignored';
+ };
+
+ $this->assertSame(
+ [
+ ['internalCalendars' => ['a', '7'], 'externalIcsUrls' => ['https://x.test/a.ics'], 'viewMode' => 'week', 'daysAhead' => 9, 'colorByCalendar' => false],
+ ['internalCalendars' => [], 'externalIcsUrls' => [], 'viewMode' => 'agenda', 'daysAhead' => 14, 'colorByCalendar' => true],
+ ['internalCalendars' => [], 'externalIcsUrls' => [], 'viewMode' => 'agenda', 'daysAhead' => 14, 'colorByCalendar' => true],
+ ],
+ [$extract->invoke($controller, $withJson), $extract->invoke($controller, $badJson), $extract->invoke($controller, $plain)]
+ );
+
+ $range = new \ReflectionMethod(WidgetApiController::class, 'resolveCalendarRange');
+ $answer = static function (array $result): array {
+ if ($result['error'] !== null) {
+ return [$result['error']->getStatus(), $result['error']->getData(), $result['start'], $result['end']];
+ }
+ return [null, $result['start']->format('c'), $result['end']->format('c')];
+ };
+ $this->assertSame(
+ [
+ [400, ['error' => 'Both from and to are required ISO 8601 timestamps'], null, null],
+ [400, ['error' => 'Invalid date format'], null, null],
+ [400, ['error' => '`to` must be greater than or equal to `from`'], null, null],
+ [null, '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00'],
+ [null, '2026-01-01T00:00:00+00:00', '2027-01-01T00:00:00+00:00'],
+ ],
+ [
+ $answer($range->invoke($controller, '', '2026-01-01T00:00:00+00:00')),
+ $answer($range->invoke($controller, 'not a date', '2026-01-01T00:00:00+00:00')),
+ $answer($range->invoke($controller, '2026-02-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')),
+ $answer($range->invoke($controller, '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')),
+ $answer($range->invoke($controller, '2026-01-01T00:00:00+00:00', '2029-01-01T00:00:00+00:00')),
+ ]
+ );
+ }//end testCalendarConfigAndRangeParsing()
+}
diff --git a/tests/Unit/Controller/WidgetApiControllerGuardGoldenTest.php b/tests/Unit/Controller/WidgetApiControllerGuardGoldenTest.php
new file mode 100644
index 000000000..9cb8522cc
--- /dev/null
+++ b/tests/Unit/Controller/WidgetApiControllerGuardGoldenTest.php
@@ -0,0 +1,174 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\LaunchPad\Controller\WidgetApiController;
+use OCA\LaunchPad\Controller\RequestActor;
+use OCA\LaunchPad\Service\ActionAuthService;
+use OCP\AppFramework\OCS\OCSForbiddenException;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionNamedType;
+use Throwable;
+
+/**
+ * Refused and allowed outcomes of the widget API caller guard.
+ */
+class WidgetApiControllerGuardGoldenTest extends TestCase {
+
+ private const FIXTURE = __DIR__.'/fixtures/widget-api-guard.json';
+
+ private const SCENARIOS = ['no session user', 'action denied', 'no user id', 'allowed'];
+
+ /**
+ * Actions asked of the action guard in the current call.
+ *
+ * @var string[]
+ */
+ private array $asked = [];
+
+ /**
+ * Every gated endpoint in every scenario gives the recorded outcome.
+ *
+ * @return void
+ */
+ public function testGuardOutcomes(): void {
+ $actual = [];
+ $lines = file((string) (new ReflectionClass(WidgetApiController::class))->getFileName());
+ foreach ((new ReflectionClass(WidgetApiController::class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->getDeclaringClass()->getName() !== WidgetApiController::class || $method->isConstructor() === true) {
+ continue;
+ }
+ $body = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
+ if (preg_match('/requireAction|Not authenticated|actionRefusal\(|deny[A-Z]/', $body) !== 1) {
+ continue;
+ }
+ foreach (self::SCENARIOS as $scenario) {
+ $actual[$method->getName().' / '.$scenario] = $this->outcome(method: $method, scenario: $scenario);
+ }
+ }
+
+ if (getenv('LAUNCHPAD_RECORD_GOLDEN') === '1') {
+ file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ $this->markTestIncomplete('Fixture recorded.');
+ }
+
+ $this->assertSame(json_decode((string) file_get_contents(self::FIXTURE), true), $actual);
+ }//end testGuardOutcomes()
+
+ /**
+ * Call one endpoint in one scenario.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ * @param string $scenario The caller situation.
+ *
+ * @return array Status and body (or the thrown class) plus the asked actions.
+ */
+ private function outcome(ReflectionMethod $method, string $scenario): array {
+ $this->asked = [];
+ $controller = $this->controller(scenario: $scenario);
+ try {
+ $response = $method->invokeArgs($controller, $this->arguments(method: $method));
+ $result = ['status' => $response->getStatus()];
+ if ($scenario !== 'allowed') {
+ $result['body'] = $response->getData();
+ }
+ } catch (Throwable $e) {
+ $result = ['thrown' => get_class($e)];
+ }
+
+ $result['asked'] = $this->asked;
+ return $result;
+ }//end outcome()
+
+ /**
+ * Argument values for an endpoint: defaults where declared, else a plain value per type.
+ *
+ * @param ReflectionMethod $method The endpoint.
+ *
+ * @return array
+ */
+ private function arguments(ReflectionMethod $method): array {
+ $args = [];
+ foreach ($method->getParameters() as $param) {
+ if ($param->isDefaultValueAvailable() === true) {
+ $args[] = $param->getDefaultValue();
+ continue;
+ }
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match ($name) {
+ 'int' => 7,
+ 'string' => 'uuid-a',
+ 'array' => [],
+ 'bool' => true,
+ default => null,
+ };
+ }
+
+ return $args;
+ }//end arguments()
+
+ /**
+ * The controller with every collaborator mocked and the caller set up for the scenario.
+ *
+ * @param string $scenario The caller situation.
+ *
+ * @return WidgetApiController
+ */
+ private function controller(string $scenario): WidgetApiController {
+ $session = $this->createMock(IUserSession::class);
+ $user = null;
+ if ($scenario !== 'no session user') {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ }
+ $session->method('getUser')->willReturn($user);
+
+ $auth = $this->createMock(ActionAuthService::class);
+ $auth->method('requireAction')->willReturnCallback(
+ function (IUser $user, string $action) use ($scenario): void {
+ $this->asked[] = $action;
+ if ($scenario === 'action denied') {
+ throw new OCSForbiddenException('denied');
+ }
+ }
+ );
+
+ $args = [];
+ foreach ((new ReflectionClass(WidgetApiController::class))->getConstructor()->getParameters() as $param) {
+ $type = $param->getType();
+ $name = ($type instanceof ReflectionNamedType) ? $type->getName() : 'mixed';
+ $args[] = match (true) {
+ $param->getName() === 'userId' => ($scenario === 'no user id') ? null : 'alice',
+ $name === RequestActor::class => new RequestActor(userSession: $session, actionAuth: $auth),
+ default => $this->createMock($name),
+ };
+ }
+
+ return new WidgetApiController(...$args);
+ }//end controller()
+}//end class
diff --git a/tests/Unit/Controller/WidgetApiControllerRoleTest.php b/tests/Unit/Controller/WidgetApiControllerRoleTest.php
index b0e064887..3f6e587b5 100644
--- a/tests/Unit/Controller/WidgetApiControllerRoleTest.php
+++ b/tests/Unit/Controller/WidgetApiControllerRoleTest.php
@@ -28,6 +28,9 @@
namespace Unit\Controller;
+use OCA\LaunchPad\Controller\WidgetApiPolicy;
+use OCA\LaunchPad\Controller\WidgetApiSources;
+use OCA\LaunchPad\Controller\RequestActor;
use OCA\LaunchPad\Controller\WidgetApiController;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\CalendarWidgetService;
@@ -121,16 +124,22 @@ private function makeController(): WidgetApiController {
return new WidgetApiController(
request: $this->request,
- widgetService: $this->widgetService,
+ sources: new WidgetApiSources(
+ widgetService: $this->widgetService,
+ newsWidgetService: $this->createMock(originalClassName: NewsWidgetService::class),
+ calendarService: $this->createMock(originalClassName: CalendarWidgetService::class),
+ placementService: $this->createMock(originalClassName: WidgetPlacementService::class)
+ ),
permissionService: $this->createMock(originalClassName: PermissionService::class),
- newsWidgetService: $this->createMock(originalClassName: NewsWidgetService::class),
- calendarService: $this->createMock(originalClassName: CalendarWidgetService::class),
- placementService: $this->createMock(originalClassName: WidgetPlacementService::class),
- roleFeaturePerm: $this->roleFeaturePerm,
- userSession: $this->userSession,
- actionAuth: $this->actionAuth,
- logger: $this->logger,
- userId: 'jan',
+ policy: new WidgetApiPolicy(
+ roleFeaturePerm: $this->roleFeaturePerm,
+ logger: $this->logger
+ ),
+ actor: new RequestActor(
+ userSession: $this->userSession,
+ actionAuth: $this->actionAuth
+ ),
+ userId: 'jan'
);
}//end makeController()
diff --git a/tests/Unit/Controller/fixtures/admin-bulk-status.json b/tests/Unit/Controller/fixtures/admin-bulk-status.json
new file mode 100644
index 000000000..6001b06db
--- /dev/null
+++ b/tests/Unit/Controller/fixtures/admin-bulk-status.json
@@ -0,0 +1,4599 @@
+{
+ "status \"draft\" at null dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at null dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \" \" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \" \" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"2000-01-01 00:00:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"2000-01-01 00:00:00\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"2099-01-01 10:00:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"2099-01-01 10:00:00\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"2099-01-01T10:00:00+02:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"2099-01-01T10:00:00+02:00\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"garbage\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \"garbage\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \" 2099-01-01 10:00:00 \" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-pub",
+ "draft",
+ null,
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "draft",
+ null,
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "draft",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"draft\" at \" 2099-01-01 10:00:00 \" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-draft",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "status_already_matches"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "draft"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at null dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at null dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \" \" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \" \" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"2000-01-01 00:00:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"2000-01-01 00:00:00\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"2099-01-01 10:00:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"2099-01-01 10:00:00\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"2099-01-01T10:00:00+02:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"2099-01-01T10:00:00+02:00\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"garbage\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \"garbage\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \" 2099-01-01 10:00:00 \" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 3,
+ "skippedCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched",
+ "published",
+ null,
+ "",
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "published",
+ null,
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"published\" at \" 2099-01-01 10:00:00 \" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 4,
+ "wouldSkipCount": 2,
+ "errors": [
+ {
+ "uuid": "d-pub",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-pubnull",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "published"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"scheduled\" at null dry false": {
+ "status": 400,
+ "body": {
+ "error": "publishAt is required when publicationStatus is \"scheduled\""
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at null dry true": {
+ "status": 400,
+ "body": {
+ "error": "publishAt is required when publicationStatus is \"scheduled\""
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at \"\" dry false": {
+ "status": 400,
+ "body": {
+ "error": "publishAt is required when publicationStatus is \"scheduled\""
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at \"\" dry true": {
+ "status": 400,
+ "body": {
+ "error": "publishAt is required when publicationStatus is \"scheduled\""
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at \" \" dry false": {
+ "status": 400,
+ "body": {
+ "error": "publishAt is required when publicationStatus is \"scheduled\""
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at \" \" dry true": {
+ "status": 400,
+ "body": {
+ "error": "publishAt is required when publicationStatus is \"scheduled\""
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at \"2000-01-01 00:00:00\" dry false": {
+ "status": 400,
+ "body": {
+ "error": "publishAt must be a future timestamp"
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at \"2000-01-01 00:00:00\" dry true": {
+ "status": 400,
+ "body": {
+ "error": "publishAt must be a future timestamp"
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail"
+ ]
+ },
+ "status \"scheduled\" at \"2099-01-01 10:00:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 1,
+ "errors": [
+ {
+ "uuid": "d-sched",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-pub",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": false,
+ "publicationStatus": "scheduled"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"scheduled\" at \"2099-01-01 10:00:00\" dry true": {
+ "status": 200,
+ "body": {
+ "wouldUpdateCount": 5,
+ "wouldSkipCount": 1,
+ "errors": [
+ {
+ "uuid": "d-sched",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ }
+ ],
+ "dryRun": true
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "",
+ "dryRun": true,
+ "publicationStatus": "scheduled"
+ },
+ "alice"
+ ]
+ ]
+ },
+ "status \"scheduled\" at \"2099-01-01T10:00:00+02:00\" dry false": {
+ "status": 200,
+ "body": {
+ "updatedCount": 4,
+ "skippedCount": 1,
+ "errors": [
+ {
+ "uuid": "d-sched",
+ "reason": "status_already_matches"
+ },
+ {
+ "uuid": "d-missing",
+ "reason": "not_found"
+ },
+ {
+ "uuid": "d-fail",
+ "reason": "transaction_failed",
+ "detail": "disk full"
+ }
+ ],
+ "dryRun": false
+ },
+ "log": [
+ "isAdmin alice",
+ "isAdmin alice",
+ "resolveAccessLevel alice d-draft",
+ "resolveAccessLevel alice d-pub",
+ "resolveAccessLevel alice d-pubnull",
+ "resolveAccessLevel alice d-sched",
+ "resolveAccessLevel alice d-sched2",
+ "resolveAccessLevel alice d-fail",
+ [
+ "update",
+ "d-draft",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-pub",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ "2020-01-01 00:00:00",
+ ""
+ ],
+ [
+ "update",
+ "d-pubnull",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ null,
+ ""
+ ],
+ [
+ "update",
+ "d-sched2",
+ "scheduled",
+ "2099-01-01 10:00:00",
+ "2019-02-02 00:00:00",
+ ""
+ ],
+ [
+ "error",
+ "BulkOperationService::bulkStatus failed for uuid",
+ "d-fail",
+ "disk full"
+ ],
+ [
+ "publish",
+ "dashboard_updated",
+ "alice",
+ "bulk-status",
+ "Bulk status (7 dashboards)",
+ {
+ "bulkOperation": "status",
+ "dashboardCount": 7,
+ "durationMs": "