+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -185,6 +200,12 @@ export default {
getAllContexts() {
return this.contexts.filter(context => context.name.toLowerCase().includes(this.filterString.toLowerCase()))
},
+ getActiveContexts() {
+ return this.getAllContexts.filter(context => !context.archived)
+ },
+ getArchivedContexts() {
+ return this.getAllContexts.filter(context => context.archived)
+ },
isStandaloneContext() {
try {
// this state is only set by the PageController.context route
diff --git a/src/store/store.js b/src/store/store.js
index ce22251a6c..55007acc41 100644
--- a/src/store/store.js
+++ b/src/store/store.js
@@ -117,7 +117,10 @@ export const useTablesStore = defineStore('store', {
},
setTable(table) {
const index = this.tables.findIndex(t => t.id === table.id)
- this.tables.splice(index, 1, table)
+ if (index === -1) {
+ return
+ }
+ this.tables[index] = table
},
setView(view) {
const index = this.views.findIndex(v => v.id === view.id)
@@ -380,6 +383,66 @@ export const useTablesStore = defineStore('store', {
return true
},
+ /**
+ * Archive or unarchive a table or an application.
+ *
+ * The endpoints answer with the authoritative per-user state, so the
+ * response decides the stored value rather than the requested one.
+ *
+ * @param {object} params parameters
+ * @param {'tables'|'contexts'} params.collection node collection to address
+ * @param {number} params.id node id
+ * @param {boolean} params.archived target state
+ * @return {Promise} whether the call succeeded
+ */
+ async setArchiveState({ collection, id, archived }) {
+ const isTable = collection === 'tables'
+ const url = generateOcsUrl(`/apps/tables/api/2/${collection}/${id}/archive`)
+ const errorMessage = isTable
+ ? (archived ? t('tables', 'Could not archive table.') : t('tables', 'Could not unarchive table.'))
+ : (archived ? t('tables', 'Could not archive application.') : t('tables', 'Could not unarchive application.'))
+
+ let res = null
+ try {
+ res = (await (archived ? axios.post(url) : axios.delete(url))).data.ocs
+ } catch (e) {
+ displayError(e, errorMessage)
+ return false
+ }
+
+ const items = isTable ? this.tables : this.contexts
+ const index = items.findIndex(item => item.id === id)
+ if (index === -1) {
+ return true
+ }
+
+ const item = items[index]
+ item.archived = res.data?.archived ?? archived
+ if (isTable) {
+ this.setTable(item)
+ } else {
+ this.setContext(item)
+ }
+
+ return true
+ },
+
+ async archiveTable({ id }) {
+ return await this.setArchiveState({ collection: 'tables', id, archived: true })
+ },
+
+ async unarchiveTable({ id }) {
+ return await this.setArchiveState({ collection: 'tables', id, archived: false })
+ },
+
+ async archiveContext({ id }) {
+ return await this.setArchiveState({ collection: 'contexts', id, archived: true })
+ },
+
+ async unarchiveContext({ id }) {
+ return await this.setArchiveState({ collection: 'contexts', id, archived: false })
+ },
+
async shareContext({ id, previousReceivers, receivers, displayMode }) {
const share = {
nodeType: 'context',
diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts
index 85437d7fca..e9342e0f66 100644
--- a/src/types/openapi/openapi.ts
+++ b/src/types/openapi/openapi.ts
@@ -673,6 +673,30 @@ export type paths = {
readonly patch?: never;
readonly trace?: never;
};
+ readonly "/ocs/v2.php/apps/tables/api/2/tables/{id}/archive": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header?: never;
+ readonly path?: never;
+ readonly cookie?: never;
+ };
+ readonly get?: never;
+ readonly put?: never;
+ /**
+ * [api v2] Archive a table for the requesting user
+ * @description Owners set the archived default for everyone; a user who made their own choice keeps it. Non-owners archive only for themselves.
+ */
+ readonly post: operations["api_tables-archive-table"];
+ /**
+ * [api v2] Unarchive a table for the requesting user
+ * @description Owners set the unarchived default for everyone; a user who made their own choice keeps it. Non-owners remove only their personal archive override.
+ */
+ readonly delete: operations["api_tables-unarchive-table"];
+ readonly options?: never;
+ readonly head?: never;
+ readonly patch?: never;
+ readonly trace?: never;
+ };
readonly "/ocs/v2.php/apps/tables/api/2/columns/{nodeType}/{nodeId}": {
readonly parameters: {
readonly query?: never;
@@ -933,6 +957,30 @@ export type paths = {
readonly patch?: never;
readonly trace?: never;
};
+ readonly "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/archive": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header?: never;
+ readonly path?: never;
+ readonly cookie?: never;
+ };
+ readonly get?: never;
+ readonly put?: never;
+ /**
+ * [api v2] Archive a context for the requesting user
+ * @description Owners set the archived default for everyone; a user who made their own choice keeps it. Non-owners archive only for themselves.
+ */
+ readonly post: operations["context-archive-context"];
+ /**
+ * [api v2] Unarchive a context for the requesting user
+ * @description Owners set the unarchived default for everyone; a user who made their own choice keeps it. Non-owners remove only their personal archive override.
+ */
+ readonly delete: operations["context-unarchive-context"];
+ readonly options?: never;
+ readonly head?: never;
+ readonly patch?: never;
+ readonly trace?: never;
+ };
readonly "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages/{pageId}": {
readonly parameters: {
readonly query?: never;
@@ -1198,6 +1246,7 @@ export type components = {
readonly owner: string;
/** Format: int64 */
readonly ownerType: number;
+ readonly archived: boolean;
};
readonly ContextNavigation: {
/** Format: int64 */
@@ -1683,10 +1732,10 @@ export interface operations {
*/
readonly emoji?: string | null;
/**
- * @description Whether the table is archived
- * @default false
+ * @description Whether the table is archived; the state is kept unchanged when omitted
+ * @default null
*/
- readonly archived?: boolean;
+ readonly archived?: boolean | null;
};
};
};
@@ -5782,6 +5831,190 @@ export interface operations {
};
};
};
+ readonly "api_tables-archive-table": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Required to be true for the API request to pass */
+ readonly "OCS-APIRequest": boolean;
+ };
+ readonly path: {
+ /** @description Table ID */
+ readonly id: number;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Table returned with updated archived state */
+ readonly 200: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: components["schemas"]["Table"];
+ };
+ };
+ };
+ };
+ /** @description Current user is not logged in */
+ readonly 401: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: unknown;
+ };
+ };
+ };
+ };
+ /** @description No permissions */
+ readonly 403: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ /** @description Not found */
+ readonly 404: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ readonly 500: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ };
+ };
+ readonly "api_tables-unarchive-table": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Required to be true for the API request to pass */
+ readonly "OCS-APIRequest": boolean;
+ };
+ readonly path: {
+ /** @description Table ID */
+ readonly id: number;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Table returned with updated archived state */
+ readonly 200: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: components["schemas"]["Table"];
+ };
+ };
+ };
+ };
+ /** @description Current user is not logged in */
+ readonly 401: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: unknown;
+ };
+ };
+ };
+ };
+ /** @description No permissions */
+ readonly 403: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ /** @description Not found */
+ readonly 404: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ readonly 500: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ };
+ };
readonly "api_columns-index": {
readonly parameters: {
readonly query?: never;
@@ -7946,6 +8179,190 @@ export interface operations {
};
};
};
+ readonly "context-archive-context": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Required to be true for the API request to pass */
+ readonly "OCS-APIRequest": boolean;
+ };
+ readonly path: {
+ /** @description ID of the context */
+ readonly contextId: number;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Context returned with updated archived state */
+ readonly 200: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: components["schemas"]["Context"];
+ };
+ };
+ };
+ };
+ /** @description Current user is not logged in */
+ readonly 401: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: unknown;
+ };
+ };
+ };
+ };
+ /** @description No permissions */
+ readonly 403: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ /** @description Context not found or not available */
+ readonly 404: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ readonly 500: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ };
+ };
+ readonly "context-unarchive-context": {
+ readonly parameters: {
+ readonly query?: never;
+ readonly header: {
+ /** @description Required to be true for the API request to pass */
+ readonly "OCS-APIRequest": boolean;
+ };
+ readonly path: {
+ /** @description ID of the context */
+ readonly contextId: number;
+ };
+ readonly cookie?: never;
+ };
+ readonly requestBody?: never;
+ readonly responses: {
+ /** @description Context returned with updated archived state */
+ readonly 200: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: components["schemas"]["Context"];
+ };
+ };
+ };
+ };
+ /** @description Current user is not logged in */
+ readonly 401: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: unknown;
+ };
+ };
+ };
+ };
+ /** @description No permissions */
+ readonly 403: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ /** @description Context not found or not available */
+ readonly 404: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ readonly 500: {
+ headers: {
+ readonly [name: string]: unknown;
+ };
+ content: {
+ readonly "application/json": {
+ readonly ocs: {
+ readonly meta: components["schemas"]["OCSMeta"];
+ readonly data: {
+ readonly message: string;
+ };
+ };
+ };
+ };
+ };
+ };
+ };
readonly "context-update-content-order": {
readonly parameters: {
readonly query?: never;
diff --git a/tests/stub.phpstub b/tests/stub.phpstub
index 1abb26a1ec..da0b3d1340 100644
--- a/tests/stub.phpstub
+++ b/tests/stub.phpstub
@@ -71,6 +71,20 @@ namespace OCP\Share\ShareReview\Events {
namespace OCA\Circles\Model {
class Circle {
abstract public function getSingleId(): string {}
+ /** @return list */
+ abstract public function getInheritedMembers(): array {}
+ }
+ class Member {
+ public const TYPE_SINGLE = 0;
+ public const TYPE_USER = 1;
+ public const TYPE_GROUP = 2;
+ public const TYPE_MAIL = 4;
+ public const TYPE_CONTACT = 8;
+ public const TYPE_CIRCLE = 16;
+ public const TYPE_APP = 10000;
+ abstract public function getUserId(): string {}
+ abstract public function getUserType(): int {}
+ abstract public function getBasedOn(): ?Circle {}
}
}
@@ -78,6 +92,10 @@ namespace OCA\Circles\Events {
class CircleDestroyedEvent extends \OCP\EventDispatcher\Event {
public function getCircle(): \OCA\Circles\Model\Circle {}
}
+ class CircleMemberRemovedEvent extends \OCP\EventDispatcher\Event {
+ public function getCircle(): \OCA\Circles\Model\Circle {}
+ public function getMember(): ?\OCA\Circles\Model\Member {}
+ }
}
namespace OC\Collaboration\Reference {
@@ -139,6 +157,7 @@ namespace Symfony\Component\Console\Input {
const VALUE_NONE = 1;
const VALUE_REQUIRED = 1;
const VALUE_OPTIONAL = 1;
+ const VALUE_NEGATABLE = 1;
}
}
diff --git a/tests/unit/BackgroundJob/CleanupArchiveOverridesJobTest.php b/tests/unit/BackgroundJob/CleanupArchiveOverridesJobTest.php
new file mode 100644
index 0000000000..1ed7c562d1
--- /dev/null
+++ b/tests/unit/BackgroundJob/CleanupArchiveOverridesJobTest.php
@@ -0,0 +1,84 @@
+archiveCleanupService = $this->createMock(ArchiveCleanupService::class);
+ $this->job = new CleanupArchiveOverridesJob(
+ $this->createMock(ITimeFactory::class),
+ $this->archiveCleanupService,
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ private function runJob(mixed $argument): void {
+ $method = new \ReflectionMethod($this->job, 'run');
+ $method->invoke($this->job, $argument);
+ }
+
+ public function testChecksEveryGivenUser(): void {
+ $checked = [];
+ $this->archiveCleanupService->expects($this->exactly(2))
+ ->method('removeOverrideIfStale')
+ ->willReturnCallback(function (string $userId, int $nodeType, int $nodeId) use (&$checked): void {
+ $checked[] = [$userId, $nodeType, $nodeId];
+ });
+
+ $this->runJob([
+ 'nodeType' => Application::NODE_TYPE_TABLE,
+ 'nodeId' => 5,
+ 'userIds' => ['alice', 'bob'],
+ ]);
+
+ $this->assertSame([
+ ['alice', Application::NODE_TYPE_TABLE, 5],
+ ['bob', Application::NODE_TYPE_TABLE, 5],
+ ], $checked);
+ }
+
+ public function testIgnoresAnEmptyUserList(): void {
+ $this->archiveCleanupService->expects($this->never())->method('removeOverrideIfStale');
+
+ $this->runJob([
+ 'nodeType' => Application::NODE_TYPE_CONTEXT,
+ 'nodeId' => 7,
+ 'userIds' => [],
+ ]);
+ }
+
+ public function testIgnoresAnUnsupportedNodeType(): void {
+ $this->archiveCleanupService->expects($this->never())->method('removeOverrideIfStale');
+
+ $this->runJob([
+ 'nodeType' => Application::NODE_TYPE_VIEW,
+ 'nodeId' => 9,
+ 'userIds' => ['alice'],
+ ]);
+ }
+
+ public function testIgnoresAMalformedArgument(): void {
+ $this->archiveCleanupService->expects($this->never())->method('removeOverrideIfStale');
+
+ $this->runJob('not-an-array');
+ $this->runJob(['nodeType' => Application::NODE_TYPE_TABLE, 'userIds' => ['alice']]);
+ }
+}
diff --git a/tests/unit/Circles/Stubs.php b/tests/unit/Circles/Stubs.php
new file mode 100644
index 0000000000..c25577c724
--- /dev/null
+++ b/tests/unit/Circles/Stubs.php
@@ -0,0 +1,66 @@
+ */
+ public function getInheritedMembers(): array {
+ return [];
+ }
+ }
+
+ class Member {
+ public const TYPE_SINGLE = 0;
+ public const TYPE_USER = 1;
+ public const TYPE_GROUP = 2;
+ public const TYPE_MAIL = 4;
+ public const TYPE_CONTACT = 8;
+ public const TYPE_CIRCLE = 16;
+ public const TYPE_APP = 10000;
+
+ public function getUserId(): string {
+ return '';
+ }
+
+ public function getUserType(): int {
+ return self::TYPE_USER;
+ }
+
+ public function getBasedOn(): ?Circle {
+ return null;
+ }
+ }
+}
+
+namespace OCA\Circles\Events {
+
+ use OCA\Circles\Model\Circle;
+ use OCA\Circles\Model\Member;
+ use OCP\EventDispatcher\Event;
+
+ class CircleMemberRemovedEvent extends Event {
+ public function getCircle(): Circle {
+ return new Circle();
+ }
+
+ public function getMember(): ?Member {
+ return null;
+ }
+ }
+}
diff --git a/tests/unit/Controller/ApiTablesControllerArchiveTest.php b/tests/unit/Controller/ApiTablesControllerArchiveTest.php
new file mode 100644
index 0000000000..1a63b7e82c
--- /dev/null
+++ b/tests/unit/Controller/ApiTablesControllerArchiveTest.php
@@ -0,0 +1,151 @@
+tableService = $this->createMock(TableService::class);
+
+ $n = $this->createMock(IL10N::class);
+ $n->method('t')->willReturnArgument(0);
+
+ $this->controller = new ApiTablesController(
+ $this->createMock(IRequest::class),
+ $this->createMock(LoggerInterface::class),
+ $this->tableService,
+ $this->createMock(ColumnService::class),
+ $this->createMock(ViewService::class),
+ $n,
+ $this->createMock(IAppManager::class),
+ $this->createMock(IDBConnection::class),
+ $this->createMock(StructureService::class),
+ 'alice',
+ );
+ }
+
+ // archiveTable
+
+ public function testArchiveTableReturns200OnSuccess(): void {
+ $table = $this->createTableStub(5, true);
+ $this->tableService->expects($this->once())
+ ->method('archiveTable')
+ ->with(5, 'alice')
+ ->willReturn($table);
+
+ $response = $this->controller->archiveTable(5);
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $this->assertSame(5, $response->getData()['id']);
+ $this->assertTrue($response->getData()['archived']);
+ }
+
+ public function testArchiveTableReturns403OnPermissionError(): void {
+ $this->tableService->method('archiveTable')
+ ->willThrowException(new PermissionError('no access'));
+
+ $response = $this->controller->archiveTable(5);
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testArchiveTableReturns404OnNotFoundError(): void {
+ $this->tableService->method('archiveTable')
+ ->willThrowException(new NotFoundError('not found'));
+
+ $response = $this->controller->archiveTable(5);
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testArchiveTableReturns500OnInternalError(): void {
+ $this->tableService->method('archiveTable')
+ ->willThrowException(new InternalError('boom'));
+
+ $response = $this->controller->archiveTable(5);
+
+ $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
+ }
+
+ // unarchiveTable
+
+ public function testUnarchiveTableReturns200OnSuccess(): void {
+ $table = $this->createTableStub(5, false);
+ $this->tableService->expects($this->once())
+ ->method('unarchiveTable')
+ ->with(5, 'alice')
+ ->willReturn($table);
+
+ $response = $this->controller->unarchiveTable(5);
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $this->assertFalse($response->getData()['archived']);
+ }
+
+ public function testUnarchiveTableReturns403OnPermissionError(): void {
+ $this->tableService->method('unarchiveTable')
+ ->willThrowException(new PermissionError('no access'));
+
+ $response = $this->controller->unarchiveTable(5);
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testUnarchiveTableReturns404OnNotFoundError(): void {
+ $this->tableService->method('unarchiveTable')
+ ->willThrowException(new NotFoundError('not found'));
+
+ $response = $this->controller->unarchiveTable(5);
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testUnarchiveTableReturns500OnInternalError(): void {
+ $this->tableService->method('unarchiveTable')
+ ->willThrowException(new InternalError('boom'));
+
+ $response = $this->controller->unarchiveTable(5);
+
+ $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
+ }
+
+ // Helpers
+
+ private function createTableStub(int $id, bool $archived): Table {
+ $table = $this->createPartialMock(Table::class, []);
+ $table->setId($id);
+ $table->setArchived($archived);
+ $table->setTitle('Test');
+ $table->setOwnership('alice');
+ return $table;
+ }
+}
diff --git a/tests/unit/Controller/ContextControllerArchiveTest.php b/tests/unit/Controller/ContextControllerArchiveTest.php
new file mode 100644
index 0000000000..b8b058ce43
--- /dev/null
+++ b/tests/unit/Controller/ContextControllerArchiveTest.php
@@ -0,0 +1,131 @@
+contextService = $this->createMock(ContextService::class);
+
+ $n = $this->createMock(IL10N::class);
+ $n->method('t')->willReturnArgument(0);
+
+ $this->controller = new ContextController(
+ $this->createMock(IRequest::class),
+ $this->createMock(LoggerInterface::class),
+ $n,
+ 'alice',
+ $this->contextService,
+ $this->createMock(TableService::class),
+ $this->createMock(IDBConnection::class),
+ $this->createMock(ColumnService::class),
+ $this->createMock(ViewService::class),
+ );
+ }
+
+ // archiveContext
+
+ public function testArchiveContextReturns200OnSuccess(): void {
+ $context = $this->createContextStub(3, true);
+ $this->contextService->expects($this->once())
+ ->method('archiveContext')
+ ->with(3, 'alice')
+ ->willReturn($context);
+
+ $response = $this->controller->archiveContext(3);
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $this->assertSame(3, $response->getData()['id']);
+ $this->assertTrue($response->getData()['archived']);
+ }
+
+ public function testArchiveContextReturns404OnNotFoundError(): void {
+ $this->contextService->method('archiveContext')
+ ->willThrowException(new NotFoundError('not found'));
+
+ $response = $this->controller->archiveContext(3);
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testArchiveContextReturns500OnInternalError(): void {
+ $this->contextService->method('archiveContext')
+ ->willThrowException(new InternalError('boom'));
+
+ $response = $this->controller->archiveContext(3);
+
+ $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
+ }
+
+ // unarchiveContext
+
+ public function testUnarchiveContextReturns200OnSuccess(): void {
+ $context = $this->createContextStub(3, false);
+ $this->contextService->expects($this->once())
+ ->method('unarchiveContext')
+ ->with(3, 'alice')
+ ->willReturn($context);
+
+ $response = $this->controller->unarchiveContext(3);
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $this->assertFalse($response->getData()['archived']);
+ }
+
+ public function testUnarchiveContextReturns404OnNotFoundError(): void {
+ $this->contextService->method('unarchiveContext')
+ ->willThrowException(new NotFoundError('not found'));
+
+ $response = $this->controller->unarchiveContext(3);
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testUnarchiveContextReturns500OnInternalError(): void {
+ $this->contextService->method('unarchiveContext')
+ ->willThrowException(new InternalError('boom'));
+
+ $response = $this->controller->unarchiveContext(3);
+
+ $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
+ }
+
+ // Helpers
+
+ private function createContextStub(int $id, bool $archived): Context {
+ $context = new Context();
+ $context->setId($id);
+ $context->setArchived($archived);
+ $context->setName('Test');
+ $context->setOwnerId('alice');
+ $context->setOwnerType(0);
+ return $context;
+ }
+}
diff --git a/tests/unit/Db/UserArchiveMapperTest.php b/tests/unit/Db/UserArchiveMapperTest.php
new file mode 100644
index 0000000000..bee97b197e
--- /dev/null
+++ b/tests/unit/Db/UserArchiveMapperTest.php
@@ -0,0 +1,235 @@
+mapper = new UserArchiveMapper($this->connectionAdapter);
+ $this->cleanupArchiveData();
+ }
+
+ protected function tearDown(): void {
+ $this->cleanupArchiveData();
+ parent::tearDown();
+ }
+
+ private function cleanupArchiveData(): void {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->delete('tables_archive_user')->executeStatement();
+ }
+
+ private function countForNode(int $nodeType, int $nodeId): int {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select($qb->func()->count('*'))
+ ->from('tables_archive_user')
+ ->where($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)));
+ return (int)$qb->executeQuery()->fetchOne();
+ }
+
+ // findForUser
+
+ public function testFindForUserReturnsNullWhenNoRecord(): void {
+ $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 42);
+ $this->assertNull($result);
+ }
+
+ public function testFindForUserReturnsInsertedRecord(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true);
+
+ $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 42);
+ $this->assertNotNull($result);
+ $this->assertTrue($result->isArchived());
+ $this->assertSame('alice', $result->getUserId());
+ $this->assertSame(Application::NODE_TYPE_TABLE, $result->getNodeType());
+ $this->assertSame(42, $result->getNodeId());
+ }
+
+ public function testFindForUserIsScopedToUser(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true);
+
+ $result = $this->mapper->findForUser('bob', Application::NODE_TYPE_TABLE, 42);
+ $this->assertNull($result);
+ }
+
+ public function testFindForUserIsScopedToNodeType(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true);
+
+ $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_CONTEXT, 42);
+ $this->assertNull($result);
+ }
+
+ public function testFindForUserIsScopedToNodeId(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 42, true);
+
+ $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 99);
+ $this->assertNull($result);
+ }
+
+ // upsert
+
+ public function testUpsertInsertsNewRecord(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, false);
+
+ $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 1);
+ $this->assertNotNull($result);
+ $this->assertFalse($result->isArchived());
+ }
+
+ public function testUpsertUpdatesExistingRecord(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, false);
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, true);
+
+ $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 1);
+ $this->assertNotNull($result);
+ $this->assertTrue($result->isArchived());
+
+ // Confirm only one row
+ $this->assertSame(1, $this->countForNode(Application::NODE_TYPE_TABLE, 1));
+ }
+
+ public function testUpsertDoesNotAffectOtherUsers(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, true);
+ $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 1, false);
+
+ $alice = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 1);
+ $bob = $this->mapper->findForUser('bob', Application::NODE_TYPE_TABLE, 1);
+
+ $this->assertTrue($alice->isArchived());
+ $this->assertFalse($bob->isArchived());
+ }
+
+ // findAllOverridesForUser
+
+ public function testFindAllOverridesForUserReturnsKeyedByNodeId(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 10, true);
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 20, false);
+ $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 10, false); // different user
+
+ $results = $this->mapper->findAllOverridesForUser('alice', Application::NODE_TYPE_TABLE, [10, 20, 30]);
+
+ $this->assertArrayHasKey(10, $results);
+ $this->assertArrayHasKey(20, $results);
+ $this->assertArrayNotHasKey(30, $results); // no record for nodeId 30
+ $this->assertTrue($results[10]->isArchived());
+ $this->assertFalse($results[20]->isArchived());
+ }
+
+ public function testFindAllOverridesForUserReturnsEmptyArrayForEmptyInput(): void {
+ $results = $this->mapper->findAllOverridesForUser('alice', Application::NODE_TYPE_TABLE, []);
+ $this->assertSame([], $results);
+ }
+
+ public function testFindAllOverridesForUserIgnoresOtherNodeTypes(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_CONTEXT, 10, true);
+
+ $results = $this->mapper->findAllOverridesForUser('alice', Application::NODE_TYPE_TABLE, [10]);
+ $this->assertEmpty($results);
+ }
+
+ // findUserIdsForNode
+
+ public function testFindUserIdsForNodeReturnsAllHolders(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 5, true);
+ $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 5, false);
+ $this->mapper->upsert('carol', Application::NODE_TYPE_TABLE, 6, true);
+
+ $userIds = $this->mapper->findUserIdsForNode(Application::NODE_TYPE_TABLE, 5);
+
+ sort($userIds);
+ $this->assertSame(['alice', 'bob'], $userIds);
+ }
+
+ public function testFindUserIdsForNodeReturnsEmptyWhenNone(): void {
+ $this->assertSame([], $this->mapper->findUserIdsForNode(Application::NODE_TYPE_TABLE, 999));
+ }
+
+ // deleteAllForUser
+
+ public function testDeleteAllForUserRemovesAllNodesAndTypes(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 1, true);
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 2, false);
+ $this->mapper->upsert('alice', Application::NODE_TYPE_CONTEXT, 3, true);
+ $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 1, true);
+
+ $this->mapper->deleteAllForUser('alice');
+
+ $this->assertNull($this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 1));
+ $this->assertNull($this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 2));
+ $this->assertNull($this->mapper->findForUser('alice', Application::NODE_TYPE_CONTEXT, 3));
+ $this->assertNotNull($this->mapper->findForUser('bob', Application::NODE_TYPE_TABLE, 1));
+ }
+
+ // deleteForUser
+
+ public function testDeleteForUserRemovesRecord(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 7, true);
+ $this->mapper->deleteForUser('alice', Application::NODE_TYPE_TABLE, 7);
+
+ $result = $this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 7);
+ $this->assertNull($result);
+ }
+
+ public function testDeleteForUserDoesNotAffectOtherUsers(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 7, true);
+ $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 7, false);
+
+ $this->mapper->deleteForUser('alice', Application::NODE_TYPE_TABLE, 7);
+
+ $this->assertNull($this->mapper->findForUser('alice', Application::NODE_TYPE_TABLE, 7));
+ $this->assertNotNull($this->mapper->findForUser('bob', Application::NODE_TYPE_TABLE, 7));
+ }
+
+ public function testDeleteForUserIsNoOpWhenRecordAbsent(): void {
+ // Must not throw
+ $this->mapper->deleteForUser('nobody', Application::NODE_TYPE_TABLE, 999);
+ $this->assertTrue(true);
+ }
+
+ // deleteAllForNode
+
+ public function testDeleteAllForNodeRemovesAllUsers(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 8, true);
+ $this->mapper->upsert('bob', Application::NODE_TYPE_TABLE, 8, false);
+ $this->mapper->upsert('carol', Application::NODE_TYPE_TABLE, 8, true);
+
+ $this->mapper->deleteAllForNode(Application::NODE_TYPE_TABLE, 8);
+
+ $this->assertSame(0, $this->countForNode(Application::NODE_TYPE_TABLE, 8));
+ }
+
+ public function testDeleteAllForNodeDoesNotAffectOtherNodes(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 8, true);
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 9, false);
+
+ $this->mapper->deleteAllForNode(Application::NODE_TYPE_TABLE, 8);
+
+ $this->assertSame(0, $this->countForNode(Application::NODE_TYPE_TABLE, 8));
+ $this->assertSame(1, $this->countForNode(Application::NODE_TYPE_TABLE, 9));
+ }
+
+ public function testDeleteAllForNodeDoesNotAffectOtherNodeTypes(): void {
+ $this->mapper->upsert('alice', Application::NODE_TYPE_TABLE, 8, true);
+ $this->mapper->upsert('alice', Application::NODE_TYPE_CONTEXT, 8, false);
+
+ $this->mapper->deleteAllForNode(Application::NODE_TYPE_TABLE, 8);
+
+ $this->assertSame(0, $this->countForNode(Application::NODE_TYPE_TABLE, 8));
+ $this->assertSame(1, $this->countForNode(Application::NODE_TYPE_CONTEXT, 8));
+ }
+}
diff --git a/tests/unit/Federation/FederationProviderTest.php b/tests/unit/Federation/FederationProviderTest.php
index 8fdc4a5ede..ad12a9bc13 100644
--- a/tests/unit/Federation/FederationProviderTest.php
+++ b/tests/unit/Federation/FederationProviderTest.php
@@ -19,6 +19,7 @@
use OCA\Tables\Db\ViewMapper;
use OCA\Tables\Errors\FederationDisabledError;
use OCA\Tables\Federation\FederationProvider;
+use OCA\Tables\Service\ArchiveService;
use OCA\Tables\Service\FederationService;
use OCA\Tables\Service\Support\AuditLogServiceInterface;
use OCP\Federation\Exceptions\ProviderCouldNotAddShareException;
@@ -52,6 +53,7 @@ protected function setUp(): void {
$this->cloudIdManager,
$this->federationService,
$this->auditLogService,
+ $this->createMock(ArchiveService::class),
);
}
diff --git a/tests/unit/Listener/ArchiveCleanupListenerTest.php b/tests/unit/Listener/ArchiveCleanupListenerTest.php
new file mode 100644
index 0000000000..0c8ae7813a
--- /dev/null
+++ b/tests/unit/Listener/ArchiveCleanupListenerTest.php
@@ -0,0 +1,120 @@
+cleanupService = $this->createMock(ArchiveCleanupService::class);
+ $this->listener = new ArchiveCleanupListener(
+ $this->cleanupService,
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ public function testUserRemovedFromGroupSchedulesGroupCleanup(): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('bob');
+ $group = $this->createMock(IGroup::class);
+ $group->method('getGID')->willReturn('team-a');
+ $event = $this->createMock(UserRemovedEvent::class);
+ $event->method('getUser')->willReturn($user);
+ $event->method('getGroup')->willReturn($group);
+
+ $this->cleanupService->expects($this->once())
+ ->method('cleanupAfterMembershipLoss')
+ ->with(['bob'], 'team-a', ShareReceiverType::GROUP);
+
+ $this->listener->handle($event);
+ }
+
+ public function testCircleMemberRemovedDirectUserSchedulesCircleCleanup(): void {
+ $member = $this->mockMember(Member::TYPE_USER, 'bob');
+ $event = $this->mockCircleEvent($member, 'circle-single-id');
+
+ $this->cleanupService->expects($this->once())
+ ->method('cleanupAfterMembershipLoss')
+ ->with(['bob'], 'circle-single-id', ShareReceiverType::CIRCLE);
+
+ $this->listener->handle($event);
+ }
+
+ public function testCircleMemberRemovedSubCircleExpandsInheritedUsers(): void {
+ $inherited = [
+ $this->mockMember(Member::TYPE_USER, 'alice'),
+ $this->mockMember(Member::TYPE_CIRCLE, ''), // nested non-user, must be skipped
+ $this->mockMember(Member::TYPE_USER, 'bob'),
+ ];
+ $basedOn = $this->createMock(Circle::class);
+ $basedOn->method('getInheritedMembers')->willReturn($inherited);
+
+ $member = $this->createMock(Member::class);
+ $member->method('getUserType')->willReturn(Member::TYPE_CIRCLE);
+ $member->method('getBasedOn')->willReturn($basedOn);
+ $event = $this->mockCircleEvent($member, 'circle-single-id');
+
+ $this->cleanupService->expects($this->once())
+ ->method('cleanupAfterMembershipLoss')
+ ->with(['alice', 'bob'], 'circle-single-id', ShareReceiverType::CIRCLE);
+
+ $this->listener->handle($event);
+ }
+
+ public function testCircleMemberRemovedWithNullMemberDoesNothing(): void {
+ $event = $this->mockCircleEvent(null, 'circle-single-id');
+
+ $this->cleanupService->expects($this->never())->method('cleanupAfterMembershipLoss');
+
+ $this->listener->handle($event);
+ }
+
+ public function testCircleMemberRemovedSubCircleWithNullBasedOnDoesNothing(): void {
+ $member = $this->createMock(Member::class);
+ $member->method('getUserType')->willReturn(Member::TYPE_CIRCLE);
+ $member->method('getBasedOn')->willReturn(null);
+ $event = $this->mockCircleEvent($member, 'circle-single-id');
+
+ $this->cleanupService->expects($this->never())->method('cleanupAfterMembershipLoss');
+
+ $this->listener->handle($event);
+ }
+
+ private function mockMember(int $type, string $userId): Member&MockObject {
+ $member = $this->createMock(Member::class);
+ $member->method('getUserType')->willReturn($type);
+ $member->method('getUserId')->willReturn($userId);
+ return $member;
+ }
+
+ private function mockCircleEvent(?Member $member, string $singleId): CircleMemberRemovedEvent&MockObject {
+ $circle = $this->createMock(Circle::class);
+ $circle->method('getSingleId')->willReturn($singleId);
+ $event = $this->createMock(CircleMemberRemovedEvent::class);
+ $event->method('getMember')->willReturn($member);
+ $event->method('getCircle')->willReturn($circle);
+ return $event;
+ }
+}
diff --git a/tests/unit/Service/ArchiveCleanupServiceTest.php b/tests/unit/Service/ArchiveCleanupServiceTest.php
new file mode 100644
index 0000000000..c4d53a9e23
--- /dev/null
+++ b/tests/unit/Service/ArchiveCleanupServiceTest.php
@@ -0,0 +1,248 @@
+userArchiveMapper = $this->createMock(UserArchiveMapper::class);
+ $this->shareMapper = $this->createMock(ShareMapper::class);
+ $this->circleHelper = $this->createMock(CircleHelper::class);
+ $this->permissionsService = $this->createMock(PermissionsService::class);
+ $this->jobList = $this->createMock(IJobList::class);
+ $this->service = new ArchiveCleanupService(
+ $this->userArchiveMapper,
+ $this->shareMapper,
+ $this->circleHelper,
+ $this->permissionsService,
+ $this->jobList,
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ public function testMembershipLossSchedulesCleanupForHoldersOfOverrides(): void {
+ $this->shareMapper->method('findNodesByReceiver')
+ ->with('team-a', ShareReceiverType::GROUP)
+ ->willReturn([['nodeType' => 'table', 'nodeId' => 5]]);
+ $this->userArchiveMapper->expects($this->once())
+ ->method('findOverrideUsersForNodes')
+ ->with(['alice'], Application::NODE_TYPE_TABLE, [5])
+ ->willReturn([5 => ['alice']]);
+
+ $this->jobList->expects($this->once())
+ ->method('add')
+ ->with(CleanupArchiveOverridesJob::class, [
+ 'nodeType' => Application::NODE_TYPE_TABLE,
+ 'nodeId' => 5,
+ 'userIds' => ['alice'],
+ ]);
+ $this->userArchiveMapper->expects($this->never())->method('deleteForUser');
+
+ $this->service->cleanupAfterMembershipLoss(['alice'], 'team-a', ShareReceiverType::GROUP);
+ }
+
+ public function testRemoveOverrideIfStaleRemovesWithoutRemainingAccess(): void {
+ $this->userArchiveMapper->method('findForUser')->willReturn(new UserArchive());
+ $this->permissionsService->method('canAccessNodeById')
+ ->with(Application::NODE_TYPE_TABLE, 5, 'alice')
+ ->willReturn(false);
+
+ $this->userArchiveMapper->expects($this->once())
+ ->method('deleteForUser')
+ ->with('alice', Application::NODE_TYPE_TABLE, 5);
+
+ $this->service->removeOverrideIfStale('alice', Application::NODE_TYPE_TABLE, 5);
+ }
+
+ public function testRemoveOverrideIfStaleKeepsOverrideWithRemainingAccess(): void {
+ $this->userArchiveMapper->method('findForUser')->willReturn(new UserArchive());
+ $this->permissionsService->method('canAccessNodeById')
+ ->willReturn(true);
+
+ $this->userArchiveMapper->expects($this->never())->method('deleteForUser');
+
+ $this->service->removeOverrideIfStale('alice', Application::NODE_TYPE_TABLE, 5);
+ }
+
+ public function testRemoveOverrideIfStaleChecksContextsViaContextPermission(): void {
+ $this->userArchiveMapper->method('findForUser')->willReturn(new UserArchive());
+ $this->permissionsService->method('canAccessContextById')
+ ->with(7, 'alice')
+ ->willReturn(false);
+
+ $this->userArchiveMapper->expects($this->once())
+ ->method('deleteForUser')
+ ->with('alice', Application::NODE_TYPE_CONTEXT, 7);
+
+ $this->service->removeOverrideIfStale('alice', Application::NODE_TYPE_CONTEXT, 7);
+ }
+
+ public function testRemoveOverrideIfStaleKeepsOverrideReachableViaTeamShare(): void {
+ $teamShare = new Share();
+ $teamShare->setNodeType('context');
+ $teamShare->setNodeId(7);
+ $teamShare->setReceiver('team-b');
+ $teamShare->setReceiverType(ShareReceiverType::CIRCLE);
+
+ $this->permissionsService->method('canAccessContextById')->willReturn(false);
+ $this->circleHelper->method('getCircleIdsForUser')->willReturn(['team-b']);
+ $this->shareMapper->method('findAllSharesForNodeTo')->willReturn([$teamShare]);
+ $this->userArchiveMapper->method('findForUser')->willReturn(new UserArchive());
+
+ $this->userArchiveMapper->expects($this->never())->method('deleteForUser');
+
+ $this->service->removeOverrideIfStale('alice', Application::NODE_TYPE_CONTEXT, 7);
+ }
+
+ public function testRemoveOverrideIfStaleSkipsAccessCheckWithoutOverride(): void {
+ $this->userArchiveMapper->method('findForUser')->willReturn(null);
+ $this->permissionsService->expects($this->never())->method('canAccessNodeById');
+ $this->userArchiveMapper->expects($this->never())->method('deleteForUser');
+
+ $this->service->removeOverrideIfStale('alice', Application::NODE_TYPE_TABLE, 5);
+ }
+
+ public function testMembershipLossSkipsNodesWithoutOverride(): void {
+ $this->shareMapper->method('findNodesByReceiver')
+ ->willReturn([['nodeType' => 'table', 'nodeId' => 5]]);
+ $this->userArchiveMapper->method('findAllOverridesForUser')
+ ->willReturn([]);
+
+ $this->jobList->expects($this->never())->method('add');
+ $this->userArchiveMapper->expects($this->never())->method('deleteForUser');
+
+ $this->service->cleanupAfterMembershipLoss(['alice'], 'team-a', ShareReceiverType::GROUP);
+ }
+
+ public function testMembershipLossIgnoresViewShares(): void {
+ $this->shareMapper->method('findNodesByReceiver')
+ ->willReturn([['nodeType' => 'view', 'nodeId' => 9]]);
+
+ $this->userArchiveMapper->expects($this->never())->method('findAllOverridesForUser');
+
+ $this->service->cleanupAfterMembershipLoss(['alice'], 'team-a', ShareReceiverType::GROUP);
+ }
+
+ public function testPurgeNodeOverridesSchedulesTheHolders(): void {
+ $this->userArchiveMapper->method('findUserIdsForNode')
+ ->with(Application::NODE_TYPE_TABLE, 5)
+ ->willReturn(['alice', 'bob']);
+
+ $this->jobList->expects($this->once())
+ ->method('add')
+ ->with(CleanupArchiveOverridesJob::class, [
+ 'nodeType' => Application::NODE_TYPE_TABLE,
+ 'nodeId' => 5,
+ 'userIds' => ['alice', 'bob'],
+ ]);
+ $this->permissionsService->expects($this->never())->method('canAccessNodeById');
+ $this->userArchiveMapper->expects($this->never())->method('deleteForUser');
+
+ $this->service->purgeNodeOverrides(Application::NODE_TYPE_TABLE, 5);
+ }
+
+ public function testPurgeShareNodeOverridesResolvesShareNodeType(): void {
+ $this->userArchiveMapper->expects($this->once())
+ ->method('findUserIdsForNode')
+ ->with(Application::NODE_TYPE_TABLE, 5)
+ ->willReturn([]);
+ $this->jobList->expects($this->never())->method('add');
+
+ $this->service->purgeShareNodeOverrides('table', 5);
+ }
+
+ public function testPurgeShareNodeOverridesIgnoresViewShares(): void {
+ $this->userArchiveMapper->expects($this->never())->method('findUserIdsForNode');
+
+ $this->service->purgeShareNodeOverrides('view', 9);
+ }
+
+ public function testShareDeletionForUserShareChecksReceiverOnly(): void {
+ $share = new Share();
+ $share->setNodeType('table');
+ $share->setNodeId(5);
+ $share->setReceiver('alice');
+ $share->setReceiverType(ShareReceiverType::USER);
+
+ $this->permissionsService->method('canAccessNodeById')->willReturn(false);
+ $this->userArchiveMapper->method('findForUser')->willReturn(new UserArchive());
+ $this->userArchiveMapper->expects($this->once())
+ ->method('deleteForUser')
+ ->with('alice', Application::NODE_TYPE_TABLE, 5);
+ $this->userArchiveMapper->expects($this->never())->method('findUserIdsForNode');
+
+ $this->service->cleanupAfterShareDeletion($share);
+ }
+
+ public function testShareDeletionForGroupSharePurgesNode(): void {
+ $share = new Share();
+ $share->setNodeType('context');
+ $share->setNodeId(7);
+ $share->setReceiver('team-a');
+ $share->setReceiverType(ShareReceiverType::GROUP);
+
+ $this->userArchiveMapper->method('findUserIdsForNode')
+ ->with(Application::NODE_TYPE_CONTEXT, 7)
+ ->willReturn(['alice']);
+
+ $this->jobList->expects($this->once())
+ ->method('add')
+ ->with(CleanupArchiveOverridesJob::class, [
+ 'nodeType' => Application::NODE_TYPE_CONTEXT,
+ 'nodeId' => 7,
+ 'userIds' => ['alice'],
+ ]);
+
+ $this->service->cleanupAfterShareDeletion($share);
+ }
+
+ public function testShareDeletionIgnoresViewShares(): void {
+ $share = new Share();
+ $share->setNodeType('view');
+ $share->setNodeId(9);
+ $share->setReceiver('alice');
+ $share->setReceiverType(ShareReceiverType::USER);
+
+ $this->userArchiveMapper->expects($this->never())->method('deleteForUser');
+ $this->userArchiveMapper->expects($this->never())->method('findUserIdsForNode');
+
+ $this->service->cleanupAfterShareDeletion($share);
+ }
+
+ public function testCleanupDeletedUserDelegatesToMapper(): void {
+ $this->userArchiveMapper->expects($this->once())
+ ->method('deleteAllForUser')
+ ->with('alice');
+
+ $this->service->cleanupDeletedUser('alice');
+ }
+}
diff --git a/tests/unit/Service/ArchiveServiceTest.php b/tests/unit/Service/ArchiveServiceTest.php
new file mode 100644
index 0000000000..83130ab166
--- /dev/null
+++ b/tests/unit/Service/ArchiveServiceTest.php
@@ -0,0 +1,242 @@
+mapper = $this->createMock(UserArchiveMapper::class);
+ $this->connection = $this->createMock(IDBConnection::class);
+ $this->service = new ArchiveService($this->connection, $this->mapper);
+ }
+
+ // archiveForUser
+
+ public function testArchiveForUserOwnerSetsEntityFlagAndKeepsOverrides(): void {
+ $this->mapper->expects($this->never())->method('upsert');
+ // existing overrides are sticky: an owner change must never clear them
+ $this->mapper->expects($this->never())->method('deleteAllForNode');
+ $this->mapper->expects($this->never())->method('deleteForUser');
+
+ // connection->getQueryBuilder() called for setEntityArchived
+ $qb = $this->createMockQueryBuilder();
+ $this->connection->expects($this->once())
+ ->method('getQueryBuilder')
+ ->willReturn($qb);
+
+ $this->service->archiveForUser('alice', Application::NODE_TYPE_TABLE, 42, true);
+ }
+
+ public function testArchiveForUserOwnerIsANoOpWhenAlreadyArchived(): void {
+ $this->mapper->expects($this->never())->method('deleteAllForNode');
+ $this->connection->expects($this->never())->method('getQueryBuilder');
+
+ $this->service->archiveForUser('alice', Application::NODE_TYPE_TABLE, 42, true, true);
+ }
+
+ public function testArchiveForUserNonOwnerUpsertsPersonalOverride(): void {
+ $this->mapper->expects($this->once())
+ ->method('upsert')
+ ->with('bob', Application::NODE_TYPE_TABLE, 42, true);
+ $this->mapper->expects($this->never())->method('deleteAllForNode');
+ $this->connection->expects($this->never())->method('getQueryBuilder');
+
+ $this->service->archiveForUser('bob', Application::NODE_TYPE_TABLE, 42, false);
+ }
+
+ // unarchiveForUser
+
+ public function testUnarchiveForUserOwnerClearsEntityFlagAndKeepsOverrides(): void {
+ $this->mapper->expects($this->never())->method('upsert');
+ // a recipient who archived it for themselves keeps that override
+ $this->mapper->expects($this->never())->method('deleteForUser');
+ $this->mapper->expects($this->never())->method('deleteAllForNode');
+
+ $qb = $this->createMockQueryBuilder();
+ $this->connection->expects($this->once())
+ ->method('getQueryBuilder')
+ ->willReturn($qb);
+
+ $this->service->unarchiveForUser('alice', Application::NODE_TYPE_TABLE, 42, true, true);
+ }
+
+ public function testUnarchiveForUserOwnerIsANoOpWhenNotArchived(): void {
+ $this->mapper->expects($this->never())->method('deleteAllForNode');
+ $this->connection->expects($this->never())->method('getQueryBuilder');
+
+ $this->service->unarchiveForUser('alice', Application::NODE_TYPE_TABLE, 42, true, false);
+ }
+
+ public function testUnarchiveForUserNonOwnerEntityNotArchivedDeletesOverride(): void {
+ $this->mapper->expects($this->once())
+ ->method('deleteForUser')
+ ->with('bob', Application::NODE_TYPE_TABLE, 42);
+ $this->mapper->expects($this->never())->method('upsert');
+ $this->connection->expects($this->never())->method('getQueryBuilder');
+
+ $this->service->unarchiveForUser('bob', Application::NODE_TYPE_TABLE, 42, false, false);
+ }
+
+ public function testUnarchiveForUserNonOwnerEntityArchivedUpsertsFalseOverride(): void {
+ $this->mapper->expects($this->once())
+ ->method('upsert')
+ ->with('bob', Application::NODE_TYPE_TABLE, 42, false);
+ $this->mapper->expects($this->never())->method('deleteForUser');
+ $this->connection->expects($this->never())->method('getQueryBuilder');
+
+ $this->service->unarchiveForUser('bob', Application::NODE_TYPE_TABLE, 42, false, true);
+ }
+
+ // enrichTablesWithArchiveState
+
+ public function testEnrichTablesReplacesArchivedWithPerUserValue(): void {
+ $table = $this->createTableStub(7, false);
+
+ $override = new UserArchive();
+ $override->setArchived(true);
+
+ $this->mapper->expects($this->once())
+ ->method('findAllOverridesForUser')
+ ->with('alice', Application::NODE_TYPE_TABLE, [7])
+ ->willReturn([7 => $override]);
+
+ $this->service->enrichTablesWithArchiveState([$table], 'alice');
+ // Entity had archived=false but override says true
+ $this->assertTrue($table->isArchived());
+ }
+
+ public function testEnrichTablesFallsBackToEntityWhenNoOverride(): void {
+ $table = $this->createTableStub(7, true);
+
+ $this->mapper->expects($this->once())
+ ->method('findAllOverridesForUser')
+ ->willReturn([]);
+
+ $this->service->enrichTablesWithArchiveState([$table], 'alice');
+ $this->assertTrue($table->isArchived());
+ }
+
+ // prepareOwnershipTransfer
+
+ public function testPrepareOwnershipTransferNoOverrideReturnsSameArchived(): void {
+ $this->mapper->expects($this->once())
+ ->method('findForUser')
+ ->with('new', Application::NODE_TYPE_TABLE, 10)
+ ->willReturn(null);
+ $this->mapper->expects($this->never())->method('deleteForUser');
+ $this->mapper->expects($this->never())->method('upsert');
+
+ $result = $this->service->prepareOwnershipTransfer('old', 'new', Application::NODE_TYPE_TABLE, 10, false);
+ $this->assertFalse($result);
+ }
+
+ public function testPrepareOwnershipTransferOverrideSameValueNoOutgoingRecord(): void {
+ $override = new UserArchive();
+ $override->setArchived(false); // same as entity
+
+ $this->mapper->expects($this->once())
+ ->method('findForUser')
+ ->willReturn($override);
+ $this->mapper->expects($this->once())
+ ->method('deleteForUser')
+ ->with('new', Application::NODE_TYPE_TABLE, 10);
+ $this->mapper->expects($this->never())->method('upsert');
+
+ $result = $this->service->prepareOwnershipTransfer('old', 'new', Application::NODE_TYPE_TABLE, 10, false);
+ $this->assertFalse($result);
+ }
+
+ public function testPrepareOwnershipTransferOverrideDiffersPreservesOutgoingOwner(): void {
+ $override = new UserArchive();
+ $override->setArchived(true); // differs from entity (false)
+
+ $this->mapper->expects($this->once())
+ ->method('findForUser')
+ ->willReturn($override);
+ $deleted = [];
+ $this->mapper->expects($this->exactly(2))
+ ->method('deleteForUser')
+ ->willReturnCallback(function (string $userId, int $nodeType, int $nodeId) use (&$deleted): void {
+ $deleted[] = [$userId, $nodeType, $nodeId];
+ });
+ $this->mapper->expects($this->once())
+ ->method('upsert')
+ ->with('old', Application::NODE_TYPE_TABLE, 10, false); // preserve old owner's view
+
+ $result = $this->service->prepareOwnershipTransfer('old', 'new', Application::NODE_TYPE_TABLE, 10, false);
+ $this->assertTrue($result);
+ // The new owner's override is consumed, and any stale row of the outgoing
+ // owner is cleared before their preservation row is written.
+ $this->assertSame([
+ ['new', Application::NODE_TYPE_TABLE, 10],
+ ['old', Application::NODE_TYPE_TABLE, 10],
+ ], $deleted);
+ }
+
+ /**
+ * The mappers cache an entity for the whole request and hand the same instance to
+ * every later consumer, so a resolved value that is left pending would be written
+ * to the shared column by the next update of that entity — publishing one user's
+ * private archive state to everyone.
+ */
+ public function testEnrichingNeverLeavesTheResolvedValuePendingOnADirtyEntity(): void {
+ $table = $this->createTableStub(7, false);
+ $table->setTitle('Changed before enrichment');
+
+ $override = new UserArchive();
+ $override->setArchived(true);
+
+ $this->mapper->method('findAllOverridesForUser')->willReturn([7 => $override]);
+
+ $this->service->enrichTablesWithArchiveState([$table], 'alice');
+
+ $this->assertTrue($table->isArchived(), 'the user sees their own state');
+ $this->assertArrayNotHasKey('archived', $table->getUpdatedFields(), 'but it is never persisted');
+ $this->assertArrayHasKey('title', $table->getUpdatedFields(), 'while a real change still is');
+ }
+
+ // Helpers
+
+ private function createTableStub(int $id, bool $archived): \OCA\Tables\Db\Table {
+ $table = new \OCA\Tables\Db\Table();
+ $table->setId($id);
+ $table->setArchived($archived);
+ return $table;
+ }
+
+ /**
+ * Returns a mock query builder chain that absorbs update()->set()->where()->executeStatement().
+ */
+ private function createMockQueryBuilder(): \OCP\DB\QueryBuilder\IQueryBuilder {
+ $expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class);
+ $expr->method('eq')->willReturn('1=1');
+
+ $qb = $this->createMock(\OCP\DB\QueryBuilder\IQueryBuilder::class);
+ $qb->method('update')->willReturnSelf();
+ $qb->method('set')->willReturnSelf();
+ $qb->method('where')->willReturnSelf();
+ $qb->method('createNamedParameter')->willReturnArgument(0);
+ $qb->method('expr')->willReturn($expr);
+ $qb->method('executeStatement')->willReturn(1);
+ return $qb;
+ }
+}
diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php
index 67b94c8cca..6ff174701c 100644
--- a/tests/unit/Service/ContextServiceTest.php
+++ b/tests/unit/Service/ContextServiceTest.php
@@ -38,6 +38,7 @@ final class ContextServiceTest extends TestCase {
private MockObject $pageContentMapper;
private MockObject $userManager;
private MockObject $auditLogService;
+ private MockObject $archiveService;
protected function setUp(): void {
parent::setUp();
@@ -54,6 +55,7 @@ protected function setUp(): void {
$shareService = $this->createMock(ShareService::class);
$navigationManager = $this->createMock(INavigationManager::class);
$urlGenerator = $this->createMock(IURLGenerator::class);
+ $this->archiveService = $this->createMock(ArchiveService::class);
$this->service = new ContextService(
$this->contextMapper,
@@ -66,14 +68,63 @@ protected function setUp(): void {
$this->auditLogService,
$dbConnection,
$shareService,
+ $this->createMock(ArchiveCleanupService::class),
false,
$navigationManager,
$urlGenerator,
$this->createMock(TableMapper::class),
$this->createMock(ViewMapper::class),
+ $this->archiveService,
);
}
+ public function testOwnerArchivingAnApplicationIsAudited(): void {
+ $context = new Context();
+ $context->setId(7);
+ $context->setOwnerId('alice');
+ $context->setArchived(false);
+ $this->contextMapper->method('findById')->willReturn($context);
+
+ $this->auditLogService->expects($this->once())
+ ->method('log')
+ ->with(
+ Operation::ContextArchived,
+ 'Tables application with ID %s was archived',
+ ['contextId' => 7],
+ );
+
+ $this->service->archiveContext(7, 'alice');
+ }
+
+ // a receiver only stores a private override, which is nobody else's business
+ public function testArchivingByAReceiverIsNotAudited(): void {
+ $context = new Context();
+ $context->setId(7);
+ $context->setOwnerId('alice');
+ $context->setArchived(false);
+ $this->contextMapper->method('findById')->willReturn($context);
+
+ $this->auditLogService->expects($this->never())->method('log');
+
+ $this->service->archiveContext(7, 'bob');
+ }
+
+ // the receiver's own override must be resolved into the returned entity, so the
+ // response carries their state rather than the shared owner-level flag
+ public function testArchivingByAReceiverResolvesPerUserStateInResponse(): void {
+ $context = new Context();
+ $context->setId(7);
+ $context->setOwnerId('alice');
+ $context->setArchived(false);
+ $this->contextMapper->method('findById')->willReturn($context);
+
+ $this->archiveService->expects($this->once())
+ ->method('enrichContextsWithArchiveState')
+ ->with([$context], 'bob');
+
+ $this->service->archiveContext(7, 'bob');
+ }
+
public function testTransferLogsPreviousAndNewOwner(): void {
$context = new Context();
$context->setId(7);
diff --git a/tests/unit/Service/ShareServiceTest.php b/tests/unit/Service/ShareServiceTest.php
index 1ab87968e5..06f39fb073 100644
--- a/tests/unit/Service/ShareServiceTest.php
+++ b/tests/unit/Service/ShareServiceTest.php
@@ -20,6 +20,7 @@
use OCA\Tables\Helper\CircleHelper;
use OCA\Tables\Helper\GroupHelper;
use OCA\Tables\Helper\UserHelper;
+use OCA\Tables\Service\ArchiveCleanupService;
use OCA\Tables\Service\FederationService;
use OCA\Tables\Service\PermissionsService;
use OCA\Tables\Service\ShareService;
@@ -95,6 +96,7 @@ protected function setUp(): void {
$this->shareManager,
$this->federationService,
$this->auditLogService,
+ $this->createMock(ArchiveCleanupService::class),
);
$this->publicShare = new Share();
diff --git a/tests/unit/Service/TableServiceAuditLogTest.php b/tests/unit/Service/TableServiceAuditLogTest.php
index 5445bbc6fa..77639693ca 100644
--- a/tests/unit/Service/TableServiceAuditLogTest.php
+++ b/tests/unit/Service/TableServiceAuditLogTest.php
@@ -14,6 +14,8 @@
use OCA\Tables\Db\Table;
use OCA\Tables\Db\TableMapper;
use OCA\Tables\Helper\UserHelper;
+use OCA\Tables\Service\ArchiveCleanupService;
+use OCA\Tables\Service\ArchiveService;
use OCA\Tables\Service\ColumnService;
use OCA\Tables\Service\ContextService;
use OCA\Tables\Service\FavoritesService;
@@ -69,6 +71,8 @@ protected function setUp(): void {
$this->createMock(ActivityManager::class),
$this->createMock(FederationService::class),
$this->auditLogService,
+ $this->createMock(ArchiveService::class),
+ $this->createMock(ArchiveCleanupService::class),
);
}
diff --git a/tests/unit/Service/TableServiceUpdateArchiveTest.php b/tests/unit/Service/TableServiceUpdateArchiveTest.php
new file mode 100644
index 0000000000..ff297d3d2c
--- /dev/null
+++ b/tests/unit/Service/TableServiceUpdateArchiveTest.php
@@ -0,0 +1,218 @@
+permissionsService = $this->createMock(PermissionsService::class);
+ $this->mapper = $this->createMock(TableMapper::class);
+ $this->archiveService = $this->createMock(ArchiveService::class);
+ $this->archiveCleanupService = $this->createMock(ArchiveCleanupService::class);
+ $this->federationService = $this->createMock(FederationService::class);
+ $this->activityManager = $this->createMock(ActivityManager::class);
+
+ $columnService = $this->createMock(ColumnService::class);
+ $rowService = $this->createMock(RowService::class);
+ $viewService = $this->createMock(ViewService::class);
+ $shareService = $this->createMock(ShareService::class);
+ $userHelper = $this->createMock(UserHelper::class);
+ $favoritesService = $this->createMock(FavoritesService::class);
+
+ $this->permissionsService->method('preCheckUserId')->willReturnArgument(0);
+ $this->permissionsService->method('canUpdateTable')->willReturn(true);
+ $this->permissionsService->method('canChangeElementOwner')->willReturn(true);
+ $shareService->method('findAll')->willReturn([]);
+ $shareService->method('getSharedPermissionsIfSharedWithMe')->willReturn(new Permissions(read: true));
+ $rowService->method('getRowsCount')->willReturn(0);
+ $columnService->method('getColumnsCount')->willReturn(0);
+ $viewService->method('findAll')->willReturn([]);
+ $userHelper->method('getUserDisplayName')->willReturn('');
+ $favoritesService->method('isFavorite')->willReturn(false);
+
+ $this->table = new Table();
+ $this->table->setId(5);
+ $this->table->setTitle('Test');
+ $this->mapper->method('find')->willReturn($this->table);
+
+ $this->service = new TableService(
+ $this->permissionsService,
+ $this->createMock(LoggerInterface::class),
+ null,
+ $this->mapper,
+ $this->createMock(TableTemplateService::class),
+ $columnService,
+ $rowService,
+ $viewService,
+ $shareService,
+ $userHelper,
+ $favoritesService,
+ $this->createMock(IEventDispatcher::class),
+ $this->createMock(ContextService::class),
+ $this->createMock(IAppManager::class),
+ $this->createMock(IDBConnection::class),
+ $this->createMock(IL10N::class),
+ $this->createMock(Defaults::class),
+ $this->activityManager,
+ $this->federationService,
+ $this->createMock(AuditLogServiceInterface::class),
+ $this->archiveService,
+ $this->archiveCleanupService,
+ );
+ }
+
+ public function testOwnerArchivingFlipsSharedFlagAndAnnouncesChange(): void {
+ $this->table->setOwnership('alice');
+ $this->table->setArchived(false);
+
+ $this->archiveService->expects($this->once())
+ ->method('archiveForUser')
+ ->with('alice', Application::NODE_TYPE_TABLE, 5, true, false);
+ $this->mapper->expects($this->once())->method('update')->willReturnArgument(0);
+ $this->federationService->expects($this->once())->method('notifyNodeUpdate');
+ $this->activityManager->expects($this->once())->method('triggerUpdateEvents');
+
+ $result = $this->service->update(5, null, null, null, true, 'alice');
+ $this->assertTrue($result->isArchived());
+ }
+
+ public function testNonOwnerArchivingStaysPrivate(): void {
+ $this->table->setOwnership('alice');
+ $this->table->setArchived(false);
+
+ $this->archiveService->expects($this->once())
+ ->method('archiveForUser')
+ ->with('bob', Application::NODE_TYPE_TABLE, 5, false, false);
+ $this->mapper->expects($this->never())->method('update');
+ $this->federationService->expects($this->never())->method('notifyNodeUpdate');
+ $this->activityManager->expects($this->never())->method('triggerUpdateEvents');
+
+ $this->service->update(5, null, null, null, true, 'bob');
+ }
+
+ public function testNonOwnerUnarchivingStaysPrivate(): void {
+ $this->table->setOwnership('alice');
+ $this->table->setArchived(true);
+
+ $this->archiveService->expects($this->once())
+ ->method('unarchiveForUser')
+ ->with('bob', Application::NODE_TYPE_TABLE, 5, false, true);
+ $this->mapper->expects($this->never())->method('update');
+ $this->activityManager->expects($this->never())->method('triggerUpdateEvents');
+
+ $this->service->update(5, null, null, null, false, 'bob');
+ }
+
+ public function testOwnerRepeatingCurrentStateIsNotAnnounced(): void {
+ $this->table->setOwnership('alice');
+ $this->table->setArchived(true);
+
+ $this->archiveService->expects($this->once())
+ ->method('archiveForUser')
+ ->with('alice', Application::NODE_TYPE_TABLE, 5, true, true);
+ $this->mapper->expects($this->never())->method('update');
+ $this->activityManager->expects($this->never())->method('triggerUpdateEvents');
+
+ $this->service->update(5, null, null, null, true, 'alice');
+ }
+
+ public function testNonOwnerArchivingAlongsideSharedFieldStillAnnouncesTheSharedField(): void {
+ $this->table->setOwnership('alice');
+ $this->table->setArchived(false);
+
+ $this->archiveService->expects($this->once())
+ ->method('archiveForUser')
+ ->with('bob', Application::NODE_TYPE_TABLE, 5, false, false);
+ // the title change is a shared change, so the update must still be persisted and announced
+ $this->mapper->expects($this->once())->method('update')->willReturnArgument(0);
+ $this->activityManager->expects($this->once())->method('triggerUpdateEvents');
+
+ $this->service->update(5, 'New title', null, null, true, 'bob');
+ }
+
+ public function testTransferMigratesArchiveOverridesAndCleansUpStaleOverride(): void {
+ $this->table->setOwnership('alice');
+ $this->table->setArchived(false);
+
+ $this->archiveService->expects($this->once())
+ ->method('prepareOwnershipTransfer')
+ ->with('alice', 'bob', Application::NODE_TYPE_TABLE, 5, false)
+ ->willReturn(false);
+ $this->archiveCleanupService->expects($this->once())
+ ->method('removeOverrideIfStale')
+ ->with('alice', Application::NODE_TYPE_TABLE, 5);
+ $this->mapper->expects($this->once())->method('update')->willReturnArgument(0);
+
+ $result = $this->service->setOwner(5, 'bob', 'alice');
+
+ $this->assertSame('bob', $result->getOwnership());
+ $this->assertFalse($result->isArchived());
+ }
+
+ public function testTransferAppliesArchivedStateWhenPreparationFlipsIt(): void {
+ $this->table->setOwnership('alice');
+ $this->table->setArchived(false);
+
+ // the incoming owner already held a personal override, so the shared flag flips
+ $this->archiveService->expects($this->once())
+ ->method('prepareOwnershipTransfer')
+ ->willReturn(true);
+ $this->archiveCleanupService->expects($this->once())->method('removeOverrideIfStale');
+ $this->mapper->expects($this->once())->method('update')->willReturnArgument(0);
+
+ $result = $this->service->setOwner(5, 'bob', 'alice');
+
+ $this->assertTrue($result->isArchived());
+ }
+}
diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php
index ce471574ad..113ac4e91a 100644
--- a/tests/unit/bootstrap.php
+++ b/tests/unit/bootstrap.php
@@ -27,6 +27,10 @@
require_once __DIR__ . '/ShareReview/Stubs.php';
}
+if (!class_exists(\OCA\Circles\Model\Member::class)) {
+ require_once __DIR__ . '/Circles/Stubs.php';
+}
+
if (!class_exists(TestCase::class)) {
require_once('PHPUnit/Autoload.php');
}