diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 9050682..0d5ef82 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -14,6 +14,7 @@ use OCA\Forum\Db\PostMapper; use OCA\Forum\Db\RoleMapper; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Service\AdminSettingsService; +use OCA\Forum\Service\GuestService; use OCA\Forum\Service\UserRoleService; use OCA\Forum\Service\UserService; use OCP\AppFramework\Http; @@ -41,6 +42,7 @@ class AdminController extends OCSController { private IUserManager $userManager, private IUserSession $userSession, private AdminSettingsService $settingsService, + private GuestService $guestService, private LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -380,4 +382,58 @@ class AdminController extends OCSController { ], Http::STATUS_INTERNAL_SERVER_ERROR); } } + + /** + * Reassign all posts and threads from a guest to a registered user + * + * @param string $guestAuthorId The guest author ID (format: "guest:") + * @param string $targetUserId The target Nextcloud user ID to assign posts to + * @return DataResponse + * + * 200: Posts reassigned successfully + */ + #[NoAdminRequired] + #[RequirePermission('canManageUsers')] + #[ApiRoute(verb: 'POST', url: '/api/admin/guests/reassign')] + public function reassignGuestPosts(string $guestAuthorId, string $targetUserId): DataResponse { + try { + // Validate guest author ID format + if (!GuestService::isGuestAuthor($guestAuthorId)) { + return new DataResponse(['error' => 'Invalid guest author ID format'], Http::STATUS_BAD_REQUEST); + } + + // Validate target user exists + $targetUser = $this->userManager->get($targetUserId); + if ($targetUser === null) { + return new DataResponse(['error' => 'Target user does not exist'], Http::STATUS_NOT_FOUND); + } + + // Count posts before reassignment for forum user stats + $postCounts = $this->postMapper->countByAuthorId($guestAuthorId); + + // Reassign posts, threads, and last_reply_author_id references + $postsReassigned = $this->postMapper->reassignAuthor($guestAuthorId, $targetUserId); + $threadsReassigned = $this->threadMapper->reassignAuthor($guestAuthorId, $targetUserId); + $this->threadMapper->reassignLastReplyAuthor($guestAuthorId, $targetUserId); + + // Update the target user's forum user stats + if ($postCounts['replies'] > 0) { + $this->forumUserMapper->incrementPostCount($targetUserId, $postCounts['replies']); + } + if ($postCounts['threads'] > 0) { + $this->forumUserMapper->incrementThreadCount($targetUserId, $postCounts['threads']); + } + + $this->logger->info("Reassigned {$postsReassigned} posts and {$threadsReassigned} threads from guest '{$guestAuthorId}' to user '{$targetUserId}'"); + + return new DataResponse([ + 'success' => true, + 'postsReassigned' => $postsReassigned, + 'threadsReassigned' => $threadsReassigned, + ]); + } catch (\Exception $e) { + $this->logger->error('Error reassigning guest posts: ' . $e->getMessage()); + return new DataResponse(['error' => 'Failed to reassign guest posts'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } } diff --git a/lib/Db/PostMapper.php b/lib/Db/PostMapper.php index ad18c38..3a96095 100644 --- a/lib/Db/PostMapper.php +++ b/lib/Db/PostMapper.php @@ -482,6 +482,64 @@ class PostMapper extends QBMapper { return $count; } + /** + * Reassign all posts from one author to another + * + * @param string $fromAuthorId Current author ID (e.g., "guest:abc123") + * @param string $toAuthorId New author ID (e.g., "john") + * @return int Number of posts updated + */ + public function reassignAuthor(string $fromAuthorId, string $toAuthorId): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR)) + ->set('updated_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR))); + return $qb->executeStatement(); + } + + /** + * Count posts by author (including deleted) + */ + public function countByAuthorId(string $authorId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select( + $qb->func()->count('*', 'total'), + ) + ->from($this->getTableName()) + ->where($qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))); + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + // Count first posts (threads) vs replies separately + $qb2 = $this->db->getQueryBuilder(); + $qb2->select($qb2->func()->count('*', 'count')) + ->from($this->getTableName()) + ->where($qb2->expr()->eq('author_id', $qb2->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb2->expr()->eq('is_first_post', $qb2->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb2->expr()->isNull('deleted_at')); + $result2 = $qb2->executeQuery(); + $row2 = $result2->fetch(); + $result2->closeCursor(); + + $qb3 = $this->db->getQueryBuilder(); + $qb3->select($qb3->func()->count('*', 'count')) + ->from($this->getTableName()) + ->where($qb3->expr()->eq('author_id', $qb3->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb3->expr()->eq('is_first_post', $qb3->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb3->expr()->isNull('deleted_at')); + $result3 = $qb3->executeQuery(); + $row3 = $result3->fetch(); + $result3->closeCursor(); + + return [ + 'total' => (int)($row['total'] ?? 0), + 'threads' => (int)($row2['count'] ?? 0), + 'replies' => (int)($row3['count'] ?? 0), + ]; + } + /** * Find all posts for a thread, including deleted posts * diff --git a/lib/Db/ThreadMapper.php b/lib/Db/ThreadMapper.php index 15808c7..10a9755 100644 --- a/lib/Db/ThreadMapper.php +++ b/lib/Db/ThreadMapper.php @@ -359,6 +359,37 @@ class ThreadMapper extends QBMapper { return $this->findEntities($qb); } + /** + * Reassign all threads from one author to another + * + * @param string $fromAuthorId Current author ID (e.g., "guest:abc123") + * @param string $toAuthorId New author ID (e.g., "john") + * @return int Number of threads updated + */ + public function reassignAuthor(string $fromAuthorId, string $toAuthorId): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR)) + ->set('updated_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR))); + return $qb->executeStatement(); + } + + /** + * Reassign last_reply_author_id from one author to another + * + * @param string $fromAuthorId Current author ID + * @param string $toAuthorId New author ID + * @return int Number of threads updated + */ + public function reassignLastReplyAuthor(string $fromAuthorId, string $toAuthorId): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('last_reply_author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR)) + ->where($qb->expr()->eq('last_reply_author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR))); + return $qb->executeStatement(); + } + /** * Find a thread by ID including soft-deleted threads * diff --git a/openapi-full.json b/openapi-full.json index 8dd8b56..7184836 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -825,6 +825,137 @@ } } }, + "/ocs/v2.php/apps/forum/api/admin/guests/reassign": { + "post": { + "operationId": "admin-reassign-guest-posts", + "summary": "Reassign all posts and threads from a guest to a registered user", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "guestAuthorId", + "targetUserId" + ], + "properties": { + "guestAuthorId": { + "type": "string", + "description": "The guest author ID (format: \"guest:\")" + }, + "targetUserId": { + "type": "string", + "description": "The target Nextcloud user ID to assign posts to" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Posts reassigned successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "success", + "postsReassigned", + "threadsReassigned" + ], + "properties": { + "success": { + "type": "boolean" + }, + "postsReassigned": { + "type": "integer", + "format": "int64" + }, + "threadsReassigned": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/bbcodes": { "get": { "operationId": "bb_code-index", diff --git a/openapi.json b/openapi.json index 9883a37..d3fead9 100644 --- a/openapi.json +++ b/openapi.json @@ -825,6 +825,137 @@ } } }, + "/ocs/v2.php/apps/forum/api/admin/guests/reassign": { + "post": { + "operationId": "admin-reassign-guest-posts", + "summary": "Reassign all posts and threads from a guest to a registered user", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "guestAuthorId", + "targetUserId" + ], + "properties": { + "guestAuthorId": { + "type": "string", + "description": "The guest author ID (format: \"guest:\")" + }, + "targetUserId": { + "type": "string", + "description": "The target Nextcloud user ID to assign posts to" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Posts reassigned successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "success", + "postsReassigned", + "threadsReassigned" + ], + "properties": { + "success": { + "type": "boolean" + }, + "postsReassigned": { + "type": "integer", + "format": "int64" + }, + "threadsReassigned": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/bbcodes": { "get": { "operationId": "bb_code-index", diff --git a/src/components/GuestReassignDialog/GuestReassignDialog.test.ts b/src/components/GuestReassignDialog/GuestReassignDialog.test.ts new file mode 100644 index 0000000..314cb6b --- /dev/null +++ b/src/components/GuestReassignDialog/GuestReassignDialog.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createComponentMock } from '@/test-utils' + +// Mock axios +vi.mock('@/axios', () => ({ + ocs: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showSuccess: vi.fn(), + showError: vi.fn(), +})) + +// Mock NcAvatar +vi.mock('@nextcloud/vue/components/NcAvatar', () => + createComponentMock('NcAvatar', { + template: '', + props: ['user', 'size', 'showUserStatus'], + }), +) + +// Import after mocks +import { ocs } from '@/axios' +import { showSuccess } from '@nextcloud/dialogs' +import GuestReassignDialog from './GuestReassignDialog.vue' + +const mockGet = vi.mocked(ocs.get) +const mockPost = vi.mocked(ocs.post) + +describe('GuestReassignDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGet.mockResolvedValue({ data: [] } as never) + mockPost.mockResolvedValue({ + data: { success: true, postsReassigned: 3, threadsReassigned: 1 }, + } as never) + }) + + const createWrapper = (props = {}) => { + return mount(GuestReassignDialog, { + props: { + open: true, + guestAuthorId: 'guest:abcdef1234567890abcdef1234567890', + guestDisplayName: 'BrightMountain42', + ...props, + }, + }) + } + + describe('rendering', () => { + it('renders dialog when open', () => { + const wrapper = createWrapper() + expect(wrapper.find('.nc-dialog').exists()).toBe(true) + }) + + it('does not render dialog when closed', () => { + const wrapper = createWrapper({ open: false }) + expect(wrapper.find('.nc-dialog').exists()).toBe(false) + }) + + it('shows description text', () => { + const wrapper = createWrapper() + expect(wrapper.find('.description').exists()).toBe(true) + expect(wrapper.text()).toContain('All posts and threads by this guest will be reassigned') + }) + + it('shows user search input', () => { + const wrapper = createWrapper() + expect(wrapper.find('.user-search').exists()).toBe(true) + }) + }) + + describe('user search', () => { + it('calls autocomplete API when searching', async () => { + vi.useFakeTimers() + const users = [ + { id: 'alice', label: 'Alice Smith' }, + { id: 'bob', label: 'Bob Jones' }, + ] + mockGet.mockResolvedValue({ data: users } as never) + + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.handleSearch('ali') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(mockGet).toHaveBeenCalledWith('/users/autocomplete', { + params: { search: 'ali', limit: 10 }, + }) + + vi.useRealTimers() + }) + + it('does not call API for empty search', async () => { + vi.useFakeTimers() + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.handleSearch('') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(mockGet).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + + it('debounces search calls', async () => { + vi.useFakeTimers() + mockGet.mockResolvedValue({ data: [] } as never) + + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.handleSearch('a') + vm.handleSearch('al') + vm.handleSearch('ali') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith('/users/autocomplete', { + params: { search: 'ali', limit: 10 }, + }) + + vi.useRealTimers() + }) + }) + + describe('confirm action', () => { + it('calls reassign API with correct parameters', async () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + // Simulate selecting a user + vm.selectedUser = { id: 'alice', label: 'Alice Smith' } + await wrapper.vm.$nextTick() + + await vm.handleConfirm() + await flushPromises() + + expect(mockPost).toHaveBeenCalledWith('/admin/guests/reassign', { + guestAuthorId: 'guest:abcdef1234567890abcdef1234567890', + targetUserId: 'alice', + }) + }) + + it('shows success message after reassignment', async () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.selectedUser = { id: 'alice', label: 'Alice Smith' } + await vm.handleConfirm() + await flushPromises() + + expect(showSuccess).toHaveBeenCalledWith('Guest posts reassigned successfully') + }) + + it('emits reassigned event on success', async () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.selectedUser = { id: 'alice', label: 'Alice Smith' } + await vm.handleConfirm() + await flushPromises() + + expect(wrapper.emitted('reassigned')).toBeTruthy() + expect(wrapper.emitted('reassigned')![0]).toEqual([ + { + guestAuthorId: 'guest:abcdef1234567890abcdef1234567890', + targetUserId: 'alice', + targetDisplayName: 'Alice Smith', + }, + ]) + }) + + it('emits update:open false on success', async () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.selectedUser = { id: 'alice', label: 'Alice Smith' } + await vm.handleConfirm() + await flushPromises() + + expect(wrapper.emitted('update:open')).toBeTruthy() + expect(wrapper.emitted('update:open')![0]).toEqual([false]) + }) + + it('does not call API when no user is selected', async () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.selectedUser = null + await vm.handleConfirm() + await flushPromises() + + expect(mockPost).not.toHaveBeenCalled() + }) + + it('shows error message on API failure', async () => { + mockPost.mockRejectedValue({ + response: { data: { error: 'Target user does not exist' } }, + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.selectedUser = { id: 'nonexistent', label: 'Nobody' } + await vm.handleConfirm() + await flushPromises() + + expect(wrapper.find('.error-message').exists()).toBe(true) + expect(wrapper.find('.error-message').text()).toBe('Target user does not exist') + }) + }) + + describe('reset on open', () => { + it('resets state when dialog reopens', async () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + // Set some state + vm.selectedUser = { id: 'alice', label: 'Alice' } + vm.error = 'Some error' + + // Close and reopen + await wrapper.setProps({ open: false }) + await wrapper.setProps({ open: true }) + + expect(vm.selectedUser).toBeNull() + expect(vm.error).toBeNull() + }) + }) + + describe('close event', () => { + it('emits update:open false when handleClose is called', () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType + + vm.handleClose() + + expect(wrapper.emitted('update:open')).toBeTruthy() + expect(wrapper.emitted('update:open')![0]).toEqual([false]) + }) + }) +}) diff --git a/src/components/GuestReassignDialog/GuestReassignDialog.vue b/src/components/GuestReassignDialog/GuestReassignDialog.vue new file mode 100644 index 0000000..4be894e --- /dev/null +++ b/src/components/GuestReassignDialog/GuestReassignDialog.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/src/components/GuestReassignDialog/index.ts b/src/components/GuestReassignDialog/index.ts new file mode 100644 index 0000000..02ea837 --- /dev/null +++ b/src/components/GuestReassignDialog/index.ts @@ -0,0 +1,2 @@ +import GuestReassignDialog from './GuestReassignDialog.vue' +export default GuestReassignDialog diff --git a/src/components/PostCard/PostCard.test.ts b/src/components/PostCard/PostCard.test.ts index f08d679..c5240bb 100644 --- a/src/components/PostCard/PostCard.test.ts +++ b/src/components/PostCard/PostCard.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { createIconMock, createComponentMock } from '@/test-utils' -import { createMockPost, createMockUser } from '@/test-mocks' +import { createMockPost, createMockRole, createMockUser } from '@/test-mocks' +import { useUserRole } from '@/composables/useUserRole' import PostCard from './PostCard.vue' // Mock icons @@ -10,6 +11,7 @@ vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon')) vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon')) vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon')) vi.mock('@icons/LinkVariant.vue', () => createIconMock('LinkVariantIcon')) +vi.mock('@icons/AccountConvert.vue', () => createIconMock('AccountConvertIcon')) // Mock components vi.mock('@/components/UserInfo', () => @@ -40,6 +42,14 @@ vi.mock('@/components/PostHistoryDialog', () => }), ) +vi.mock('@/components/GuestReassignDialog', () => + createComponentMock('GuestReassignDialog', { + template: '
', + props: ['open', 'guestAuthorId', 'guestDisplayName'], + emits: ['update:open', 'reassigned'], + }), +) + vi.mock('@nextcloud/dialogs', () => ({ showSuccess: vi.fn(), showError: vi.fn(), @@ -72,6 +82,8 @@ describe('PostCard', () => { beforeEach(() => { vi.clearAllMocks() mockCurrentUser.mockReturnValue({ uid: 'testuser', displayName: 'Test User' }) + const { clear } = useUserRole() + clear() }) describe('rendering', () => { @@ -502,4 +514,99 @@ describe('PostCard', () => { expect(buttons.some((b) => b.text().includes('Delete'))).toBe(false) }) }) + + describe('guest reassignment', () => { + it('should show assign to account button for guest posts when user has canManageUsers', () => { + const { setRoles } = useUserRole() + setRoles('testuser', [createMockRole({ canManageUsers: true })]) + + const guestAuthor = createMockUser({ + userId: 'guest:abc123', + displayName: 'BrightMountain42', + isGuest: true, + }) + const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor }) + const wrapper = mount(PostCard, { + props: { post }, + }) + + const buttons = wrapper.findAll('.nc-action-button') + expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(true) + }) + + it('should not show assign to account button for non-guest posts', () => { + const { setRoles } = useUserRole() + setRoles('testuser', [createMockRole({ canManageUsers: true })]) + + const author = createMockUser({ userId: 'alice', isGuest: false }) + const post = createMockPost({ authorId: 'alice', author }) + const wrapper = mount(PostCard, { + props: { post }, + }) + + const buttons = wrapper.findAll('.nc-action-button') + expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(false) + }) + + it('should not show assign to account button when user lacks canManageUsers', () => { + const { setRoles } = useUserRole() + setRoles('testuser', [createMockRole({ canManageUsers: false })]) + + const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true }) + const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor }) + const wrapper = mount(PostCard, { + props: { post }, + }) + + const buttons = wrapper.findAll('.nc-action-button') + expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(false) + }) + + it('should open reassign dialog when button is clicked', async () => { + const { setRoles } = useUserRole() + setRoles('testuser', [createMockRole({ canManageUsers: true })]) + + const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true }) + const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor }) + const wrapper = mount(PostCard, { + props: { post }, + }) + + expect(wrapper.find('.guest-reassign-dialog-mock').exists()).toBe(false) + + const button = wrapper + .findAll('.nc-action-button') + .find((b) => b.text().includes('Assign to account')) + await button?.trigger('click') + + expect(wrapper.find('.guest-reassign-dialog-mock').exists()).toBe(true) + }) + + it('should emit reassigned event from dialog', async () => { + const { setRoles } = useUserRole() + setRoles('testuser', [createMockRole({ canManageUsers: true })]) + + const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true }) + const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor }) + const wrapper = mount(PostCard, { + props: { post }, + }) + + const vm = wrapper.vm as InstanceType + vm.handleReassigned({ + guestAuthorId: 'guest:abc123', + targetUserId: 'alice', + targetDisplayName: 'Alice', + }) + + expect(wrapper.emitted('reassigned')).toBeTruthy() + expect(wrapper.emitted('reassigned')![0]).toEqual([ + { + guestAuthorId: 'guest:abc123', + targetUserId: 'alice', + targetDisplayName: 'Alice', + }, + ]) + }) + }) }) diff --git a/src/components/PostCard/PostCard.vue b/src/components/PostCard/PostCard.vue index ff81a10..1bc8f3f 100644 --- a/src/components/PostCard/PostCard.vue +++ b/src/components/PostCard/PostCard.vue @@ -54,6 +54,12 @@ {{ strings.directLink }} + + + {{ strings.assignToAccount }} +
@@ -91,6 +97,15 @@ :post-id="post.id" @update:open="showHistoryDialog = $event" /> + + + @@ -104,14 +119,17 @@ import PencilIcon from '@icons/Pencil.vue' import DeleteIcon from '@icons/Delete.vue' import HistoryIcon from '@icons/History.vue' import LinkVariantIcon from '@icons/LinkVariant.vue' +import AccountConvertIcon from '@icons/AccountConvert.vue' import UserInfo from '@/components/UserInfo' import PostReactions from '@/components/PostReactions' import PostEditForm from '@/components/PostEditForm' import PostHistoryDialog from '@/components/PostHistoryDialog' +import GuestReassignDialog from '@/components/GuestReassignDialog' import { t } from '@nextcloud/l10n' import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' import { showSuccess } from '@nextcloud/dialogs' +import { useUserRole } from '@/composables/useUserRole' import type { Post } from '@/types' import type { ReactionGroup } from '@/composables/useReactions' @@ -126,10 +144,12 @@ export default defineComponent({ DeleteIcon, HistoryIcon, LinkVariantIcon, + AccountConvertIcon, UserInfo, PostReactions, PostEditForm, PostHistoryDialog, + GuestReassignDialog, }, props: { post: { @@ -157,14 +177,16 @@ export default defineComponent({ default: 1, }, }, - emits: ['reply', 'edit', 'delete', 'update'], + emits: ['reply', 'edit', 'delete', 'update', 'reassigned'], setup() { - return {} + const { canManageUsers } = useUserRole() + return { canManageUsers } }, data() { return { isEditing: false, showHistoryDialog: false, + showReassignDialog: false, strings: { edited: t('forum', 'Edited'), reply: t('forum', 'Quote reply'), @@ -178,6 +200,7 @@ export default defineComponent({ unread: t('forum', 'Unread'), directLink: t('forum', 'Direct link'), directLinkCopied: t('forum', 'Direct link copied to clipboard'), + assignToAccount: t('forum', 'Assign to account'), }, } }, @@ -209,6 +232,9 @@ export default defineComponent({ hasSignature(): boolean { return !!this.post.author?.signature }, + canReassignGuest(): boolean { + return this.canManageUsers && !!this.post.author?.isGuest + }, }, methods: { closeActionsMenu() { @@ -245,6 +271,19 @@ export default defineComponent({ this.showHistoryDialog = true }, + handleReassignGuest() { + this.closeActionsMenu() + this.showReassignDialog = true + }, + + handleReassigned(data: { + guestAuthorId: string + targetUserId: string + targetDisplayName: string + }) { + this.$emit('reassigned', data) + }, + async handleDirectLink() { this.closeActionsMenu() diff --git a/src/views/ThreadView.vue b/src/views/ThreadView.vue index c3150ff..962bce9 100644 --- a/src/views/ThreadView.vue +++ b/src/views/ThreadView.vue @@ -209,6 +209,7 @@ @reply="handleReply" @update="handleUpdate" @delete="handleDelete" + @reassigned="handleReassigned" /> @@ -246,6 +247,7 @@ @reply="handleReply" @update="handleUpdate" @delete="handleDelete" + @reassigned="handleReassigned" /> @@ -874,6 +876,60 @@ export default defineComponent({ } }, + async handleReassigned(data: { + guestAuthorId: string + targetUserId: string + targetDisplayName: string + }): Promise { + try { + // Fetch the target user's roles from the forum user endpoint + let roles: any[] = [] + try { + const response = await ocs.get(`/users/${data.targetUserId}`) + roles = response.data?.roles || [] + } catch { + // User may not have a forum profile yet - that is fine + } + + const newAuthor = { + userId: data.targetUserId, + displayName: data.targetDisplayName, + isDeleted: false, + isGuest: false, + roles, + signature: null, + signatureRaw: null, + } + + // Update first post if it belonged to this guest + if (this.firstPost && this.firstPost.authorId === data.guestAuthorId) { + this.firstPost = { ...this.firstPost, authorId: data.targetUserId, author: newAuthor } + } + + // Update all replies that belonged to this guest + this.replies = this.replies.map((reply) => { + if (reply.authorId === data.guestAuthorId) { + return { ...reply, authorId: data.targetUserId, author: newAuthor } + } + return reply + }) + + // Update thread header if the thread author was this guest + if (this.thread && this.thread.authorId === data.guestAuthorId) { + this.thread.authorId = data.targetUserId + this.thread.author = newAuthor + } + + // Update lastReplyAuthorId if it was this guest + if (this.thread && this.thread.lastReplyAuthorId === data.guestAuthorId) { + this.thread.lastReplyAuthorId = data.targetUserId + } + } catch (e) { + console.error('Failed to update posts after reassignment', e) + // Posts were reassigned on the backend; a refresh will show the correct state + } + }, + replyToThread(): void { // Redirect guests to login (only if they cannot reply) if (this.userId === null && !this.canReply) { diff --git a/src/views/__tests__/ThreadView.test.ts b/src/views/__tests__/ThreadView.test.ts index 2dcb173..d1f800c 100644 --- a/src/views/__tests__/ThreadView.test.ts +++ b/src/views/__tests__/ThreadView.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { computed, ref } from 'vue' import { createIconMock, createComponentMock } from '@/test-utils' -import { createMockThread, createMockPost } from '@/test-mocks' +import { createMockThread, createMockPost, createMockUser } from '@/test-mocks' // Mock axios vi.mock('@/axios', () => ({ @@ -82,7 +82,7 @@ vi.mock('@/components/PostCard', () => template: '
', props: ['post', 'isFirstPost', 'isUnread', 'canModerateCategory', 'canReply'], - emits: ['reply', 'update', 'delete'], + emits: ['reply', 'update', 'delete', 'reassigned'], }), ) @@ -342,4 +342,243 @@ describe('ThreadView', () => { expect(wrapper.text()).toContain('You do not have permission to reply in this category.') }) }) + + describe('guest reassignment', () => { + const guestAuthorId = 'guest:abcdef1234567890abcdef1234567890' + const guestAuthor = createMockUser({ + userId: guestAuthorId, + displayName: 'BrightMountain42', + isGuest: true, + }) + + it('updates first post author in-place after reassignment', async () => { + const guestFirstPost = createMockPost({ + id: 1, + authorId: guestAuthorId, + author: guestAuthor, + content: '

Guest post

', + }) + + mockOcsGet.mockImplementation((url: string) => { + if (url.includes('/posts/paginated')) { + return mockGetResponse({ + data: { + firstPost: guestFirstPost, + replies: [], + pagination: { + page: 1, + perPage: 20, + total: 0, + totalPages: 1, + startPage: 1, + lastReadPostId: null, + }, + }, + }) + } + if (url === '/users/alice') { + return mockGetResponse({ data: { userId: 'alice', roles: [{ id: 1, name: 'Default' }] } }) + } + return mockGetResponse({ data: null }) + }) + + const wrapper = createWrapper() + await flushPromises() + + const vm = wrapper.vm as InstanceType + await vm.handleReassigned({ + guestAuthorId, + targetUserId: 'alice', + targetDisplayName: 'Alice Smith', + }) + await flushPromises() + + expect(vm.firstPost!.authorId).toBe('alice') + expect(vm.firstPost!.author!.displayName).toBe('Alice Smith') + expect(vm.firstPost!.author!.isGuest).toBe(false) + }) + + it('updates replies author in-place after reassignment', async () => { + const guestReply = createMockPost({ id: 10, authorId: guestAuthorId, author: guestAuthor }) + const otherReply = createMockPost({ + id: 11, + authorId: 'bob', + author: createMockUser({ userId: 'bob' }), + }) + + mockOcsGet.mockImplementation((url: string) => { + if (url.includes('/posts/paginated')) { + return mockGetResponse({ + data: { + firstPost: mockFirstPost, + replies: [guestReply, otherReply], + pagination: { + page: 1, + perPage: 20, + total: 2, + totalPages: 1, + startPage: 1, + lastReadPostId: null, + }, + }, + }) + } + if (url === '/users/alice') { + return mockGetResponse({ data: { userId: 'alice', roles: [] } }) + } + return mockGetResponse({ data: null }) + }) + + const wrapper = createWrapper() + await flushPromises() + + const vm = wrapper.vm as InstanceType + await vm.handleReassigned({ + guestAuthorId, + targetUserId: 'alice', + targetDisplayName: 'Alice Smith', + }) + await flushPromises() + + // Guest reply should be updated + expect(vm.replies[0]!.authorId).toBe('alice') + expect(vm.replies[0]!.author!.displayName).toBe('Alice Smith') + expect(vm.replies[0]!.author!.isGuest).toBe(false) + + // Other reply should be unchanged + expect(vm.replies[1]!.authorId).toBe('bob') + }) + + it('updates thread header author after reassignment', async () => { + mockThread.value = createMockThread({ + id: 1, + categoryId: 5, + authorId: guestAuthorId, + author: guestAuthor, + }) + mockFetchThread.mockResolvedValue(mockThread.value) + + mockOcsGet.mockImplementation((url: string) => { + if (url.includes('/posts/paginated')) { + return mockGetResponse({ + data: { + firstPost: mockFirstPost, + replies: [], + pagination: { + page: 1, + perPage: 20, + total: 0, + totalPages: 1, + startPage: 1, + lastReadPostId: null, + }, + }, + }) + } + if (url === '/users/alice') { + return mockGetResponse({ data: { userId: 'alice', roles: [] } }) + } + return mockGetResponse({ data: null }) + }) + + const wrapper = createWrapper() + await flushPromises() + + const vm = wrapper.vm as InstanceType + await vm.handleReassigned({ + guestAuthorId, + targetUserId: 'alice', + targetDisplayName: 'Alice Smith', + }) + await flushPromises() + + expect(vm.thread!.authorId).toBe('alice') + expect(vm.thread!.author!.displayName).toBe('Alice Smith') + }) + + it('updates thread lastReplyAuthorId after reassignment', async () => { + mockThread.value = createMockThread({ + id: 1, + categoryId: 5, + lastReplyAuthorId: guestAuthorId, + }) + mockFetchThread.mockResolvedValue(mockThread.value) + + mockOcsGet.mockImplementation((url: string) => { + if (url.includes('/posts/paginated')) { + return mockGetResponse({ + data: { + firstPost: mockFirstPost, + replies: [], + pagination: { + page: 1, + perPage: 20, + total: 0, + totalPages: 1, + startPage: 1, + lastReadPostId: null, + }, + }, + }) + } + return mockGetResponse({ data: null }) + }) + + const wrapper = createWrapper() + await flushPromises() + + const vm = wrapper.vm as InstanceType + await vm.handleReassigned({ + guestAuthorId, + targetUserId: 'alice', + targetDisplayName: 'Alice Smith', + }) + await flushPromises() + + expect(vm.thread!.lastReplyAuthorId).toBe('alice') + }) + + it('handles user fetch failure gracefully', async () => { + const guestReply = createMockPost({ id: 10, authorId: guestAuthorId, author: guestAuthor }) + + mockOcsGet.mockImplementation((url: string) => { + if (url.includes('/posts/paginated')) { + return mockGetResponse({ + data: { + firstPost: mockFirstPost, + replies: [guestReply], + pagination: { + page: 1, + perPage: 20, + total: 1, + totalPages: 1, + startPage: 1, + lastReadPostId: null, + }, + }, + }) + } + if (url === '/users/alice') { + return Promise.reject(new Error('Not found')) + } + return mockGetResponse({ data: null }) + }) + + const wrapper = createWrapper() + await flushPromises() + + const vm = wrapper.vm as InstanceType + await vm.handleReassigned({ + guestAuthorId, + targetUserId: 'alice', + targetDisplayName: 'Alice Smith', + }) + await flushPromises() + + // Should still update with empty roles + expect(vm.replies[0]!.authorId).toBe('alice') + expect(vm.replies[0]!.author!.displayName).toBe('Alice Smith') + expect(vm.replies[0]!.author!.roles).toEqual([]) + }) + }) }) diff --git a/tests/unit/Controller/AdminControllerTest.php b/tests/unit/Controller/AdminControllerTest.php index 82456aa..1cc5920 100644 --- a/tests/unit/Controller/AdminControllerTest.php +++ b/tests/unit/Controller/AdminControllerTest.php @@ -12,8 +12,10 @@ use OCA\Forum\Db\PostMapper; use OCA\Forum\Db\RoleMapper; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Service\AdminSettingsService; +use OCA\Forum\Service\GuestService; use OCA\Forum\Service\UserRoleService; use OCA\Forum\Service\UserService; +use OCP\AppFramework\Http; use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; @@ -45,6 +47,8 @@ class AdminControllerTest extends TestCase { private IUserSession $userSession; /** @var AdminSettingsService&MockObject */ private AdminSettingsService $settingsService; + /** @var GuestService&MockObject */ + private GuestService $guestService; /** @var LoggerInterface&MockObject */ private LoggerInterface $logger; /** @var IRequest&MockObject */ @@ -62,6 +66,7 @@ class AdminControllerTest extends TestCase { $this->userManager = $this->createMock(IUserManager::class); $this->userSession = $this->createMock(IUserSession::class); $this->settingsService = $this->createMock(AdminSettingsService::class); + $this->guestService = $this->createMock(GuestService::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new AdminController( @@ -77,6 +82,7 @@ class AdminControllerTest extends TestCase { $this->userManager, $this->userSession, $this->settingsService, + $this->guestService, $this->logger ); } @@ -260,4 +266,114 @@ class AdminControllerTest extends TestCase { $this->assertEquals(3, $contributor['postCount']); $this->assertEquals(1, $contributor['threadCount']); } + + // ── Guest reassignment tests ───────────────────────────────────── + + public function testReassignGuestPostsSuccess(): void { + $guestAuthorId = 'guest:abcdef1234567890abcdef1234567890'; + $targetUserId = 'alice'; + + $targetUser = $this->createMock(IUser::class); + $this->userManager->expects($this->once()) + ->method('get') + ->with($targetUserId) + ->willReturn($targetUser); + + $this->postMapper->expects($this->once()) + ->method('countByAuthorId') + ->with($guestAuthorId) + ->willReturn(['total' => 5, 'threads' => 1, 'replies' => 4]); + + $this->postMapper->expects($this->once()) + ->method('reassignAuthor') + ->with($guestAuthorId, $targetUserId) + ->willReturn(5); + + $this->threadMapper->expects($this->once()) + ->method('reassignAuthor') + ->with($guestAuthorId, $targetUserId) + ->willReturn(1); + + $this->threadMapper->expects($this->once()) + ->method('reassignLastReplyAuthor') + ->with($guestAuthorId, $targetUserId); + + $this->forumUserMapper->expects($this->once()) + ->method('incrementPostCount') + ->with($targetUserId, 4); + + $this->forumUserMapper->expects($this->once()) + ->method('incrementThreadCount') + ->with($targetUserId, 1); + + $response = $this->controller->reassignGuestPosts($guestAuthorId, $targetUserId); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertTrue($data['success']); + $this->assertEquals(5, $data['postsReassigned']); + $this->assertEquals(1, $data['threadsReassigned']); + } + + public function testReassignGuestPostsRejectsInvalidGuestId(): void { + $response = $this->controller->reassignGuestPosts('not-a-guest-id', 'alice'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertEquals('Invalid guest author ID format', $response->getData()['error']); + } + + public function testReassignGuestPostsRejectsNonexistentTargetUser(): void { + $this->userManager->expects($this->once()) + ->method('get') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->reassignGuestPosts( + 'guest:abcdef1234567890abcdef1234567890', + 'nonexistent' + ); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertEquals('Target user does not exist', $response->getData()['error']); + } + + public function testReassignGuestPostsDoesNotIncrementZeroCounts(): void { + $guestAuthorId = 'guest:abcdef1234567890abcdef1234567890'; + $targetUserId = 'alice'; + + $targetUser = $this->createMock(IUser::class); + $this->userManager->method('get')->willReturn($targetUser); + + $this->postMapper->method('countByAuthorId') + ->willReturn(['total' => 0, 'threads' => 0, 'replies' => 0]); + $this->postMapper->method('reassignAuthor')->willReturn(0); + $this->threadMapper->method('reassignAuthor')->willReturn(0); + $this->threadMapper->method('reassignLastReplyAuthor'); + + $this->forumUserMapper->expects($this->never())->method('incrementPostCount'); + $this->forumUserMapper->expects($this->never())->method('incrementThreadCount'); + + $response = $this->controller->reassignGuestPosts($guestAuthorId, $targetUserId); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($response->getData()['success']); + } + + public function testReassignGuestPostsHandlesException(): void { + $guestAuthorId = 'guest:abcdef1234567890abcdef1234567890'; + + $targetUser = $this->createMock(IUser::class); + $this->userManager->method('get')->willReturn($targetUser); + + $this->postMapper->method('countByAuthorId') + ->willThrowException(new \Exception('DB error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Error reassigning guest posts')); + + $response = $this->controller->reassignGuestPosts($guestAuthorId, 'alice'); + + $this->assertEquals(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + } }