mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
feat: allow reassigning guests to actual users
This commit is contained in:
@@ -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:<token>")
|
||||
* @param string $targetUserId The target Nextcloud user ID to assign posts to
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool, postsReassigned: int, threadsReassigned: int}, array{}>
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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:<token>\")"
|
||||
},
|
||||
"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",
|
||||
|
||||
131
openapi.json
131
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:<token>\")"
|
||||
},
|
||||
"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",
|
||||
|
||||
253
src/components/GuestReassignDialog/GuestReassignDialog.test.ts
Normal file
253
src/components/GuestReassignDialog/GuestReassignDialog.test.ts
Normal file
@@ -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: '<span class="nc-avatar-mock" :data-user="user" />',
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
// 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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
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<typeof GuestReassignDialog>
|
||||
|
||||
// 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<typeof GuestReassignDialog>
|
||||
|
||||
vm.handleClose()
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
})
|
||||
})
|
||||
234
src/components/GuestReassignDialog/GuestReassignDialog.vue
Normal file
234
src/components/GuestReassignDialog/GuestReassignDialog.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<NcDialog :name="strings.title" :open="open" size="normal" @update:open="handleClose">
|
||||
<div class="guest-reassign-dialog">
|
||||
<p class="description">
|
||||
{{ strings.description }}
|
||||
</p>
|
||||
|
||||
<div class="user-search">
|
||||
<NcSelect
|
||||
v-model="selectedUser"
|
||||
:options="userOptions"
|
||||
:placeholder="strings.searchPlaceholder"
|
||||
:input-label="strings.searchLabel"
|
||||
:loading="searching"
|
||||
:filterable="false"
|
||||
label="label"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #option="option">
|
||||
<div class="user-option">
|
||||
<NcAvatar :user="option.id" :size="24" :show-user-status="false" />
|
||||
<span class="user-option-label">{{ option.label }}</span>
|
||||
<span class="user-option-id muted">@{{ option.id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #selected-option="option">
|
||||
<div class="user-option">
|
||||
<NcAvatar :user="option.id" :size="20" :show-user-status="false" />
|
||||
<span class="user-option-label">{{ option.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-options>
|
||||
{{ searchQuery ? strings.noResults : strings.typeToSearch }}
|
||||
</template>
|
||||
</NcSelect>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="handleClose">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" :disabled="!selectedUser || submitting" @click="handleConfirm">
|
||||
{{ submitting ? strings.reassigning : strings.confirm }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { showSuccess, showError } from '@nextcloud/dialogs'
|
||||
|
||||
interface UserOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'GuestReassignDialog',
|
||||
components: {
|
||||
NcDialog,
|
||||
NcButton,
|
||||
NcSelect,
|
||||
NcAvatar,
|
||||
},
|
||||
props: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
guestAuthorId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
guestDisplayName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:open', 'reassigned'],
|
||||
data() {
|
||||
return {
|
||||
selectedUser: null as UserOption | null,
|
||||
userOptions: [] as UserOption[],
|
||||
searching: false,
|
||||
submitting: false,
|
||||
error: null as string | null,
|
||||
searchQuery: '',
|
||||
searchTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||
strings: {
|
||||
title: t('forum', 'Assign guest posts to account'),
|
||||
description: t(
|
||||
'forum',
|
||||
'All posts and threads by this guest will be reassigned to the selected account.',
|
||||
),
|
||||
searchPlaceholder: t('forum', 'Search for an account …'),
|
||||
searchLabel: t('forum', 'Account'),
|
||||
noResults: t('forum', 'No accounts found'),
|
||||
typeToSearch: t('forum', 'Type to search for an account'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
confirm: t('forum', 'Reassign'),
|
||||
reassigning: t('forum', 'Reassigning …'),
|
||||
successMessage: t('forum', 'Guest posts reassigned successfully'),
|
||||
errorMessage: t('forum', 'Failed to reassign guest posts'),
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
open(newVal: boolean) {
|
||||
if (newVal) {
|
||||
this.reset()
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
reset() {
|
||||
this.selectedUser = null
|
||||
this.userOptions = []
|
||||
this.error = null
|
||||
this.searchQuery = ''
|
||||
},
|
||||
|
||||
handleClose() {
|
||||
this.$emit('update:open', false)
|
||||
},
|
||||
|
||||
handleSearch(query: string) {
|
||||
this.searchQuery = query
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
}
|
||||
if (!query || query.length < 1) {
|
||||
this.userOptions = []
|
||||
return
|
||||
}
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.fetchUsers(query)
|
||||
}, 300)
|
||||
},
|
||||
|
||||
async fetchUsers(query: string) {
|
||||
try {
|
||||
this.searching = true
|
||||
const response = await ocs.get<Array<{ id: string; label: string }>>(
|
||||
'/users/autocomplete',
|
||||
{
|
||||
params: { search: query, limit: 10 },
|
||||
},
|
||||
)
|
||||
this.userOptions = (response.data || []).map((u) => ({
|
||||
id: u.id,
|
||||
label: u.label,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('Error searching users:', e)
|
||||
this.userOptions = []
|
||||
} finally {
|
||||
this.searching = false
|
||||
}
|
||||
},
|
||||
|
||||
async handleConfirm() {
|
||||
if (!this.selectedUser || !this.guestAuthorId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.submitting = true
|
||||
this.error = null
|
||||
|
||||
await ocs.post('/admin/guests/reassign', {
|
||||
guestAuthorId: this.guestAuthorId,
|
||||
targetUserId: this.selectedUser.id,
|
||||
})
|
||||
|
||||
showSuccess(this.strings.successMessage)
|
||||
this.$emit('reassigned', {
|
||||
guestAuthorId: this.guestAuthorId,
|
||||
targetUserId: this.selectedUser.id,
|
||||
targetDisplayName: this.selectedUser.label,
|
||||
})
|
||||
this.handleClose()
|
||||
} catch (e) {
|
||||
console.error('Error reassigning guest posts:', e)
|
||||
const errorData = (e as any)?.response?.data
|
||||
this.error = errorData?.error || this.strings.errorMessage
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.guest-reassign-dialog {
|
||||
padding: 8px 0;
|
||||
|
||||
.description {
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.user-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-option-id {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 12px;
|
||||
color: var(--color-error);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2
src/components/GuestReassignDialog/index.ts
Normal file
2
src/components/GuestReassignDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import GuestReassignDialog from './GuestReassignDialog.vue'
|
||||
export default GuestReassignDialog
|
||||
@@ -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: '<div class="guest-reassign-dialog-mock" v-if="open" />',
|
||||
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<typeof PostCard>
|
||||
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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
</template>
|
||||
{{ strings.directLink }}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="canReassignGuest" @click="handleReassignGuest">
|
||||
<template #icon>
|
||||
<AccountConvertIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.assignToAccount }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,6 +97,15 @@
|
||||
:post-id="post.id"
|
||||
@update:open="showHistoryDialog = $event"
|
||||
/>
|
||||
|
||||
<!-- Guest Reassign Dialog -->
|
||||
<GuestReassignDialog
|
||||
:open="showReassignDialog"
|
||||
:guest-author-id="post.authorId"
|
||||
:guest-display-name="post.author?.displayName || ''"
|
||||
@update:open="showReassignDialog = $event"
|
||||
@reassigned="handleReassigned"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
@reply="handleReply"
|
||||
@update="handleUpdate"
|
||||
@delete="handleDelete"
|
||||
@reassigned="handleReassigned"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -246,6 +247,7 @@
|
||||
@reply="handleReply"
|
||||
@update="handleUpdate"
|
||||
@delete="handleDelete"
|
||||
@reassigned="handleReassigned"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -874,6 +876,60 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
async handleReassigned(data: {
|
||||
guestAuthorId: string
|
||||
targetUserId: string
|
||||
targetDisplayName: string
|
||||
}): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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:
|
||||
'<div class="post-card-mock" :data-can-reply="canReply" :data-can-moderate="canModerateCategory" />',
|
||||
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: '<p>Guest post</p>',
|
||||
})
|
||||
|
||||
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<typeof ThreadView>
|
||||
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<typeof ThreadView>
|
||||
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<typeof ThreadView>
|
||||
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<typeof ThreadView>
|
||||
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<typeof ThreadView>
|
||||
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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user