diff --git a/docs/FRONTEND_INTEGRATION.md b/docs/FRONTEND_INTEGRATION.md index 47999c8..ff9d49b 100644 --- a/docs/FRONTEND_INTEGRATION.md +++ b/docs/FRONTEND_INTEGRATION.md @@ -105,6 +105,8 @@ Supported filters: - `read`, `type`, `resourceType`, `sortOrder` - `GET /comments/post/:postId` - `page`, `limit`, `sortOrder` +- `PATCH /comments/:commentId` + - JSON body: `content`, `mentionUsernames` ## WebSocket auth @@ -171,6 +173,34 @@ Posts and comments support: The backend also extracts `@username` from `content` automatically and emits mention notifications for matched users. +## Posts Upload Contract + +Post creation and updates now require `multipart/form-data` for all post payloads, including text-only posts and posts that use external media links. + +- `POST /posts` +- `PATCH /posts/:postId` +- `POST /posts/reels` + +Send media uploads as files: + +- `imageFiles` +- `videoFile` +- `audioFile` + +Send link-based fields as text fields inside the same form-data payload: + +- `imageUrls` +- `videoUrl` +- `audioUrl` +- `thumbnailUrl` + +Array fields may be sent either as repeated form keys or JSON text: + +- `taggedUserIds` +- `mentionUsernames` +- `imageUrls` +- `waveformPeaks` + ## Marketplace split Marketplace is now separated from musical instruments at the API contract level: diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index 2515af9..2b5ab3d 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -1341,10 +1341,6 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{accessToken}}" @@ -1352,8 +1348,44 @@ ], "url": "{{baseUrl}}/posts", "body": { - "mode": "raw", - "raw": "{\n \"content\": \"First text post with mention @{{targetUsername}} #music\",\n \"taggedUserIds\": [\"{{targetUserId}}\"],\n \"mentionUsernames\": [\"{{targetUsername}}\"],\n \"location\": \"Riyadh, Saudi Arabia\",\n \"latitude\": 24.7136,\n \"longitude\": 46.6753,\n \"visibility\": \"public\"\n}" + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "First text post with mention @{{targetUsername}} #music", + "type": "text" + }, + { + "key": "taggedUserIds", + "value": "{{targetUserId}}", + "type": "text" + }, + { + "key": "mentionUsernames", + "value": "{{targetUsername}}", + "type": "text" + }, + { + "key": "location", + "value": "Riyadh, Saudi Arabia", + "type": "text" + }, + { + "key": "latitude", + "value": "24.7136", + "type": "text" + }, + { + "key": "longitude", + "value": "46.6753", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] } }, "event": [ @@ -1689,10 +1721,6 @@ "request": { "method": "PATCH", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{accessToken}}" @@ -1700,8 +1728,39 @@ ], "url": "{{baseUrl}}/posts/{{postId}}", "body": { - "mode": "raw", - "raw": "{\n \"content\": \"Updated post content with @{{targetUsername}} #live\",\n \"taggedUserIds\": [\"{{targetUserId}}\"],\n \"mentionUsernames\": [\"{{targetUsername}}\"],\n \"location\": \"Jeddah, Saudi Arabia\",\n \"latitude\": 21.5433,\n \"longitude\": 39.1728\n}" + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Updated post content with @{{targetUsername}} #live", + "type": "text" + }, + { + "key": "taggedUserIds", + "value": "{{targetUserId}}", + "type": "text" + }, + { + "key": "mentionUsernames", + "value": "{{targetUsername}}", + "type": "text" + }, + { + "key": "location", + "value": "Jeddah, Saudi Arabia", + "type": "text" + }, + { + "key": "latitude", + "value": "21.5433", + "type": "text" + }, + { + "key": "longitude", + "value": "39.1728", + "type": "text" + } + ] } }, "event": [ @@ -2064,10 +2123,6 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{accessToken}}" @@ -2075,8 +2130,54 @@ ], "url": "{{baseUrl}}/posts", "body": { - "mode": "raw", - "raw": "{\n \"content\": \"Audio post with waveform #oud #hijaz\",\n \"audioUrl\": \"https://cdn.example.com/audio-sample.mp3\",\n \"durationSeconds\": 54,\n \"thumbnailUrl\": \"https://cdn.example.com/audio-cover.jpg\",\n \"style\": \"Sharqi\",\n \"maqam\": \"Hijaz\",\n \"rhythmSignature\": \"6/8\",\n \"waveformPeaks\": [12, 38, 27, 49, 22, 44],\n \"visibility\": \"public\"\n}" + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Audio post with waveform #oud #hijaz", + "type": "text" + }, + { + "key": "audioUrl", + "value": "https://cdn.example.com/audio-sample.mp3", + "type": "text" + }, + { + "key": "durationSeconds", + "value": "54", + "type": "text" + }, + { + "key": "thumbnailUrl", + "value": "https://cdn.example.com/audio-cover.jpg", + "type": "text" + }, + { + "key": "style", + "value": "Sharqi", + "type": "text" + }, + { + "key": "maqam", + "value": "Hijaz", + "type": "text" + }, + { + "key": "rhythmSignature", + "value": "6/8", + "type": "text" + }, + { + "key": "waveformPeaks", + "value": "[12,38,27,49,22,44]", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] } }, "event": [ @@ -2140,10 +2241,6 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{accessToken}}" @@ -2151,8 +2248,29 @@ ], "url": "{{baseUrl}}/posts", "body": { - "mode": "raw", - "raw": "{\n \"content\": \"Invalid post\",\n \"videoUrl\": \"https://cdn.example.com/video.mp4\",\n \"audioUrl\": \"https://cdn.example.com/audio.mp3\",\n \"visibility\": \"public\"\n}" + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Invalid post", + "type": "text" + }, + { + "key": "videoUrl", + "value": "https://cdn.example.com/video.mp4", + "type": "text" + }, + { + "key": "audioUrl", + "value": "https://cdn.example.com/audio.mp3", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] } }, "event": [ @@ -2528,6 +2646,40 @@ } ] }, + { + "name": "Update Comment", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/comments/{{commentId}}", + "body": { + "mode": "raw", + "raw": "{\n \"content\": \"Edited comment @{{secondaryUsername}}\",\n \"mentionUsernames\": [\"{{secondaryUsername}}\"]\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.content).to.include(\u0027Edited comment\u0027);" + ] + } + } + ] + }, { "name": "Delete Comment", "request": { @@ -3812,10 +3964,6 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{accessToken}}" @@ -3823,8 +3971,19 @@ ], "url": "{{baseUrl}}/posts", "body": { - "mode": "raw", - "raw": "{\n \"content\": \"E2E text post\",\n \"visibility\": \"public\"\n}" + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "E2E text post", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] } } }, @@ -3914,10 +4073,6 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{accessToken}}" @@ -3925,8 +4080,29 @@ ], "url": "{{baseUrl}}/posts", "body": { - "mode": "raw", - "raw": "{\n \"content\": \"Invalid post\",\n \"videoUrl\": \"https://cdn.example.com/video.mp4\",\n \"audioUrl\": \"https://cdn.example.com/audio.mp3\",\n \"visibility\": \"public\"\n}" + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "Invalid post", + "type": "text" + }, + { + "key": "videoUrl", + "value": "https://cdn.example.com/video.mp4", + "type": "text" + }, + { + "key": "audioUrl", + "value": "https://cdn.example.com/audio.mp3", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] } }, "event": [ @@ -3946,15 +4122,22 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - } ], "url": "{{baseUrl}}/posts", "body": { - "mode": "raw", - "raw": "{\n \"content\": \"No token request\",\n \"visibility\": \"public\"\n}" + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "No token request", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] } }, "event": [ diff --git a/src/common/guards/multipart-form-data.guard.spec.ts b/src/common/guards/multipart-form-data.guard.spec.ts new file mode 100644 index 0000000..276a380 --- /dev/null +++ b/src/common/guards/multipart-form-data.guard.spec.ts @@ -0,0 +1,29 @@ +import { ExecutionContext, UnsupportedMediaTypeException } from '@nestjs/common'; +import { MultipartFormDataGuard } from './multipart-form-data.guard'; + +describe('MultipartFormDataGuard', () => { + const guard = new MultipartFormDataGuard(); + + const createContext = (contentType: string | null): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => ({ + is: (type: string) => (contentType === type ? contentType : false), + }), + }), + }) as ExecutionContext; + + it('allows multipart/form-data requests', () => { + expect(guard.canActivate(createContext('multipart/form-data'))).toBe(true); + }); + + it('rejects non-multipart requests', () => { + expect(() => guard.canActivate(createContext('application/json'))).toThrow( + new UnsupportedMediaTypeException('Content-Type must be multipart/form-data'), + ); + }); + + it('rejects requests without a content type match', () => { + expect(() => guard.canActivate(createContext(null))).toThrow(UnsupportedMediaTypeException); + }); +}); diff --git a/src/common/guards/multipart-form-data.guard.ts b/src/common/guards/multipart-form-data.guard.ts new file mode 100644 index 0000000..ed13373 --- /dev/null +++ b/src/common/guards/multipart-form-data.guard.ts @@ -0,0 +1,20 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnsupportedMediaTypeException, +} from '@nestjs/common'; +import { Request } from 'express'; + +@Injectable() +export class MultipartFormDataGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + if (request.is('multipart/form-data')) { + return true; + } + + throw new UnsupportedMediaTypeException('Content-Type must be multipart/form-data'); + } +} diff --git a/src/modules/comments/comments.controller.ts b/src/modules/comments/comments.controller.ts index c2d11aa..29bf63e 100644 --- a/src/modules/comments/comments.controller.ts +++ b/src/modules/comments/comments.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator'; @@ -9,6 +9,7 @@ import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { AdminCommentQueryDto } from './dto/admin-comment-query.dto'; import { CommentQueryDto } from './dto/comment-query.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; +import { UpdateCommentDto } from './dto/update-comment.dto'; import { CommentsService } from './comments.service'; import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions'; @@ -38,6 +39,17 @@ export class CommentsController { return this.commentsService.findReplies(commentId, query); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Patch(':commentId') + async update( + @CurrentUser() user: JwtPayload, + @Param('commentId') commentId: string, + @Body() dto: UpdateCommentDto, + ) { + return this.commentsService.update(user.sub, commentId, dto); + } + @ApiBearerAuth() @UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard) @SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE) diff --git a/src/modules/comments/comments.repository.ts b/src/modules/comments/comments.repository.ts index 48f621a..cee2a5f 100644 --- a/src/modules/comments/comments.repository.ts +++ b/src/modules/comments/comments.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { ClientSession, FilterQuery, Model, Types } from 'mongoose'; +import { ClientSession, FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { Comment, CommentDocument } from './schemas/comment.schema'; @@ -73,6 +73,22 @@ export class CommentsRepository { return !!updated; } + async updateById( + commentId: string, + payload: UpdateQuery>, + ): Promise { + if (!Types.ObjectId.isValid(commentId)) { + return null; + } + + return this.commentModel + .findOneAndUpdate({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } }, payload, { + new: true, + }) + .populate({ path: 'authorId', select: 'name username avatar stageName isVerified' }) + .exec(); + } + async findMany( filter: FilterQuery, skip: number, diff --git a/src/modules/comments/comments.service.spec.ts b/src/modules/comments/comments.service.spec.ts new file mode 100644 index 0000000..96b3fea --- /dev/null +++ b/src/modules/comments/comments.service.spec.ts @@ -0,0 +1,81 @@ +import { Types } from 'mongoose'; +import { CommentsService } from './comments.service'; + +describe('CommentsService', () => { + it('updates own comment and notifies newly mentioned users', async () => { + const userId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId().toString(); + const commentId = new Types.ObjectId().toString(); + const updatedContent = 'Edited comment @new_mention'; + + const commentsRepository = { + findById: jest.fn().mockResolvedValue({ + _id: new Types.ObjectId(commentId), + authorId: new Types.ObjectId(userId), + postId: new Types.ObjectId(postId), + content: 'Original comment', + mentionUsernames: ['old_mention'], + }), + updateById: jest.fn().mockResolvedValue({ + _id: new Types.ObjectId(commentId), + authorId: new Types.ObjectId(userId), + postId: new Types.ObjectId(postId), + content: updatedContent, + mentionUsernames: ['new_mention'], + }), + }; + const postsRepository = { + findById: jest.fn(), + setCommentsCount: jest.fn(), + }; + const auditService = { + logSuperAdminAction: jest.fn(), + }; + const feedVersionService = { + bumpGlobalVersion: jest.fn(), + }; + const notificationsService = { + createCommentNotification: jest.fn(), + createMentionNotification: jest.fn(), + }; + const usersRepository = { + findByUsernames: jest.fn().mockResolvedValue([{ id: 'mentioned-user-id', username: 'new_mention' }]), + }; + + const service = new CommentsService( + commentsRepository as any, + postsRepository as any, + auditService as any, + feedVersionService as any, + notificationsService as any, + usersRepository as any, + ); + + const result = await service.update(userId, commentId, { + content: updatedContent, + mentionUsernames: ['new_mention'], + }); + + expect(commentsRepository.updateById).toHaveBeenCalledWith(commentId, { + content: updatedContent, + mentionUsernames: ['new_mention'], + }); + expect(feedVersionService.bumpGlobalVersion).toHaveBeenCalled(); + expect(notificationsService.createMentionNotification).toHaveBeenCalledWith( + userId, + 'mentioned-user-id', + postId, + { + resourceType: 'comment', + previewText: updatedContent, + deepLink: `/posts/${postId}`, + }, + ); + expect(result).toEqual( + expect.objectContaining({ + content: updatedContent, + mentionUsernames: ['new_mention'], + }), + ); + }); +}); diff --git a/src/modules/comments/comments.service.ts b/src/modules/comments/comments.service.ts index 98137c7..3163ae8 100644 --- a/src/modules/comments/comments.service.ts +++ b/src/modules/comments/comments.service.ts @@ -11,6 +11,7 @@ import { UsersRepository } from '../users/users.repository'; import { AdminCommentQueryDto } from './dto/admin-comment-query.dto'; import { CommentQueryDto } from './dto/comment-query.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; +import { UpdateCommentDto } from './dto/update-comment.dto'; import { CommentsRepository } from './comments.repository'; @Injectable() @@ -87,6 +88,54 @@ export class CommentsService { return { success: true }; } + async update(userId: string, commentId: string, dto: UpdateCommentDto) { + const comment = await this.commentsRepository.findById(commentId); + if (!comment) { + throw new NotFoundException('Comment not found'); + } + + if (comment.authorId.toString() !== userId) { + throw new ForbiddenException('You can only update your own comments'); + } + + const hasContentUpdate = typeof dto.content === 'string'; + const hasMentionUpdate = typeof dto.mentionUsernames !== 'undefined'; + if (!hasContentUpdate && !hasMentionUpdate) { + throw new BadRequestException('Nothing to update'); + } + + const nextContent = hasContentUpdate ? dto.content!.trim() : comment.content; + if (!nextContent) { + throw new BadRequestException('Comment content cannot be empty'); + } + + const previousMentionUsernames = this.normalizeMentionUsernames(comment.mentionUsernames ?? []); + const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, nextContent, userId); + const updated = await this.commentsRepository.updateById(commentId, { + content: nextContent, + mentionUsernames: mentionResolution.mentionUsernames, + }); + if (!updated) { + throw new NotFoundException('Comment not found'); + } + + await this.feedVersionService.bumpGlobalVersion(); + + const previousMentionSet = new Set(previousMentionUsernames); + const nextMentionedUsers = mentionResolution.mentionedUsers.filter( + (mentionedUser) => !previousMentionSet.has(mentionedUser.username), + ); + await this.notifyMentionedUsers( + userId, + comment.postId.toString(), + nextMentionedUsers, + nextContent.slice(0, 160), + new Set(), + ); + + return updated; + } + async removeBySuperAdmin(superAdminIdentifier: string, commentId: string) { const comment = await this.commentsRepository.findById(commentId); if (!comment) { diff --git a/src/modules/comments/dto/update-comment.dto.ts b/src/modules/comments/dto/update-comment.dto.ts index 39a1aa1..d227f15 100644 --- a/src/modules/comments/dto/update-comment.dto.ts +++ b/src/modules/comments/dto/update-comment.dto.ts @@ -1,8 +1,21 @@ -import { IsOptional, IsString, Length } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsOptional, IsString, Length } from 'class-validator'; +import { toStringArray } from '../../../common/utils/array-transform.util'; export class UpdateCommentDto { + @ApiPropertyOptional({ maxLength: 1000 }) @IsOptional() @IsString() @Length(1, 1000) content?: string; + + @ApiPropertyOptional({ type: [String], description: 'Set mention usernames like rami_sabry (max 30)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(30) + @IsString({ each: true }) + @Length(1, 30, { each: true }) + mentionUsernames?: string[]; } diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index a515e5c..8624b24 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -17,6 +17,7 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { MultipartFormDataGuard } from '../../common/guards/multipart-form-data.guard'; import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard'; import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; @@ -36,7 +37,7 @@ export class PostsController { constructor(private readonly postsService: PostsService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, MultipartFormDataGuard) @UseInterceptors( FileFieldsInterceptor([ { name: 'imageFiles', maxCount: 10 }, @@ -107,7 +108,7 @@ export class PostsController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, MultipartFormDataGuard) @UseInterceptors( FileFieldsInterceptor([ { name: 'videoFile', maxCount: 1 }, @@ -158,7 +159,7 @@ export class PostsController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, MultipartFormDataGuard) @UseInterceptors( FileFieldsInterceptor([ { name: 'imageFiles', maxCount: 10 }, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 814da6c..06c6a1f 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -66,6 +66,7 @@ describe('Oudelaa smoke (e2e)', () => { secondRefreshToken: '', }; let postId = ''; + let editableCommentId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -270,6 +271,57 @@ describe('Oudelaa smoke (e2e)', () => { expect(mention).toBeTruthy(); }); + it('updates own comment content and mention usernames', async () => { + const createResponse = await request(app.getHttpServer()) + .post('/api/v1/comments') + .set('Authorization', `Bearer ${secondary.accessToken}`) + .send({ + postId, + content: 'Needs edit', + }) + .expect(201); + + editableCommentId = createResponse.body._id || createResponse.body.id; + + const updatedContent = `Edited with @${primary.username}`; + const updateResponse = await request(app.getHttpServer()) + .patch(`/api/v1/comments/${editableCommentId}`) + .set('Authorization', `Bearer ${secondary.accessToken}`) + .send({ + content: updatedContent, + mentionUsernames: [primary.username], + }) + .expect(200); + + expect(updateResponse.body.content).toBe(updatedContent); + expect(updateResponse.body.mentionUsernames).toContain(primary.username.toLowerCase()); + + const commentsResponse = await request(app.getHttpServer()) + .get(`/api/v1/comments/post/${postId}?page=1&limit=20`) + .set('Authorization', `Bearer ${primary.accessToken}`) + .expect(200); + + const updatedComment = (commentsResponse.body.items ?? []).find( + (item: any) => (item._id || item.id) === editableCommentId, + ); + + expect(updatedComment?.content).toBe(updatedContent); + + const notificationsResponse = await request(app.getHttpServer()) + .get('/api/v1/notifications?resourceType=comment') + .set('Authorization', `Bearer ${primary.accessToken}`) + .expect(200); + + const mention = (notificationsResponse.body.items ?? []).find( + (item: any) => + item.type === 'mention' && + item.resourceType === 'comment' && + item.previewText === updatedContent, + ); + + expect(mention).toBeTruthy(); + }); + it('supports superadmin sessions refresh rotation and notifications access', async () => { const loginOne = await request(app.getHttpServer()) .post('/api/v1/auth/superadmin/login')