mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: add "last reply by" to thread card
This commit is contained in:
@@ -401,6 +401,8 @@ class PostController extends OCSController {
|
||||
$thread = $this->threadMapper->find($threadId);
|
||||
$thread->setPostCount($thread->getPostCount() + 1);
|
||||
$thread->setLastPostId($createdPost->getId());
|
||||
$thread->setLastReplyAuthorId($createdPost->getAuthorId());
|
||||
$thread->setLastReplyAt($createdPost->getCreatedAt());
|
||||
$thread->setUpdatedAt(time());
|
||||
$this->threadMapper->update($thread);
|
||||
} catch (\Exception $e) {
|
||||
@@ -561,9 +563,19 @@ class PostController extends OCSController {
|
||||
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
|
||||
if ($latestPost) {
|
||||
$thread->setLastPostId($latestPost->getId());
|
||||
if (!$latestPost->getIsFirstPost()) {
|
||||
$thread->setLastReplyAuthorId($latestPost->getAuthorId());
|
||||
$thread->setLastReplyAt($latestPost->getCreatedAt());
|
||||
} else {
|
||||
// Only the first post remains — no replies
|
||||
$thread->setLastReplyAuthorId(null);
|
||||
$thread->setLastReplyAt(null);
|
||||
}
|
||||
} else {
|
||||
// No other posts in thread, set to null (or keep first post ID)
|
||||
$thread->setLastPostId(null);
|
||||
$thread->setLastReplyAuthorId(null);
|
||||
$thread->setLastReplyAt(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,15 +151,26 @@ class ThreadController extends OCSController {
|
||||
// Fetch threads for the current page
|
||||
$threads = $this->threadMapper->findByCategoryId($categoryId, $perPage, $offset);
|
||||
|
||||
// Extract unique author IDs
|
||||
$authorIds = array_unique(array_map(fn ($t) => $t->getAuthorId(), $threads));
|
||||
// Extract unique author IDs (thread authors + last reply authors)
|
||||
$authorIds = array_map(fn ($t) => $t->getAuthorId(), $threads);
|
||||
$lastReplyAuthorIds = array_filter(array_map(fn ($t) => $t->getLastReplyAuthorId(), $threads));
|
||||
$allAuthorIds = array_unique(array_merge($authorIds, $lastReplyAuthorIds));
|
||||
|
||||
// Batch fetch author data (includes roles)
|
||||
$authors = $this->userService->enrichMultipleUsers($authorIds);
|
||||
$authors = $this->userService->enrichMultipleUsers($allAuthorIds);
|
||||
|
||||
// Enrich threads with pre-fetched author data
|
||||
// Enrich threads with pre-fetched author data and last reply info
|
||||
$enrichedThreads = array_map(function ($t) use ($authors) {
|
||||
return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null);
|
||||
$lastReply = null;
|
||||
$lastReplyAuthorId = $t->getLastReplyAuthorId();
|
||||
if ($lastReplyAuthorId !== null) {
|
||||
$lastReply = [
|
||||
'postId' => $t->getLastPostId(),
|
||||
'author' => $authors[$lastReplyAuthorId] ?? null,
|
||||
'createdAt' => $t->getLastReplyAt(),
|
||||
];
|
||||
}
|
||||
return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null, $lastReply);
|
||||
}, $threads);
|
||||
|
||||
return new DataResponse([
|
||||
|
||||
@@ -93,6 +93,25 @@ class PostMapper extends QBMapper {
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find posts by multiple IDs
|
||||
*
|
||||
* @param array<int> $ids
|
||||
* @return array<Post>
|
||||
*/
|
||||
public function findByIds(array $ids): array {
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)))
|
||||
->andWhere($qb->expr()->isNull('deleted_at'));
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Post>
|
||||
*/
|
||||
|
||||
@@ -28,6 +28,10 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setPostCount(int $value)
|
||||
* @method int|null getLastPostId()
|
||||
* @method void setLastPostId(?int $value)
|
||||
* @method string|null getLastReplyAuthorId()
|
||||
* @method void setLastReplyAuthorId(?string $value)
|
||||
* @method int|null getLastReplyAt()
|
||||
* @method void setLastReplyAt(?int $value)
|
||||
* @method bool getIsLocked()
|
||||
* @method void setIsLocked(bool $value)
|
||||
* @method bool getIsPinned()
|
||||
@@ -49,6 +53,8 @@ class Thread extends Entity implements JsonSerializable {
|
||||
protected $viewCount;
|
||||
protected $postCount;
|
||||
protected $lastPostId;
|
||||
protected $lastReplyAuthorId;
|
||||
protected $lastReplyAt;
|
||||
protected $isLocked;
|
||||
protected $isPinned;
|
||||
protected $isHidden;
|
||||
@@ -65,6 +71,8 @@ class Thread extends Entity implements JsonSerializable {
|
||||
$this->addType('viewCount', 'integer');
|
||||
$this->addType('postCount', 'integer');
|
||||
$this->addType('lastPostId', 'integer');
|
||||
$this->addType('lastReplyAuthorId', 'string');
|
||||
$this->addType('lastReplyAt', 'integer');
|
||||
$this->addType('isLocked', 'boolean');
|
||||
$this->addType('isPinned', 'boolean');
|
||||
$this->addType('isHidden', 'boolean');
|
||||
@@ -83,6 +91,8 @@ class Thread extends Entity implements JsonSerializable {
|
||||
'viewCount' => $this->getViewCount(),
|
||||
'postCount' => $this->getPostCount(),
|
||||
'lastPostId' => $this->getLastPostId(),
|
||||
'lastReplyAuthorId' => $this->getLastReplyAuthorId(),
|
||||
'lastReplyAt' => $this->getLastReplyAt(),
|
||||
'isLocked' => $this->getIsLocked(),
|
||||
'isPinned' => $this->getIsPinned(),
|
||||
'isHidden' => $this->getIsHidden(),
|
||||
|
||||
83
lib/Migration/Version26Date20260321000000.php
Normal file
83
lib/Migration/Version26Date20260321000000.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\Types;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 26 Migration:
|
||||
* - Add last_reply_author_id and last_reply_at columns to forum_threads
|
||||
* - Backfill from existing posts data
|
||||
*/
|
||||
class Version26Date20260321000000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
}
|
||||
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('forum_threads')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('forum_threads');
|
||||
|
||||
if (!$table->hasColumn('last_reply_author_id')) {
|
||||
$table->addColumn('last_reply_author_id', Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 64,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('last_reply_at')) {
|
||||
$table->addColumn('last_reply_at', Types::BIGINT, [
|
||||
'notnull' => false,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
$output->info('Forum: Backfilling last_reply_author_id and last_reply_at from posts …');
|
||||
|
||||
// Find threads that have a last_post_id pointing to a non-first-post
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('t.id', 'p.author_id', 'p.created_at')
|
||||
->from('forum_threads', 't')
|
||||
->innerJoin('t', 'forum_posts', 'p', $qb->expr()->eq('t.last_post_id', 'p.id'))
|
||||
->where($qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
|
||||
->andWhere($qb->expr()->isNull('p.deleted_at'))
|
||||
->andWhere($qb->expr()->isNull('t.deleted_at'));
|
||||
$result = $qb->executeQuery();
|
||||
|
||||
$count = 0;
|
||||
while ($row = $result->fetch()) {
|
||||
$update = $this->db->getQueryBuilder();
|
||||
$update->update('forum_threads')
|
||||
->set('last_reply_author_id', $update->createNamedParameter($row['author_id']))
|
||||
->set('last_reply_at', $update->createNamedParameter((int)$row['created_at'], IQueryBuilder::PARAM_INT))
|
||||
->where($update->expr()->eq('id', $update->createNamedParameter((int)$row['id'], IQueryBuilder::PARAM_INT)));
|
||||
$update->executeStatement();
|
||||
$count++;
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
$output->info("Forum: Backfilled last reply info for $count threads.");
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class ThreadEnrichmentService {
|
||||
* @param array|null $author Optional pre-loaded author data
|
||||
* @return array Enriched thread data
|
||||
*/
|
||||
public function enrichThread(mixed $thread, ?array $author = null): array {
|
||||
public function enrichThread(mixed $thread, ?array $author = null, ?array $lastReply = null): array {
|
||||
if (!is_array($thread)) {
|
||||
$thread = $thread->jsonSerialize();
|
||||
}
|
||||
@@ -45,6 +45,9 @@ class ThreadEnrichmentService {
|
||||
$thread['author'] = $author;
|
||||
}
|
||||
|
||||
// Add last reply info (author + timestamp)
|
||||
$thread['lastReply'] = $lastReply;
|
||||
|
||||
// Add category information (slug and name) for navigation
|
||||
try {
|
||||
$category = $this->categoryMapper->find($thread['categoryId']);
|
||||
|
||||
@@ -133,4 +133,82 @@ describe('ThreadCard', () => {
|
||||
expect(statValues[1]!.text()).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('last reply', () => {
|
||||
it('should show last reply info when thread has a last reply', () => {
|
||||
const thread = createMockThread({
|
||||
lastPostId: 42,
|
||||
lastReplyAuthorId: 'alice',
|
||||
lastReplyAt: 1700000000,
|
||||
lastReply: {
|
||||
postId: 42,
|
||||
author: {
|
||||
userId: 'alice',
|
||||
displayName: 'Alice',
|
||||
isDeleted: false,
|
||||
roles: [],
|
||||
signature: null,
|
||||
signatureRaw: null,
|
||||
},
|
||||
createdAt: 1700000000,
|
||||
},
|
||||
})
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
const lastReply = wrapper.find('.last-reply')
|
||||
expect(lastReply.exists()).toBe(true)
|
||||
expect(lastReply.text()).toContain('Alice')
|
||||
})
|
||||
|
||||
it('should not show last reply when thread has no last reply', () => {
|
||||
const thread = createMockThread({ lastReply: undefined })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.last-reply').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should emit navigate-last-reply when last reply link is clicked', async () => {
|
||||
const thread = createMockThread({
|
||||
lastPostId: 42,
|
||||
lastReplyAuthorId: 'alice',
|
||||
lastReplyAt: 1700000000,
|
||||
lastReply: {
|
||||
postId: 42,
|
||||
author: {
|
||||
userId: 'alice',
|
||||
displayName: 'Alice',
|
||||
isDeleted: false,
|
||||
roles: [],
|
||||
signature: null,
|
||||
signatureRaw: null,
|
||||
},
|
||||
createdAt: 1700000000,
|
||||
},
|
||||
})
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
await wrapper.find('.last-reply').trigger('click')
|
||||
expect(wrapper.emitted('navigate-last-reply')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should fall back to lastReplyAuthorId when author displayName is unavailable', () => {
|
||||
const thread = createMockThread({
|
||||
lastPostId: 42,
|
||||
lastReplyAuthorId: 'bob',
|
||||
lastReplyAt: 1700000000,
|
||||
lastReply: {
|
||||
postId: 42,
|
||||
author: null,
|
||||
createdAt: 1700000000,
|
||||
},
|
||||
})
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.last-reply').text()).toContain('bob')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,21 @@
|
||||
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
|
||||
</template>
|
||||
</UserInfo>
|
||||
<template v-if="thread.lastReply">
|
||||
<span class="meta-divider">·</span>
|
||||
<a
|
||||
class="last-reply"
|
||||
:href="lastReplyUrl"
|
||||
@click.prevent.stop="$emit('navigate-last-reply', thread)"
|
||||
>
|
||||
{{
|
||||
strings.lastReplyBy(
|
||||
thread.lastReply.author?.displayName || thread.lastReplyAuthorId || '',
|
||||
)
|
||||
}}
|
||||
<NcDateTime :timestamp="thread.lastReply.createdAt * 1000" />
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +102,12 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['navigate-last-reply'],
|
||||
computed: {
|
||||
lastReplyUrl(): string {
|
||||
return `/apps/forum/t/${this.thread.slug}?page=last&post=${this.thread.lastPostId}`
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
strings: {
|
||||
@@ -95,6 +116,7 @@ export default defineComponent({
|
||||
pinned: t('forum', 'Pinned thread'),
|
||||
locked: t('forum', 'Locked thread'),
|
||||
unread: t('forum', 'Unread'),
|
||||
lastReplyBy: (name: string) => t('forum', 'Last reply by {name}', { name }),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -265,6 +287,22 @@ export default defineComponent({
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.last-reply {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -67,6 +67,8 @@ export function createMockThread(overrides: Partial<Thread> = {}): Thread {
|
||||
viewCount: 100,
|
||||
postCount: 10,
|
||||
lastPostId: null,
|
||||
lastReplyAuthorId: null,
|
||||
lastReplyAt: null,
|
||||
isLocked: false,
|
||||
isPinned: false,
|
||||
isHidden: false,
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface Thread {
|
||||
viewCount: number
|
||||
postCount: number
|
||||
lastPostId: number | null
|
||||
lastReplyAuthorId: string | null
|
||||
lastReplyAt: number | null
|
||||
isLocked: boolean
|
||||
isPinned: boolean
|
||||
isHidden: boolean
|
||||
@@ -65,6 +67,11 @@ export interface Thread {
|
||||
categoryName?: string | null
|
||||
isSubscribed?: boolean
|
||||
isBookmarked?: boolean
|
||||
lastReply?: {
|
||||
postId: number
|
||||
author: User | null
|
||||
createdAt: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
:thread="thread"
|
||||
:is-unread="isThreadUnread(thread)"
|
||||
@click="navigateToThread(thread)"
|
||||
@navigate-last-reply="navigateToLastReply(thread)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -371,6 +372,14 @@ export default defineComponent({
|
||||
this.$router.push(`/t/${thread.slug}`)
|
||||
},
|
||||
|
||||
navigateToLastReply(thread: Thread) {
|
||||
const query: Record<string, string> = { page: 'last' }
|
||||
if (thread.lastPostId) {
|
||||
query.post = String(thread.lastPostId)
|
||||
}
|
||||
this.$router.push({ path: `/t/${thread.slug}`, query })
|
||||
},
|
||||
|
||||
createThread() {
|
||||
// Redirect guests to login only if they cannot post
|
||||
if (this.userId === null && !this.canPost) {
|
||||
|
||||
@@ -497,10 +497,15 @@ export default defineComponent({
|
||||
// Allow if user is the author, or has moderation permissions
|
||||
return this.thread?.authorId === this.userId || this.canModerate
|
||||
},
|
||||
// Whether ?page=last was requested
|
||||
isLastPageRequested(): boolean {
|
||||
return this.$route.query.page === 'last'
|
||||
},
|
||||
// Get page from query param
|
||||
pageFromQuery(): number | null {
|
||||
const page = this.$route.query.page
|
||||
if (page) {
|
||||
if (page === 'last') return null // handled separately
|
||||
const parsed = parseInt(page as string)
|
||||
return isNaN(parsed) ? null : parsed
|
||||
}
|
||||
@@ -568,7 +573,8 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// Fetch posts - use page from query param if present
|
||||
const initialPage = this.pageFromQuery || 0
|
||||
// page=last → use a high number so backend clamps to last page
|
||||
const initialPage = this.isLastPageRequested ? 999999 : this.pageFromQuery || 0
|
||||
await this.fetchPosts(initialPage)
|
||||
// Check permissions
|
||||
await this.checkPermissions()
|
||||
|
||||
@@ -614,9 +614,12 @@ class PostControllerTest extends TestCase {
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
->willReturnCallback(function ($updatedThread) use ($userId, $createdPost) {
|
||||
// Thread post count should be incremented from 5 to 6
|
||||
$this->assertEquals(6, $updatedThread->getPostCount());
|
||||
// Last reply info should be set
|
||||
$this->assertEquals($userId, $updatedThread->getLastReplyAuthorId());
|
||||
$this->assertEquals($createdPost->getCreatedAt(), $updatedThread->getLastReplyAt());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
@@ -683,9 +686,12 @@ class PostControllerTest extends TestCase {
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
->willReturnCallback(function ($updatedThread) use ($userId) {
|
||||
// Thread post count should be decremented from 5 to 4
|
||||
$this->assertEquals(4, $updatedThread->getPostCount());
|
||||
// Last reply info should be updated to the new last post
|
||||
$this->assertEquals($userId, $updatedThread->getLastReplyAuthorId());
|
||||
$this->assertNotNull($updatedThread->getLastReplyAt());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
@@ -789,6 +795,78 @@ class PostControllerTest extends TestCase {
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testDestroyLastReplyClearsLastReplyFieldsWhenOnlyFirstPostRemains(): void {
|
||||
$postId = 5;
|
||||
$userId = 'user1';
|
||||
$post = $this->createMockPost($postId, 1, $userId, 'The only reply');
|
||||
$post->setIsFirstPost(false);
|
||||
|
||||
$thread = new Thread();
|
||||
$thread->setId(1);
|
||||
$thread->setCategoryId(1);
|
||||
$thread->setPostCount(1);
|
||||
$thread->setLastPostId($postId);
|
||||
$thread->setLastReplyAuthorId($userId);
|
||||
$thread->setLastReplyAt(1700000000);
|
||||
|
||||
$category = new Category();
|
||||
$category->setId(1);
|
||||
$category->setPostCount(10);
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->permissionService->method('getCategoryIdFromPost')->willReturn(1);
|
||||
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($post);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturn($post);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($thread);
|
||||
|
||||
// Only the first post remains after deletion
|
||||
$firstPost = $this->createMockPost(1, 1, $userId, 'First post');
|
||||
$firstPost->setIsFirstPost(true);
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('findLatestByThreadId')
|
||||
->with(1, $postId)
|
||||
->willReturn($firstPost);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
// Last reply fields should be cleared
|
||||
$this->assertNull($updatedThread->getLastReplyAuthorId());
|
||||
$this->assertNull($updatedThread->getLastReplyAt());
|
||||
$this->assertEquals(1, $updatedThread->getLastPostId()); // Set to first post
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($category);
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturn($category);
|
||||
|
||||
$this->forumUserMapper->expects($this->once())
|
||||
->method('decrementPostCount')
|
||||
->with($userId);
|
||||
|
||||
$response = $this->controller->destroy($postId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testByThreadPaginatedReturnsPostsSuccessfully(): void {
|
||||
$threadId = 1;
|
||||
$page = 1;
|
||||
|
||||
@@ -104,12 +104,13 @@ class ThreadControllerTest extends TestCase {
|
||||
|
||||
// Mock thread enrichment service to return serialized thread with mock data
|
||||
$this->threadEnrichmentService->method('enrichThread')
|
||||
->willReturnCallback(function ($thread) {
|
||||
->willReturnCallback(function ($thread, $author = null, $lastReply = null) {
|
||||
$data = is_array($thread) ? $thread : $thread->jsonSerialize();
|
||||
$data['author'] = ['userId' => $data['authorId'], 'displayName' => 'Test User'];
|
||||
$data['author'] = $author ?? ['userId' => $data['authorId'], 'displayName' => 'Test User'];
|
||||
$data['categorySlug'] = 'test-category';
|
||||
$data['categoryName'] = 'Test Category';
|
||||
$data['isSubscribed'] = false;
|
||||
$data['lastReply'] = $lastReply;
|
||||
return $data;
|
||||
});
|
||||
|
||||
@@ -1256,6 +1257,79 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->assertEquals(0, $data['pagination']['total']);
|
||||
}
|
||||
|
||||
public function testByCategoryPaginatedEnrichesLastReplyData(): void {
|
||||
$categoryId = 1;
|
||||
$page = 1;
|
||||
$perPage = 20;
|
||||
|
||||
$thread = $this->createMockThread(1, $categoryId, 'user1', 'Thread With Reply');
|
||||
$thread->setLastPostId(10);
|
||||
$thread->setLastReplyAuthorId('user2');
|
||||
$thread->setLastReplyAt(1700000000);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('countByCategoryId')
|
||||
->with($categoryId)
|
||||
->willReturn(1);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('findByCategoryId')
|
||||
->with($categoryId, $perPage, 0)
|
||||
->willReturn([$thread]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->willReturn([
|
||||
'user1' => ['userId' => 'user1', 'displayName' => 'User 1'],
|
||||
'user2' => ['userId' => 'user2', 'displayName' => 'User 2'],
|
||||
]);
|
||||
|
||||
$response = $this->controller->byCategoryPaginated($categoryId, $page, $perPage);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertCount(1, $data['threads']);
|
||||
|
||||
$enrichedThread = $data['threads'][0];
|
||||
$this->assertNotNull($enrichedThread['lastReply']);
|
||||
$this->assertEquals(10, $enrichedThread['lastReply']['postId']);
|
||||
$this->assertEquals('user2', $enrichedThread['lastReply']['author']['userId']);
|
||||
$this->assertEquals(1700000000, $enrichedThread['lastReply']['createdAt']);
|
||||
}
|
||||
|
||||
public function testByCategoryPaginatedOmitsLastReplyWhenNoReplies(): void {
|
||||
$categoryId = 1;
|
||||
$page = 1;
|
||||
$perPage = 20;
|
||||
|
||||
// Thread with no replies (lastReplyAuthorId is null)
|
||||
$thread = $this->createMockThread(1, $categoryId, 'user1', 'Thread Without Reply');
|
||||
$thread->setLastPostId(1);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('countByCategoryId')
|
||||
->with($categoryId)
|
||||
->willReturn(1);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('findByCategoryId')
|
||||
->with($categoryId, $perPage, 0)
|
||||
->willReturn([$thread]);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichMultipleUsers')
|
||||
->willReturn([
|
||||
'user1' => ['userId' => 'user1', 'displayName' => 'User 1'],
|
||||
]);
|
||||
|
||||
$response = $this->controller->byCategoryPaginated($categoryId, $page, $perPage);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$enrichedThread = $data['threads'][0];
|
||||
$this->assertNull($enrichedThread['lastReply']);
|
||||
}
|
||||
|
||||
public function testCreateThreadUpdatesCategoryReadMarkerWhenCategoryWasRead(): void {
|
||||
$categoryId = 1;
|
||||
$title = 'New Thread';
|
||||
|
||||
Reference in New Issue
Block a user