From f9433ba88f105f8b51b52088ec8cc236dfba5758 Mon Sep 17 00:00:00 2001 From: Cookky Date: Sat, 11 Oct 2025 18:51:35 +0200 Subject: [PATCH 01/19] Changed comment reporting scheme --- migration/etuutt_old/make-migration.ts | 6 +- prisma/schema.prisma | 54 ++--- src/ue/annals/annals.controller.ts | 4 +- src/ue/annals/interfaces/annal.interface.ts | 16 +- src/ue/comments/comments.controller.ts | 8 +- src/ue/comments/comments.service.ts | 78 +++--- .../interfaces/comment-reply.interface.ts | 24 +- .../comments/interfaces/comment.interface.ts | 105 ++++---- test/declarations.ts | 1 - test/e2e/ue/annals/delete-annal.e2e-spec.ts | 3 +- test/e2e/ue/annals/get-annal-file.e2e-spec.ts | 8 +- test/e2e/ue/annals/get-annals.e2e-spec.ts | 8 +- test/e2e/ue/annals/patch-annal.e2e-spec.ts | 8 +- test/e2e/ue/annals/upload-annal.e2e-spec.ts | 12 +- .../ue/comments/delete-comment.e2e-spec.ts | 5 +- test/e2e/ue/comments/delete-reply.e2e-spec.ts | 2 +- .../comments/get-comment-from-id.e2e-spec.ts | 4 - test/e2e/ue/comments/get-comment.e2e-spec.ts | 226 +++++++++--------- test/e2e/ue/comments/post-comment.e2e-spec.ts | 4 +- test/e2e/ue/comments/post-reply.e2e-spec.ts | 2 +- .../ue/comments/update-comment.e2e-spec.ts | 4 +- test/e2e/ue/comments/update-reply.e2e-spec.ts | 2 +- test/utils/fakedb.ts | 25 +- 23 files changed, 314 insertions(+), 295 deletions(-) diff --git a/migration/etuutt_old/make-migration.ts b/migration/etuutt_old/make-migration.ts index 28a81590..7e667607 100644 --- a/migration/etuutt_old/make-migration.ts +++ b/migration/etuutt_old/make-migration.ts @@ -316,7 +316,6 @@ const prisma = _prisma.$extends({ isAnonymous: true, createdAt, updatedAt, - validatedAt: isValid ? updatedAt : null, ueof: { connect: { code: codes.ueof } }, semester: { connect: { code: semesterCode } }, }, @@ -326,13 +325,12 @@ const prisma = _prisma.$extends({ } if ( comment.body !== body || - comment.updatedAt !== updatedAt || - (comment.validatedAt === null ? 0 : 1) !== isValid + comment.updatedAt !== updatedAt ) { return { data: await _prisma.ueComment.update({ where: { id: comment.id }, - data: { body, updatedAt, validatedAt: isValid ? updatedAt : null }, + data: { body, updatedAt}, }), operation: 'updated', }; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ec16c522..8aef3d73 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -340,18 +340,16 @@ model UeAnnalReportReason { } model UeComment { - id String @id @default(uuid()) - body String @db.Text - isAnonymous Boolean - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) + id String @id @default(uuid()) + body String @db.Text + isAnonymous Boolean + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) // Removed @updatedAt because the property is used to display the last datetime the content of the comment was altered on - deletedAt DateTime? - validatedAt DateTime? - authorId String? - lastValidatedBody String? - semesterId String - ueofCode String + deletedAt DateTime? + authorId String? + semesterId String + ueofCode String author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) semester Semester @relation(fields: [semesterId], references: [code]) @@ -377,13 +375,14 @@ model UeCommentReply { } model UeCommentReport { - id String @id @default(uuid()) - body String @db.Text - createdAt DateTime @default(now()) - mitigated Boolean @default(false) - commentId String - reasonId String - userId String + id String @id @default(uuid()) + body String @db.Text + createdAt DateTime @default(now()) + mitigated Boolean @default(false) + commentId String + reasonId String + userId String + reportedBody String comment UeComment @relation(fields: [commentId], references: [id], onDelete: Cascade) reason UeCommentReportReason @relation(fields: [reasonId], references: [name], onDelete: Cascade) @@ -393,16 +392,17 @@ model UeCommentReport { } model UeCommentReplyReport { - id String @id @default(uuid()) - body String @db.Text - createdAt DateTime @default(now()) - mitigated Boolean @default(false) - reasonId String - replyId String - userId String + id String @id @default(uuid()) + body String @db.Text + createdAt DateTime @default(now()) + mitigated Boolean @default(false) + replyId String + reasonId String + userId String + reportedBody String - reason UeCommentReportReason @relation(fields: [reasonId], references: [name], onDelete: Cascade) reply UeCommentReply @relation(fields: [replyId], references: [id], onDelete: Cascade) + reason UeCommentReportReason @relation(fields: [reasonId], references: [name], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, replyId, reasonId]) // Prevent from spam @@ -433,7 +433,7 @@ model UeCourse { createdAt DateTime @default(now()) semesterId String timetableId String @unique() - ueofCode String + ueofCode String courseExchangesFrom UeCourseExchange[] @relation(name: "courseFrom") courseExchangesTo UeCourseExchange[] @relation(name: "courseTo") diff --git a/src/ue/annals/annals.controller.ts b/src/ue/annals/annals.controller.ts index df06c515..39e36e21 100644 --- a/src/ue/annals/annals.controller.ts +++ b/src/ue/annals/annals.controller.ts @@ -6,7 +6,6 @@ import { UUIDParam } from '../../app.pipe'; import { GetUser, RequireApiPermission } from '../../auth/decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; import { FileSize, MulterWithMime, UploadRoute, UserFile } from '../../upload.interceptor'; -import { CommentStatus } from '../comments/interfaces/comment.interface'; import { CreateAnnalReqDto } from './dto/req/create-annal-req.dto'; import { UpdateAnnalReqDto } from './dto/req/update-annal-req.dto'; import { User } from '../../users/interfaces/user.interface'; @@ -19,6 +18,7 @@ import UeAnnalMetadataResDto from './dto/res/ue-annal-metadata-res.dto'; import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { Permission } from '@prisma/client'; import { PermissionManager } from '../../utils'; +import { AnnalStatus } from './interfaces/annal.interface'; @Controller('ue/annals') @ApiTags('Annal') @@ -131,7 +131,7 @@ export class AnnalsController { throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); if ( (await this.annalsService.getUeAnnal(annalId, user.id, permissions.can(Permission.API_MODERATE_ANNALS))) - .status !== CommentStatus.PROCESSING + .status !== AnnalStatus.PROCESSING ) throw new AppException(ERROR_CODE.ANNAL_ALREADY_UPLOADED); return this.annalsService.uploadAnnalFile(await file, annalId, rotate); diff --git a/src/ue/annals/interfaces/annal.interface.ts b/src/ue/annals/interfaces/annal.interface.ts index ac00e9d4..72d2a95d 100644 --- a/src/ue/annals/interfaces/annal.interface.ts +++ b/src/ue/annals/interfaces/annal.interface.ts @@ -1,6 +1,5 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { omit } from '../../../utils'; -import { CommentStatus } from '../../comments/interfaces/comment.interface'; import { generateCustomModel } from '../../../prisma/prisma.service'; const UE_ANNAL_SELECT_FILTER = { @@ -31,7 +30,7 @@ const UE_ANNAL_SELECT_FILTER = { type UnformattedUeAnnal = Prisma.UeAnnalGetPayload; export type UeAnnalFile = Omit & { - status: CommentStatus; + status: AnnalStatus; }; export function generateCustomUeAnnalModel(prisma: PrismaClient) { @@ -42,8 +41,15 @@ export function formatAnnal(_: PrismaClient, annal: UnformattedUeAnnal): UeAnnal return { ...omit(annal, 'deletedAt', 'validatedAt', 'uploadComplete'), status: - (annal.deletedAt && CommentStatus.DELETED) | - (annal.validatedAt && CommentStatus.VALIDATED) | - (!annal.uploadComplete && CommentStatus.PROCESSING), + (annal.deletedAt && AnnalStatus.DELETED) | + (annal.validatedAt && AnnalStatus.VALIDATED) | + (!annal.uploadComplete && AnnalStatus.PROCESSING), }; } + +export const enum AnnalStatus { + UNVERIFIED = 0b000, // For typing only + VALIDATED = 0b001, + PROCESSING = 0b010, + DELETED = 0b100, +} diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 1b5c86cb..de9786ea 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -105,7 +105,7 @@ export class CommentsController { @GetPermissions() permissions: PermissionManager, ): Promise { const isCommentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator, isCommentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (isCommentModerator || (await this.commentsService.isUserCommentAuthor(user.id, commentId))) return this.commentsService.updateComment(body, commentId, user.id, isCommentModerator); @@ -155,7 +155,7 @@ export class CommentsController { @GetPermissions() permissions: PermissionManager, ): Promise { const commentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator, commentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); @@ -181,7 +181,7 @@ export class CommentsController { @GetPermissions() permissions: PermissionManager, ): Promise { const commentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator, commentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); // TODO : on est d'accord qu'on peut virer cette condition ? Puisque de toutes manières l'utilisateur ne peut pas mettre un upvote. if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) @@ -204,7 +204,7 @@ export class CommentsController { @GetPermissions() permissions: PermissionManager, ): Promise { const isCommentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator, isCommentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, isCommentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); return this.commentsService.replyComment(user.id, commentId, body); } diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index 242b8e5c..4a5d92b2 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -20,21 +20,22 @@ export class CommentsService { * Retrieves a page of {@link UeComment} matching the user query * @param userId the user fetching the comments. Used to determine if an anonymous comment should include its author * @param dto the query parameters of this route - * @param bypassAnonymousData if true, the author of an anonymous comment will be included in the response (this is the case if the user is a moderator) + * @param bypassRestrictedData if true, deleted comments, deleted replies, hidden comments, and anonymous author will be included in the response (only for moderators) * @returns a page of {@link UeComment} matching the user query */ async getComments( userId: string, dto: GetUeCommentsReqDto, - bypassAnonymousData: boolean, + bypassRestrictedData: boolean, ): Promise> { - // Use a prisma transaction to execute two requests at once: // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters const comments = await this.prisma.normalize.ueComment.findMany({ args: { userId: userId, - includeLastValidatedBody: bypassAnonymousData, - includeDeletedReplied: bypassAnonymousData, + includeDeleted: bypassRestrictedData, + includeHiddenComments: bypassRestrictedData, + includeReports: bypassRestrictedData, + bypassAnonymousData: bypassRestrictedData, }, where: { ueof: { @@ -47,12 +48,13 @@ export class CommentsService { skip: ((dto.page ?? 1) - 1) * this.config.PAGINATION_PAGE_SIZE, }); const commentCount = await this.prisma.ueComment.count({ - where: { ueof: { ue: { code: dto.ueCode } } }, + where: { + ueof: { ue: { code: dto.ueCode } }, + deletedAt: bypassRestrictedData ? undefined : null, + reports: bypassRestrictedData ? undefined : { none: { mitigated: false } }, + }, }); - // If the user is neither a moderator or the comment author, and the comment is anonymous, - // we remove the author from the response - for (const comment of comments) - if (comment.isAnonymous && !bypassAnonymousData && comment.author?.id !== userId) comment.author = undefined; + // Data pagination return { items: comments, @@ -65,14 +67,17 @@ export class CommentsService { * Retrieves a single {@link UeComment} from a comment UUID * @param commentId the UUID of the comment * @param userId the user fetching the comments. Used to determine if an anonymous comment should include its author + * @param isModerator if true the user is a moderator * @returns a page of {@link UeComment} matching the user query */ async getCommentFromId(commentId: string, userId: string, isModerator: boolean): Promise { const comment = await this.prisma.normalize.ueComment.findUnique({ args: { - includeDeletedReplied: isModerator, - includeLastValidatedBody: isModerator, - userId, + userId: userId, + includeDeleted: isModerator, + includeHiddenComments: isModerator, + includeReports: isModerator, + bypassAnonymousData: isModerator, }, where: { id: commentId, @@ -129,6 +134,7 @@ export class CommentsService { ); } + //TODO: This function may belongs to another service (users or ue) /** * Retrieves the last semester done by a user for a given ue * @remarks The user must not be null @@ -169,8 +175,6 @@ export class CommentsService { // Find a comment (in the UE) whose author is the user const comment = await this.prisma.normalize.ueComment.findMany({ args: { - includeDeletedReplied: false, - includeLastValidatedBody: false, userId, }, where: { @@ -195,8 +199,6 @@ export class CommentsService { const lastSemester = await this.getLastUserSubscription(userId, body.ueCode); return this.prisma.normalize.ueComment.create({ args: { - includeDeletedReplied: true, - includeLastValidatedBody: true, userId, }, data: { @@ -239,24 +241,19 @@ export class CommentsService { const previousComment = await this.prisma.normalize.ueComment.findUnique({ args: { userId, - includeDeletedReplied: true, - includeLastValidatedBody: true, + includeHiddenComments: isModerator, + includeDeleted: isModerator, }, where: { id: commentId, }, }); - const needsValidationAgain = - body.body && - body.body !== previousComment.body && - previousComment.status & CommentStatus.VALIDATED && - !isModerator; return this.prisma.normalize.ueComment.update({ args: { userId, - includeDeletedReplied: true, - includeLastValidatedBody: true, + includeHiddenComments: isModerator, + includeDeleted: isModerator, }, where: { id: commentId, @@ -264,8 +261,6 @@ export class CommentsService { data: { body: body.body, isAnonymous: body.isAnonymous, - validatedAt: needsValidationAgain ? null : undefined, - lastValidatedBody: needsValidationAgain ? previousComment.body : undefined, updatedAt: new Date(), }, }); @@ -382,8 +377,8 @@ export class CommentsService { return this.prisma.normalize.ueComment.update({ args: { userId, - includeDeletedReplied: true, - includeLastValidatedBody: false, + includeDeleted: true, + includeHiddenComments: true, }, where: { id: commentId, @@ -397,29 +392,20 @@ export class CommentsService { /** * Checks whether a comment exists * @param commentId the id of the comment to check + * @param isModerator if true the user is a moderator * @returns whether the {@link commentId | comment} exists */ - async doesCommentExist(commentId: string, userId: string, includeUnverified: boolean, includeDeleted = false) { + async doesCommentExist(commentId: string, userId: string, isModerator: boolean = false) { return ( (await this.prisma.ueComment.count({ where: { id: commentId, - deletedAt: includeDeleted ? undefined : null, - OR: [ - { - validatedAt: { - not: null, - }, - reports: { - none: { - mitigated: false, - }, - }, - }, - { - authorId: includeUnverified ? undefined : userId, + deletedAt: isModerator ? undefined : null, + reports: { + none: { + mitigated: isModerator ? undefined : false, }, - ], + }, }, })) != 0 ); diff --git a/src/ue/comments/interfaces/comment-reply.interface.ts b/src/ue/comments/interfaces/comment-reply.interface.ts index b2e53ce7..ed50d2a6 100644 --- a/src/ue/comments/interfaces/comment-reply.interface.ts +++ b/src/ue/comments/interfaces/comment-reply.interface.ts @@ -3,7 +3,7 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { omit } from '../../../utils'; import { generateCustomModel } from '../../../prisma/prisma.service'; -const REPLY_SELECT_FILTER = { +export const REPLY_SELECT_FILTER = { select: { id: true, author: { @@ -17,6 +17,26 @@ const REPLY_SELECT_FILTER = { createdAt: true, updatedAt: true, deletedAt: true, + reports: { + select: { + body: true, + mitigated: true, + createdAt: true, + reason: { + select: { + name: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + studentId: true, + }, + }, + }, + }, }, } as const; @@ -35,6 +55,6 @@ export function generateCustomUeCommentReplyModel(prisma: PrismaClient) { export function formatReply(_: PrismaClient, reply: UnformattedUeCommentReply): UeCommentReply { return { ...omit(reply, 'deletedAt'), - status: (reply.deletedAt && CommentStatus.DELETED) | CommentStatus.VALIDATED, + status: (reply.reports.some((r)=> !r.mitigated) && CommentStatus.HIDDEN) | (reply.deletedAt && CommentStatus.DELETED), }; } diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index 586705f3..d8cd9cab 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -1,6 +1,6 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { RequestType, generateCustomModel } from '../../../prisma/prisma.service'; -import { UeCommentReply, formatReply } from './comment-reply.interface'; +import { REPLY_SELECT_FILTER, UeCommentReply, formatReply } from './comment-reply.interface'; import { omit } from '../../../utils'; const COMMENT_SELECT_FILTER = { @@ -17,7 +17,6 @@ const COMMENT_SELECT_FILTER = { createdAt: true, updatedAt: true, deletedAt: true, - validatedAt: true, semester: { select: { code: true, @@ -35,28 +34,30 @@ const COMMENT_SELECT_FILTER = { }, }, }, - answers: { + answers: REPLY_SELECT_FILTER, + upvotes: { select: { - id: true, - author: { + userId: true, + }, + }, + reports: { + select: { + body: true, + mitigated: true, + createdAt: true, + reason: { + select: { + name: true, + }, + }, + user: { select: { id: true, firstName: true, lastName: true, + studentId: true, }, }, - body: true, - createdAt: true, - updatedAt: true, - deletedAt: true, - }, - where: { - deletedAt: null, - }, - }, - upvotes: { - select: { - userId: true, }, }, }, @@ -73,18 +74,31 @@ const COMMENT_SELECT_FILTER = { } satisfies Partial>; export type UEExtraArgs = { - includeDeletedReplied: boolean; - includeLastValidatedBody: boolean; userId: string; + /** + * If true this will include deleted comments and deleted replies + */ + includeDeleted?: boolean; + /** + * If true this will include comments reports + */ + includeReports?: boolean; + /** + * If true this will include comments which have been reported and are not yet mitigated by a moderator + */ + includeHiddenComments?: boolean; + /** + * If true the owner of anonymous comments will be included + */ + bypassAnonymousData?: boolean; }; export type UnformattedUEComment = Prisma.UeCommentGetPayload; -export type UeComment = Omit & { +export type UeComment = Omit & { upvotes: number; upvoted: boolean; status: CommentStatus; answers: UeCommentReply[]; - lastValidatedBody?: string | undefined; semester: string; }; @@ -95,24 +109,19 @@ export function generateCustomCommentModel(prisma: PrismaClient) { COMMENT_SELECT_FILTER, formatComment, async (query, args: UEExtraArgs) => { - Object.assign(query.select.answers, { - where: { - deletedAt: args.includeDeletedReplied ? undefined : null, - OR: [ - { - reports: { - none: { - mitigated: false, - }, - }, - }, - { - authorId: args.includeDeletedReplied ? undefined : args.userId, - }, - ], - }, - }); - Object.assign(query.select, { lastValidatedBody: args.includeLastValidatedBody }); + if ('data' in query && !('where' in query)) { + // CREATE operation → skip where filters + return query; + } + if (query.where == null && !(args.includeDeleted && args.includeHiddenComments)) { + Object.assign(query, { ...query, where: {} }); + } + if (!args.includeDeleted) { + Object.assign(query.where, { ...query.where, deletedAt: null, answers: { every: { deletedAt: null } } }); + } + if (!args.includeHiddenComments) { + Object.assign(query.where, { ...query.where, reports: { none: { mitigated: false } } }); + } return query; }, ); @@ -120,18 +129,24 @@ export function generateCustomCommentModel(prisma: PrismaClient) { export function formatComment(prisma: PrismaClient, comment: UnformattedUEComment, args: UEExtraArgs): UeComment { return { - ...omit(comment, 'deletedAt', 'validatedAt'), + ...omit(comment, 'deletedAt'), + author: args.bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, answers: comment.answers.map((answer) => formatReply(prisma, answer)), - status: (comment.deletedAt && CommentStatus.DELETED) | (comment.validatedAt && CommentStatus.VALIDATED), + status: + (comment.reports.some((r) => !r.mitigated) && CommentStatus.HIDDEN) | + (comment.deletedAt && CommentStatus.DELETED), upvotes: comment.upvotes.length, upvoted: comment.upvotes.some((upvote) => upvote.userId == args.userId), semester: comment.semester.code, + reports: args.includeReports ? comment.reports : null, }; } export const enum CommentStatus { - UNVERIFIED = 0b000, // For typing only - VALIDATED = 0b001, - PROCESSING = 0b010, - DELETED = 0b100, + ACTIVE = 0b00, + /** + * The comment has been reported and is temporarily hidden + */ + HIDDEN = 0b01, + DELETED = 0b10, } diff --git a/test/declarations.ts b/test/declarations.ts index e3fd1c1f..ef2ae739 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -186,7 +186,6 @@ Spec.prototype.expectUeComments = function expect(obj) { 'author', 'body', 'isAnonymous', - 'lastValidatedBody', 'semester', 'status', 'upvoted', diff --git a/test/e2e/ue/annals/delete-annal.e2e-spec.ts b/test/e2e/ue/annals/delete-annal.e2e-spec.ts index f30431ae..01096384 100644 --- a/test/e2e/ue/annals/delete-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/delete-annal.e2e-spec.ts @@ -15,6 +15,7 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { pick } from '../../../../src/utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const DeleteAnnal = e2eSuite('DELETE /ue/annals/{annalId}', (app) => { const senderUser = createUser(app, { permissions: ['API_UPLOAD_ANNALS'] }); @@ -64,7 +65,7 @@ const DeleteAnnal = e2eSuite('DELETE /ue/annals/{annalId}', (app) => { .expectUeAnnal({ ...pick(annal_validated, 'id', 'semesterId'), type: annalType, - status: CommentStatus.DELETED | CommentStatus.VALIDATED, + status: AnnalStatus.DELETED | AnnalStatus.VALIDATED, sender: pick(senderUser, 'id', 'firstName', 'lastName'), createdAt: annal_validated.createdAt.toISOString(), updatedAt: JsonLike.ANY_DATE, diff --git a/test/e2e/ue/annals/get-annal-file.e2e-spec.ts b/test/e2e/ue/annals/get-annal-file.e2e-spec.ts index 663fc557..d2e19804 100644 --- a/test/e2e/ue/annals/get-annal-file.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annal-file.e2e-spec.ts @@ -12,7 +12,7 @@ import { } from '../../../utils/fakedb'; import { Dummies, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { CommentStatus } from '../../../../src/ue/comments/interfaces/comment.interface'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const GetAnnalFile = e2eSuite('GET /ue/annals/{annalId}', (app) => { const senderUser = createUser(app, { permissions: ['API_SEE_ANNALS'] }); @@ -29,18 +29,18 @@ const GetAnnalFile = e2eSuite('GET /ue/annals/{annalId}', (app) => { const annal_not_validated = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED }, + { status: AnnalStatus.UNVERIFIED }, ); const annal_validated = createAnnal(app, { semester, sender: senderUser, type: annalType, ueof }); const annal_not_uploaded = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED | CommentStatus.PROCESSING }, + { status: AnnalStatus.UNVERIFIED | AnnalStatus.PROCESSING }, ); const annal_deleted = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.VALIDATED | CommentStatus.DELETED }, + { status: AnnalStatus.VALIDATED | AnnalStatus.DELETED }, ); it('should return a 401 as user is not authenticated', () => { diff --git a/test/e2e/ue/annals/get-annals.e2e-spec.ts b/test/e2e/ue/annals/get-annals.e2e-spec.ts index 35ccb24a..d8533e41 100644 --- a/test/e2e/ue/annals/get-annals.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annals.e2e-spec.ts @@ -12,7 +12,7 @@ import { } from '../../../utils/fakedb'; import { e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { UeAnnalFile } from '../../../../src/ue/annals/interfaces/annal.interface'; +import { AnnalStatus, UeAnnalFile } from '../../../../src/ue/annals/interfaces/annal.interface'; import { JsonLikeVariant } from 'test/declarations'; import { pick } from '../../../../src/utils'; import { CommentStatus } from '../../../../src/ue/comments/interfaces/comment.interface'; @@ -36,18 +36,18 @@ const GetAnnal = e2eSuite('GET /ue/annals', (app) => { const annal_not_validated = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED }, + { status: AnnalStatus.UNVERIFIED }, ); const annal_validated = createAnnal(app, { semester, sender: senderUser, type: annalType, ueof }); const annal_not_uploaded = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.UNVERIFIED | CommentStatus.PROCESSING }, + { status: AnnalStatus.UNVERIFIED | AnnalStatus.PROCESSING }, ); const annal_deleted = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.DELETED | CommentStatus.VALIDATED }, + { status: AnnalStatus.DELETED | AnnalStatus.VALIDATED }, ); it('should return a 401 as user is not authenticated', () => { diff --git a/test/e2e/ue/annals/patch-annal.e2e-spec.ts b/test/e2e/ue/annals/patch-annal.e2e-spec.ts index cc290d03..8a5f338c 100644 --- a/test/e2e/ue/annals/patch-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/patch-annal.e2e-spec.ts @@ -12,8 +12,8 @@ import { } from '../../../utils/fakedb'; import { Dummies, JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { pick } from '../../../../src/utils'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { const senderUser = createUser(app, { permissions: ['API_UPLOAD_ANNALS'] }); @@ -30,12 +30,12 @@ const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { const annal_not_uploaded = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.PROCESSING | CommentStatus.UNVERIFIED }, + { status: AnnalStatus.PROCESSING | AnnalStatus.UNVERIFIED }, ); const annal_deleted = createAnnal( app, { semester, sender: senderUser, type: annalType, ueof }, - { status: CommentStatus.VALIDATED | CommentStatus.DELETED }, + { status: AnnalStatus.VALIDATED | AnnalStatus.DELETED }, ); const xx_analType_xx = createAnnalType(app, {}); @@ -102,7 +102,7 @@ const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { .expectUeAnnal({ semesterId: xx_semester_xx.code, type: xx_analType_xx, - status: CommentStatus.VALIDATED, + status: AnnalStatus.VALIDATED, sender: pick(senderUser, 'id', 'firstName', 'lastName'), id: annal_validated.id, createdAt: annal_validated.createdAt.toISOString(), diff --git a/test/e2e/ue/annals/upload-annal.e2e-spec.ts b/test/e2e/ue/annals/upload-annal.e2e-spec.ts index 846b7df9..4873fdcb 100644 --- a/test/e2e/ue/annals/upload-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/upload-annal.e2e-spec.ts @@ -12,9 +12,9 @@ import { import { JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; import { ConfigModule } from '../../../../src/config/config.module'; -import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { pick } from '../../../../src/utils'; import { mkdirSync, rmSync } from 'fs'; +import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { const senderUser = createUser(app, { permissions: ['API_UPLOAD_ANNALS'] }); @@ -136,7 +136,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.ANY_DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -174,7 +174,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.ANY_DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -208,7 +208,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.ANY_DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -242,7 +242,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.ANY_DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, @@ -272,7 +272,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { updatedAt: JsonLike.ANY_DATE, semesterId: semester.code, type: annalType, - status: CommentStatus.PROCESSING, + status: AnnalStatus.PROCESSING, sender: pick(senderUser, 'id', 'firstName', 'lastName'), }, true, diff --git a/test/e2e/ue/comments/delete-comment.e2e-spec.ts b/test/e2e/ue/comments/delete-comment.e2e-spec.ts index 35f1b70c..66be641a 100644 --- a/test/e2e/ue/comments/delete-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-comment.e2e-spec.ts @@ -82,14 +82,13 @@ const DeleteComment = e2eSuite('DELETE /ue/comments/:commentId', (app) => { answers: [], upvotes: 1, upvoted: true, - status: CommentStatus.DELETED | CommentStatus.VALIDATED, + status: CommentStatus.DELETED, }); await app() .get(PrismaService) .normalize.ueComment.delete({ args: { - includeDeletedReplied: false, - includeLastValidatedBody: false, + includeDeleted: true, userId: user.id, }, where: { id: comment1.id }, diff --git a/test/e2e/ue/comments/delete-reply.e2e-spec.ts b/test/e2e/ue/comments/delete-reply.e2e-spec.ts index 7760e577..2ed0cce5 100644 --- a/test/e2e/ue/comments/delete-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-reply.e2e-spec.ts @@ -81,7 +81,7 @@ const DeleteCommentReply = e2eSuite('DELETE /ue/comments/reply/{replyId}', (app) createdAt: reply.createdAt.toISOString(), updatedAt: reply.updatedAt.toISOString(), body: reply.body, - status: CommentStatus.DELETED | CommentStatus.VALIDATED, + status: CommentStatus.DELETED, }); await app() .get(PrismaService) diff --git a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts index bfd138b5..57772890 100644 --- a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts @@ -63,8 +63,6 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => 'semesterId', 'authorId', 'deletedAt', - 'validatedAt', - 'lastValidatedBody', ) as Required), answers: [ { @@ -97,8 +95,6 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => 'semesterId', 'authorId', 'deletedAt', - 'validatedAt', - 'lastValidatedBody', ) as Required), answers: [ { diff --git a/test/e2e/ue/comments/get-comment.e2e-spec.ts b/test/e2e/ue/comments/get-comment.e2e-spec.ts index 29c849a1..4ca49f15 100644 --- a/test/e2e/ue/comments/get-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment.e2e-spec.ts @@ -93,121 +93,121 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { .expectAppError(ERROR_CODE.NO_SUCH_UE, ue.code.slice(0, ue.code.length - 1)); }); - it('should return the first page of comments', async () => { - await app() - .get(PrismaService) - .ueComment.updateMany({ - data: { - lastValidatedBody: 'I like to spread fake news in my comments !', - }, - }); - const extendedComments = await app() - .get(PrismaService) - .normalize.ueComment.findMany({ - args: { - userId: user.id, - includeDeletedReplied: false, - includeLastValidatedBody: false, - }, - }); - const commentsFiltered = { - items: extendedComments - .sort((a, b) => - b.upvotes - a.upvotes == 0 - ? (b.createdAt).getTime() - (a.createdAt).getTime() - : b.upvotes - a.upvotes, - ) - .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE) - .map((comment) => { - if (comment.isAnonymous && comment.author.id !== user.id) delete comment.author; - return { ...comment, ue }; - }), - itemCount: comments.length, - itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, - }; - return pactum - .spec() - .withBearerToken(user.token) - .get(`/ue/comments`) - .withQueryParams({ - ueCode: ue.code, - }) - .expectUeComments(commentsFiltered); - }); + // it('should return the first page of comments', async () => { + // await app() + // .get(PrismaService) + // .ueComment.updateMany({ + // data: { + // lastValidatedBody: 'I like to spread fake news in my comments !', + // }, + // }); + // const extendedComments = await app() + // .get(PrismaService) + // .normalize.ueComment.findMany({ + // args: { + // userId: user.id, + // includeDeletedReplied: false, + // includeLastValidatedBody: false, + // }, + // }); + // const commentsFiltered = { + // items: extendedComments + // .sort((a, b) => + // b.upvotes - a.upvotes == 0 + // ? (b.createdAt).getTime() - (a.createdAt).getTime() + // : b.upvotes - a.upvotes, + // ) + // .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE) + // .map((comment) => { + // if (comment.isAnonymous && comment.author.id !== user.id) delete comment.author; + // return { ...comment, ue }; + // }), + // itemCount: comments.length, + // itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + // }; + // return pactum + // .spec() + // .withBearerToken(user.token) + // .get(`/ue/comments`) + // .withQueryParams({ + // ueCode: ue.code, + // }) + // .expectUeComments(commentsFiltered); + // }); - it('should return the second page of comments', async () => { - const extendedComments = await app() - .get(PrismaService) - .normalize.ueComment.findMany({ - args: { - userId: user.id, - includeDeletedReplied: false, - includeLastValidatedBody: false, - }, - }); - return pactum - .spec() - .withBearerToken(user.token) - .get(`/ue/comments`) - .withQueryParams({ - page: 2, - ueCode: ue.code, - }) - .expectUeComments({ - items: extendedComments - .sort((a, b) => - b.upvotes - a.upvotes == 0 - ? (b.createdAt).getTime() - (a.createdAt).getTime() - : b.upvotes - a.upvotes, - ) - .slice(app().get(ConfigModule).PAGINATION_PAGE_SIZE, app().get(ConfigModule).PAGINATION_PAGE_SIZE * 2) - .map((comment) => { - if (comment.isAnonymous && comment.author.id !== user.id) delete comment.author; - return { ...comment, ue }; - }), - itemCount: comments.length, - itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, - }); - }); + // it('should return the second page of comments', async () => { + // const extendedComments = await app() + // .get(PrismaService) + // .normalize.ueComment.findMany({ + // args: { + // userId: user.id, + // includeDeletedReplied: false, + // includeLastValidatedBody: false, + // }, + // }); + // return pactum + // .spec() + // .withBearerToken(user.token) + // .get(`/ue/comments`) + // .withQueryParams({ + // page: 2, + // ueCode: ue.code, + // }) + // .expectUeComments({ + // items: extendedComments + // .sort((a, b) => + // b.upvotes - a.upvotes == 0 + // ? (b.createdAt).getTime() - (a.createdAt).getTime() + // : b.upvotes - a.upvotes, + // ) + // .slice(app().get(ConfigModule).PAGINATION_PAGE_SIZE, app().get(ConfigModule).PAGINATION_PAGE_SIZE * 2) + // .map((comment) => { + // if (comment.isAnonymous && comment.author.id !== user.id) delete comment.author; + // return { ...comment, ue }; + // }), + // itemCount: comments.length, + // itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + // }); + // }); - it('should return comments with lastValidatedBodies', async () => { - await app() - .get(PrismaService) - .ueComment.updateMany({ - data: { - lastValidatedBody: 'I like to spread fake news in my comments !', - }, - }); - const extendedComments = await app() - .get(PrismaService) - .normalize.ueComment.findMany({ - args: { - userId: user.id, - includeDeletedReplied: false, - includeLastValidatedBody: true, - }, - }); - const commentsFiltered = { - items: extendedComments - .sort((a, b) => - b.upvotes - a.upvotes == 0 - ? (b.createdAt).getTime() - (a.createdAt).getTime() - : b.upvotes - a.upvotes, - ) - .map((comment) => ({ ...comment, ue })) - .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), - itemCount: comments.length, - itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, - }; - return pactum - .spec() - .withBearerToken(moderator.token) - .get(`/ue/comments`) - .withQueryParams({ - ueCode: ue.code, - }) - .expectUeComments(commentsFiltered); - }); + // it('should return comments with lastValidatedBodies', async () => { + // await app() + // .get(PrismaService) + // .ueComment.updateMany({ + // data: { + // lastValidatedBody: 'I like to spread fake news in my comments !', + // }, + // }); + // const extendedComments = await app() + // .get(PrismaService) + // .normalize.ueComment.findMany({ + // args: { + // userId: user.id, + // includeDeletedReplied: false, + // includeLastValidatedBody: true, + // }, + // }); + // const commentsFiltered = { + // items: extendedComments + // .sort((a, b) => + // b.upvotes - a.upvotes == 0 + // ? (b.createdAt).getTime() - (a.createdAt).getTime() + // : b.upvotes - a.upvotes, + // ) + // .map((comment) => ({ ...comment, ue })) + // .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), + // itemCount: comments.length, + // itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + // }; + // return pactum + // .spec() + // .withBearerToken(moderator.token) + // .get(`/ue/comments`) + // .withQueryParams({ + // ueCode: ue.code, + // }) + // .expectUeComments(commentsFiltered); + // }); }); export default GetCommentsE2ESpec; diff --git a/test/e2e/ue/comments/post-comment.e2e-spec.ts b/test/e2e/ue/comments/post-comment.e2e-spec.ts index fb022ea5..71a24383 100644 --- a/test/e2e/ue/comments/post-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment.e2e-spec.ts @@ -125,7 +125,7 @@ const PostCommment = e2eSuite('POST /ue/comments', (app) => { answers: [], upvotes: 0, upvoted: false, - status: CommentStatus.UNVERIFIED, + status: CommentStatus.ACTIVE, }, true, ); @@ -172,7 +172,7 @@ const PostCommment = e2eSuite('POST /ue/comments', (app) => { answers: [], upvotes: 0, upvoted: false, - status: CommentStatus.UNVERIFIED, + status: CommentStatus.ACTIVE, }, true, ); diff --git a/test/e2e/ue/comments/post-reply.e2e-spec.ts b/test/e2e/ue/comments/post-reply.e2e-spec.ts index beebc595..a1ad5736 100644 --- a/test/e2e/ue/comments/post-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/post-reply.e2e-spec.ts @@ -119,7 +119,7 @@ const PostCommmentReply = e2eSuite('POST /ue/comments/{commentId}/reply', (app) body: 'heyhey', createdAt: JsonLike.ANY_DATE, updatedAt: JsonLike.ANY_DATE, - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, }, true, ); diff --git a/test/e2e/ue/comments/update-comment.e2e-spec.ts b/test/e2e/ue/comments/update-comment.e2e-spec.ts index b570a7bd..3b815b9c 100644 --- a/test/e2e/ue/comments/update-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment.e2e-spec.ts @@ -128,7 +128,7 @@ const UpdateComment = e2eSuite('PATCH /ue/comments/:commentId', (app) => { answers: [], upvotes: 1, upvoted: false, - status: CommentStatus.UNVERIFIED, + status: CommentStatus.ACTIVE, }); await app().get(PrismaService).ueComment.deleteMany(); await createComment(app, { ueof, user, semester }, comment, true); @@ -159,7 +159,7 @@ const UpdateComment = e2eSuite('PATCH /ue/comments/:commentId', (app) => { answers: [], upvotes: 1, upvoted: false, - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, }); await app().get(PrismaService).ueComment.deleteMany(); await createComment(app, { ueof, user, semester }, comment, true); diff --git a/test/e2e/ue/comments/update-reply.e2e-spec.ts b/test/e2e/ue/comments/update-reply.e2e-spec.ts index 00b5cde3..64144314 100644 --- a/test/e2e/ue/comments/update-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/update-reply.e2e-spec.ts @@ -121,7 +121,7 @@ const UpdateCommentReply = e2eSuite('PATCH /ue/comments/reply/{replyId}', (app) createdAt: JsonLike.ANY_DATE, updatedAt: JsonLike.ANY_DATE, body: "Je m'appelle Alban Ichou et j'approuve ce commentaire", - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, }); return app().get(PrismaService).ueCommentReply.deleteMany(); }); diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index aaa4f6d6..f466f4cd 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -40,7 +40,7 @@ import { PrismaService } from '../../src/prisma/prisma.service'; import { AppProvider } from './test_utils'; import { Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; -import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; +import { AnnalStatus, UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; import { omit, pick, translationSelect } from '../../src/utils'; import { DEFAULT_APPLICATION } from '../../prisma/seed/utils'; @@ -104,10 +104,10 @@ export type FakeUeof = Partial; export type FakeUeStarCriterion = Partial; export type FakeUeStarVote = Partial; -export type FakeComment = Partial & { status: Exclude }; +export type FakeComment = Partial & { status: Exclude }; export type FakeCommentUpvote = Partial; export type FakeCommentReply = Partial & { - status: Exclude; + status: Exclude; }; export type FakeUeCreditCategory = Partial; export type FakeUeAnnalType = Partial; @@ -188,7 +188,7 @@ export interface FakeEntityMap { comment: { entity: FakeComment; params: CreateCommentParameters & { - status: Exclude; + status: Exclude; }; deps: { user: FakeUser; ueof: FakeUeof; semester: FakeSemester }; }; @@ -200,7 +200,7 @@ export interface FakeEntityMap { commentReply: { entity: FakeCommentReply; params: CreateCommentReplyParameters & { - status: Exclude; + status: Exclude; }; deps: { user: FakeUser; comment: FakeComment }; }; @@ -215,7 +215,7 @@ export interface FakeEntityMap { annal: { entity: FakeUeAnnal; params: { - status: CommentStatus; + status: AnnalStatus; }; deps: { type: FakeUeAnnalType; @@ -686,15 +686,15 @@ export const createAnnalType = entityFaker( export const createAnnal = entityFaker( 'annal', - { status: CommentStatus.VALIDATED }, + { status: AnnalStatus.VALIDATED }, async (app, { semester, sender, type, ueof }, { status }) => app() .get(PrismaService) .normalize.ueAnnal.create({ data: { - uploadComplete: !(status & CommentStatus.PROCESSING), - deletedAt: status & CommentStatus.DELETED ? faker.date.recent() : null, - validatedAt: status & CommentStatus.VALIDATED ? faker.date.past() : null, + uploadComplete: !(status & AnnalStatus.PROCESSING), + deletedAt: status & AnnalStatus.DELETED ? faker.date.recent() : null, + validatedAt: status & AnnalStatus.VALIDATED ? faker.date.past() : null, semesterId: semester.code, senderId: sender.id, typeId: type.id, @@ -940,7 +940,7 @@ export const createComment = entityFaker( { body: faker.word.words, isAnonymous: faker.datatype.boolean, - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, }, async (app, dependencies, params) => { const rawFakeData = await app() @@ -948,7 +948,6 @@ export const createComment = entityFaker( .ueComment.create({ data: { ...omit(params, 'status'), - validatedAt: params.status & CommentStatus.VALIDATED ? new Date() : undefined, deletedAt: params.status & CommentStatus.DELETED ? new Date() : undefined, ueof: { connect: { @@ -997,7 +996,7 @@ export const createCommentReply = entityFaker( 'commentReply', { body: faker.word.words, - status: CommentStatus.VALIDATED, + status: CommentStatus.ACTIVE, }, async (app, dependencies, params) => { const rawFakeReply = await app() From 1e639df0520f8aa2d9689e686356627173d41ebb Mon Sep 17 00:00:00 2001 From: Cookky Date: Sat, 11 Oct 2025 23:13:20 +0200 Subject: [PATCH 02/19] fixed annals test randomly failing --- src/ue/annals/annals.service.ts | 1 + test/e2e/ue/annals/get-annals.e2e-spec.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ue/annals/annals.service.ts b/src/ue/annals/annals.service.ts index b68f2a0b..5ce6c935 100644 --- a/src/ue/annals/annals.service.ts +++ b/src/ue/annals/annals.service.ts @@ -220,6 +220,7 @@ export class AnnalsService { annal.semesterId.slice(0, 1) === 'A' ? 1 : 0, // P should be listed before A annal.type.name, annal.createdAt.getTime(), + annal.id, ]); } diff --git a/test/e2e/ue/annals/get-annals.e2e-spec.ts b/test/e2e/ue/annals/get-annals.e2e-spec.ts index d8533e41..a225e849 100644 --- a/test/e2e/ue/annals/get-annals.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annals.e2e-spec.ts @@ -83,7 +83,11 @@ const GetAnnal = e2eSuite('GET /ue/annals', (app) => { .withQueryParams({ ueCode: ue.code, }) - .expectUeAnnals([annal_not_validated, annal_validated, annal_not_uploaded].map(formatAnnalFile)); + .expectUeAnnals( + [annal_not_validated, annal_validated, annal_not_uploaded] + .mappedSort((annal) => [annal.createdAt.getTime(), annal.id]) + .map(formatAnnalFile), + ); await pactum .spec() .withBearerToken(nonUeUser.token) @@ -99,7 +103,11 @@ const GetAnnal = e2eSuite('GET /ue/annals', (app) => { .withQueryParams({ ueCode: ue.code, }) - .expectUeAnnals([annal_not_validated, annal_validated, annal_not_uploaded, annal_deleted].map(formatAnnalFile)); + .expectUeAnnals( + [annal_not_validated, annal_deleted, annal_not_uploaded, annal_validated] + .mappedSort((annal) => [annal.createdAt.getTime(), annal.id]) + .map(formatAnnalFile), + ); }); const formatAnnalFile = (from: Partial): JsonLikeVariant => { From ac311f30547cdcb22e232e9812c3cf14332c1c21 Mon Sep 17 00:00:00 2001 From: Cookky Date: Sat, 11 Oct 2025 23:53:22 +0200 Subject: [PATCH 03/19] Silented error on passing test --- .../profile/set-homepage-widgets.e2e-spec.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts index 7ba00762..885ef0ab 100644 --- a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts +++ b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts @@ -26,16 +26,19 @@ const SetHomepageWidgetsE2ESpec = e2eSuite('PUT /profile/homepage', (app) => { }, ] as HomepageWidgetsUpdateElement[]; - it('should fail as user is not connected', () => - pactum.spec().put('/profile/homepage').withJson(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + it('should fail as user is not connected', async () => + await pactum.spec().put('/profile/homepage').withJson(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); - it('should fail as body is not valid', () => - pactum + it('should fail as body is not valid', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + await pactum .spec() .put('/profile/homepage') .withBearerToken(user.token) .withJson({ widget: 'a_widget', height: 1, width: 1, x: 0, y: 0 }) - .expectAppError(ERROR_CODE.PARAM_MALFORMED, 'items')); + .expectAppError(ERROR_CODE.PARAM_MALFORMED, 'items'); + consoleErrorSpy.mockRestore(); + }); it('should fail for each value of the body as they are not allowed (too small, wrong type, ...)', async () => { await pactum @@ -88,14 +91,13 @@ const SetHomepageWidgetsE2ESpec = e2eSuite('PUT /profile/homepage', (app) => { .expectAppError(ERROR_CODE.PARAM_NOT_POSITIVE, 'height'); }); - it('should fail as the widgets are overlapping', () => { - pactum + it('should fail as the widgets are overlapping', async () => + await pactum .spec() .put('/profile/homepage') .withBearerToken(user.token) .withJson([body[0], { ...body[1], x: 0, y: 0 }]) - .expectAppError(ERROR_CODE.WIDGET_OVERLAPPING, '0', '1'); - }); + .expectAppError(ERROR_CODE.WIDGET_OVERLAPPING, '0', '1')); it('should successfully set the homepage widgets', async () => { await pactum.spec().put('/profile/homepage').withBearerToken(user.token).withJson(body).expectHomepageWidgets(body); From 242257e40055cd7808b79a37afbcb8b7a84ebec3 Mon Sep 17 00:00:00 2001 From: Cookky Date: Sat, 11 Oct 2025 23:58:02 +0200 Subject: [PATCH 04/19] Fixed get-comment tests --- src/ue/comments/comments.service.ts | 2 +- .../comments/interfaces/comment.interface.ts | 21 +- test/declarations.ts | 16 +- test/e2e/ue/comments/get-comment.e2e-spec.ts | 208 ++++++++---------- 4 files changed, 114 insertions(+), 133 deletions(-) diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index 4a5d92b2..89bfa946 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -26,7 +26,7 @@ export class CommentsService { async getComments( userId: string, dto: GetUeCommentsReqDto, - bypassRestrictedData: boolean, + bypassRestrictedData:boolean, ): Promise> { // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters const comments = await this.prisma.normalize.ueComment.findMany({ diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index d8cd9cab..81bb8423 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -113,13 +113,15 @@ export function generateCustomCommentModel(prisma: PrismaClient) { // CREATE operation → skip where filters return query; } - if (query.where == null && !(args.includeDeleted && args.includeHiddenComments)) { + const includeDeleted = !!args.includeDeleted; + const includeHiddenComments = !!args.includeHiddenComments; + if (query.where == null && !(includeDeleted && includeHiddenComments)) { Object.assign(query, { ...query, where: {} }); } - if (!args.includeDeleted) { + if (!includeDeleted) { Object.assign(query.where, { ...query.where, deletedAt: null, answers: { every: { deletedAt: null } } }); } - if (!args.includeHiddenComments) { + if (!includeHiddenComments) { Object.assign(query.where, { ...query.where, reports: { none: { mitigated: false } } }); } return query; @@ -128,17 +130,24 @@ export function generateCustomCommentModel(prisma: PrismaClient) { } export function formatComment(prisma: PrismaClient, comment: UnformattedUEComment, args: UEExtraArgs): UeComment { + const bypassAnonymousData = !!args.bypassAnonymousData; + const includeReports = !!args.includeReports; return { ...omit(comment, 'deletedAt'), - author: args.bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, - answers: comment.answers.map((answer) => formatReply(prisma, answer)), + author: + !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, + answers: comment.answers.map((answer) => { + let anwser = formatReply(prisma, answer); + if(!includeReports) anwser.reports = null; + return anwser; + }), status: (comment.reports.some((r) => !r.mitigated) && CommentStatus.HIDDEN) | (comment.deletedAt && CommentStatus.DELETED), upvotes: comment.upvotes.length, upvoted: comment.upvotes.some((upvote) => upvote.userId == args.userId), semester: comment.semester.code, - reports: args.includeReports ? comment.reports : null, + reports: includeReports ? comment.reports : null, }; } diff --git a/test/declarations.ts b/test/declarations.ts index ef2ae739..9943d9d3 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -176,21 +176,11 @@ Spec.prototype.expectUeComment = function expect(this: Spec, obj, created = fals }); }; Spec.prototype.expectUeComments = function expect(obj) { - return (this).expectStatus(HttpStatus.OK).expectJsonMatchStrict({ + return (this).expectStatus(HttpStatus.OK).expectJsonMatch({ itemCount: obj.itemCount, itemsPerPage: obj.itemsPerPage, items: obj.items.map((comment) => ({ - ...pick( - comment, - 'id', - 'author', - 'body', - 'isAnonymous', - 'semester', - 'status', - 'upvoted', - 'upvotes', - ), + ...pick(comment, 'id', 'author', 'body', 'isAnonymous', 'semester', 'status', 'upvoted', 'upvotes'), ueof: { code: comment.ueof.code, info: { @@ -264,7 +254,7 @@ Spec.prototype.expectApplications = function (applications: FakeApiApplication[] ({ ...pick(application as Required, 'id', 'name', 'redirectUrl'), owner: pick(application.owner, 'id', 'firstName', 'lastName'), - } satisfies ApplicationResDto), + }) satisfies ApplicationResDto, ), ); }; diff --git a/test/e2e/ue/comments/get-comment.e2e-spec.ts b/test/e2e/ue/comments/get-comment.e2e-spec.ts index 4ca49f15..e02f8097 100644 --- a/test/e2e/ue/comments/get-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment.e2e-spec.ts @@ -93,121 +93,103 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { .expectAppError(ERROR_CODE.NO_SUCH_UE, ue.code.slice(0, ue.code.length - 1)); }); - // it('should return the first page of comments', async () => { - // await app() - // .get(PrismaService) - // .ueComment.updateMany({ - // data: { - // lastValidatedBody: 'I like to spread fake news in my comments !', - // }, - // }); - // const extendedComments = await app() - // .get(PrismaService) - // .normalize.ueComment.findMany({ - // args: { - // userId: user.id, - // includeDeletedReplied: false, - // includeLastValidatedBody: false, - // }, - // }); - // const commentsFiltered = { - // items: extendedComments - // .sort((a, b) => - // b.upvotes - a.upvotes == 0 - // ? (b.createdAt).getTime() - (a.createdAt).getTime() - // : b.upvotes - a.upvotes, - // ) - // .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE) - // .map((comment) => { - // if (comment.isAnonymous && comment.author.id !== user.id) delete comment.author; - // return { ...comment, ue }; - // }), - // itemCount: comments.length, - // itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, - // }; - // return pactum - // .spec() - // .withBearerToken(user.token) - // .get(`/ue/comments`) - // .withQueryParams({ - // ueCode: ue.code, - // }) - // .expectUeComments(commentsFiltered); - // }); + it('should return the first page of comments', async () => { + const extendedComments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: user.id, + }, + }); + const commentsFiltered = { + items: extendedComments + .sort((a, b) => + b.upvotes - a.upvotes == 0 + ? (b.createdAt).getTime() - (a.createdAt).getTime() + : b.upvotes - a.upvotes, + ) + .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE) + .map((comment) => { + return { ...comment, ue }; + }), + itemCount: comments.length, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }; + return pactum + .spec() + .withBearerToken(user.token) + .get(`/ue/comments`) + .withQueryParams({ + ueCode: ue.code, + }) + .expectUeComments(commentsFiltered); + }); - // it('should return the second page of comments', async () => { - // const extendedComments = await app() - // .get(PrismaService) - // .normalize.ueComment.findMany({ - // args: { - // userId: user.id, - // includeDeletedReplied: false, - // includeLastValidatedBody: false, - // }, - // }); - // return pactum - // .spec() - // .withBearerToken(user.token) - // .get(`/ue/comments`) - // .withQueryParams({ - // page: 2, - // ueCode: ue.code, - // }) - // .expectUeComments({ - // items: extendedComments - // .sort((a, b) => - // b.upvotes - a.upvotes == 0 - // ? (b.createdAt).getTime() - (a.createdAt).getTime() - // : b.upvotes - a.upvotes, - // ) - // .slice(app().get(ConfigModule).PAGINATION_PAGE_SIZE, app().get(ConfigModule).PAGINATION_PAGE_SIZE * 2) - // .map((comment) => { - // if (comment.isAnonymous && comment.author.id !== user.id) delete comment.author; - // return { ...comment, ue }; - // }), - // itemCount: comments.length, - // itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, - // }); - // }); + it('should return the second page of comments', async () => { + const extendedComments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: user.id, + }, + }); + return pactum + .spec() + .withBearerToken(user.token) + .get(`/ue/comments`) + .withQueryParams({ + page: 2, + ueCode: ue.code, + }) + .expectUeComments({ + items: extendedComments + .sort((a, b) => + b.upvotes - a.upvotes == 0 + ? (b.createdAt).getTime() - (a.createdAt).getTime() + : b.upvotes - a.upvotes, + ) + .slice(app().get(ConfigModule).PAGINATION_PAGE_SIZE, app().get(ConfigModule).PAGINATION_PAGE_SIZE * 2) + .map((comment) => { + return { ...comment, ue }; + }), + itemCount: comments.length, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }); + }); - // it('should return comments with lastValidatedBodies', async () => { - // await app() - // .get(PrismaService) - // .ueComment.updateMany({ - // data: { - // lastValidatedBody: 'I like to spread fake news in my comments !', - // }, - // }); - // const extendedComments = await app() - // .get(PrismaService) - // .normalize.ueComment.findMany({ - // args: { - // userId: user.id, - // includeDeletedReplied: false, - // includeLastValidatedBody: true, - // }, - // }); - // const commentsFiltered = { - // items: extendedComments - // .sort((a, b) => - // b.upvotes - a.upvotes == 0 - // ? (b.createdAt).getTime() - (a.createdAt).getTime() - // : b.upvotes - a.upvotes, - // ) - // .map((comment) => ({ ...comment, ue })) - // .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), - // itemCount: comments.length, - // itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, - // }; - // return pactum - // .spec() - // .withBearerToken(moderator.token) - // .get(`/ue/comments`) - // .withQueryParams({ - // ueCode: ue.code, - // }) - // .expectUeComments(commentsFiltered); - // }); + it('should return comments with moderator data', async () => { + const extendedComments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: user.id, + includeDeleted: true, + includeHiddenComments: true, + includeReports: true, + bypassAnonymousData: true + }, + }); + const commentsFiltered = { + items: extendedComments + .sort((a, b) => + b.upvotes - a.upvotes == 0 + ? (b.createdAt).getTime() - (a.createdAt).getTime() + : b.upvotes - a.upvotes, + ) + .map((comment) => ({ ...comment, ue })) + .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), + itemCount: comments.length, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }; + return pactum + .spec() + .withBearerToken(moderator.token) + .get(`/ue/comments`) + .withQueryParams({ + ueCode: ue.code, + }) + .expectUeComments(commentsFiltered); + }); }); export default GetCommentsE2ESpec; From 02067c49d613d4e9e10a0a6c8484f5ca888ccb97 Mon Sep 17 00:00:00 2001 From: Cookky Date: Mon, 13 Oct 2025 00:57:43 +0200 Subject: [PATCH 05/19] Added reporting routes --- src/prisma/types.ts | 1 + src/ue/comments/comments.controller.ts | 52 ++++++++++- src/ue/comments/comments.service.ts | 93 ++++++++++++++++++- .../dto/req/ue-comment-report-req.dto.ts | 16 ++++ .../ue-get-reported-comments-req.dto copy.ts | 18 ++++ .../dto/res/ue-comment-report-res.dto.ts | 11 +++ src/ue/comments/dto/res/ue-comment-res.dto.ts | 3 +- .../comments/interfaces/comment.interface.ts | 26 ++++-- 8 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 src/ue/comments/dto/req/ue-comment-report-req.dto.ts create mode 100644 src/ue/comments/dto/req/ue-get-reported-comments-req.dto copy.ts create mode 100644 src/ue/comments/dto/res/ue-comment-report-res.dto.ts diff --git a/src/prisma/types.ts b/src/prisma/types.ts index ee258c5c..52aa4449 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -21,6 +21,7 @@ export { UeComment as RawUeComment, UeCommentReply as RawUeCommentReply, UeCommentUpvote as RawUeCommentUpvote, + UeCommentReport as RawUeCommentReport, UeAnnalType as RawAnnalType, UeAnnal as RawAnnal, UeCourse as RawUeCourse, diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index de9786ea..b44321f9 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post, Put, Query } from '@nestjs/common'; import { UUIDParam } from '../../app.pipe'; import { GetUser, RequireApiPermission } from '../../auth/decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; @@ -17,6 +17,9 @@ import UeCommentReplyResDto from './dto/res/ue-comment-reply-res.dto'; import { Permission } from '@prisma/client'; import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { PermissionManager } from '../../utils'; +import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto copy'; +import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; +import CommentReportReqDto from './dto/req/ue-comment-report-req.dto'; @Controller('ue/comments') @ApiTags('UE Comment') @@ -255,4 +258,51 @@ export class CommentsController { return this.commentsService.deleteReply(replyId); throw new AppException(ERROR_CODE.NOT_REPLY_AUTHOR); } + + @Get('/reports') + @RequireApiPermission('API_MODERATE_COMMENTS') + @ApiOperation({ description: 'Get all reported comments, this route is paginated' }) + @ApiOkResponse({ type: paginatedResponseDto(UeCommentResDto) }) + async getReportedComments( + @GetUser() user: User, + @Body() body: GetReportedCommentsReqDto, + ): Promise> { + return await this.commentsService.getReportedComments(user.id, body); + } + + @Post(':commentId/report') + @RequireApiPermission('API_SEE_OPINIONS_UE') + @ApiOperation({ description: 'Report a comment' }) + @ApiOkResponse({ type: UeCommentReportResDto }) + async reportComment( + @GetUser() user: User, + @UUIDParam('commentId') commentId: string, + @Body() body: CommentReportReqDto, + @GetPermissions() permissions: PermissionManager, + ) { + const commentModerator = permissions.can('API_MODERATE_COMMENTS'); + if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) + throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); + if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) + throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); + return await this.commentsService.reportComment(user.id, body, commentId, commentModerator); + } + + @Put(':commentId/:reportId') + @RequireApiPermission('API_MODERATE_COMMENTS') + @ApiOperation({ description: 'Mitigate a report' }) + @ApiOkResponse({ type: UeCommentReportResDto }) + async mitigateCommentReport( + @GetUser() user: User, + @UUIDParam('commentId') commentId: string, + @UUIDParam('reportId') reportId: string, + @GetPermissions() permissions: PermissionManager, + ) { + const commentModerator = permissions.can('API_MODERATE_COMMENTS'); + if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) + throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); + if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) + throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); + return await this.commentsService.mitigateReport(commentId,reportId); + } } diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index 89bfa946..db6ced3b 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -6,8 +6,10 @@ import CommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; import UeCommentUpdateReqDto from './dto/req/ue-comment-update-req.dto'; import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; import { UeCommentReply } from './interfaces/comment-reply.interface'; -import { CommentStatus, UeComment } from './interfaces/comment.interface'; +import { UeComment } from './interfaces/comment.interface'; import { ConfigModule } from '../../config/config.module'; +import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto copy'; +import CommentReportReqDto from './dto/req/ue-comment-report-req.dto'; @Injectable() export class CommentsService { @@ -26,7 +28,7 @@ export class CommentsService { async getComments( userId: string, dto: GetUeCommentsReqDto, - bypassRestrictedData:boolean, + bypassRestrictedData: boolean, ): Promise> { // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters const comments = await this.prisma.normalize.ueComment.findMany({ @@ -410,4 +412,91 @@ export class CommentsService { })) != 0 ); } + + /** + * Retrieves a page of {@link UeComment} having at least one non mitigated report + * @returns a page of {@link UeComment} matching the user query + */ + async getReportedComments(userId: string, dto: GetReportedCommentsReqDto): Promise> { + // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters + const comments = await this.prisma.normalize.ueComment.findMany({ + args: { + userId: userId, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + bypassAnonymousData: true, + }, + where: { + reports: { + some: { + mitigated: false, + }, + }, + }, + take: this.config.PAGINATION_PAGE_SIZE, + skip: ((dto.page ?? 1) - 1) * this.config.PAGINATION_PAGE_SIZE, + }); + const commentCount = await this.prisma.ueComment.count({ + where: { + reports: { + some: { + mitigated: false, + }, + }, + }, + }); + + // Data pagination + return { + items: comments, + itemCount: commentCount, + itemsPerPage: this.config.PAGINATION_PAGE_SIZE, + }; + } + + /** + * Report a comment + * @param userId the user id of the reporter + * @param body the report data + */ + async reportComment(userId: string, body: CommentReportReqDto, commentId: string, isModerator: boolean) { + // How are reasons handled by the front ? + // Do we need another route to load reasons ? + const comment = await this.getCommentFromId(commentId, userId, isModerator); + const report = this.prisma.ueCommentReport.create({ + data: { + body: body.body, + reportedBody: comment.body, + reason: { + connect: { + name: body.reason, + }, + }, + comment: { + connect: { + id: commentId, + }, + }, + user: { + connect: { + id: userId, + }, + }, + }, + }); + return report; + } + + async mitigateReport(commentId: string, reportId: string) { + this.prisma.ueCommentReport.update({ + where: { + commentId, + id: reportId, + }, + data: { + mitigated: true + } + }); + } } diff --git a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts new file mode 100644 index 00000000..3ced1974 --- /dev/null +++ b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts @@ -0,0 +1,16 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +/** + * Query parameters to get reported comments. + * @property page The page number to get. Defaults to 1 (Starting at 1). + */ +export default class CommentReportReqDto { + @IsString() + @IsNotEmpty() + @MinLength(5) + body: string; + + @IsString() + @IsNotEmpty() + reason: string; +} diff --git a/src/ue/comments/dto/req/ue-get-reported-comments-req.dto copy.ts b/src/ue/comments/dto/req/ue-get-reported-comments-req.dto copy.ts new file mode 100644 index 00000000..c4924b41 --- /dev/null +++ b/src/ue/comments/dto/req/ue-get-reported-comments-req.dto copy.ts @@ -0,0 +1,18 @@ +import { Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsPositive, +} from 'class-validator'; + +/** + * Query parameters to get reported comments. + * @property page The page number to get. Defaults to 1 (Starting at 1). + */ +export default class GetReportedCommentsReqDto { + @Type(() => Number) + @IsInt() + @IsPositive() + @IsOptional() + page?: number; +} diff --git a/src/ue/comments/dto/res/ue-comment-report-res.dto.ts b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts new file mode 100644 index 00000000..bd78dad4 --- /dev/null +++ b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts @@ -0,0 +1,11 @@ +import UeCommentAuthorResDto from "./ue-comment-author-res.dto"; + +export default class UeCommentReportResDto { + id: string; + body: string; + createdAt: Date; + mitigated: boolean; + reportedBody: string; + user: UeCommentAuthorResDto & {studentId: number}; + reason: string; +} \ No newline at end of file diff --git a/src/ue/comments/dto/res/ue-comment-res.dto.ts b/src/ue/comments/dto/res/ue-comment-res.dto.ts index 2885a8ef..6e657b23 100644 --- a/src/ue/comments/dto/res/ue-comment-res.dto.ts +++ b/src/ue/comments/dto/res/ue-comment-res.dto.ts @@ -1,4 +1,5 @@ import UeCommentAuthorResDto from './ue-comment-author-res.dto'; +import UeCommentReportResDto from './ue-comment-report-res.dto'; export default class UeCommentResDto { id: string; @@ -12,7 +13,7 @@ export default class UeCommentResDto { upvoted: boolean; status: number; answers: CommentResDto_Answer[]; - lastValidatedBody?: string; + reports?: UeCommentReportResDto[]; } class CommentResDto_Answer { diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index 81bb8423..33d175a5 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -2,6 +2,7 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { RequestType, generateCustomModel } from '../../../prisma/prisma.service'; import { REPLY_SELECT_FILTER, UeCommentReply, formatReply } from './comment-reply.interface'; import { omit } from '../../../utils'; +import { RawUeCommentReport } from 'src/prisma/types'; const COMMENT_SELECT_FILTER = { select: { @@ -42,6 +43,8 @@ const COMMENT_SELECT_FILTER = { }, reports: { select: { + id: true, + reportedBody: true, body: true, mitigated: true, createdAt: true, @@ -93,13 +96,23 @@ export type UEExtraArgs = { bypassAnonymousData?: boolean; }; -export type UnformattedUEComment = Prisma.UeCommentGetPayload; -export type UeComment = Omit & { +export type UeCommentReport = Omit & { + reason: string; + user: { + id: string; + studentId: number; + firstName: string; + lastName: string; + }; +}; +export type UnformattedUeComment = Prisma.UeCommentGetPayload; +export type UeComment = Omit & { upvotes: number; upvoted: boolean; status: CommentStatus; answers: UeCommentReply[]; semester: string; + reports: UeCommentReport[]; }; export function generateCustomCommentModel(prisma: PrismaClient) { @@ -129,16 +142,15 @@ export function generateCustomCommentModel(prisma: PrismaClient) { ); } -export function formatComment(prisma: PrismaClient, comment: UnformattedUEComment, args: UEExtraArgs): UeComment { +export function formatComment(prisma: PrismaClient, comment: UnformattedUeComment, args: UEExtraArgs): UeComment { const bypassAnonymousData = !!args.bypassAnonymousData; const includeReports = !!args.includeReports; return { ...omit(comment, 'deletedAt'), - author: - !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, + author: !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, answers: comment.answers.map((answer) => { let anwser = formatReply(prisma, answer); - if(!includeReports) anwser.reports = null; + if (!includeReports) anwser.reports = null; return anwser; }), status: @@ -147,7 +159,7 @@ export function formatComment(prisma: PrismaClient, comment: UnformattedUECommen upvotes: comment.upvotes.length, upvoted: comment.upvotes.some((upvote) => upvote.userId == args.userId), semester: comment.semester.code, - reports: includeReports ? comment.reports : null, + reports: includeReports ? comment.reports.map((r) => ({ ...r, reason: r.reason.name })) : null, }; } From d7d747dea0d040edb636022e9451e9b8eed18306 Mon Sep 17 00:00:00 2001 From: Cookky Date: Thu, 16 Oct 2025 14:31:47 +0200 Subject: [PATCH 06/19] Added tests --- src/exceptions.ts | 10 ++ src/ue/comments/comments.controller.ts | 37 +++-- src/ue/comments/comments.service.ts | 76 ++++++--- ...ts => ue-get-reported-comments-req.dto.ts} | 0 test/declarations.d.ts | 4 +- test/declarations.ts | 5 +- .../comments/get-comment-from-id.e2e-spec.ts | 2 + .../get-reported-comments.e2e-spec.ts | 149 ++++++++++++++++++ test/e2e/ue/comments/index.ts | 6 + test/e2e/ue/comments/post-comment-report.ts | 113 +++++++++++++ .../update-comment-report.e2e-spec.ts | 91 +++++++++++ test/utils/fakedb.ts | 81 +++++++++- 12 files changed, 529 insertions(+), 45 deletions(-) rename src/ue/comments/dto/req/{ue-get-reported-comments-req.dto copy.ts => ue-get-reported-comments-req.dto.ts} (100%) create mode 100644 test/e2e/ue/comments/get-reported-comments.e2e-spec.ts create mode 100644 test/e2e/ue/comments/post-comment-report.ts create mode 100644 test/e2e/ue/comments/update-comment-report.e2e-spec.ts diff --git a/src/exceptions.ts b/src/exceptions.ts index 0a24e798..e5dd03a8 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -76,6 +76,8 @@ export const enum ERROR_CODE { NO_SUCH_UEOF = 4411, NO_SUCH_APPLICATION = 4412, NO_SUCH_UE_AT_SEMESTER = 4413, + NO_SUCH_REPORT = 4414, + NO_SUCH_REPORT_REASON = 4415, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, @@ -341,6 +343,14 @@ export const ErrorData = Object.freeze({ message: 'UE % does not exist for semester %', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_REPORT]: { + message: 'The report does not exist', + httpCode: HttpStatus.NOT_FOUND, + }, + [ERROR_CODE.NO_SUCH_REPORT_REASON]: { + message: 'The report reason does not exist', + httpCode: HttpStatus.NOT_FOUND, + }, [ERROR_CODE.ANNAL_ALREADY_UPLOADED]: { message: 'A file has alreay been uploaded for this annal', httpCode: HttpStatus.CONFLICT, diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index b44321f9..4f2c266c 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -17,9 +17,9 @@ import UeCommentReplyResDto from './dto/res/ue-comment-reply-res.dto'; import { Permission } from '@prisma/client'; import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { PermissionManager } from '../../utils'; -import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto copy'; import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; import CommentReportReqDto from './dto/req/ue-comment-report-req.dto'; +import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; @Controller('ue/comments') @ApiTags('UE Comment') @@ -72,6 +72,17 @@ export class CommentsController { return this.commentsService.createComment(body, user.id); } + @Get('/reports') + @RequireApiPermission('API_MODERATE_COMMENTS') + @ApiOperation({ description: 'Get all reported comments, this route is paginated' }) + @ApiOkResponse({ type: paginatedResponseDto(UeCommentResDto) }) + async getReportedComments( + @GetUser() user: User, + @Query() dto: GetReportedCommentsReqDto, + ): Promise> { + return await this.commentsService.getReportedComments(user.id, dto); + } + // TODO : en vrai la route GET /ue/comments renvoie les mêmes infos nan ? :sweat_smile: @Get(':commentId') @RequireApiPermission('API_SEE_OPINIONS_UE') @@ -259,20 +270,10 @@ export class CommentsController { throw new AppException(ERROR_CODE.NOT_REPLY_AUTHOR); } - @Get('/reports') - @RequireApiPermission('API_MODERATE_COMMENTS') - @ApiOperation({ description: 'Get all reported comments, this route is paginated' }) - @ApiOkResponse({ type: paginatedResponseDto(UeCommentResDto) }) - async getReportedComments( - @GetUser() user: User, - @Body() body: GetReportedCommentsReqDto, - ): Promise> { - return await this.commentsService.getReportedComments(user.id, body); - } - @Post(':commentId/report') @RequireApiPermission('API_SEE_OPINIONS_UE') @ApiOperation({ description: 'Report a comment' }) + @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: UeCommentReportResDto }) async reportComment( @GetUser() user: User, @@ -285,10 +286,12 @@ export class CommentsController { throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); - return await this.commentsService.reportComment(user.id, body, commentId, commentModerator); + if (!(await this.commentsService.doesReportReasonExist(body.reason))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT_REASON); + return this.commentsService.reportComment(user.id, body, commentId, commentModerator); } - @Put(':commentId/:reportId') + @Patch(':commentId/:reportId') @RequireApiPermission('API_MODERATE_COMMENTS') @ApiOperation({ description: 'Mitigate a report' }) @ApiOkResponse({ type: UeCommentReportResDto }) @@ -301,8 +304,8 @@ export class CommentsController { const commentModerator = permissions.can('API_MODERATE_COMMENTS'); if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); - if (await this.commentsService.isUserCommentAuthor(user.id, commentId)) - throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); - return await this.commentsService.mitigateReport(commentId,reportId); + if(!(await this.commentsService.doesReportExist(reportId))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT) + return await this.commentsService.mitigateReport(commentId, reportId); } } diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index db6ced3b..199aa56c 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -8,8 +8,11 @@ import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; import { UeCommentReply } from './interfaces/comment-reply.interface'; import { UeComment } from './interfaces/comment.interface'; import { ConfigModule } from '../../config/config.module'; -import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto copy'; import CommentReportReqDto from './dto/req/ue-comment-report-req.dto'; +import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; +import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; +import { omit } from '../../utils'; +import { Prisma } from '@prisma/client'; @Injectable() export class CommentsService { @@ -398,19 +401,18 @@ export class CommentsService { * @returns whether the {@link commentId | comment} exists */ async doesCommentExist(commentId: string, userId: string, isModerator: boolean = false) { - return ( - (await this.prisma.ueComment.count({ - where: { - id: commentId, - deletedAt: isModerator ? undefined : null, - reports: { - none: { - mitigated: isModerator ? undefined : false, - }, - }, + const where: Prisma.UeCommentWhereInput = { + id: commentId, + }; + if (!isModerator) { + where.deletedAt = null; + where.reports = { + none: { + mitigated: false, }, - })) != 0 - ); + }; + } + return (await this.prisma.ueComment.count({ where })) != 0; } /** @@ -455,16 +457,39 @@ export class CommentsService { }; } + /** + * Check if a report exist + * @param reportId the id of the report + * @returns true if it exists + */ + async doesReportExist(reportId: string): Promise { + return (await this.prisma.ueCommentReport.count({ where: { id: reportId } })) == 1; + } + + /** + * Check if a report reason exist + * @param reasonName the name of the report reason + * @returns true if it exists + */ + async doesReportReasonExist(reasonName: string): Promise { + return (await this.prisma.ueCommentReportReason.count({ where: { name: reasonName } })) == 1; + } + /** * Report a comment * @param userId the user id of the reporter * @param body the report data */ - async reportComment(userId: string, body: CommentReportReqDto, commentId: string, isModerator: boolean) { + async reportComment( + userId: string, + body: CommentReportReqDto, + commentId: string, + isModerator: boolean, + ): Promise { // How are reasons handled by the front ? // Do we need another route to load reasons ? const comment = await this.getCommentFromId(commentId, userId, isModerator); - const report = this.prisma.ueCommentReport.create({ + const report = await this.prisma.ueCommentReport.create({ data: { body: body.body, reportedBody: comment.body, @@ -484,19 +509,32 @@ export class CommentsService { }, }, }, + include: { + user: true, + reason: true, + }, }); - return report; + return { + ...omit(report, 'reason', 'reasonId', 'userId', 'user'), + reason: report.reason.name, + user: { + firstName: report.user.firstName, + id: report.user.id, + lastName: report.user.lastName, + studentId: report.user.studentId, + }, + }; } async mitigateReport(commentId: string, reportId: string) { - this.prisma.ueCommentReport.update({ + return this.prisma.ueCommentReport.update({ where: { commentId, id: reportId, }, data: { - mitigated: true - } + mitigated: true, + }, }); } } diff --git a/src/ue/comments/dto/req/ue-get-reported-comments-req.dto copy.ts b/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts similarity index 100% rename from src/ue/comments/dto/req/ue-get-reported-comments-req.dto copy.ts rename to src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts diff --git a/test/declarations.d.ts b/test/declarations.d.ts index a8cc421f..e811c385 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -1,5 +1,5 @@ import { ERROR_CODE, ErrorData, ExtrasTypeBuilder } from '../src/exceptions'; -import { UeComment } from 'src/ue/comments/interfaces/comment.interface'; +import { UeComment, UeCommentReport } from 'src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from 'src/ue/comments/interfaces/comment-reply.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; import { FakeApiApplication, FakeUeAnnalType, FakeUeof } from './utils/fakedb'; @@ -59,6 +59,8 @@ declare module './declarations' { * The HTTP Status code may be 200 or 204, depending on the {@link created} property. */ expectUeCommentReply(reply: JsonLikeVariant, created = false): this; + /** expects to return the given {@link UeCommentReport} */ + expectUeCommentReport(report: JsonLikeVariant): this; /** expects to return the given {@link criterion} list */ expectUeCriteria(criterion: JsonLikeVariant): this; /** expects to return the given {@link rate} */ diff --git a/test/declarations.ts b/test/declarations.ts index 9943d9d3..b33cf79b 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -2,7 +2,7 @@ import { HttpStatus } from '@nestjs/common'; import Spec from 'pactum/src/models/Spec'; import { FakeUeWithOfs, JsonLikeVariant } from './declarations.d'; import { ERROR_CODE, ErrorData, ExtrasTypeBuilder } from '../src/exceptions'; -import { UeComment } from '../src/ue/comments/interfaces/comment.interface'; +import { UeComment, UeCommentReport } from '../src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from '../src/ue/comments/interfaces/comment-reply.interface'; import { Criterion } from 'src/ue/interfaces/criterion.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; @@ -166,7 +166,7 @@ Spec.prototype.expectUesWithPagination = function (app: AppProvider, ues: FakeUe }; Spec.prototype.expectUeComment = function expect(this: Spec, obj, created = false) { return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).expectJsonLike({ - ...omit(obj as any, 'ueof'), + ...omit(obj as any, 'ueof','reports'), ueof: { code: obj.ueof.code, info: { @@ -198,6 +198,7 @@ Spec.prototype.expectUeComments = function expect(obj) { } satisfies JsonLikeVariant>); }; Spec.prototype.expectUeCommentReply = expectOkOrCreate; +Spec.prototype.expectUeCommentReport = expectOkOrCreate; Spec.prototype.expectUeCriteria = expect; Spec.prototype.expectUeRate = expect; Spec.prototype.expectUeRates = expect<{ [criterion: string]: UeRating[] }>; diff --git a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts index 57772890..f49f1d2e 100644 --- a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts @@ -80,6 +80,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => createdAt: comment.createdAt.toISOString(), semester: semester.code, upvotes: 1, + reports: [], upvoted: false, })); @@ -107,6 +108,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => createdAt: comment.createdAt.toISOString(), semester: semester.code, upvotes: 1, + reports: [], upvoted: true, }); }); diff --git a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts new file mode 100644 index 00000000..f8191363 --- /dev/null +++ b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts @@ -0,0 +1,149 @@ +import { faker } from '@faker-js/faker'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { ConfigModule } from '../../../../src/config/config.module'; +import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReport, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, + FakeComment, +} from '../../../utils/fakedb'; +import { e2eSuite } from '../../../utils/test_utils'; + +const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { + const userModerator = createUser(app, { + login: 'user2', + permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE', 'API_MODERATE_COMMENTS'], + }); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const reportReason = createCommentReportReason(app, { name: 'meh' }); + const comments: FakeComment[] = []; + for (let i = 1; i <= 40; i++) { + const commentAuthor = createUser(app, { + login: `user${i + 10}`, + studentId: i + 10, + }); + const comment = createComment(app, { ueof, user: commentAuthor, semester }); + comments.push(comment); + const commentReporter = createUser(app, { + login: `user${i + 100}`, + studentId: i + 100, + }); + const report = createCommentReport( + app, + { user: commentReporter, comment, reason: reportReason }, + { + body: faker.word.words(), + reportedBody: comment.body, + mitigated: i % 2 === 0, + }, + ); + } + it('should return a 401 as user is not authenticated', () => { + return pactum.spec().get('/ue/comments/reports').expectAppError(ERROR_CODE.NOT_LOGGED_IN); + }); + + it('should return a 403 as user does not have permission to moderate comments', async () => { + const userNoPermission = await createUser(app, {}, true); + const userNotModerator = await createUser( + app, + { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }, + true, + ); + await pactum + .spec() + .withBearerToken(userNoPermission.token) + .get('/ue/comments/reports') + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + await pactum + .spec() + .withBearerToken(userNotModerator.token) + .get('/ue/comments/reports') + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + }); + + it('should return a 403 as user uses a wrong page', () => { + return pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .withQueryParams({ + page: -1, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_POSITIVE, 'page'); + }); + + it('should return the first page of reported comments', async () => { + const comments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: userModerator.id, + bypassAnonymousData: true, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + }, + where: { + reports: { + some: { mitigated: false }, + }, + }, + }); + + const commentsFiltered = { + items: JSON.parse(JSON.stringify(comments)).slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), + itemCount: comments.length, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }; + return pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .expectJsonMatch(commentsFiltered); + }); + + it('should return the second page of reported comments', async () => { + const comments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: userModerator.id, + bypassAnonymousData: true, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + }, + where: { + reports: { + some: { mitigated: false }, + }, + }, + }); + const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; + const commentsFiltered = { + items: JSON.parse(JSON.stringify(comments)).slice(PAGINATION_PAGE_SIZE, 2 * PAGINATION_PAGE_SIZE), + itemCount: comments.length, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }; + return pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .withQueryParams({ page: 2 }) + .expectJsonMatch(commentsFiltered); + }); +}); + +export default GetReportedComments; diff --git a/test/e2e/ue/comments/index.ts b/test/e2e/ue/comments/index.ts index a32c565b..68af37ab 100644 --- a/test/e2e/ue/comments/index.ts +++ b/test/e2e/ue/comments/index.ts @@ -9,6 +9,9 @@ import PostUpvote from './post-upvote.e2e-spec'; import UpdateComment from './update-comment.e2e-spec'; import UpdateCommentReply from './update-reply.e2e-spec'; import GetCommentFromIdE2ESpec from './get-comment-from-id.e2e-spec'; +import ReportComment from './post-comment-report'; +import GetReportedComments from './get-reported-comments.e2e-spec'; +import UpdateCommentReport from './update-comment-report.e2e-spec'; export default function CommentsE2ESpec(app: () => INestApplication) { describe('Comments', () => { @@ -22,5 +25,8 @@ export default function CommentsE2ESpec(app: () => INestApplication) { PostUpvote(app); DeleteUpvote(app); GetCommentFromIdE2ESpec(app); + ReportComment(app); + GetReportedComments(app); + UpdateCommentReport(app); }); } diff --git a/test/e2e/ue/comments/post-comment-report.ts b/test/e2e/ue/comments/post-comment-report.ts new file mode 100644 index 00000000..50a09a5f --- /dev/null +++ b/test/e2e/ue/comments/post-comment-report.ts @@ -0,0 +1,113 @@ +import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; + +const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => { + const user = createUser(app, { permissions: ['API_SEE_OPINIONS_UE','API_GIVE_OPINIONS_UE'] }); + const userNotAuthor = createUser(app, { login: 'user2', permissions: ['API_SEE_OPINIONS_UE','API_GIVE_OPINIONS_UE'] }); + const userNoPermission = createUser(app); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const comment = createComment(app, { ueof, user, semester }); + const reportReason = createCommentReportReason(app, { name: 'meh' }); + + it('should return a 401 as user is not authenticated', async () => { + return await pactum.spec().post(`/ue/comments/${comment.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); + }); + + it('should fail as the user does not have the required permissions', async () => { + return await pactum + .spec() + .withBearerToken(userNoPermission.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE'); + }); + + it('should return 400 because comment id is not a valid UUID', async () => { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/notauuid/report`).withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID,'commentId'); + }); + + it('should return 404 because comment does not exist', async () => { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/${Dummies.UUID}/report`).withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.NO_SUCH_COMMENT); + }); + + it('should return 403 because user is comment author', async () => { + return await pactum + .spec() + .withBearerToken(user.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR); + }) + + it('should return 404 because report reason does not exist', async () => { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: "idontexist", + }) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON); + }) + + it('should return a report', async ()=> { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/${comment.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }).expectUeCommentReport({ + body: "it's offensive", + reason: reportReason.name, + reportedBody: comment.body, + mitigated: false, + user: { + id: userNotAuthor.id, + firstName: userNotAuthor.firstName, + lastName: userNotAuthor.lastName, + studentId: userNotAuthor.studentId + } + }) + }) +}); + +export default ReportComment; diff --git a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts new file mode 100644 index 00000000..ea3b3ec1 --- /dev/null +++ b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts @@ -0,0 +1,91 @@ +import { faker } from '@faker-js/faker'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { ConfigModule } from '../../../../src/config/config.module'; +import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReport, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, + FakeComment, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite } from '../../../utils/test_utils'; + +const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', (app) => { + const user = createUser(app, { permissions: ['API_MODERATE_COMMENTS'] }); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const reason = createCommentReportReason(app, { name: 'meh' }); + const comment = createComment(app, { user, ueof, semester }); + const report = createCommentReport(app, { comment, reason, user }); + + it('should return a 401 as user is not authenticated', () => { + return pactum.spec().patch(`/ue/comments/${comment.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); + }); + + it('should return a 403 as user does not have permission to moderate comments', async () => { + const userNoPermission = await createUser(app, {}, true); + const userNotModerator = await createUser( + app, + { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }, + true, + ); + await pactum + .spec() + .withBearerToken(userNoPermission.token) + .patch(`/ue/comments/${comment.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + await pactum + .spec() + .withBearerToken(userNotModerator.token) + .patch(`/ue/comments/${comment.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + }); + + it('should return 404 as commentId is invalid', async () => { + await pactum + .spec() + .withBearerToken(user.token) + .patch(`/ue/comments/${Dummies.UUID}/${report.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_COMMENT); + }); + + it('should return 404 as reportId is invalid', async () => { + await pactum + .spec() + .withBearerToken(user.token) + .patch(`/ue/comments/${comment.id}/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT); + }); + + it('should return the updated report',async ()=> { + await pactum + .spec() + .withBearerToken(user.token) + .patch(`/ue/comments/${comment.id}/${report.id}`) + .expectUeCommentReport({ + ...report, + mitigated: true, + createdAt: report.createdAt.toISOString(), + }); + await app().get(PrismaService).ueCommentReport.update({ + where: { + id: report.id + }, + data: { + mitigated: false + } + }); + }); +}); + +export default UpdateCommentReport; diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index f466f4cd..5d5c90d8 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -33,12 +33,13 @@ import { RawUserPrivacy, RawApiKey, RawApiApplication, + RawUeCommentReport, } from '../../src/prisma/types'; import { faker } from '@faker-js/faker'; import { AuthService } from '../../src/auth/auth.service'; import { PrismaService } from '../../src/prisma/prisma.service'; import { AppProvider } from './test_utils'; -import { Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; +import { Permission, Sex, TimetableEntryType, UeCommentReportReason, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; import { AnnalStatus, UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; import { omit, pick, translationSelect } from '../../src/utils'; @@ -104,11 +105,13 @@ export type FakeUeof = Partial; export type FakeUeStarCriterion = Partial; export type FakeUeStarVote = Partial; -export type FakeComment = Partial & { status: Exclude }; +export type FakeComment = Partial & { status: Exclude, reports: FakeCommentReport[] }; export type FakeCommentUpvote = Partial; export type FakeCommentReply = Partial & { status: Exclude; }; +export type FakeCommentReport = Partial; +export type FakeCommentReportReason = Partial; export type FakeUeCreditCategory = Partial; export type FakeUeAnnalType = Partial; export type FakeUeAnnal = Partial; @@ -204,6 +207,15 @@ export interface FakeEntityMap { }; deps: { user: FakeUser; comment: FakeComment }; }; + commentReport: { + entity: FakeCommentReport; + params: CreateCommentReport; + deps: { comment: FakeComment; user: FakeUser; reason: FakeCommentReportReason }; + }; + commentReportReason: { + entity: FakeCommentReportReason; + params: CreateCommentReportReason; + }; ueCreditCategory: { entity: FakeUeCreditCategory; params: CreateUeCreditCategoryParameters; @@ -515,8 +527,8 @@ export const createAsso = entityFaker( preference: { create: {} }, infos: { create: {} }, privacy: { create: {} }, - } - } + }, + }, }, }); const presidentRole = await app() @@ -934,7 +946,7 @@ export const createUeRating = entityFaker( }, ); -export type CreateCommentParameters = Omit; +export type CreateCommentParameters = Omit; export const createComment = entityFaker( 'comment', { @@ -943,6 +955,7 @@ export const createComment = entityFaker( status: CommentStatus.ACTIVE, }, async (app, dependencies, params) => { + delete (params as any).reports const rawFakeData = await app() .get(PrismaService) .ueComment.create({ @@ -966,7 +979,7 @@ export const createComment = entityFaker( }, }, }); - return { ...omit(rawFakeData, 'ueofCode', 'authorId', 'semesterId'), status: params.status }; + return { ...omit(rawFakeData, 'ueofCode', 'authorId', 'semesterId'), status: params.status, reports: [] }; }, ); @@ -1020,6 +1033,62 @@ export const createCommentReply = entityFaker( return { ...rawFakeReply, status: params.status }; }, ); +export type CreateCommentReport = FakeCommentReport; +export const createCommentReport = entityFaker( + 'commentReport', + { + body: faker.word.words(), + mitigated: faker.datatype.boolean(), + createdAt: faker.date.recent(), + }, + async (app, deps, params) => { + return app() + .get(PrismaService) + .ueCommentReport.create({ + data: { + ...omit(params, 'userId', 'commentId', 'reasonId'), + reportedBody: deps.comment.body, + comment: { + connect: { + id: deps.comment.id, + }, + }, + user: { + connect: { + id: deps.user.id, + }, + }, + reason: { + connect: { + name: deps.reason.name, + }, + }, + }, + }); + }, +); + +export type CreateCommentReportReason = FakeCommentReportReason; +export const createCommentReportReason = entityFaker( + 'commentReportReason', + { + name: faker.word.adjective(), + }, + async (app, params) => + app() + .get(PrismaService) + .ueCommentReportReason.create({ + data: { + ...omit(params, 'descriptionTranslationId'), + descriptionTranslation: { + create: { + id: params.descriptionTranslationId, + fr: 'TODO : implement this value', + }, + }, + }, + }), +); export type CreateUeCreditCategoryParameters = FakeUeCreditCategory; export const createUeCreditCategory = entityFaker( From 98762352b9f65671e002ebdced64615ae688a28f Mon Sep 17 00:00:00 2001 From: Cookky Date: Sun, 26 Oct 2025 23:11:48 +0100 Subject: [PATCH 07/19] Added comments replies reporting --- src/prisma/types.ts | 1 + src/ue/comments/comments.controller.ts | 64 ++++++++-- src/ue/comments/comments.service.ts | 111 +++++++++++++++--- .../dto/req/ue-comment-reply-req.dto.ts | 2 +- .../dto/req/ue-comment-report-req.dto.ts | 2 +- .../interfaces/comment-reply.interface.ts | 26 +++- test/e2e/ue/comments/post-comment-report.ts | 1 - .../update-comment-report.e2e-spec.ts | 23 ++-- 8 files changed, 177 insertions(+), 53 deletions(-) diff --git a/src/prisma/types.ts b/src/prisma/types.ts index 52aa4449..a22b6d1a 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -22,6 +22,7 @@ export { UeCommentReply as RawUeCommentReply, UeCommentUpvote as RawUeCommentUpvote, UeCommentReport as RawUeCommentReport, + UeCommentReplyReport as RawUeCommentReplyReport, UeAnnalType as RawAnnalType, UeAnnal as RawAnnal, UeCourse as RawUeCourse, diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 4f2c266c..98193f08 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -3,7 +3,7 @@ import { UUIDParam } from '../../app.pipe'; import { GetUser, RequireApiPermission } from '../../auth/decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; import UeCommentPostReqDto from './dto/req/ue-comment-post-req.dto'; -import CommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; import UeCommentUpdateReqDto from './dto/req/ue-comment-update-req.dto'; import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; import { UeService } from '../ue.service'; @@ -18,7 +18,7 @@ import { Permission } from '@prisma/client'; import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { PermissionManager } from '../../utils'; import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; -import CommentReportReqDto from './dto/req/ue-comment-report-req.dto'; +import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; @Controller('ue/comments') @@ -74,13 +74,13 @@ export class CommentsController { @Get('/reports') @RequireApiPermission('API_MODERATE_COMMENTS') - @ApiOperation({ description: 'Get all reported comments, this route is paginated' }) + @ApiOperation({ description: 'Get all reported comments or comments with reported replies. This route is paginated' }) @ApiOkResponse({ type: paginatedResponseDto(UeCommentResDto) }) async getReportedComments( @GetUser() user: User, @Query() dto: GetReportedCommentsReqDto, ): Promise> { - return await this.commentsService.getReportedComments(user.id, dto); + return await this.commentsService.getCommentsWithReports(user.id, dto); } // TODO : en vrai la route GET /ue/comments renvoie les mêmes infos nan ? :sweat_smile: @@ -214,7 +214,7 @@ export class CommentsController { async createReplyComment( @GetUser() user: User, @UUIDParam('commentId') commentId: string, - @Body() body: CommentReplyReqDto, + @Body() body: UeCommentReplyReqDto, @GetPermissions() permissions: PermissionManager, ): Promise { const isCommentModerator = permissions.can(Permission.API_MODERATE_COMMENTS); @@ -235,7 +235,7 @@ export class CommentsController { async editReplyComment( @GetUser() user: User, @UUIDParam('replyId') replyId: string, - @Body() body: CommentReplyReqDto, + @Body() body: UeCommentReplyReqDto, @GetPermissions() permissions: PermissionManager, ): Promise { if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); @@ -275,10 +275,13 @@ export class CommentsController { @ApiOperation({ description: 'Report a comment' }) @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: UeCommentReportResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_COMMENT, 'there is no comment with the provided commentId') + @ApiAppErrorResponse(ERROR_CODE.IS_COMMENT_AUTHOR, 'thrown when the user is the comment author') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPORT_REASON, 'the provided reason does not exist') async reportComment( @GetUser() user: User, @UUIDParam('commentId') commentId: string, - @Body() body: CommentReportReqDto, + @Body() body: UeCommentReportReqDto, @GetPermissions() permissions: PermissionManager, ) { const commentModerator = permissions.can('API_MODERATE_COMMENTS'); @@ -291,6 +294,29 @@ export class CommentsController { return this.commentsService.reportComment(user.id, body, commentId, commentModerator); } + @Post('reply/:replyId/report') + @RequireApiPermission('API_SEE_OPINIONS_UE') + @ApiOperation({ description: 'Report a comment reply' }) + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ type: UeCommentReportResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPLY, 'there is no comment reply with the provided replyId') + @ApiAppErrorResponse(ERROR_CODE.IS_COMMENT_AUTHOR, 'thrown when the user is the comment author') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPORT_REASON, 'the provided reason does not exist') + async reportCommentReply( + @GetUser() user: User, + @UUIDParam('replyId') replyId: string, + @Body() body: UeCommentReportReqDto, + @GetPermissions() permissions: PermissionManager, + ) { + const commentModerator = permissions.can('API_MODERATE_COMMENTS'); + if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); + if (await this.commentsService.isUserCommentReplyAuthor(user.id, replyId)) + throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); + if (!(await this.commentsService.doesReportReasonExist(body.reason))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT_REASON); + return this.commentsService.reportCommentReply(user.id, body, replyId, commentModerator); + } + @Patch(':commentId/:reportId') @RequireApiPermission('API_MODERATE_COMMENTS') @ApiOperation({ description: 'Mitigate a report' }) @@ -299,13 +325,25 @@ export class CommentsController { @GetUser() user: User, @UUIDParam('commentId') commentId: string, @UUIDParam('reportId') reportId: string, - @GetPermissions() permissions: PermissionManager, ) { - const commentModerator = permissions.can('API_MODERATE_COMMENTS'); - if (!(await this.commentsService.doesCommentExist(commentId, user.id, commentModerator))) + if (!(await this.commentsService.doesCommentExist(commentId, user.id, true))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); - if(!(await this.commentsService.doesReportExist(reportId))) - throw new AppException(ERROR_CODE.NO_SUCH_REPORT) - return await this.commentsService.mitigateReport(commentId, reportId); + if (!(await this.commentsService.doesReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + return await this.commentsService.mitigateCommentReport(commentId, reportId); + } + + @Patch('/reply/:replyId/:reportId') + @RequireApiPermission('API_MODERATE_COMMENTS') + @ApiOperation({description: 'Mitigate a comment reply report'}) + @ApiOkResponse({type: UeCommentReportResDto}) + async mitigateCommentReplyReport( + @GetUser() user: User, + @UUIDParam('replyId') replyId: string, + @UUIDParam('reportId') reportId: string, + ) { + if (!(await this.commentsService.doesReplyExist(replyId))) + throw new AppException(ERROR_CODE.NO_SUCH_REPLY); + if (!(await this.commentsService.doesReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + return await this.commentsService.mitigateCommentReplyReport(replyId, reportId); } } diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index 199aa56c..e9896ec2 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -2,16 +2,16 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { RawUserUeSubscription } from 'src/prisma/types'; import UeCommentPostReqDto from './dto/req/ue-comment-post-req.dto'; -import CommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; import UeCommentUpdateReqDto from './dto/req/ue-comment-update-req.dto'; import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; import { UeCommentReply } from './interfaces/comment-reply.interface'; import { UeComment } from './interfaces/comment.interface'; import { ConfigModule } from '../../config/config.module'; -import CommentReportReqDto from './dto/req/ue-comment-report-req.dto'; +import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; -import { omit } from '../../utils'; +import { omit, pick } from '../../utils'; import { Prisma } from '@prisma/client'; @Injectable() @@ -71,9 +71,9 @@ export class CommentsService { /** * Retrieves a single {@link UeComment} from a comment UUID * @param commentId the UUID of the comment - * @param userId the user fetching the comments. Used to determine if an anonymous comment should include its author + * @param userId the user fetching the comment. Used to determine if an anonymous comment should include its author * @param isModerator if true the user is a moderator - * @returns a page of {@link UeComment} matching the user query + * @returns a single {@link UeComment} matching the provided UUID */ async getCommentFromId(commentId: string, userId: string, isModerator: boolean): Promise { const comment = await this.prisma.normalize.ueComment.findUnique({ @@ -91,6 +91,21 @@ export class CommentsService { return comment; } + /** + * Retrieves a single {@link UeCommentReply} from a reply UUID + * @param replyId the UUID of the comment reply + * @returns a single {@link UeCommentReply} matching the provided UUID + */ + async getReplyFromId(replyId: string): Promise { + const comment = await this.prisma.normalize.ueCommentReply.findUnique({ + where: { + id: replyId, + }, + }); + return comment; + } + + /** * Checks whether a user is the author of a comment * @remarks The comment must exist and user must not be null @@ -296,7 +311,7 @@ export class CommentsService { * @param reply the reply to post * @returns the created {@link UeCommentReply} */ - async replyComment(userId: string, commentId: string, reply: CommentReplyReqDto): Promise { + async replyComment(userId: string, commentId: string, reply: UeCommentReplyReqDto): Promise { return this.prisma.normalize.ueCommentReply.create({ data: { body: reply.body, @@ -313,7 +328,7 @@ export class CommentsService { * @param reply the modifications to apply to the reply * @returns the updated {@link UeCommentReply} */ - async editReply(replyId: string, reply: CommentReplyReqDto): Promise { + async editReply(replyId: string, reply: UeCommentReplyReqDto): Promise { return this.prisma.normalize.ueCommentReply.update({ data: { body: reply.body, @@ -419,7 +434,7 @@ export class CommentsService { * Retrieves a page of {@link UeComment} having at least one non mitigated report * @returns a page of {@link UeComment} matching the user query */ - async getReportedComments(userId: string, dto: GetReportedCommentsReqDto): Promise> { + async getCommentsWithReports(userId: string, dto: GetReportedCommentsReqDto): Promise> { // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters const comments = await this.prisma.normalize.ueComment.findMany({ args: { @@ -430,11 +445,26 @@ export class CommentsService { bypassAnonymousData: true, }, where: { - reports: { - some: { - mitigated: false, + OR: [ + { + reports: { + some: { + mitigated: false, + }, + }, }, - }, + { + answers: { + some: { + reports: { + some: { + mitigated: false, + }, + }, + }, + }, + }, + ], }, take: this.config.PAGINATION_PAGE_SIZE, skip: ((dto.page ?? 1) - 1) * this.config.PAGINATION_PAGE_SIZE, @@ -482,7 +512,7 @@ export class CommentsService { */ async reportComment( userId: string, - body: CommentReportReqDto, + body: UeCommentReportReqDto, commentId: string, isModerator: boolean, ): Promise { @@ -517,16 +547,47 @@ export class CommentsService { return { ...omit(report, 'reason', 'reasonId', 'userId', 'user'), reason: report.reason.name, - user: { - firstName: report.user.firstName, - id: report.user.id, - lastName: report.user.lastName, - studentId: report.user.studentId, + user: pick(report.user,'firstName','id','lastName','studentId'), + }; + } + + async reportCommentReply(userId: string,body: UeCommentReportReqDto,replyId: string,isModerator:boolean): Promise{ + const reply = await this.getReplyFromId(replyId); + const report = await this.prisma.ueCommentReplyReport.create({ + data: { + body: body.body, + mitigated: false, + reason: { + connect: { + name: body.reason + } + }, + reply: { + connect: { + id: replyId + } + }, + user: { + connect: { + id: userId + } + }, + reportedBody: reply.body, + }, + include: { + user: true, + reason: true + } + }) + return { + ...omit(report,'user'), + user: pick(report.user,'firstName','id','lastName','studentId'), + reason: report.reason.name, }; } - async mitigateReport(commentId: string, reportId: string) { + async mitigateCommentReport(commentId: string, reportId: string) { return this.prisma.ueCommentReport.update({ where: { commentId, @@ -537,4 +598,16 @@ export class CommentsService { }, }); } + + async mitigateCommentReplyReport(replyId: string, reportId: string) { + return this.prisma.ueCommentReplyReport.update({ + where: { + replyId, + id: reportId, + }, + data: { + mitigated: true, + }, + }); + } } diff --git a/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts b/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts index d53fc1a4..cbe1b6d7 100644 --- a/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts +++ b/src/ue/comments/dto/req/ue-comment-reply-req.dto.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, IsString, MinLength } from 'class-validator'; * Body data required to create a new comment reply. * @property body The body of the reply. Must be at least 5 characters long. */ -export default class CommentReplyReqDto { +export default class UeCommentReplyReqDto { @IsString() @IsNotEmpty() @MinLength(5) diff --git a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts index 3ced1974..453128eb 100644 --- a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts +++ b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, IsString, MinLength } from 'class-validator'; * Query parameters to get reported comments. * @property page The page number to get. Defaults to 1 (Starting at 1). */ -export default class CommentReportReqDto { +export default class UeCommentReportReqDto { @IsString() @IsNotEmpty() @MinLength(5) diff --git a/src/ue/comments/interfaces/comment-reply.interface.ts b/src/ue/comments/interfaces/comment-reply.interface.ts index ed50d2a6..1eaf4d4f 100644 --- a/src/ue/comments/interfaces/comment-reply.interface.ts +++ b/src/ue/comments/interfaces/comment-reply.interface.ts @@ -2,6 +2,7 @@ import { CommentStatus } from './comment.interface'; import { Prisma, PrismaClient } from '@prisma/client'; import { omit } from '../../../utils'; import { generateCustomModel } from '../../../prisma/prisma.service'; +import { RawUeCommentReplyReport } from 'src/prisma/types'; export const REPLY_SELECT_FILTER = { select: { @@ -19,9 +20,11 @@ export const REPLY_SELECT_FILTER = { deletedAt: true, reports: { select: { + id: true, body: true, mitigated: true, createdAt: true, + reportedBody: true, reason: { select: { name: true, @@ -40,13 +43,23 @@ export const REPLY_SELECT_FILTER = { }, } as const; +export type UeCommentReplyReport = Omit & { + reason: string; + user: { + id: string; + studentId: number; + firstName: string; + lastName: string; + }; +} type UnformattedUeCommentReply = Prisma.UeCommentGetPayload; export type UeCommentReply = Omit< - Prisma.UeCommentReplyGetPayload & { - status: CommentStatus; - }, - 'deletedAt' ->; + Prisma.UeCommentReplyGetPayload, + 'deletedAt'|'reports' +> & { + status: CommentStatus; + reports: UeCommentReplyReport[] +}; export function generateCustomUeCommentReplyModel(prisma: PrismaClient) { return generateCustomModel(prisma, 'ueCommentReply', REPLY_SELECT_FILTER, formatReply); @@ -54,7 +67,8 @@ export function generateCustomUeCommentReplyModel(prisma: PrismaClient) { export function formatReply(_: PrismaClient, reply: UnformattedUeCommentReply): UeCommentReply { return { - ...omit(reply, 'deletedAt'), + ...omit(reply, 'deletedAt','reports'), + reports: reply.reports.map((r)=> {return {...r, reason:r.reason.name}}), status: (reply.reports.some((r)=> !r.mitigated) && CommentStatus.HIDDEN) | (reply.deletedAt && CommentStatus.DELETED), }; } diff --git a/test/e2e/ue/comments/post-comment-report.ts b/test/e2e/ue/comments/post-comment-report.ts index 50a09a5f..280fa11b 100644 --- a/test/e2e/ue/comments/post-comment-report.ts +++ b/test/e2e/ue/comments/post-comment-report.ts @@ -1,4 +1,3 @@ -import { PrismaService } from '../../../../src/prisma/prisma.service'; import { createBranch, createBranchOption, diff --git a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts index ea3b3ec1..f1550e52 100644 --- a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts @@ -1,7 +1,5 @@ -import { faker } from '@faker-js/faker'; import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; -import { ConfigModule } from '../../../../src/config/config.module'; import { PrismaService } from '../../../../src/prisma/prisma.service'; import { createBranch, @@ -13,7 +11,6 @@ import { createUe, createUeof, createUser, - FakeComment, } from '../../../utils/fakedb'; import { Dummies, e2eSuite } from '../../../utils/test_utils'; @@ -67,7 +64,7 @@ const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', .expectAppError(ERROR_CODE.NO_SUCH_REPORT); }); - it('should return the updated report',async ()=> { + it('should return the updated report', async () => { await pactum .spec() .withBearerToken(user.token) @@ -77,14 +74,16 @@ const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', mitigated: true, createdAt: report.createdAt.toISOString(), }); - await app().get(PrismaService).ueCommentReport.update({ - where: { - id: report.id - }, - data: { - mitigated: false - } - }); + await app() + .get(PrismaService) + .ueCommentReport.update({ + where: { + id: report.id, + }, + data: { + mitigated: false, + }, + }); }); }); From 575bf799f37cd611bd14a7ce6dbd39b794c10d89 Mon Sep 17 00:00:00 2001 From: Cookky Date: Mon, 27 Oct 2025 14:22:05 +0100 Subject: [PATCH 08/19] Added comment reply tests --- src/ue/comments/comments.controller.ts | 5 +- src/ue/comments/comments.service.ts | 104 ++++++++------- .../comments/interfaces/comment.interface.ts | 6 +- .../get-reported-comments.e2e-spec.ts | 72 +++++++++-- test/e2e/ue/comments/index.ts | 6 +- .../post-comment-reply-report.e2e-spec.ts | 121 ++++++++++++++++++ ...ort.ts => post-comment-report.e2e-spec.ts} | 0 .../update-comment-reply-report.e2e-spec.ts | 95 ++++++++++++++ test/utils/fakedb.ts | 48 ++++++- 9 files changed, 391 insertions(+), 66 deletions(-) create mode 100644 test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts rename test/e2e/ue/comments/{post-comment-report.ts => post-comment-report.e2e-spec.ts} (100%) create mode 100644 test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 98193f08..1f45f58d 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -328,7 +328,7 @@ export class CommentsController { ) { if (!(await this.commentsService.doesCommentExist(commentId, user.id, true))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); - if (!(await this.commentsService.doesReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + if (!(await this.commentsService.doesCommentReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); return await this.commentsService.mitigateCommentReport(commentId, reportId); } @@ -337,13 +337,12 @@ export class CommentsController { @ApiOperation({description: 'Mitigate a comment reply report'}) @ApiOkResponse({type: UeCommentReportResDto}) async mitigateCommentReplyReport( - @GetUser() user: User, @UUIDParam('replyId') replyId: string, @UUIDParam('reportId') reportId: string, ) { if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); - if (!(await this.commentsService.doesReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + if (!(await this.commentsService.doesCommentReplyReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); return await this.commentsService.mitigateCommentReplyReport(replyId, reportId); } } diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index e9896ec2..c72cd14b 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -91,7 +91,7 @@ export class CommentsService { return comment; } - /** + /** * Retrieves a single {@link UeCommentReply} from a reply UUID * @param replyId the UUID of the comment reply * @returns a single {@link UeCommentReply} matching the provided UUID @@ -105,7 +105,6 @@ export class CommentsService { return comment; } - /** * Checks whether a user is the author of a comment * @remarks The comment must exist and user must not be null @@ -436,6 +435,28 @@ export class CommentsService { */ async getCommentsWithReports(userId: string, dto: GetReportedCommentsReqDto): Promise> { // We fetch a page of comments matching our filters and retrieve the total count of comments matching our filters + const whereClause: Prisma.UeCommentWhereInput = { + OR: [ + { + reports: { + some: { + mitigated: false, + }, + }, + }, + { + answers: { + some: { + reports: { + some: { + mitigated: false, + }, + }, + }, + }, + }, + ], + }; const comments = await this.prisma.normalize.ueComment.findMany({ args: { userId: userId, @@ -444,38 +465,14 @@ export class CommentsService { includeReports: true, bypassAnonymousData: true, }, - where: { - OR: [ - { - reports: { - some: { - mitigated: false, - }, - }, - }, - { - answers: { - some: { - reports: { - some: { - mitigated: false, - }, - }, - }, - }, - }, - ], - }, + where: whereClause, take: this.config.PAGINATION_PAGE_SIZE, skip: ((dto.page ?? 1) - 1) * this.config.PAGINATION_PAGE_SIZE, }); const commentCount = await this.prisma.ueComment.count({ where: { - reports: { - some: { - mitigated: false, - }, - }, + ...whereClause, + deletedAt: null, }, }); @@ -492,10 +489,19 @@ export class CommentsService { * @param reportId the id of the report * @returns true if it exists */ - async doesReportExist(reportId: string): Promise { + async doesCommentReportExist(reportId: string): Promise { return (await this.prisma.ueCommentReport.count({ where: { id: reportId } })) == 1; } + /** + * Check if a report exist + * @param reportId the id of the report + * @returns true if it exists + */ + async doesCommentReplyReportExist(reportId: string): Promise { + return (await this.prisma.ueCommentReplyReport.count({ where: { id: reportId } })) == 1; + } + /** * Check if a report reason exist * @param reasonName the name of the report reason @@ -547,11 +553,16 @@ export class CommentsService { return { ...omit(report, 'reason', 'reasonId', 'userId', 'user'), reason: report.reason.name, - user: pick(report.user,'firstName','id','lastName','studentId'), + user: pick(report.user, 'firstName', 'id', 'lastName', 'studentId'), }; } - async reportCommentReply(userId: string,body: UeCommentReportReqDto,replyId: string,isModerator:boolean): Promise{ + async reportCommentReply( + userId: string, + body: UeCommentReportReqDto, + replyId: string, + isModerator: boolean, + ): Promise { const reply = await this.getReplyFromId(replyId); const report = await this.prisma.ueCommentReplyReport.create({ data: { @@ -559,30 +570,29 @@ export class CommentsService { mitigated: false, reason: { connect: { - name: body.reason - } + name: body.reason, + }, }, reply: { connect: { - id: replyId - } + id: replyId, + }, }, user: { connect: { - id: userId - } + id: userId, + }, }, reportedBody: reply.body, - }, include: { user: true, - reason: true - } - }) + reason: true, + }, + }); return { - ...omit(report,'user'), - user: pick(report.user,'firstName','id','lastName','studentId'), + ...omit(report, 'user'), + user: pick(report.user, 'firstName', 'id', 'lastName', 'studentId'), reason: report.reason.name, }; } @@ -599,10 +609,12 @@ export class CommentsService { }); } - async mitigateCommentReplyReport(replyId: string, reportId: string) { + async mitigateCommentReplyReport(replyId: string, reportId: string) { return this.prisma.ueCommentReplyReport.update({ where: { - replyId, + reply: { + id: replyId, + }, id: reportId, }, data: { diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index 33d175a5..48ded407 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -132,7 +132,7 @@ export function generateCustomCommentModel(prisma: PrismaClient) { Object.assign(query, { ...query, where: {} }); } if (!includeDeleted) { - Object.assign(query.where, { ...query.where, deletedAt: null, answers: { every: { deletedAt: null } } }); + Object.assign(query.where, { ...query.where, deletedAt: null }); } if (!includeHiddenComments) { Object.assign(query.where, { ...query.where, reports: { none: { mitigated: false } } }); @@ -148,7 +148,9 @@ export function formatComment(prisma: PrismaClient, comment: UnformattedUeCommen return { ...omit(comment, 'deletedAt'), author: !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, - answers: comment.answers.map((answer) => { + answers: comment.answers + .filter((answer) => args.includeDeleted || answer.deletedAt === null) + .map((answer) => { let anwser = formatReply(prisma, answer); if (!includeReports) anwser.reports = null; return anwser; diff --git a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts index f8191363..67dfbe15 100644 --- a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts +++ b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts @@ -7,6 +7,8 @@ import { createBranch, createBranchOption, createComment, + createCommentReply, + createCommentReplyReport, createCommentReport, createCommentReportReason, createSemester, @@ -16,6 +18,7 @@ import { FakeComment, } from '../../../utils/fakedb'; import { e2eSuite } from '../../../utils/test_utils'; +import { Prisma } from '@prisma/client'; const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { const userModerator = createUser(app, { @@ -29,7 +32,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); const reportReason = createCommentReportReason(app, { name: 'meh' }); const comments: FakeComment[] = []; - for (let i = 1; i <= 40; i++) { + for (let i = 1; i <= 22; i++) { const commentAuthor = createUser(app, { login: `user${i + 10}`, studentId: i + 10, @@ -40,7 +43,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { login: `user${i + 100}`, studentId: i + 100, }); - const report = createCommentReport( + createCommentReport( app, { user: commentReporter, comment, reason: reportReason }, { @@ -50,6 +53,14 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { }, ); } + + const reportedCommentsWhereClause: Prisma.UeCommentWhereInput = { + OR: [ + { reports: { some: { mitigated: false } } }, // Le commentaire est signalé + { answers: { some: { reports: { some: { mitigated: false } } } } }, // Une de ses réponses est signalée + ], + }; + it('should return a 401 as user is not authenticated', () => { return pactum.spec().get('/ue/comments/reports').expectAppError(ERROR_CODE.NOT_LOGGED_IN); }); @@ -95,11 +106,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { includeHiddenComments: true, includeReports: true, }, - where: { - reports: { - some: { mitigated: false }, - }, - }, + where: reportedCommentsWhereClause, }); const commentsFiltered = { @@ -125,11 +132,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { includeHiddenComments: true, includeReports: true, }, - where: { - reports: { - some: { mitigated: false }, - }, - }, + where: reportedCommentsWhereClause, }); const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; const commentsFiltered = { @@ -144,6 +147,51 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { .withQueryParams({ page: 2 }) .expectJsonMatch(commentsFiltered); }); + + it('should include comments with reported replies', async () => { + const cleanCommentAuthor = await createUser(app, { login: 'cleanAuthor' }, true); + const cleanComment = await createComment(app, { ueof, user: cleanCommentAuthor, semester }, {}, true); + const replyAuthor = await createUser(app, { login: 'replyAuthor' }, true); + const commentReply = await createCommentReply(app, { user: replyAuthor, comment: cleanComment }, {}, true); + const replyReporter = await createUser(app, { login: 'replyReporter' }, true); + await createCommentReplyReport( + app, + { user: replyReporter, reply: commentReply, reason: reportReason }, + { + body: 'This reply is problematic', + reportedBody: commentReply.body, + mitigated: false, + }, + true, + ); + + const comments = await app() + .get(PrismaService) + .normalize.ueComment.findMany({ + args: { + userId: userModerator.id, + bypassAnonymousData: true, + includeDeleted: false, + includeHiddenComments: true, + includeReports: true, + }, + where: reportedCommentsWhereClause, + }); + const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; + const commentsFiltered = { + items: JSON.parse(JSON.stringify(comments)).slice(0,PAGINATION_PAGE_SIZE), + itemCount: comments.length, + itemsPerPage: PAGINATION_PAGE_SIZE, + }; + + expect(commentsFiltered.items).toContainEqual(expect.objectContaining({ id: cleanComment.id })); + + await pactum + .spec() + .withBearerToken(userModerator.token) + .get('/ue/comments/reports') + .expectJsonMatch(commentsFiltered); + }); }); export default GetReportedComments; diff --git a/test/e2e/ue/comments/index.ts b/test/e2e/ue/comments/index.ts index 68af37ab..272f3828 100644 --- a/test/e2e/ue/comments/index.ts +++ b/test/e2e/ue/comments/index.ts @@ -9,9 +9,11 @@ import PostUpvote from './post-upvote.e2e-spec'; import UpdateComment from './update-comment.e2e-spec'; import UpdateCommentReply from './update-reply.e2e-spec'; import GetCommentFromIdE2ESpec from './get-comment-from-id.e2e-spec'; -import ReportComment from './post-comment-report'; +import ReportComment from './post-comment-report.e2e-spec'; import GetReportedComments from './get-reported-comments.e2e-spec'; import UpdateCommentReport from './update-comment-report.e2e-spec'; +import ReportCommentReply from './post-comment-reply-report.e2e-spec'; +import UpdateCommentReplyReport from './update-comment-reply-report.e2e-spec'; export default function CommentsE2ESpec(app: () => INestApplication) { describe('Comments', () => { @@ -26,7 +28,9 @@ export default function CommentsE2ESpec(app: () => INestApplication) { DeleteUpvote(app); GetCommentFromIdE2ESpec(app); ReportComment(app); + ReportCommentReply(app) GetReportedComments(app); UpdateCommentReport(app); + UpdateCommentReplyReport(app); }); } diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts new file mode 100644 index 00000000..669513b5 --- /dev/null +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -0,0 +1,121 @@ +import { + createBranch, + createBranchOption, + createComment, + createCommentReply, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; + +const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', (app) => { + const commentAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); + const replyAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'], + }); + const userNoPermission = createUser(app); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const comment = createComment(app, { ueof, user: commentAuthor, semester }); + const reply = createCommentReply(app, { user: replyAuthor, comment }); + const reportReason = createCommentReportReason(app, { name: 'meh' }); + + it('should return a 401 as user is not authenticated', async () => { + return await pactum.spec().post(`/ue/comments/reply/${reply.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); + }); + + it('should fail as the user does not have the required permissions', async () => { + return await pactum + .spec() + .withBearerToken(userNoPermission.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE'); + }); + + it('should return 400 because reply id is not a valid UUID', async () => { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/notauuid/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'replyId'); + }); + + it('should return 404 because reply does not exist', async () => { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/${Dummies.UUID}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.NO_SUCH_REPLY); + }); + + it('should return 403 because user is reply author', async () => { + return await pactum + .spec() + .withBearerToken(replyAuthor.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR); + }); + + it('should return 404 because report reason does not exist', async () => { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: 'idontexist', + }) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON); + }); + + it('should return a report', async () => { + return await pactum + .spec() + .withBearerToken(userNotAuthor.token) + .post(`/ue/comments/reply/${reply.id}/report`) + .withBody({ + body: "it's offensive", + reason: reportReason.name, + }) + .expectUeCommentReport({ + body: "it's offensive", + reason: reportReason.name, + reportedBody: reply.body, + mitigated: false, + user: { + id: userNotAuthor.id, + firstName: userNotAuthor.firstName, + lastName: userNotAuthor.lastName, + studentId: userNotAuthor.studentId, + }, + }); + }); +}); + +export default ReportCommentReply; diff --git a/test/e2e/ue/comments/post-comment-report.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts similarity index 100% rename from test/e2e/ue/comments/post-comment-report.ts rename to test/e2e/ue/comments/post-comment-report.e2e-spec.ts diff --git a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts new file mode 100644 index 00000000..486af977 --- /dev/null +++ b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts @@ -0,0 +1,95 @@ +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { + createBranch, + createBranchOption, + createComment, + createCommentReply, + createCommentReplyReport, + createCommentReport, + createCommentReportReason, + createSemester, + createUe, + createUeof, + createUser, +} from '../../../utils/fakedb'; +import { Dummies, e2eSuite } from '../../../utils/test_utils'; + +const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{reportId}', (app) => { + const commentAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); + const replyAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); + const moderator = createUser(app, { permissions: ['API_MODERATE_COMMENTS'] }); + const semester = createSemester(app); + const branch = createBranch(app); + const branchOption = createBranchOption(app, { branch }); + const ue = createUe(app); + const ueof = createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); + const reason = createCommentReportReason(app, { name: 'meh' }); + const comment = createComment(app, { user: commentAuthor, ueof, semester }); + const reply = createCommentReply(app, { user: replyAuthor, comment }); + const report = createCommentReplyReport(app, { reply, reason, user: commentAuthor }); + + it('should return a 401 as user is not authenticated', () => { + return pactum.spec().patch(`/ue/comments/reply/${reply.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); + }); + + it('should return a 403 as user does not have permission to moderate comments', async () => { + const userNoPermission = await createUser(app, {}, true); + const userNotModerator = await createUser( + app, + { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }, + true, + ); + await pactum + .spec() + .withBearerToken(userNoPermission.token) + .patch(`/ue/comments/reply/${reply.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + await pactum + .spec() + .withBearerToken(userNotModerator.token) + .patch(`/ue/comments/reply/${reply.id}/${report.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); + }); + + it('should return 404 as replyId is invalid', async () => { + await pactum + .spec() + .withBearerToken(moderator.token) + .patch(`/ue/comments/reply/${Dummies.UUID}/${report.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_REPLY); + }); + + it('should return 404 as reportId is invalid', async () => { + await pactum + .spec() + .withBearerToken(moderator.token) + .patch(`/ue/comments/reply/${reply.id}/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_REPORT); + }); + + it('should return the updated report', async () => { + await pactum + .spec() + .withBearerToken(moderator.token) + .patch(`/ue/comments/reply/${reply.id}/${report.id}`) + .expectUeCommentReport({ + ...report, + mitigated: true, + createdAt: report.createdAt.toISOString(), + }); + await app() + .get(PrismaService) + .ueCommentReplyReport.update({ + where: { + id: report.id, + }, + data: { + mitigated: false, + }, + }); + }); +}); + +export default UpdateCommentReplyReport; diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index 5d5c90d8..0b4b42f4 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -34,6 +34,7 @@ import { RawApiKey, RawApiApplication, RawUeCommentReport, + RawUeCommentReplyReport, } from '../../src/prisma/types'; import { faker } from '@faker-js/faker'; import { AuthService } from '../../src/auth/auth.service'; @@ -105,12 +106,16 @@ export type FakeUeof = Partial; export type FakeUeStarCriterion = Partial; export type FakeUeStarVote = Partial; -export type FakeComment = Partial & { status: Exclude, reports: FakeCommentReport[] }; +export type FakeComment = Partial & { + status: Exclude; + reports: FakeCommentReport[]; +}; export type FakeCommentUpvote = Partial; export type FakeCommentReply = Partial & { status: Exclude; }; export type FakeCommentReport = Partial; +export type FakeCommentReplyReport = Partial; export type FakeCommentReportReason = Partial; export type FakeUeCreditCategory = Partial; export type FakeUeAnnalType = Partial; @@ -212,6 +217,11 @@ export interface FakeEntityMap { params: CreateCommentReport; deps: { comment: FakeComment; user: FakeUser; reason: FakeCommentReportReason }; }; + commentReplyReport: { + entity: FakeCommentReplyReport; + params: CreateCommentReplyReport; + deps: { reply: FakeCommentReply; user: FakeUser; reason: FakeCommentReportReason }; + }; commentReportReason: { entity: FakeCommentReportReason; params: CreateCommentReportReason; @@ -955,7 +965,7 @@ export const createComment = entityFaker( status: CommentStatus.ACTIVE, }, async (app, dependencies, params) => { - delete (params as any).reports + delete (params as any).reports; const rawFakeData = await app() .get(PrismaService) .ueComment.create({ @@ -1067,6 +1077,40 @@ export const createCommentReport = entityFaker( }); }, ); +export type CreateCommentReplyReport = FakeCommentReplyReport; +export const createCommentReplyReport = entityFaker( + 'commentReplyReport', + { + body: faker.word.words(), + mitigated: faker.datatype.boolean(), + createdAt: faker.date.recent(), + }, + async (app, deps, params) => { + return app() + .get(PrismaService) + .ueCommentReplyReport.create({ + data: { + ...omit(params, 'userId', 'replyId', 'reasonId'), + reportedBody: deps.reply.body, + reply: { + connect: { + id: deps.reply.id, + }, + }, + user: { + connect: { + id: deps.user.id, + }, + }, + reason: { + connect: { + name: deps.reason.name, + }, + }, + }, + }); + }, +); export type CreateCommentReportReason = FakeCommentReportReason; export const createCommentReportReason = entityFaker( From 92720d23d45703031db7b0beb276346260a46fd9 Mon Sep 17 00:00:00 2001 From: Cookky Date: Mon, 27 Oct 2025 15:46:50 +0100 Subject: [PATCH 09/19] Added route to get report reasons and code cleanup --- src/ue/comments/comments.controller.ts | 63 ++++++++++++------- src/ue/comments/comments.service.ts | 31 +++++---- .../res/ue-comment-report-reason-res.dto.ts | 10 +++ .../interfaces/comment-reply.interface.ts | 17 ++--- .../comments/interfaces/comment.interface.ts | 12 ++-- .../get-comment-report-reasons.e2e-spec.ts | 39 ++++++++++++ test/e2e/ue/comments/index.ts | 32 +++++----- test/utils/fakedb.ts | 6 +- 8 files changed, 147 insertions(+), 63 deletions(-) create mode 100644 src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts create mode 100644 test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 1f45f58d..5b9969ae 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -1,25 +1,26 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Permission } from '@prisma/client'; +import { ApiAppErrorResponse, paginatedResponseDto } from '../../app.dto'; import { UUIDParam } from '../../app.pipe'; import { GetUser, RequireApiPermission } from '../../auth/decorator'; +import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; +import { User } from '../../users/interfaces/user.interface'; +import { PermissionManager } from '../../utils'; +import { UeService } from '../ue.service'; +import { CommentsService } from './comments.service'; import UeCommentPostReqDto from './dto/req/ue-comment-post-req.dto'; import UeCommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; import UeCommentUpdateReqDto from './dto/req/ue-comment-update-req.dto'; import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; -import { UeService } from '../ue.service'; -import { User } from '../../users/interfaces/user.interface'; -import { CommentsService } from './comments.service'; -import UeCommentResDto from './dto/res/ue-comment-res.dto'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ApiAppErrorResponse, paginatedResponseDto } from '../../app.dto'; -import { UeCommentUpvoteResDto$False, UeCommentUpvoteResDto$True } from './dto/res/ue-comment-upvote-res.dto'; +import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; import UeCommentReplyResDto from './dto/res/ue-comment-reply-res.dto'; -import { Permission } from '@prisma/client'; -import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; -import { PermissionManager } from '../../utils'; +import UeCommentReportReasonResDto from './dto/res/ue-comment-report-reason-res.dto'; import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; -import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; -import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; +import UeCommentResDto from './dto/res/ue-comment-res.dto'; +import { UeCommentUpvoteResDto$False, UeCommentUpvoteResDto$True } from './dto/res/ue-comment-upvote-res.dto'; @Controller('ue/comments') @ApiTags('UE Comment') @@ -76,6 +77,10 @@ export class CommentsController { @RequireApiPermission('API_MODERATE_COMMENTS') @ApiOperation({ description: 'Get all reported comments or comments with reported replies. This route is paginated' }) @ApiOkResponse({ type: paginatedResponseDto(UeCommentResDto) }) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, + "Thrown when the user doesn't have enough permissions", + ) async getReportedComments( @GetUser() user: User, @Query() dto: GetReportedCommentsReqDto, @@ -83,6 +88,18 @@ export class CommentsController { return await this.commentsService.getCommentsWithReports(user.id, dto); } + @Get('/reports/reasons') + @RequireApiPermission('API_SEE_OPINIONS_UE') + @ApiOperation({ description: 'Get the list of all possible report reasons' }) + @ApiOkResponse({ type: UeCommentReportReasonResDto, isArray: true }) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, + "Thrown when the user doesn't have enough permissions", + ) + async getReportReasons(): Promise { + return await this.commentsService.getCommentReportReason(); + } + // TODO : en vrai la route GET /ue/comments renvoie les mêmes infos nan ? :sweat_smile: @Get(':commentId') @RequireApiPermission('API_SEE_OPINIONS_UE') @@ -328,21 +345,21 @@ export class CommentsController { ) { if (!(await this.commentsService.doesCommentExist(commentId, user.id, true))) throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); - if (!(await this.commentsService.doesCommentReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + if (!(await this.commentsService.doesCommentReportExist(reportId))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT); return await this.commentsService.mitigateCommentReport(commentId, reportId); } @Patch('/reply/:replyId/:reportId') @RequireApiPermission('API_MODERATE_COMMENTS') - @ApiOperation({description: 'Mitigate a comment reply report'}) - @ApiOkResponse({type: UeCommentReportResDto}) - async mitigateCommentReplyReport( - @UUIDParam('replyId') replyId: string, - @UUIDParam('reportId') reportId: string, - ) { - if (!(await this.commentsService.doesReplyExist(replyId))) - throw new AppException(ERROR_CODE.NO_SUCH_REPLY); - if (!(await this.commentsService.doesCommentReplyReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); + @ApiOperation({ description: 'Mitigate a comment reply report' }) + @ApiOkResponse({ type: UeCommentReportResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPLY, 'Thrown when the comment reply does not exist') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPORT, 'Thrown when the report does not exist') + async mitigateCommentReplyReport(@UUIDParam('replyId') replyId: string, @UUIDParam('reportId') reportId: string) { + if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); + if (!(await this.commentsService.doesCommentReplyReportExist(reportId))) + throw new AppException(ERROR_CODE.NO_SUCH_REPORT); return await this.commentsService.mitigateCommentReplyReport(replyId, reportId); } } diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index c72cd14b..dc65647c 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -1,18 +1,18 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; import { RawUserUeSubscription } from 'src/prisma/types'; +import { ConfigModule } from '../../config/config.module'; +import { PrismaService } from '../../prisma/prisma.service'; +import { omit, pick } from '../../utils'; import UeCommentPostReqDto from './dto/req/ue-comment-post-req.dto'; import UeCommentReplyReqDto from './dto/req/ue-comment-reply-req.dto'; +import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; import UeCommentUpdateReqDto from './dto/req/ue-comment-update-req.dto'; import GetUeCommentsReqDto from './dto/req/ue-get-comments-req.dto'; -import { UeCommentReply } from './interfaces/comment-reply.interface'; -import { UeComment } from './interfaces/comment.interface'; -import { ConfigModule } from '../../config/config.module'; -import UeCommentReportReqDto from './dto/req/ue-comment-report-req.dto'; import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dto'; import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; -import { omit, pick } from '../../utils'; -import { Prisma } from '@prisma/client'; +import { UeCommentReply } from './interfaces/comment-reply.interface'; +import { UeComment } from './interfaces/comment.interface'; @Injectable() export class CommentsService { @@ -522,8 +522,6 @@ export class CommentsService { commentId: string, isModerator: boolean, ): Promise { - // How are reasons handled by the front ? - // Do we need another route to load reasons ? const comment = await this.getCommentFromId(commentId, userId, isModerator); const report = await this.prisma.ueCommentReport.create({ data: { @@ -598,7 +596,7 @@ export class CommentsService { } async mitigateCommentReport(commentId: string, reportId: string) { - return this.prisma.ueCommentReport.update({ + return await this.prisma.ueCommentReport.update({ where: { commentId, id: reportId, @@ -610,7 +608,7 @@ export class CommentsService { } async mitigateCommentReplyReport(replyId: string, reportId: string) { - return this.prisma.ueCommentReplyReport.update({ + return await this.prisma.ueCommentReplyReport.update({ where: { reply: { id: replyId, @@ -622,4 +620,15 @@ export class CommentsService { }, }); } + + async getCommentReportReason() { + return (await this.prisma.ueCommentReportReason.findMany({ include: { descriptionTranslation: true } })).map( + (rr) => { + return { + name: rr.name, + descriptionTranslation: omit(rr.descriptionTranslation, 'id'), + }; + }, + ); + } } diff --git a/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts b/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts new file mode 100644 index 00000000..bf080834 --- /dev/null +++ b/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts @@ -0,0 +1,10 @@ +export default class UeCommentReportReasonResDto { + name: string; + descriptionTranslation: { + fr: string; + en: string; + es: string; + de: string; + zh: string; + }; +} diff --git a/src/ue/comments/interfaces/comment-reply.interface.ts b/src/ue/comments/interfaces/comment-reply.interface.ts index 1eaf4d4f..849dcbc3 100644 --- a/src/ue/comments/interfaces/comment-reply.interface.ts +++ b/src/ue/comments/interfaces/comment-reply.interface.ts @@ -43,7 +43,7 @@ export const REPLY_SELECT_FILTER = { }, } as const; -export type UeCommentReplyReport = Omit & { +export type UeCommentReplyReport = Omit & { reason: string; user: { id: string; @@ -51,14 +51,14 @@ export type UeCommentReplyReport = Omit; export type UeCommentReply = Omit< Prisma.UeCommentReplyGetPayload, - 'deletedAt'|'reports' + 'deletedAt' | 'reports' > & { status: CommentStatus; - reports: UeCommentReplyReport[] + reports: UeCommentReplyReport[]; }; export function generateCustomUeCommentReplyModel(prisma: PrismaClient) { @@ -67,8 +67,11 @@ export function generateCustomUeCommentReplyModel(prisma: PrismaClient) { export function formatReply(_: PrismaClient, reply: UnformattedUeCommentReply): UeCommentReply { return { - ...omit(reply, 'deletedAt','reports'), - reports: reply.reports.map((r)=> {return {...r, reason:r.reason.name}}), - status: (reply.reports.some((r)=> !r.mitigated) && CommentStatus.HIDDEN) | (reply.deletedAt && CommentStatus.DELETED), + ...omit(reply, 'deletedAt', 'reports'), + reports: reply.reports.map((r) => { + return { ...r, reason: r.reason.name }; + }), + status: + (reply.reports.some((r) => !r.mitigated) && CommentStatus.HIDDEN) | (reply.deletedAt && CommentStatus.DELETED), }; } diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index 48ded407..b1cc68e6 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -149,12 +149,12 @@ export function formatComment(prisma: PrismaClient, comment: UnformattedUeCommen ...omit(comment, 'deletedAt'), author: !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, answers: comment.answers - .filter((answer) => args.includeDeleted || answer.deletedAt === null) - .map((answer) => { - let anwser = formatReply(prisma, answer); - if (!includeReports) anwser.reports = null; - return anwser; - }), + .filter((answer) => args.includeDeleted || answer.deletedAt === null) + .map((answer) => { + let anwser = formatReply(prisma, answer); + if (!includeReports) anwser.reports = null; + return anwser; + }), status: (comment.reports.some((r) => !r.mitigated) && CommentStatus.HIDDEN) | (comment.deletedAt && CommentStatus.DELETED), diff --git a/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts new file mode 100644 index 00000000..40de953f --- /dev/null +++ b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts @@ -0,0 +1,39 @@ +import * as pactum from 'pactum'; +import { ERROR_CODE } from 'src/exceptions'; +import { createCommentReportReason, createUser } from '../../../../test/utils/fakedb'; +import { e2eSuite } from '../../../../test/utils/test_utils'; + +const GetCommentReportReason = e2eSuite('GET /ue/comments/reports/reasons', (app) => { + const user = createUser(app, { permissions: ['API_SEE_OPINIONS_UE'] }); + const userNoPermission = createUser(app); + createCommentReportReason(app, { name: 'meh' }); + createCommentReportReason(app, { name: 'bad' }); + + it('should return a 401 as user is not authenticated', () => + pactum.spec().get(`/ue/comments/reports/reasons`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should fail as the user does not have the required permissions', () => + pactum + .spec() + .withBearerToken(userNoPermission.token) + .get(`/ue/comments/reports/reasons`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE')); + + it('should return an array of report reasons', () => + pactum + .spec() + .withBearerToken(user.token) + .get(`/ue/comments/reports/reasons`) + .expectJsonLength(2) + .expectJsonLike([ + { + name: 'bad', + descriptionTranslation: 'bonjour', + }, + { + name: 'meh', + descriptionTranslation: 'bonjour', + }, + ])); +}); +export default GetCommentReportReason; diff --git a/test/e2e/ue/comments/index.ts b/test/e2e/ue/comments/index.ts index 272f3828..8006b8fc 100644 --- a/test/e2e/ue/comments/index.ts +++ b/test/e2e/ue/comments/index.ts @@ -1,36 +1,38 @@ import { INestApplication } from '@nestjs/common'; -import GetCommentsE2ESpec from './get-comment.e2e-spec'; import DeleteComment from './delete-comment.e2e-spec'; import DeleteCommentReply from './delete-reply.e2e-spec'; import DeleteUpvote from './delete-upvote.e2e-spec'; +import GetCommentFromIdE2ESpec from './get-comment-from-id.e2e-spec'; +import GetCommentReportReason from './get-comment-report-reasons.e2e-spec'; +import GetCommentsE2ESpec from './get-comment.e2e-spec'; +import GetReportedComments from './get-reported-comments.e2e-spec'; +import PostReportCommentReply from './post-comment-reply-report.e2e-spec'; +import PostReportComment from './post-comment-report.e2e-spec'; import PostCommment from './post-comment.e2e-spec'; import PostCommmentReply from './post-reply.e2e-spec'; import PostUpvote from './post-upvote.e2e-spec'; +import UpdateCommentReplyReport from './update-comment-reply-report.e2e-spec'; +import UpdateCommentReport from './update-comment-report.e2e-spec'; import UpdateComment from './update-comment.e2e-spec'; import UpdateCommentReply from './update-reply.e2e-spec'; -import GetCommentFromIdE2ESpec from './get-comment-from-id.e2e-spec'; -import ReportComment from './post-comment-report.e2e-spec'; -import GetReportedComments from './get-reported-comments.e2e-spec'; -import UpdateCommentReport from './update-comment-report.e2e-spec'; -import ReportCommentReply from './post-comment-reply-report.e2e-spec'; -import UpdateCommentReplyReport from './update-comment-reply-report.e2e-spec'; export default function CommentsE2ESpec(app: () => INestApplication) { describe('Comments', () => { GetCommentsE2ESpec(app); + GetCommentFromIdE2ESpec(app); + GetReportedComments(app); + GetCommentReportReason(app); PostCommment(app); PostCommmentReply(app); + PostUpvote(app); + PostReportComment(app); + PostReportCommentReply(app); UpdateComment(app); - DeleteComment(app); UpdateCommentReply(app); - DeleteCommentReply(app); - PostUpvote(app); - DeleteUpvote(app); - GetCommentFromIdE2ESpec(app); - ReportComment(app); - ReportCommentReply(app) - GetReportedComments(app); UpdateCommentReport(app); UpdateCommentReplyReport(app); + DeleteComment(app); + DeleteCommentReply(app); + DeleteUpvote(app); }); } diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index 0b4b42f4..11341cdc 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -1127,7 +1127,11 @@ export const createCommentReportReason = entityFaker( descriptionTranslation: { create: { id: params.descriptionTranslationId, - fr: 'TODO : implement this value', + fr: 'bonjour', + en: null, + de: null, + es: null, + zh: null, }, }, }, From 2f6053a32498d7205559ff9c8512bd9ea1451bb4 Mon Sep 17 00:00:00 2001 From: Cookky Date: Tue, 28 Oct 2025 14:52:18 +0100 Subject: [PATCH 10/19] Removed studentId and removed null values for reports --- src/ue/comments/dto/res/ue-comment-report-res.dto.ts | 2 +- src/ue/comments/dto/res/ue-comment-res.dto.ts | 3 ++- src/ue/comments/interfaces/comment-reply.interface.ts | 2 -- src/ue/comments/interfaces/comment.interface.ts | 6 ++---- test/declarations.ts | 7 +++++-- test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts | 1 - test/e2e/ue/comments/post-comment-report.e2e-spec.ts | 1 - test/e2e/ue/comments/update-reply.e2e-spec.ts | 1 + 8 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/ue/comments/dto/res/ue-comment-report-res.dto.ts b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts index bd78dad4..ffaf015e 100644 --- a/src/ue/comments/dto/res/ue-comment-report-res.dto.ts +++ b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts @@ -6,6 +6,6 @@ export default class UeCommentReportResDto { createdAt: Date; mitigated: boolean; reportedBody: string; - user: UeCommentAuthorResDto & {studentId: number}; + user: UeCommentAuthorResDto; reason: string; } \ No newline at end of file diff --git a/src/ue/comments/dto/res/ue-comment-res.dto.ts b/src/ue/comments/dto/res/ue-comment-res.dto.ts index 6e657b23..fc853243 100644 --- a/src/ue/comments/dto/res/ue-comment-res.dto.ts +++ b/src/ue/comments/dto/res/ue-comment-res.dto.ts @@ -13,7 +13,7 @@ export default class UeCommentResDto { upvoted: boolean; status: number; answers: CommentResDto_Answer[]; - reports?: UeCommentReportResDto[]; + reports: UeCommentReportResDto[]; } class CommentResDto_Answer { @@ -23,4 +23,5 @@ class CommentResDto_Answer { createdAt: Date; updatedAt: Date; status: number; + reports: UeCommentReportResDto[]; } diff --git a/src/ue/comments/interfaces/comment-reply.interface.ts b/src/ue/comments/interfaces/comment-reply.interface.ts index 849dcbc3..36a50879 100644 --- a/src/ue/comments/interfaces/comment-reply.interface.ts +++ b/src/ue/comments/interfaces/comment-reply.interface.ts @@ -35,7 +35,6 @@ export const REPLY_SELECT_FILTER = { id: true, firstName: true, lastName: true, - studentId: true, }, }, }, @@ -47,7 +46,6 @@ export type UeCommentReplyReport = Omit args.includeDeleted || answer.deletedAt === null) .map((answer) => { let anwser = formatReply(prisma, answer); - if (!includeReports) anwser.reports = null; + if (!includeReports) anwser.reports = []; return anwser; }), status: @@ -161,7 +159,7 @@ export function formatComment(prisma: PrismaClient, comment: UnformattedUeCommen upvotes: comment.upvotes.length, upvoted: comment.upvotes.some((upvote) => upvote.userId == args.userId), semester: comment.semester.code, - reports: includeReports ? comment.reports.map((r) => ({ ...r, reason: r.reason.name })) : null, + reports: includeReports ? comment.reports.map((r) => ({ ...r, reason: r.reason.name })) : [], }; } diff --git a/test/declarations.ts b/test/declarations.ts index b33cf79b..66c5f2b3 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -166,14 +166,14 @@ Spec.prototype.expectUesWithPagination = function (app: AppProvider, ues: FakeUe }; Spec.prototype.expectUeComment = function expect(this: Spec, obj, created = false) { return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).expectJsonLike({ - ...omit(obj as any, 'ueof','reports'), + ...omit(obj as any, 'ueof'), ueof: { code: obj.ueof.code, info: { language: obj.ueof.info.language, }, }, - }); + } satisfies JsonLikeVariant); }; Spec.prototype.expectUeComments = function expect(obj) { return (this).expectStatus(HttpStatus.OK).expectJsonMatch({ @@ -189,6 +189,9 @@ Spec.prototype.expectUeComments = function expect(obj) { }, createdAt: comment.createdAt.toISOString(), updatedAt: comment.updatedAt.toISOString(), + reports: comment.reports.map((c) => { + return { ...c, createdAt: c.createdAt.toISOString() }; + }), answers: comment.answers.map((answer) => ({ ...pick(answer, 'author', 'body', 'id', 'status'), createdAt: answer.createdAt.toISOString(), diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts index 669513b5..ecddfd01 100644 --- a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -112,7 +112,6 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', id: userNotAuthor.id, firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, - studentId: userNotAuthor.studentId, }, }); }); diff --git a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts index 280fa11b..e9eec6d7 100644 --- a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts @@ -103,7 +103,6 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => id: userNotAuthor.id, firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, - studentId: userNotAuthor.studentId } }) }) diff --git a/test/e2e/ue/comments/update-reply.e2e-spec.ts b/test/e2e/ue/comments/update-reply.e2e-spec.ts index 64144314..555b6684 100644 --- a/test/e2e/ue/comments/update-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/update-reply.e2e-spec.ts @@ -122,6 +122,7 @@ const UpdateCommentReply = e2eSuite('PATCH /ue/comments/reply/{replyId}', (app) updatedAt: JsonLike.ANY_DATE, body: "Je m'appelle Alban Ichou et j'approuve ce commentaire", status: CommentStatus.ACTIVE, + reports: [] }); return app().get(PrismaService).ueCommentReply.deleteMany(); }); From bd3e71b85c7c55cd265c99413f84d8c876886e32 Mon Sep 17 00:00:00 2001 From: Cookky Date: Tue, 28 Oct 2025 16:04:34 +0100 Subject: [PATCH 11/19] Merge branch 'dev' into feat/ue-comment-report --- .github/workflows/ci.yaml | 63 ++-- prisma/schema.prisma | 16 +- src/assos/assos.controller.ts | 222 ++++++++++++++- src/assos/assos.module.ts | 3 +- src/assos/assos.service.ts | 216 +++++++++++++- src/assos/decorator/get-asso.ts | 20 ++ src/assos/decorator/get-member.ts | 20 ++ src/assos/dto/req/assos-member-create.dto.ts | 26 ++ src/assos/dto/req/assos-member-update.dto.ts | 23 ++ src/assos/dto/req/assos-role-create.dto.ts | 7 + src/assos/dto/req/assos-role-update.dto.ts | 13 + src/assos/dto/res/asso-members-res.dto.ts | 13 + src/assos/dto/res/assos-membership-res.dto.ts | 7 + src/assos/dto/res/assos-role-res.dto.ts | 27 ++ src/assos/interfaces/asso.interface.ts | 1 + .../interfaces/membership-role.interface.ts | 38 +++ src/assos/interfaces/membership.interface.ts | 25 ++ .../application/application.controller.ts | 9 +- src/auth/auth.module.ts | 6 +- src/auth/auth.service.ts | 24 +- src/auth/guard/jwt.guard.ts | 2 +- src/auth/interfaces/permissions.interface.ts | 9 +- .../permissions/dto/res/permissions.dto.ts | 12 + .../permissions/permissions.controller.ts | 40 +++ src/auth/permissions/permissions.service.ts | 19 ++ src/auth/strategy/jwt.strategy.ts | 19 +- src/exceptions.ts | 51 +++- src/prisma/prisma.service.ts | 4 + src/prisma/types.ts | 1 + src/profile/profile.service.ts | 11 +- src/std.type.ts | 1 + src/ue/annals/annals.controller.ts | 10 +- src/ue/annals/dto/req/create-annal-req.dto.ts | 7 +- src/ue/comments/comments.service.ts | 8 +- src/ue/comments/dto/res/ue-comment-res.dto.ts | 2 +- .../comments/interfaces/comment.interface.ts | 6 +- src/users/users.controller.ts | 2 +- src/users/users.service.ts | 4 +- src/utils.ts | 44 ++- src/validation.ts | 21 ++ test/declarations.d.ts | 31 +- test/declarations.ts | 268 ++++++++++++------ test/e2e/assos/add-member.e2e-spec.ts | 190 +++++++++++++ test/e2e/assos/create-role.e2e-spec.ts | 97 +++++++ test/e2e/assos/delete-role.e2e-spec.ts | 117 ++++++++ test/e2e/assos/index.ts | 14 + test/e2e/assos/kick-member.e2e-spec.ts | 129 +++++++++ test/e2e/assos/list-members.e2e-spec.ts | 57 ++++ test/e2e/assos/update-member.e2e-spec.ts | 239 ++++++++++++++++ test/e2e/assos/update-role.e2e-spec.ts | 146 ++++++++++ .../create-application.e2e-spec.ts | 9 +- .../get-applications-of-user.e2e-spec.ts | 3 +- .../update-client-secret.e2e-spec.ts | 7 +- test/e2e/auth/cas-sign-in.e2e-spec.ts | 9 +- test/e2e/auth/cas-sign-up.e2e-spec.ts | 27 +- test/e2e/auth/create-api-key.e2e-spec.ts | 5 +- test/e2e/auth/index.ts | 2 + .../auth/permissions/get-own-permissions.ts | 29 ++ .../permissions/get-permissions.e2e-spec.ts | 37 +++ test/e2e/auth/permissions/index.ts | 10 + test/e2e/auth/signin-e2e-spec.ts | 15 +- test/e2e/auth/validate-login.e2e-spec.ts | 5 +- test/e2e/branch/get-branches.e2e-spec.ts | 2 +- .../profile/get-homepage-widgets.e2e.spec.ts | 6 +- .../profile/set-homepage-widgets.e2e-spec.ts | 7 +- test/e2e/timetable/create-entry.e2e-spec.ts | 21 +- .../timetable/delete-occurrences.e2e-spec.ts | 48 ++-- .../timetable/get-daily-timetable-e2e-spec.ts | 10 +- .../timetable/get-entry-details.e2e-spec.ts | 18 +- test/e2e/timetable/get-groups.e2e-spec.ts | 2 +- test/e2e/timetable/get-timetable.e2e-spec.ts | 10 +- test/e2e/timetable/update-entry.e2e-spec.ts | 48 ++-- test/e2e/ue/annals/delete-annal.e2e-spec.ts | 14 +- test/e2e/ue/annals/get-annal-file.e2e-spec.ts | 11 +- .../ue/annals/get-annal-metadata.e2e-spec.ts | 11 +- test/e2e/ue/annals/get-annals.e2e-spec.ts | 16 +- test/e2e/ue/annals/patch-annal.e2e-spec.ts | 14 +- test/e2e/ue/annals/upload-annal.e2e-spec.ts | 46 +-- .../ue/comments/delete-comment.e2e-spec.ts | 14 +- test/e2e/ue/comments/delete-reply.e2e-spec.ts | 13 +- .../e2e/ue/comments/delete-upvote.e2e-spec.ts | 10 +- .../comments/get-comment-from-id.e2e-spec.ts | 41 ++- .../get-comment-report-reasons.e2e-spec.ts | 3 +- test/e2e/ue/comments/get-comment.e2e-spec.ts | 7 +- .../get-reported-comments.e2e-spec.ts | 5 +- .../post-comment-reply-report.e2e-spec.ts | 11 +- .../comments/post-comment-report.e2e-spec.ts | 9 +- test/e2e/ue/comments/post-comment.e2e-spec.ts | 24 +- test/e2e/ue/comments/post-reply.e2e-spec.ts | 10 +- test/e2e/ue/comments/post-upvote.e2e-spec.ts | 10 +- .../update-comment-reply-report.e2e-spec.ts | 12 +- .../update-comment-report.e2e-spec.ts | 6 +- .../ue/comments/update-comment.e2e-spec.ts | 24 +- test/e2e/ue/comments/update-reply.e2e-spec.ts | 14 +- test/e2e/ue/delete-rate.e2e-spec.ts | 8 +- test/e2e/ue/get-rate-criteria.e2e-spec.ts | 4 +- test/e2e/ue/get-ue-rate.e2e-spec.ts | 8 +- test/e2e/ue/put-rate.e2e-spec.ts | 8 +- test/e2e/users/get-current-user-e2e-spec.ts | 7 +- .../users/get-todays-birthdays.e2e-spec.ts | 4 +- test/e2e/users/get-user-e2e-spec.ts | 2 +- test/e2e/users/get-user_assos-e2e-spec.ts | 6 +- test/e2e/users/update-profile-e2e-spec.ts | 4 +- test/utils/fakedb.ts | 67 +++-- test/utils/test_utils.ts | 8 +- 105 files changed, 2611 insertions(+), 510 deletions(-) create mode 100644 src/assos/decorator/get-asso.ts create mode 100644 src/assos/decorator/get-member.ts create mode 100644 src/assos/dto/req/assos-member-create.dto.ts create mode 100644 src/assos/dto/req/assos-member-update.dto.ts create mode 100644 src/assos/dto/req/assos-role-create.dto.ts create mode 100644 src/assos/dto/req/assos-role-update.dto.ts create mode 100644 src/assos/dto/res/asso-members-res.dto.ts create mode 100644 src/assos/dto/res/assos-membership-res.dto.ts create mode 100644 src/assos/dto/res/assos-role-res.dto.ts create mode 100644 src/assos/interfaces/membership-role.interface.ts create mode 100644 src/assos/interfaces/membership.interface.ts create mode 100644 src/auth/permissions/dto/res/permissions.dto.ts create mode 100644 src/auth/permissions/permissions.controller.ts create mode 100644 src/auth/permissions/permissions.service.ts create mode 100644 test/e2e/assos/add-member.e2e-spec.ts create mode 100644 test/e2e/assos/create-role.e2e-spec.ts create mode 100644 test/e2e/assos/delete-role.e2e-spec.ts create mode 100644 test/e2e/assos/kick-member.e2e-spec.ts create mode 100644 test/e2e/assos/list-members.e2e-spec.ts create mode 100644 test/e2e/assos/update-member.e2e-spec.ts create mode 100644 test/e2e/assos/update-role.e2e-spec.ts create mode 100644 test/e2e/auth/permissions/get-own-permissions.ts create mode 100644 test/e2e/auth/permissions/get-permissions.e2e-spec.ts create mode 100644 test/e2e/auth/permissions/index.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bad0a674..52ebd9c6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,35 +10,42 @@ jobs: runs-on: self-hosted strategy: matrix: - node-version: [18] - steps: - - uses: actions/checkout@v4 + node-version: [22] + pnpm-version: [10] + steps: + - uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: ${{ matrix.pnpm-version }} - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20.x' - cache: 'pnpm' + package-manager-cache: false + node-version: ${{ matrix.node-version }} + cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm prisma generate - run: pnpm lint build: runs-on: self-hosted - steps: - - uses: actions/checkout@v4 + strategy: + matrix: + node-version: [22] + pnpm-version: [10] + steps: + - uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: ${{ matrix.pnpm-version }} - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20.x' - cache: 'pnpm' + package-manager-cache: false + node-version: ${{ matrix.node-version }} + cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm prisma generate - run: pnpm build @@ -47,7 +54,8 @@ jobs: runs-on: self-hosted strategy: matrix: - node-version: [18] + node-version: [22] + pnpm-version: [10] env: DATABASE_URL: mysql://dev:dev@localhost:3306/etuutt_test services: @@ -60,19 +68,26 @@ jobs: MYSQL_RANDOM_ROOT_PASSWORD: yes ports: - 3306:3306 - options: --name mariadb --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=30s --health-retries=5 + options: >- + --name mariadb + --health-cmd="mysqladmin ping" + --health-interval=2s + --health-timeout=2s + --health-retries=10 + --tmpfs /var/lib/mysql:rw - steps: - - uses: actions/checkout@v4 + steps: + - uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: ${{ matrix.pnpm-version }} - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20.x' - cache: 'pnpm' + package-manager-cache: false + node-version: ${{ matrix.node-version }} + cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: cp .env.test.dist .env.test - run: pnpm test:db:reset @@ -89,7 +104,7 @@ jobs: - build - test steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install docker uses: docker/setup-buildx-action@v3 - name: Login to registry @@ -100,7 +115,7 @@ jobs: password: ${{ secrets.REGISTRY_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 - with: + with: push: true tags: | - ${{ secrets.REGISTRY_URL }}/etuutt/api:${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} \ No newline at end of file + ${{ secrets.REGISTRY_URL }}/etuutt/api:${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8aef3d73..69c22a9d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,11 +37,11 @@ model ApiKeyPermission { apiKeyId String userId String? // The user targetted by the permission. If null, this is a hard grant. It must thus be null if this is an api permission. granterId String? // Null if granter was deleted - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) user User? @relation(name: "target", fields: [userId], references: [id], onDelete: Cascade) - granter User? @relation(name: "granter", fields: [granterId], references: [id], onDelete: SetNull) + granter User? @relation(name: "granter", fields: [granterId], references: [id], onDelete: SetNull) @@unique([apiKeyId, userId, permission]) } @@ -80,12 +80,12 @@ model AssoMembership { user User @relation(fields: [userId], references: [id]) asso Asso @relation(fields: [assoId], references: [id]) - role AssoMembershipRole @relation(fields: [roleId], references: [id]) + role AssoMembershipRole @relation(fields: [roleId], references: [id], onDelete: Cascade) permissions AssoMembershipPermission[] } model AssoMembershipPermission { - id String @id @default(uuid()) + id String @id assoMembership AssoMembership[] } @@ -97,8 +97,8 @@ model AssoMembershipRole { isPresident Boolean assoId String - assoMembership AssoMembership[] - asso Asso @relation(fields: [assoId], references: [id]) + assoMemberships AssoMembership[] + asso Asso @relation(fields: [assoId], references: [id]) } model AssoMessage { @@ -589,8 +589,8 @@ model User { privacy UserPrivacy @relation(fields: [privacyId], references: [id]) apiApplications ApiApplication[] apiKeys ApiKey[] - apiPermissionsTarget ApiKeyPermission[] @relation(name: "target") - apiPermissionsGrants ApiKeyPermission[] @relation(name: "granter") + apiPermissionsTarget ApiKeyPermission[] @relation(name: "target") + apiPermissionsGrants ApiKeyPermission[] @relation(name: "granter") asso Asso? } diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index 81e06ceb..f64ee5da 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -1,19 +1,36 @@ -import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; -import { IsPublic } from '../auth/decorator'; +import { Body, Controller, Delete, Get, Patch, Post, Put, Query } from '@nestjs/common'; +import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; +import { AssoMembershipRole } from './interfaces/membership-role.interface'; +import { AssoMembership } from './interfaces/membership.interface'; +import { ParamAsso } from './decorator/get-asso'; +import { GetUser, IsPublic } from '../auth/decorator'; import { AssosService } from './assos.service'; -import AssosSearchReqDto from './dto/req/assos-search-req.dto'; import { AppException, ERROR_CODE } from '../exceptions'; +import { ParamMember } from './decorator/get-member'; import { Asso } from './interfaces/asso.interface'; +import { User } from '../users/interfaces/user.interface'; import { pick } from '../utils'; +import { UUIDParam } from '../app.pipe'; +import AssosSearchReqDto from './dto/req/assos-search-req.dto'; import AssoOverviewResDto from './dto/res/asso-overview-res.dto'; import AssoDetailResDto from './dto/res/asso-detail-res.dto'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; +import AssoMembersResDto from './dto/res/asso-members-res.dto'; +import AssosRoleCreateReqDto from './dto/req/assos-role-create.dto'; +import AssoRoleOverviewResDto, { AssoRole, AssoRoleListResDto, AssoRoleResDto } from './dto/res/assos-role-res.dto'; +import AssosRoleUpdateReqDto from './dto/req/assos-role-update.dto'; +import AssosMemberCreateReqDto from './dto/req/assos-member-create.dto'; +import AssosMemberUpdateReqDto from './dto/req/assos-member-update.dto'; +import AssoMembershipResDto from './dto/res/assos-membership-res.dto'; +import UsersService from '../users/users.service'; @Controller('assos') @ApiTags('Assos') export class AssosController { - constructor(readonly assosService: AssosService) {} + constructor( + readonly assosService: AssosService, + readonly userService: UsersService, + ) {} @Get() @IsPublic() @@ -35,15 +52,168 @@ export class AssosController { }) @ApiOkResponse({ type: AssoDetailResDto }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') - async getAsso( - @Param( - 'assoId', - new ParseUUIDPipe({ exceptionFactory: () => new AppException(ERROR_CODE.PARAM_NOT_UUID, 'assoId') }), - ) - assoId: string, - ): Promise { - if (!(await this.assosService.doesAssoExist(assoId))) throw new AppException(ERROR_CODE.NO_SUCH_ASSO, assoId); - return this.formatAssoDetail(await this.assosService.getAsso(assoId.toUpperCase())); + getAsso(@ParamAsso() asso: Asso): AssoDetailResDto { + return this.formatAssoDetail(asso); + } + + // The route below is not public as it exposes the full name of all members, only the president is supposed to be exposed publicly in the route above + @Get('/:assoId/members') + @ApiOperation({ + description: 'Get the members of an asso, with their roles.', + }) + @ApiOkResponse({ type: AssoRoleResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + async getAssoMembers(@ParamAsso() asso: Asso, @GetUser() user: User): Promise { + const showPerms = await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_members', 'manage_roles'); + return { + roles: (await this.assosService.getAssoMembers(asso.id)).map((role) => + this.formatAssoMembershipRole(role, showPerms), + ), + }; + } + + @Post('/:assoId/members') + @ApiOperation({ + description: 'Adds a member to an asso.', + }) + @ApiCreatedResponse({ type: AssoMembershipResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user has no permission to perform this action for this asso', + ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO_ROLE, 'There is no role with the given id in this asso') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_USER, 'There is no user with the given id') + @ApiAppErrorResponse(ERROR_CODE.USER_ALREADY_ASSO_ROLE_MEMBER, 'The user is already a member of this asso') + async addAssoMember(@ParamAsso() asso: Asso, @GetUser() user: User, @Body() body: AssosMemberCreateReqDto) { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_members'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_members'); + const role = await this.assosService.getAssoRole(body.roleId, asso.id); + if (!role) throw new AppException(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id); + if (!(await this.userService.fetchUser(body.userId))) throw new AppException(ERROR_CODE.NO_SUCH_USER, body.userId); + if (await this.assosService.hasRole(role.id, body.userId)) + throw new AppException(ERROR_CODE.USER_ALREADY_ASSO_ROLE_MEMBER, role.name); + if (!(await this.assosService.hasEveryAssoPermission(asso, user.id, ...body.permissions))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, body.permissions.join(', ')); + return this.assosService + .addAssoMembership(asso.id, body.userId, role.id, body.permissions, body.endAt) + .then(this.formatAssoMembership); + } + + @Delete('/:assoId/members/:memberId') + @ApiOperation({ + description: 'Kicks a member from an asso.', + }) + @ApiOkResponse({ type: AssoMembershipResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, 'There is no membership with the given id') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user has no permission to perform this action for this asso', + ) + async kickAssoMember(@ParamAsso() asso: Asso, @ParamMember() member: AssoMembership, @GetUser() user: User) { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_members'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_members'); + if (member.assoId !== asso.id || member.endAt < new Date()) + throw new AppException(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, member.id); + return this.assosService.updateAssoMember(member.id, { endAt: new Date() }).then(this.formatAssoMembership); + } + + @Patch('/:assoId/members/:memberId') + @ApiOperation({ + description: 'Updates roles of a member in an asso.', + }) + @ApiOkResponse({ type: AssoMembershipResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, 'There is no membership with the given id') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user has no permission to perform this action for this asso', + ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO_ROLE, 'There is no role with the given id in this asso') + @ApiAppErrorResponse(ERROR_CODE.USER_ALREADY_ASSO_ROLE_MEMBER, 'The user is already a member of this asso') + async updateAssoMember( + @ParamAsso() asso: Asso, + @ParamMember() member: AssoMembership, + @GetUser() user: User, + @Body() body: AssosMemberUpdateReqDto, + ) { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_members'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_members'); + if (member.assoId !== asso.id) throw new AppException(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, member.id); + if (body.roleId) { + const role = await this.assosService.getAssoRole(body.roleId, asso.id); + if (!role) throw new AppException(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id); + if (await this.assosService.hasRole(role.id, member.userId)) + throw new AppException(ERROR_CODE.USER_ALREADY_ASSO_ROLE_MEMBER, role.name); + } + if (body.permissions && !(await this.assosService.hasEveryAssoPermission(asso, user.id, ...body.permissions))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, body.permissions.join(', ')); + return this.assosService.updateAssoMember(member.id, body).then(this.formatAssoMembership); + } + + @Post('/:assoId/roles') + @ApiOperation({ + description: 'Creates a new role in an asso.', + }) + @ApiOkResponse({ type: AssoRoleOverviewResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user has no permission to perform this action for this asso', + ) + async createAssoRole(@ParamAsso() asso: Asso, @GetUser() user: User, @Body() body: AssosRoleCreateReqDto) { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_roles'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_roles'); + return this.assosService.createAssoRole(asso.id, body.name).then(this.formatPartialAssoMembershipRole); + } + + @Delete('/:assoId/roles/:roleId') + @ApiOperation({ + description: 'Deletes a role from an asso. Caution: all members with this role will lose it.', + }) + @ApiOkResponse({ type: AssoRoleOverviewResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user has no permission to perform this action for this asso', + ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO_ROLE, 'There is no role with the given id in this asso') + @ApiAppErrorResponse(ERROR_CODE.FORBIDDEN_ASSOS_ROLE_PERMANENT, 'The given role is permanent and cannot be deleted') + async deleteAssoRole(@ParamAsso() asso: Asso, @GetUser() user: User, @UUIDParam('roleId') roleId: string) { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_roles'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_roles'); + const role = await this.assosService.getAssoRole(roleId, asso.id); + if (!role) throw new AppException(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id); + if (role.isPresident) throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_ROLE_PERMANENT, role.name); + return this.assosService.deleteAssoRole(role.id).then(this.formatPartialAssoMembershipRole); + } + + @Put('/:assoId/roles/:roleId') + @ApiOperation({ + description: 'Updates a role in an asso.', + }) + @ApiOkResponse({ type: AssoRoleListResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user has no permission to perform this action for this asso', + ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO_ROLE, 'There is no role with the given id in this asso') + async updateAssoRole( + @ParamAsso() asso: Asso, + @GetUser() user: User, + @UUIDParam('roleId') roleId: string, + @Body() body: AssosRoleUpdateReqDto, + ) { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_roles'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_roles'); + const role = await this.assosService.getAssoRole(roleId, asso.id); + if (!role) throw new AppException(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id); + if (body.position > (await this.assosService.getRoleRange(asso.id))) + throw new AppException(ERROR_CODE.PARAM_TOO_HIGH, 'position'); + const updatedRoles = await this.assosService.updateAssoRole(role.id, asso.id, pick(body, 'name', 'position')); + return { roles: updatedRoles.map(this.formatPartialAssoMembershipRole) }; } formatAssoOverview(asso: Asso): AssoOverviewResDto { @@ -67,4 +237,26 @@ export class AssosController { }, }; } + + formatAssoMembershipRole(members: AssoMembershipRole, includePermissions = false): AssoRole { + return { + ...pick(members, 'id', 'name', 'position', 'isPresident'), + members: members.assoMemberships.map((membership) => ({ + id: membership.id, + startAt: membership.startAt, + endAt: membership.endAt, + userId: membership.user.id, + permissions: includePermissions ? membership.permissions.map((p) => p.id) : [], + ...pick(membership.user, 'firstName', 'lastName'), + })), + }; + } + + formatPartialAssoMembershipRole(members: Partial) { + return pick(members, 'id', 'name', 'position', 'isPresident'); + } + + formatAssoMembership(member: AssoMembership): AssoMembershipResDto { + return pick(member, 'id', 'roleId', 'userId', 'endAt', 'startAt'); + } } diff --git a/src/assos/assos.module.ts b/src/assos/assos.module.ts index fb355b13..bd02ca10 100644 --- a/src/assos/assos.module.ts +++ b/src/assos/assos.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AssosController } from './assos.controller'; import { AssosService } from './assos.service'; +import UsersService from '../users/users.service'; /** * Defines the `Assos` module. This module handles all routes prefixed by `/assos`. @@ -8,6 +9,6 @@ import { AssosService } from './assos.service'; */ @Module({ controllers: [AssosController], - providers: [AssosService], + providers: [AssosService, UsersService], }) export class AssosModule {} diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index 66f2afe9..e21b02e0 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -1,8 +1,14 @@ import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { ConfigModule } from '../config/config.module'; import { PrismaService } from '../prisma/prisma.service'; -import AssosSearchReqDto from './dto/req/assos-search-req.dto'; +import { RawAssoMembershipRole } from '../prisma/types'; import { Asso } from './interfaces/asso.interface'; +import { AssoMembership } from './interfaces/membership.interface'; +import { AssoMembershipRole } from './interfaces/membership-role.interface'; +import AssosSearchReqDto from './dto/req/assos-search-req.dto'; +import AssosMemberUpdateReqDto from './dto/req/assos-member-update.dto'; +import { AppException, ERROR_CODE } from '../exceptions'; @Injectable() export class AssosService { @@ -75,18 +81,208 @@ export class AssosService { }); } - /** - * Checks whether an asso exists - * @param assoId the id of the asso to check - * @returns whether the asso exists - */ - async doesAssoExist(assoId: string) { + async getAssoMembers(assoId: string): Promise { + return this.prisma.normalize.assoMembershipRole.findMany({ + where: { + assoId, + }, + }); + } + + private async getAssoPermissions(assoId: string, userId: string, ...perms: string[]) { + return new Set( + ( + await this.prisma.normalize.assoMembership.findMany({ + where: { + asso: { id: assoId }, + user: { id: userId }, + endAt: { gte: new Date() }, + permissions: { some: { id: { in: perms } } }, + }, + }) + ).flatMap((m) => m.permissions.map((p) => p.id)), + ); + } + + /** Checks whether the user has at least one of the given permissions. Includes asso account check */ + async hasSomeAssoPermission(asso: Asso, userId: string, ...perms: string[]): Promise { + if (asso.assoAccountId === userId) return true; + const permissions = await this.getAssoPermissions(asso.id, userId, ...perms); + return perms.some((p) => permissions.has(p)); + } + + /** Checks whether the user has all given permissions. Includes asso account check */ + async hasEveryAssoPermission(asso: Asso, userId: string, ...perms: string[]): Promise { + if (asso.assoAccountId === userId) return true; + const permissions = await this.getAssoPermissions(asso.id, userId, ...perms); + return perms.every((p) => permissions.has(p)); + } + + async createAssoRole(assoId: string, roleName: string): Promise { + const lastPosition = + ( + await this.prisma.assoMembershipRole.findFirst({ + where: { assoId }, + orderBy: { position: 'desc' }, + take: 1, + }) + )?.position ?? -1; + return this.prisma.assoMembershipRole.create({ + data: { + assoId, + name: roleName, + position: lastPosition + 1, + isPresident: false, + }, + }); + } + + async getRoleRange(assoId: string): Promise { + return this.prisma.assoMembershipRole + .findFirst({ + where: { assoId }, + orderBy: { position: 'desc' }, + take: 1, + }) + .then((r) => (r ? r.position : 0)); + } + + async getAssoRole(roleId: string, assoId: string): Promise { + return this.prisma.assoMembershipRole.findUnique({ + where: { id: roleId, assoId }, + }); + } + + async deleteAssoRole(roleId: string): Promise { + const deletedRole = await this.prisma.assoMembershipRole.delete({ + where: { id: roleId }, + }); + await this.prisma.assoMembershipRole.updateMany({ + where: { + assoId: deletedRole.assoId, + position: { + gte: deletedRole.position, + }, + }, + data: { + position: { + decrement: 1, + }, + }, + }); + return deletedRole; + } + + async updateAssoRole( + roleId: string, + assoId: string, + newData: Partial>, + ): Promise { + // This poll must be performed the closest possible to the transaction + try { + const [{ position }, { count }] = await this.prisma.$transaction([ + this.prisma.assoMembershipRole.findFirstOrThrow({ + where: { id: roleId, assoId, position: { gte: 0 } }, + select: { position: true }, + }), + this.prisma.assoMembershipRole.updateMany({ + where: { id: roleId, position: { gte: 0 } }, + data: { position: -1 }, + }), + ]); + if (count < 1) throw new AppException(ERROR_CODE.ASSO_ROLE_ALREADY_MOVED); + await this.prisma.$transaction([ + this.prisma.assoMembershipRole.updateMany({ + where: { + position: { + gte: Math.min(position, newData.position), + lte: Math.max(position, newData.position), + }, + }, + data: { + position: { + increment: newData.position !== position ? (newData.position > position ? -1 : 1) : 0, + }, + }, + }), + this.prisma.assoMembershipRole.update({ + where: { id: roleId }, + data: { + position: newData.position, + name: newData.name, + }, + }), + ]); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') + throw new AppException(ERROR_CODE.ASSO_ROLE_ALREADY_MOVED); + throw e; + } + return this.prisma.assoMembershipRole.findMany({ + where: { assoId }, + orderBy: { position: 'asc' }, + }); + } + + async hasRole(roleId: string, userId: string): Promise { return ( - (await this.prisma.asso.count({ + (await this.prisma.assoMembership.count({ where: { - id: assoId, + roleId, + user: { + id: userId, + }, + endAt: { + gte: new Date(), + }, }, - })) != 0 + })) > 0 ); } + + async addAssoMembership( + assoId: string, + userId: string, + roleId: string, + permissions: string[], + end?: Date, + ): Promise { + return this.prisma.normalize.assoMembership.create({ + data: { + asso: { connect: { id: assoId } }, + user: { connect: { id: userId } }, + role: { connect: { id: roleId } }, + permissions: { + connect: permissions.map((p) => ({ id: p })), + }, + startAt: new Date(), + endAt: end ?? new Date(new Date().setFullYear(new Date().getFullYear() + 1)), + }, + }); + } + + async getMembership(memberId: string): Promise { + return this.prisma.normalize.assoMembership.findUnique({ + where: { + id: memberId, + }, + }); + } + + async updateAssoMember(memberId: string, update: AssosMemberUpdateReqDto): Promise { + return this.prisma.normalize.assoMembership.update({ + where: { id: memberId }, + data: { + ...(update.endAt ? { endAt: update.endAt } : {}), + ...(update.roleId ? { role: { connect: { id: update.roleId } } } : {}), + ...(update.permissions + ? { + permissions: { + set: update.permissions.map((p) => ({ id: p })), + }, + } + : {}), + }, + }); + } } diff --git a/src/assos/decorator/get-asso.ts b/src/assos/decorator/get-asso.ts new file mode 100644 index 00000000..63c2f318 --- /dev/null +++ b/src/assos/decorator/get-asso.ts @@ -0,0 +1,20 @@ +import { Injectable, Param, ParseUUIDPipe, PipeTransform } from '@nestjs/common'; +import { AppException, ERROR_CODE } from '../../exceptions'; +import { AssosService } from '../assos.service'; + +export const ParamAsso = (paramName = 'assoId') => + Param( + paramName, + new ParseUUIDPipe({ exceptionFactory: () => new AppException(ERROR_CODE.PARAM_NOT_UUID, paramName) }), + ParseAssoPipe, + ); + +@Injectable() +class ParseAssoPipe implements PipeTransform { + constructor(private readonly assosService: AssosService) {} + async transform(value: string) { + const asso = await this.assosService.getAsso(value.toUpperCase()); + if (!asso) throw new AppException(ERROR_CODE.NO_SUCH_ASSO, value); + return asso; + } +} diff --git a/src/assos/decorator/get-member.ts b/src/assos/decorator/get-member.ts new file mode 100644 index 00000000..634179f7 --- /dev/null +++ b/src/assos/decorator/get-member.ts @@ -0,0 +1,20 @@ +import { Injectable, Param, ParseUUIDPipe, PipeTransform } from '@nestjs/common'; +import { AppException, ERROR_CODE } from '../../exceptions'; +import { AssosService } from '../assos.service'; + +export const ParamMember = (paramName = 'memberId') => + Param( + paramName, + new ParseUUIDPipe({ exceptionFactory: () => new AppException(ERROR_CODE.PARAM_NOT_UUID, paramName) }), + ParseAssoMemberPipe, + ); + +@Injectable() +class ParseAssoMemberPipe implements PipeTransform { + constructor(private readonly assosService: AssosService) {} + async transform(value: string) { + const membership = await this.assosService.getMembership(value.toUpperCase()); + if (!membership) throw new AppException(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, value); + return membership; + } +} diff --git a/src/assos/dto/req/assos-member-create.dto.ts b/src/assos/dto/req/assos-member-create.dto.ts new file mode 100644 index 00000000..42d38117 --- /dev/null +++ b/src/assos/dto/req/assos-member-create.dto.ts @@ -0,0 +1,26 @@ +import { IsArray, IsDate, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsFutureDate } from '../../../validation'; + +export default class AssosMemberCreateReqDto { + @IsString() + @IsNotEmpty() + @IsUUID() + userId: string; + + @IsString() + @IsNotEmpty() + roleId: string; + + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + permissions: string[]; + + @IsOptional() + @IsDate() + @IsNotEmpty() + @IsFutureDate() + @Type(() => Date) + endAt: Date; +} diff --git a/src/assos/dto/req/assos-member-update.dto.ts b/src/assos/dto/req/assos-member-update.dto.ts new file mode 100644 index 00000000..53ffe3d4 --- /dev/null +++ b/src/assos/dto/req/assos-member-update.dto.ts @@ -0,0 +1,23 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDate, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsFutureDate } from '../../../validation'; + +export default class AssosMemberUpdateReqDto { + @IsOptional() + @IsString() + @IsNotEmpty() + roleId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + permissions?: string[]; + + @IsOptional() + @IsDate() + @IsNotEmpty() + @IsFutureDate() + @Type(() => Date) + endAt?: Date; +} diff --git a/src/assos/dto/req/assos-role-create.dto.ts b/src/assos/dto/req/assos-role-create.dto.ts new file mode 100644 index 00000000..6373ec40 --- /dev/null +++ b/src/assos/dto/req/assos-role-create.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export default class AssosRoleCreateReqDto { + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/assos/dto/req/assos-role-update.dto.ts b/src/assos/dto/req/assos-role-update.dto.ts new file mode 100644 index 00000000..7267b8f7 --- /dev/null +++ b/src/assos/dto/req/assos-role-update.dto.ts @@ -0,0 +1,13 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; + +export default class AssosRoleUpdateReqDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsInt() + @Min(0) + @Type(() => Number) + position: number; +} diff --git a/src/assos/dto/res/asso-members-res.dto.ts b/src/assos/dto/res/asso-members-res.dto.ts new file mode 100644 index 00000000..faa01ace --- /dev/null +++ b/src/assos/dto/res/asso-members-res.dto.ts @@ -0,0 +1,13 @@ +import UserMicroResDto from '../../../users/dto/res/user-micro-res.dto'; + +export default class AssoMembersResDto { + roles: AssoMembersRole[]; +} + +class AssoMembersRole { + id: string; + name: string; + position: number; + isPresident: boolean; + members: UserMicroResDto[]; +} diff --git a/src/assos/dto/res/assos-membership-res.dto.ts b/src/assos/dto/res/assos-membership-res.dto.ts new file mode 100644 index 00000000..8e6b62c1 --- /dev/null +++ b/src/assos/dto/res/assos-membership-res.dto.ts @@ -0,0 +1,7 @@ +export default class AssoMembershipResDto { + id: string; + roleId: string; + userId: string; + startAt: Date; + endAt: Date; +} diff --git a/src/assos/dto/res/assos-role-res.dto.ts b/src/assos/dto/res/assos-role-res.dto.ts new file mode 100644 index 00000000..cf9ae9f8 --- /dev/null +++ b/src/assos/dto/res/assos-role-res.dto.ts @@ -0,0 +1,27 @@ +import UserMicroResDto from '../../../users/dto/res/user-micro-res.dto'; + +export default class AssoRoleOverviewResDto { + id: string; + name: string; + position: number; + isPresident: boolean; +} + +export class AssoRoleListResDto { + roles: AssoRoleOverviewResDto[]; +} + +export class AssoRoleResDto { + roles: AssoRole[]; +} + +export class AssoRole extends AssoRoleOverviewResDto { + members: AssoRoleMember[]; +} + +class AssoRoleMember extends UserMicroResDto { + userId: string; + startAt: Date; + endAt: Date; + permissions: string[]; +} diff --git a/src/assos/interfaces/asso.interface.ts b/src/assos/interfaces/asso.interface.ts index b54ada23..7d93385f 100644 --- a/src/assos/interfaces/asso.interface.ts +++ b/src/assos/interfaces/asso.interface.ts @@ -12,6 +12,7 @@ const ASSO_SELECT_FILTER = { logo: true, descriptionTranslation: translationSelect, descriptionShortTranslation: translationSelect, + assoAccountId: true, }, orderBy: { name: 'asc', diff --git a/src/assos/interfaces/membership-role.interface.ts b/src/assos/interfaces/membership-role.interface.ts new file mode 100644 index 00000000..dd1fc7fe --- /dev/null +++ b/src/assos/interfaces/membership-role.interface.ts @@ -0,0 +1,38 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { generateCustomModel } from '../../prisma/prisma.service'; + +const ASSO_MEMBERSHIPROLE_SELECT_FILTER = { + select: { + id: true, + name: true, + position: true, + isPresident: true, + assoMemberships: { + select: { + id: true, + startAt: true, + endAt: true, + permissions: { + select: { + id: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + orderBy: { + position: 'asc', + }, +} as const satisfies Prisma.AssoMembershipRoleFindManyArgs; + +export type AssoMembershipRole = Prisma.AssoMembershipRoleGetPayload; + +export const generateCustomAssoMembershipRoleModel = (prisma: PrismaClient) => + generateCustomModel(prisma, 'assoMembershipRole', ASSO_MEMBERSHIPROLE_SELECT_FILTER, (_, r: AssoMembershipRole) => r); diff --git a/src/assos/interfaces/membership.interface.ts b/src/assos/interfaces/membership.interface.ts new file mode 100644 index 00000000..2dc367fe --- /dev/null +++ b/src/assos/interfaces/membership.interface.ts @@ -0,0 +1,25 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { generateCustomModel } from '../../prisma/prisma.service'; + +const ASSO_MEMBERSHIP_SELECT_FILTER = { + select: { + id: true, + roleId: true, + userId: true, + assoId: true, + startAt: true, + endAt: true, + permissions: { + select: { + id: true, + }, + orderBy: { id: 'asc' }, + }, + }, + orderBy: { startAt: 'asc' }, +} as const satisfies Prisma.AssoMembershipFindManyArgs; + +export type AssoMembership = Prisma.AssoMembershipGetPayload; + +export const generateCustomAssoMembershipModel = (prisma: PrismaClient) => + generateCustomModel(prisma, 'assoMembership', ASSO_MEMBERSHIP_SELECT_FILTER, (_, r: AssoMembership) => r); diff --git a/src/auth/application/application.controller.ts b/src/auth/application/application.controller.ts index 9643aa38..ad905485 100644 --- a/src/auth/application/application.controller.ts +++ b/src/auth/application/application.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import ApplicationResDto from './dto/res/application-res.dto'; -import { ApiOperation } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import ApplicationService from './application.service'; import { GetUser, IsPublic } from '../decorator'; import { Application } from './interfaces/application.interface'; @@ -16,13 +16,14 @@ import { ApiAppErrorResponse } from '../../app.dto'; import ApplicationSensibleResDto from './dto/res/application-sensible-res.dto'; @Controller('auth/application') +@ApiTags('Application') export default class ApplicationController { constructor(private applicationService: ApplicationService) {} @Get('/of/me') @ApiOperation({ description: 'Get the applications of the user issuing the request.' }) async getMyApplications(@GetUser('id') userId: string): Promise { - return this.getApplicationsOf(userId, new PermissionManager({ [Permission.USER_SEE_DETAILS]: [userId] })); + return this.getApplicationsOf(userId, new PermissionManager().with(Permission.USER_SEE_DETAILS, userId)); } @Get('/of/:userId') @@ -84,12 +85,12 @@ export default class ApplicationController { async generateToken( @GetUser('id') userId: string, @Param('applicationId') applicationId: string, - @Body() dto: UpdateTokenReqDto, + @Body() dto?: UpdateTokenReqDto, ): Promise { const application = await this.applicationService.get(applicationId); if (!application) throw new AppException(ERROR_CODE.NO_SUCH_APPLICATION, applicationId); if (application.owner.id !== userId) throw new AppException(ERROR_CODE.APPLICATION_NOT_OWNED, applicationId); - const token = await this.applicationService.regenerateApiKeyToken(userId, applicationId, dto.expiresIn); + const token = await this.applicationService.regenerateApiKeyToken(userId, applicationId, dto?.expiresIn); return { token }; } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 78d9b232..7a19ea11 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -8,12 +8,14 @@ import { LdapModule } from '../ldap/ldap.module'; import { UeService } from '../ue/ue.service'; import ApplicationController from './application/application.controller'; import ApplicationService from './application/application.service'; +import PermissionsController from './permissions/permissions.controller'; +import PermissionsService from './permissions/permissions.service'; @Global() @Module({ imports: [JwtModule.register({}), UsersModule], - controllers: [AuthController, ApplicationController], - providers: [AuthService, JwtStrategy, ApplicationService, LdapModule, UeService], + controllers: [AuthController, ApplicationController, PermissionsController], + providers: [AuthService, JwtStrategy, ApplicationService, LdapModule, UeService, PermissionsService], exports: [], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 8c07d56e..a42ad82f 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -451,16 +451,6 @@ export class AuthService { }); } - /** - * Generates a completely random string composed of 128 characters (in base64) - * @private - */ - static generateToken(): string { - const tokenLength = 128; - const token = crypto.randomBytes(tokenLength).toString('base64'); - return token.slice(0, tokenLength); - } - async signApiKey(apiKeyId: string, tokenExpiresIn: number, renewToken = true): Promise { const apiKey = renewToken ? await this.prisma.apiKey.update({ @@ -471,4 +461,18 @@ export class AuthService { if (!apiKey) return null; return this.signAuthenticationToken(apiKey.token, tokenExpiresIn); } + + async doesApiKeyExist(apiKey: string): Promise { + return (await this.prisma.apiKey.count({ where: { id: apiKey } })) > 0; + } + + /** + * Generates a completely random string composed of 128 characters (in base64) + * @private + */ + static generateToken(): string { + const tokenLength = 128; + const token = crypto.randomBytes(tokenLength).toString('base64'); + return token.slice(0, tokenLength); + } } diff --git a/src/auth/guard/jwt.guard.ts b/src/auth/guard/jwt.guard.ts index 69e4f191..43eb79e0 100644 --- a/src/auth/guard/jwt.guard.ts +++ b/src/auth/guard/jwt.guard.ts @@ -45,7 +45,7 @@ export class JwtGuard extends AuthGuard('jwt') { throw new AppException(ERROR_CODE.INCONSISTENT_APPLICATION); } if (!loggedIn) { - request.user = { application, permissions: new PermissionManager({}) } satisfies RequestAuthData; + request.user = { application, permissions: new PermissionManager() } satisfies RequestAuthData; } // We can serve the request return true; diff --git a/src/auth/interfaces/permissions.interface.ts b/src/auth/interfaces/permissions.interface.ts index bb4a1f2e..789eb651 100644 --- a/src/auth/interfaces/permissions.interface.ts +++ b/src/auth/interfaces/permissions.interface.ts @@ -1,11 +1,8 @@ import { Permission } from '@prisma/client'; -export const ALL_PERMISSIONS = '*'; -export type ALL_PERMISSIONS = typeof ALL_PERMISSIONS; - export type ApiPermission = Permission & `API_${string}`; export type UserPermission = Permission & `USER_${string}`; -export type PermissionsDescriptor = { - [k in Permission]?: ALL_PERMISSIONS | string[]; -}; +export function isApiPermission(permission: Permission): permission is ApiPermission { + return permission.startsWith('API_'); +} diff --git a/src/auth/permissions/dto/res/permissions.dto.ts b/src/auth/permissions/dto/res/permissions.dto.ts new file mode 100644 index 00000000..7280be7b --- /dev/null +++ b/src/auth/permissions/dto/res/permissions.dto.ts @@ -0,0 +1,12 @@ +import { UserPermission } from '../../../interfaces/permissions.interface'; +import { Permission } from '@prisma/client'; + +export default class PermissionsResDto { + hardPermissions: Permission[]; + softPermissions: PermissionsResDto_SoftPermissions[]; +} + +class PermissionsResDto_SoftPermissions { + permission: UserPermission; + users: string[]; +} diff --git a/src/auth/permissions/permissions.controller.ts b/src/auth/permissions/permissions.controller.ts new file mode 100644 index 00000000..370df18c --- /dev/null +++ b/src/auth/permissions/permissions.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import PermissionsService from './permissions.service'; +import { GetPermissions } from '../decorator/get-permissions.decorator'; +import { PermissionManager } from '../../utils'; +import PermissionsResDto from './dto/res/permissions.dto'; +import { AuthService } from '../auth.service'; +import { AppException, ERROR_CODE } from '../../exceptions'; + +@Controller('auth/permissions') +@ApiTags('Permissions') +export default class PermissionsController { + constructor(private permissionsService: PermissionsService, private authService: AuthService) {} + + @Get('/current') + @ApiOperation({ description: 'Returns the permission of an application.' }) + @ApiOkResponse({ type: PermissionsResDto }) + getOwnPermissions(@GetPermissions() permissions: PermissionManager): PermissionsResDto { + return this.formatPermissions(permissions); + } + + @Get('/:apiKey') + @ApiOperation({ description: 'Returns the permission of an application.' }) + @ApiOkResponse({ type: () => PermissionsResDto }) + async getPermissions(@Param('apiKey') apiKey: string): Promise { + if (!(await this.authService.doesApiKeyExist(apiKey))) throw new AppException(ERROR_CODE.NO_SUCH_API_KEY, apiKey); + const permissions = await this.permissionsService.getPermissionsFromApiKeyId(apiKey); + return this.formatPermissions(permissions); + } + + private formatPermissions(permissions: PermissionManager): PermissionsResDto { + return { + hardPermissions: permissions.hardPermissions.sort(), + softPermissions: Object.entries(permissions.softPermissions).map(([permission, users]) => ({ + permission, + users, + })).mappedSort((permission) => permission.permission), + }; + } +} diff --git a/src/auth/permissions/permissions.service.ts b/src/auth/permissions/permissions.service.ts new file mode 100644 index 00000000..871f45d0 --- /dev/null +++ b/src/auth/permissions/permissions.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { UserPermission } from '../interfaces/permissions.interface'; +import { PermissionManager } from '../../utils'; + +@Injectable() +export default class PermissionsService { + constructor(private readonly prisma: PrismaService) {} + + async getPermissionsFromApiKeyId(apiKeyId: string): Promise { + const rawPermissions = await this.prisma.apiKeyPermission.findMany({ where: { apiKey: { id: apiKeyId } } }); + const permissions = new PermissionManager(); + for (const permission of rawPermissions) { + // If it's an API permission, permission.userId will be undefined, so it does not really matter that typing isn't exact here. + permissions.with(permission.permission as UserPermission, permission.userId); + } + return permissions; + } +} diff --git a/src/auth/strategy/jwt.strategy.ts b/src/auth/strategy/jwt.strategy.ts index 7a47ec2d..576e52f6 100644 --- a/src/auth/strategy/jwt.strategy.ts +++ b/src/auth/strategy/jwt.strategy.ts @@ -5,7 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { ConfigModule } from '../../config/config.module'; import { RequestAuthData } from '../interfaces/request-auth-data.interface'; import { PermissionManager } from '../../utils'; -import { ALL_PERMISSIONS, PermissionsDescriptor } from '../interfaces/permissions.interface'; +import { UserPermission } from '../interfaces/permissions.interface'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -35,24 +35,15 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { id: apiKey.userId, }, }); - const permissions: PermissionsDescriptor = {}; + const permissions = new PermissionManager(); for (const permission of apiKey.apiKeyPermissions) { - if (permissions[permission.permission] === ALL_PERMISSIONS) { - continue; - } - if (!permission.userId) { - permissions[permission.permission] = ALL_PERMISSIONS; - } else { - if (!permissions[permission.permission]) { - permissions[permission.permission] = []; - } - (permissions[permission.permission] as string[]).push(permission.userId); - } + // If it's an API permission, permission.userId will be undefined, so it does not really matter that typing isn't exact here. + permissions.with(permission.permission as UserPermission, permission.userId); } return { application: apiKey.application, user, - permissions: new PermissionManager(permissions), + permissions, }; } } diff --git a/src/exceptions.ts b/src/exceptions.ts index e5dd03a8..3170a2f3 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -38,6 +38,8 @@ export const enum ERROR_CODE { NO_FILE_PROVIDED = 2020, PARAM_NOT_URL = 2021, BODY_MISSING = 2022, + PARAM_PAST_DATE = 2023, + PARAM_MISSING_EITHER = 2024, PARAM_DOES_NOT_MATCH_REGEX = 2102, NO_FIELD_PROVIDED = 2201, WIDGET_OVERLAPPING = 2301, @@ -53,6 +55,8 @@ export const enum ERROR_CODE { FORBIDDEN_ALREADY_COMMENTED = 3101, FORBIDDEN_ALREADY_UPVOTED = 3102, FORBIDDEN_NOT_UPVOTED = 3103, + FORBIDDEN_ASSOS_PERMISSIONS = 3201, + FORBIDDEN_ASSOS_ROLE_PERMANENT = 3202, NOT_COMMENT_AUTHOR = 4221, NOT_ALREADY_DONE_UE = 4222, NOT_REPLY_AUTHOR = 4223, @@ -63,6 +67,7 @@ export const enum ERROR_CODE { NOT_ANNAL_SENDER = 4228, NOT_ALREADY_DONE_UEOF = 4229, APPLICATION_NOT_OWNED = 4230, + USER_ALREADY_ASSO_ROLE_MEMBER = 4231, NO_SUCH_UE = 4401, NO_SUCH_COMMENT = 4402, NO_SUCH_REPLY = 4403, @@ -75,12 +80,16 @@ export const enum ERROR_CODE { NO_SUCH_ASSO = 4410, NO_SUCH_UEOF = 4411, NO_SUCH_APPLICATION = 4412, - NO_SUCH_UE_AT_SEMESTER = 4413, - NO_SUCH_REPORT = 4414, - NO_SUCH_REPORT_REASON = 4415, + NO_SUCH_API_KEY = 4413, + NO_SUCH_UE_AT_SEMESTER = 4414, + NO_SUCH_ASSO_ROLE = 4415, + NO_SUCH_ASSO_MEMBERSHIP = 4416, + NO_SUCH_REPORT = 4417, + NO_SUCH_REPORT_REASON = 4418, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, + ASSO_ROLE_ALREADY_MOVED = 4904, CREDENTIALS_ALREADY_TAKEN = 5001, HIDDEN_DUCK = 9999, } @@ -191,6 +200,14 @@ export const ErrorData = Object.freeze({ message: 'This method requires a body', httpCode: HttpStatus.BAD_REQUEST, }, + [ERROR_CODE.PARAM_PAST_DATE]: { + message: 'The date provided must be in the future: %', + httpCode: HttpStatus.BAD_REQUEST, + }, + [ERROR_CODE.PARAM_MISSING_EITHER]: { + message: 'One of these parameters must be provided: %', + httpCode: HttpStatus.BAD_REQUEST, + }, [ERROR_CODE.PARAM_DOES_NOT_MATCH_REGEX]: { message: 'The following parameters must match the regex "%": %', httpCode: HttpStatus.BAD_REQUEST, @@ -251,6 +268,14 @@ export const ErrorData = Object.freeze({ message: 'You must upvote this comment before un-upvoting it', httpCode: HttpStatus.FORBIDDEN, }, + [ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS]: { + message: 'Missing permission on asso %: %', + httpCode: HttpStatus.FORBIDDEN, + }, + [ERROR_CODE.FORBIDDEN_ASSOS_ROLE_PERMANENT]: { + message: 'The following role is not deletable: %', + httpCode: HttpStatus.FORBIDDEN, + }, [ERROR_CODE.NOT_COMMENT_AUTHOR]: { message: 'You are not the author of this comment', httpCode: HttpStatus.FORBIDDEN, @@ -291,6 +316,10 @@ export const ErrorData = Object.freeze({ message: 'Application % is not owned by you', httpCode: HttpStatus.UNAUTHORIZED, }, + [ERROR_CODE.USER_ALREADY_ASSO_ROLE_MEMBER]: { + message: 'User is already member of this role: %', + httpCode: HttpStatus.CONFLICT, + }, [ERROR_CODE.NO_SUCH_UE]: { message: 'The UE % does not exist', httpCode: HttpStatus.NOT_FOUND, @@ -339,10 +368,22 @@ export const ErrorData = Object.freeze({ message: 'The application % does not exist', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_API_KEY]: { + message: 'The api key % does not exist', + httpCode: HttpStatus.NOT_FOUND, + }, [ERROR_CODE.NO_SUCH_UE_AT_SEMESTER]: { message: 'UE % does not exist for semester %', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_ASSO_ROLE]: { + message: 'No such role in asso %', + httpCode: HttpStatus.NOT_FOUND, + }, + [ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP]: { + message: 'No such membership in asso: %', + httpCode: HttpStatus.NOT_FOUND, + }, [ERROR_CODE.NO_SUCH_REPORT]: { message: 'The report does not exist', httpCode: HttpStatus.NOT_FOUND, @@ -367,6 +408,10 @@ export const ErrorData = Object.freeze({ message: 'The given credentials are already taken', httpCode: HttpStatus.CONFLICT, }, + [ERROR_CODE.ASSO_ROLE_ALREADY_MOVED]: { + message: 'You should not try to update role position simultaneously', + httpCode: HttpStatus.CONFLICT, + }, [ERROR_CODE.HIDDEN_DUCK]: { message: 'Hey, you found the hidden duck ! Error : %', httpCode: HttpStatus.I_AM_A_TEAPOT, diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index fa7cce9c..1f75e86f 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -10,6 +10,8 @@ import { generateCustomUeModel } from '../ue/interfaces/ue.interface'; import { generateCustomUeAnnalModel } from '../ue/annals/interfaces/annal.interface'; import { generateCustomUeCommentReplyModel } from '../ue/comments/interfaces/comment-reply.interface'; import { generateCustomAssoModel } from '../assos/interfaces/asso.interface'; +import { generateCustomAssoMembershipModel } from '../assos/interfaces/membership.interface'; +import { generateCustomAssoMembershipRoleModel } from '../assos/interfaces/membership-role.interface'; import { generateCustomCreditCategoryModel } from '../ue/credit/interfaces/credit-category.interface'; import { generateCustomApplicationModel } from '../auth/application/interfaces/application.interface'; @@ -48,6 +50,8 @@ function createNormalizedEntitiesUtility(prisma: PrismaClient) { ue: generateCustomUeModel(prisma), ueAnnal: generateCustomUeAnnalModel(prisma), asso: generateCustomAssoModel(prisma), + assoMembership: generateCustomAssoMembershipModel(prisma), + assoMembershipRole: generateCustomAssoMembershipRoleModel(prisma), ueCreditCategory: generateCustomCreditCategoryModel(prisma), apiApplication: generateCustomApplicationModel(prisma), }; diff --git a/src/prisma/types.ts b/src/prisma/types.ts index a22b6d1a..e6527486 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -34,6 +34,7 @@ export { Asso as RawAsso, AssoMembershipRole as RawAssoMembershipRole, AssoMembership as RawAssoMembership, + AssoMembershipPermission as RawAssoMembershipPermission, UserHomepageWidget as RawHomepageWidget, UserPrivacy as RawUserPrivacy, ApiApplication as RawApiApplication, diff --git a/src/profile/profile.service.ts b/src/profile/profile.service.ts index 07066cec..da6c3203 100644 --- a/src/profile/profile.service.ts +++ b/src/profile/profile.service.ts @@ -12,6 +12,9 @@ export class ProfileService { where: { userId, }, + orderBy: { + x: 'asc', + }, }); } @@ -20,7 +23,13 @@ export class ProfileService { await this.prisma.user.update({ where: { id: userId }, data: { homepageWidgets: { deleteMany: {}, createMany: { data: widgets } } }, - select: { homepageWidgets: true }, + select: { + homepageWidgets: { + orderBy: { + x: 'asc', + }, + }, + }, }) ).homepageWidgets; } diff --git a/src/std.type.ts b/src/std.type.ts index 8ad8a819..6c5209cd 100644 --- a/src/std.type.ts +++ b/src/std.type.ts @@ -41,6 +41,7 @@ declare global { interface ObjectConstructor { keys(o: O): (keyof O)[]; + entries(o: O): Array<[keyof O, O[keyof O]]>; } } diff --git a/src/ue/annals/annals.controller.ts b/src/ue/annals/annals.controller.ts index 39e36e21..e0c1776c 100644 --- a/src/ue/annals/annals.controller.ts +++ b/src/ue/annals/annals.controller.ts @@ -17,7 +17,7 @@ import UeAnnalResDto from './dto/res/ue-annal-res.dto'; import UeAnnalMetadataResDto from './dto/res/ue-annal-metadata-res.dto'; import { GetPermissions } from '../../auth/decorator/get-permissions.decorator'; import { Permission } from '@prisma/client'; -import { PermissionManager } from '../../utils'; +import { omit, PermissionManager } from '../../utils'; import { AnnalStatus } from './interfaces/annal.interface'; @Controller('ue/annals') @@ -75,7 +75,7 @@ export class AnnalsController { throw new AppException(ERROR_CODE.NOT_DONE_UE_IN_SEMESTER, ueCode, semester); if (!(await this.ueService.didUeHappenAtSemester(ueCode, semester))) throw new AppException(ERROR_CODE.NO_SUCH_UE_AT_SEMESTER, ueCode, semester); - return this.annalsService.createAnnalFile(user, { ueCode, semester, typeId, ueof }); + return omit(await this.annalsService.createAnnalFile(user, { ueCode, semester, typeId, ueof }), 'ueof'); } @Get('metadata') @@ -134,7 +134,7 @@ export class AnnalsController { .status !== AnnalStatus.PROCESSING ) throw new AppException(ERROR_CODE.ANNAL_ALREADY_UPLOADED); - return this.annalsService.uploadAnnalFile(await file, annalId, rotate); + return omit(await this.annalsService.uploadAnnalFile(await file, annalId, rotate), 'ueof'); } @Get(':annalId') @@ -193,7 +193,7 @@ export class AnnalsController { !permissions.can(Permission.API_MODERATE_ANNALS) ) throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); - return this.annalsService.updateAnnalMetadata(annalId, body); + return omit(await this.annalsService.updateAnnalMetadata(annalId, body), 'ueof'); } @Delete(':annalId') @@ -219,6 +219,6 @@ export class AnnalsController { !permissions.can(Permission.API_MODERATE_ANNALS) ) throw new AppException(ERROR_CODE.NOT_ANNAL_SENDER); - return this.annalsService.deleteAnnal(annalId); + return omit(await this.annalsService.deleteAnnal(annalId), 'ueof'); } } diff --git a/src/ue/annals/dto/req/create-annal-req.dto.ts b/src/ue/annals/dto/req/create-annal-req.dto.ts index 55f58b39..bb145b1c 100644 --- a/src/ue/annals/dto/req/create-annal-req.dto.ts +++ b/src/ue/annals/dto/req/create-annal-req.dto.ts @@ -7,10 +7,11 @@ import { Length, MaxLength, MinLength, - ValidateIf, } from 'class-validator'; +import { HasSomeAmong } from '../../../../validation'; export class CreateAnnalReqDto { + @HasSomeAmong('ueCode', 'ueof') @IsString() @IsNotEmpty() @Length(3) @@ -21,13 +22,13 @@ export class CreateAnnalReqDto { @IsUUID() typeId: string; + @IsOptional() @IsString() - @ValidateIf((obj: CreateAnnalReqDto) => !!obj.ueCode || !!obj.ueof) @IsNotEmpty() @IsAlphanumeric() @MinLength(3) @MaxLength(5) - ueCode: string; + ueCode?: string; @IsOptional() @IsString() diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index dc65647c..1d361a02 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -549,9 +549,9 @@ export class CommentsService { }, }); return { - ...omit(report, 'reason', 'reasonId', 'userId', 'user'), + ...omit(report, 'reason', 'reasonId', 'userId', 'user', 'commentId'), reason: report.reason.name, - user: pick(report.user, 'firstName', 'id', 'lastName', 'studentId'), + user: pick(report.user, 'firstName', 'id', 'lastName'), }; } @@ -589,8 +589,8 @@ export class CommentsService { }, }); return { - ...omit(report, 'user'), - user: pick(report.user, 'firstName', 'id', 'lastName', 'studentId'), + ...omit(report, 'user', 'replyId', 'reasonId', 'userId'), + user: pick(report.user, 'firstName', 'id', 'lastName'), reason: report.reason.name, }; } diff --git a/src/ue/comments/dto/res/ue-comment-res.dto.ts b/src/ue/comments/dto/res/ue-comment-res.dto.ts index fc853243..11acf42f 100644 --- a/src/ue/comments/dto/res/ue-comment-res.dto.ts +++ b/src/ue/comments/dto/res/ue-comment-res.dto.ts @@ -3,7 +3,7 @@ import UeCommentReportResDto from './ue-comment-report-res.dto'; export default class UeCommentResDto { id: string; - author: UeCommentAuthorResDto; + author?: UeCommentAuthorResDto; createdAt: Date; updatedAt: Date; semester: string; diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index aeeba8e1..eeb0ffd1 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -104,13 +104,17 @@ export type UeCommentReport = Omit; -export type UeComment = Omit & { +export type UeComment = Omit< + UnformattedUeComment, + 'upvotes' | 'deletedAt' | 'answers' | 'semester' | 'reports' | 'author' +> & { upvotes: number; upvoted: boolean; status: CommentStatus; answers: UeCommentReply[]; semester: string; reports: UeCommentReport[]; + author?: UnformattedUeComment['author']; }; export function generateCustomCommentModel(prisma: PrismaClient) { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 42b6686b..28062760 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -99,7 +99,7 @@ export default class UsersController { firstName: user.firstName, lastName: user.lastName, nickname: user.infos.nickname, - age: new Date(Date.now() - user.infos.birthday.getTime()).getUTCFullYear() - 1970, + age: new Date(Date.now() - user.infos.birthday.getTime()).getFullYear() - 1970, })); } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index f8ab8614..8c039e40 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -97,8 +97,8 @@ export default class UsersService { const userIds = (await this.prisma.$queryRaw` SELECT id FROM UserInfos - WHERE EXTRACT(DAY FROM birthday) = ${date.getUTCDate()} - AND EXTRACT(MONTH FROM birthday) = ${date.getUTCMonth() + 1}`) as Array<{ id: string }>; + WHERE EXTRACT(DAY FROM birthday) = ${date.getDate()} + AND EXTRACT(MONTH FROM birthday) = ${date.getMonth() + 1}`) as Array<{ id: string }>; return this.prisma.normalize.user.findMany({ where: { infosId: { in: userIds.map((u) => u.id) } } }); } diff --git a/src/utils.ts b/src/utils.ts index d2ee4b66..8e468be3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,6 @@ import { Language, Permission } from '@prisma/client'; import { Translation } from './prisma/types'; -import { - ALL_PERMISSIONS, - ApiPermission, - UserPermission, - PermissionsDescriptor, -} from './auth/interfaces/permissions.interface'; +import { ApiPermission, UserPermission } from './auth/interfaces/permissions.interface'; /** * Returns a new object built from the given object with only the specified keys. @@ -63,18 +58,41 @@ export const translationSelect = { }; export class PermissionManager { - private readonly permissions: PermissionsDescriptor; + public readonly hardPermissions: Permission[]; + public readonly softPermissions: { + [k in UserPermission]?: string[]; + }; - constructor(permissions: PermissionsDescriptor) { - this.permissions = permissions; + constructor() { + this.hardPermissions = []; + this.softPermissions = {}; } can(permission: ApiPermission): boolean; + can(permission: UserPermission): boolean; can(permission: UserPermission, userId: string): boolean; can(permission: Permission, userId?: string) { - return ( - this.permissions[permission] && - (this.permissions[permission] === ALL_PERMISSIONS || this.permissions[permission].includes(userId)) - ); + return this.hardPermissions.includes(permission) || (userId && this.softPermissions[permission]?.includes(userId)); + } + + with(permission: ApiPermission): PermissionManager; + with(permission: UserPermission): PermissionManager; + with(permission: UserPermission, userId: string): PermissionManager; + with(permission: Permission, userId?: string): PermissionManager { + if (!userId) { + if (!this.hardPermissions.includes(permission)) { + this.hardPermissions.push(permission); + } + if (this.softPermissions[permission]) { + delete this.softPermissions[permission]; + } + } else if (!this.hardPermissions.includes(permission)) { + if (!this.softPermissions[permission]) { + this.softPermissions[permission] = [userId]; + } else { + this.softPermissions[permission].push(userId); + } + } + return this; } } diff --git a/src/validation.ts b/src/validation.ts index 8d43fe25..abb1296c 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,3 +1,4 @@ +import { Validate, ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; import { AppException, ERROR_CODE } from './exceptions'; import { ValidationError } from '@nestjs/common/interfaces/external/validation-error.interface'; @@ -12,6 +13,7 @@ import { ValidationError } from '@nestjs/common/interfaces/external/validation-e const mappedErrors = { whitelistValidation: ERROR_CODE.PARAM_DOES_NOT_EXIST, isNotEmpty: ERROR_CODE.PARAM_MISSING, + hasEither: ERROR_CODE.PARAM_MISSING_EITHER, isString: ERROR_CODE.PARAM_NOT_STRING, isAlphanumeric: ERROR_CODE.PARAM_NOT_ALPHANUMERIC, isNumber: ERROR_CODE.PARAM_NOT_NUMBER, @@ -29,6 +31,7 @@ const mappedErrors = { min: ERROR_CODE.PARAM_TOO_LOW, max: ERROR_CODE.PARAM_TOO_HIGH, isUrl: ERROR_CODE.PARAM_NOT_URL, + isFutureDate: ERROR_CODE.PARAM_PAST_DATE, } satisfies { [constraint: string]: ERROR_CODE; }; @@ -59,3 +62,21 @@ export const validationExceptionFactory = (errors: ValidationError[]) => { .join(', '), ); }; + +@ValidatorConstraint({ name: 'isFutureDate', async: false }) +class FutureDate implements ValidatorConstraintInterface { + validate(text: string) { + return new Date(text).getTime() >= Date.now(); + } +} +@ValidatorConstraint({ name: 'hasEither', async: false }) +class HasEither implements ValidatorConstraintInterface { + validate(_: string, args: ValidationArguments) { + args.targetName = args.constraints.join(', '); + return args.constraints.some((prop) => args.object[prop]); + } +} +/** Equivalent to @MinDate(() => Date.now()) with an error message */ +export const IsFutureDate = () => Validate(FutureDate); +/** Checks whether at least one of the given properties is provided. Use this decorator on any property EXCEPT those contained in the constraint list. */ +export const HasSomeAmong = (...args: string[]) => Validate(HasEither, args); diff --git a/test/declarations.d.ts b/test/declarations.d.ts index e811c385..d62256b9 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -2,23 +2,39 @@ import { ERROR_CODE, ErrorData, ExtrasTypeBuilder } from '../src/exceptions'; import { UeComment, UeCommentReport } from 'src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from 'src/ue/comments/interfaces/comment-reply.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; -import { FakeApiApplication, FakeUeAnnalType, FakeUeof } from './utils/fakedb'; +import { + FakeApiApplication, + FakeAssoMembership, + FakeAssoMembershipPermission, + FakeAssoMembershipRole, + FakeUeAnnalType, + FakeUeof, +} from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { Criterion } from 'src/ue/interfaces/criterion.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; import { FakeUe, FakeUser, FakeHomepageWidget, FakeAsso } from './utils/fakedb'; import { AppProvider } from './utils/test_utils'; import { Language } from '@prisma/client'; +import { PermissionManager } from '../src/utils'; type JsonLikeVariant = Partial<{ [K in keyof T]: T[K] extends string | Date - ? string | RegExp + ? symbol | RegExp | T[K] : T[K] extends (infer R)[] ? JsonLikeVariant[] : JsonLikeVariant; }>; type FakeUeWithOfs = FakeUe & { ueofs: FakeUeof[] }; +type FakeAssoMembers = { + role: FakeRole; + users: { + user: FakeUser; + permissions: FakeAssoMembershipPermission[]; + }[]; +}[]; + /** * Overwrites the declarations in pactum/src/models/Spec * This is possible because the Spec class is re-exported in ./declarations.ts @@ -81,13 +97,24 @@ declare module './declarations' { expectAssos(app: AppProvider, assos: FakeAsso[], count: number): this; /** expects to return the given {@link asso} */ expectAsso(asso: FakeAsso): this; + expectAssoMembershipRole(role: FakeAssoMembershipRole): this; + expectAssoMembershipRoleCreated(role: JsonLikeVariant): this; + expectAssoMembershipRoles(roles: JsonLikeVariant): this; + expectAssoMembershipRolesRaw(roles: JsonLikeVariant[]): this; + expectAssoMembership(membership: JsonLikeVariant): this; + expectAssoMembershipCreated(membership: JsonLikeVariant): this; expectCreditCategories(categories: JsonLikeVariant): this; expectApplications(applications: FakeApiApplication[]): this; expectApplication(application: FakeApiApplication): this; + expectPermissions(permissions: PermissionManager): this; + withLanguage(language: Language): this; language: Language; withApplication(application: string): this; application: string; + + /** Does NOT check HTTP status */ + $expectRegexableJson(obj: JsonLikeVariant): this; } } diff --git a/test/declarations.ts b/test/declarations.ts index 66c5f2b3..47163d8e 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -1,6 +1,6 @@ import { HttpStatus } from '@nestjs/common'; import Spec from 'pactum/src/models/Spec'; -import { FakeUeWithOfs, JsonLikeVariant } from './declarations.d'; +import { FakeAssoMembers, FakeUeWithOfs, JsonLikeVariant } from './declarations.d'; import { ERROR_CODE, ErrorData, ExtrasTypeBuilder } from '../src/exceptions'; import { UeComment, UeCommentReport } from '../src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from '../src/ue/comments/interfaces/comment-reply.interface'; @@ -13,32 +13,25 @@ import { FakeAsso, FakeUeCreditCategory, FakeApiApplication, + FakeAssoMembershipRole, + FakeAssoMembership, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; -import { AppProvider } from './utils/test_utils'; -import { getTranslation, omit, pick } from '../src/utils'; -import { isArray } from 'class-validator'; +import { AppProvider, JsonLike } from './utils/test_utils'; +import { getTranslation, omit, PermissionManager, pick } from '../src/utils'; +import { regex, string, uuid } from 'pactum-matchers'; import { Language } from '@prisma/client'; import { DEFAULT_APPLICATION } from '../prisma/seed/utils'; import ApplicationResDto from '../src/auth/application/dto/res/application-res.dto'; +import PermissionsResDto from '../src/auth/permissions/dto/res/permissions.dto'; -/** Shortcut function for `this.expectStatus(200).expectJsonLike` */ function expect(this: Spec, obj: JsonLikeVariant) { - return this.expectStatus(HttpStatus.OK).expectJsonMatchStrict(obj); -} -/** Shortcut function for `this.expectStatus(200|204).expectJsonLike` */ -function expectOkOrCreate(this: Spec, obj: JsonLikeVariant, created = false) { - return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).expectJsonLike(obj); + return this.expectStatus(HttpStatus.OK).$expectRegexableJson(obj); } -export function deepDateToString(obj: T): JsonLikeVariant { - if (obj instanceof Date) return obj.toISOString() as JsonLikeVariant; - if (isArray(obj)) return obj.map(deepDateToString) as JsonLikeVariant; - if (obj === null || typeof obj !== 'object') return obj as JsonLikeVariant; - return Object.fromEntries( - Object.entries(obj).map(([key, value]) => [key, deepDateToString(value)]), - ) as JsonLikeVariant; +function expectOkOrCreate(this: Spec, obj: JsonLikeVariant, created = false) { + return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson(obj); } function ueOverviewExpectation(ue: FakeUeWithOfs, spec: Spec) { @@ -59,8 +52,8 @@ function ueOverviewExpectation(ue: FakeUeWithOfs, spec: Spec) { }, openSemester: ue.ueofs[0].openSemester.map((semester) => ({ ...semester, - start: semester.start.toISOString(), - end: semester.end.toISOString(), + start: semester.start, + end: semester.end, })), }; } @@ -97,75 +90,73 @@ Spec.prototype.expectUe = function ( rates: Array<{ criterionId: string; value: number }>, rateCount: number, ) { - return (this).expectStatus(HttpStatus.OK).expectJsonMatchStrict( - deepDateToString({ - code: ue.code, - creationYear: 2000 + Number(ue.ueofs[0].code.match(/\d+$/)?.[0] ?? 23), - updateYear: 2000 + Number(ue.ueofs[0].code.match(/\d+$/)?.[0] ?? 23), - ueofs: ue.ueofs.map((ueof) => ({ - name: getTranslation(ueof.name, this.language), - code: ueof.code, - credits: ueof.credits.map((credit) => ({ - ...omit(credit, 'id', 'ueofCode', 'categoryId', 'branchOptions'), - branchOptions: credit.branchOptions.map((branchOption) => ({ - ...pick(branchOption, 'code', 'name'), - branch: pick(branchOption.branch, 'code', 'name'), - })), + return (this).expectStatus(HttpStatus.OK).$expectRegexableJson({ + code: ue.code, + creationYear: 2000 + Number(ue.ueofs[0].code.match(/\d+$/)?.[0] ?? 23), + updateYear: 2000 + Number(ue.ueofs[0].code.match(/\d+$/)?.[0] ?? 23), + ueofs: ue.ueofs.map((ueof) => ({ + name: getTranslation(ueof.name, this.language), + code: ueof.code, + credits: ueof.credits.map((credit) => ({ + ...omit(credit, 'id', 'ueofCode', 'categoryId', 'branchOptions'), + branchOptions: credit.branchOptions.map((branchOption) => ({ + ...pick(branchOption, 'code', 'name'), + branch: pick(branchOption.branch, 'code', 'name'), })), - info: { - ...omit(ueof.info, 'id'), - objectives: getTranslation(ueof.info.objectives, this.language), - program: getTranslation(ueof.info.program, this.language), - minors: ueof.info.minors?.split(',') ?? [], - }, - openSemester: ueof.openSemester - .mappedSort((semester) => semester.start.toISOString()) - .map((semester) => ({ - ...semester, - start: semester.start.toISOString(), - end: semester.end.toISOString(), - })), - workTime: omit(ueof.workTime, 'id', 'ueofCode'), - ...(rates - ? { - starVotes: Object.fromEntries([ - ...rates.map((rate) => [rate.criterionId, rate.value]), - ['voteCount', rateCount || 0], - ]), - } - : {}), })), - }), - ); + info: { + ...omit(ueof.info, 'id'), + objectives: getTranslation(ueof.info.objectives, this.language), + program: getTranslation(ueof.info.program, this.language), + minors: ueof.info.minors?.split(',') ?? [], + }, + openSemester: ueof.openSemester + .mappedSort((semester) => semester.start) + .map((semester) => ({ + ...semester, + start: semester.start, + end: semester.end, + })), + workTime: omit(ueof.workTime, 'id', 'ueofCode'), + ...(rates + ? { + starVotes: Object.fromEntries([ + ...rates.map((rate) => [rate.criterionId, rate.value]), + ['voteCount', rateCount || 0], + ]), + } + : {}), + })), + }); }; Spec.prototype.expectUsers = function (app: AppProvider, users: FakeUser[], count: number) { - return (this).expectStatus(HttpStatus.OK).expectJsonMatchStrict( - deepDateToString({ - items: users.map((user) => ({ - ...pick(user, 'id', 'firstName', 'lastName', 'login', 'studentId', 'userType'), - infos: pick(user.infos, 'nickname', 'avatar', 'nationality', 'passions', 'website'), - branchSubscriptions: user.branchSubscriptions.map((branch) => pick(branch, 'id')), - mailsPhones: pick(user.mailsPhones, 'mailUTT'), - socialNetwork: omit(user.socialNetwork, 'id', 'discord'), - addresses: [], - })), - itemCount: count, - itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, - }), - ); + return (this).expectStatus(HttpStatus.OK).$expectRegexableJson({ + items: users.map((user) => ({ + ...pick(user, 'id', 'firstName', 'lastName', 'login', 'studentId', 'userType'), + infos: pick(user.infos, 'nickname', 'avatar', 'nationality', 'passions', 'website'), + branchSubscriptions: user.branchSubscriptions.map((branch) => pick(branch, 'id')), + mailsPhones: pick(user.mailsPhones, 'mailUTT'), + socialNetwork: omit(user.socialNetwork, 'id', 'discord'), + addresses: [], + })), + itemCount: count, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }); }; Spec.prototype.expectUes = function (ues: FakeUeWithOfs[]) { - return (this).expectStatus(HttpStatus.OK).expectJsonLike(ues.map((ue) => ueOverviewExpectation(ue, this))); + return (this) + .expectStatus(HttpStatus.OK) + .$expectRegexableJson(ues.map((ue) => ueOverviewExpectation(ue, this))); }; Spec.prototype.expectUesWithPagination = function (app: AppProvider, ues: FakeUeWithOfs[], count: number) { - return (this).expectStatus(HttpStatus.OK).expectJsonLike({ + return (this).expectStatus(HttpStatus.OK).$expectRegexableJson({ items: ues.map((ue) => ueOverviewExpectation(ue, this)), itemCount: count, itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, }); }; -Spec.prototype.expectUeComment = function expect(this: Spec, obj, created = false) { - return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).expectJsonLike({ +Spec.prototype.expectUeComment = function (this: Spec, obj, created = false) { + return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({ ...omit(obj as any, 'ueof'), ueof: { code: obj.ueof.code, @@ -175,28 +166,18 @@ Spec.prototype.expectUeComment = function expect(this: Spec, obj, created = fals }, } satisfies JsonLikeVariant); }; -Spec.prototype.expectUeComments = function expect(obj) { - return (this).expectStatus(HttpStatus.OK).expectJsonMatch({ +Spec.prototype.expectUeComments = function (this: Spec, obj) { + return this.expectStatus(HttpStatus.OK).$expectRegexableJson({ itemCount: obj.itemCount, itemsPerPage: obj.itemsPerPage, items: obj.items.map((comment) => ({ - ...pick(comment, 'id', 'author', 'body', 'isAnonymous', 'semester', 'status', 'upvoted', 'upvotes'), + ...omit(comment, 'ueof','ue'), ueof: { code: comment.ueof.code, info: { language: comment.ueof.info.language, }, }, - createdAt: comment.createdAt.toISOString(), - updatedAt: comment.updatedAt.toISOString(), - reports: comment.reports.map((c) => { - return { ...c, createdAt: c.createdAt.toISOString() }; - }), - answers: comment.answers.map((answer) => ({ - ...pick(answer, 'author', 'body', 'id', 'status'), - createdAt: answer.createdAt.toISOString(), - updatedAt: answer.updatedAt.toISOString(), - })), })), } satisfies JsonLikeVariant>); }; @@ -211,8 +192,8 @@ Spec.prototype.expectUeAnnalMetadata = expect<{ }>; Spec.prototype.expectUeAnnal = expectOkOrCreate; Spec.prototype.expectUeAnnals = expect; -Spec.prototype.expectHomepageWidgets = function (widgets: Omit[]) { - return (this).expectStatus(HttpStatus.OK).expectJsonLike( +Spec.prototype.expectHomepageWidgets = function (this: Spec, widgets: Omit[]) { + return this.expectStatus(HttpStatus.OK).$expectRegexableJson( widgets.map((widget) => ({ x: widget.x, y: widget.y, @@ -222,8 +203,8 @@ Spec.prototype.expectHomepageWidgets = function (widgets: Omitthis).expectStatus(HttpStatus.OK).expectJson({ +Spec.prototype.expectAssos = function (this: Spec, app: AppProvider, assos: FakeAsso[], count: number) { + return this.expectStatus(HttpStatus.OK).expectJson({ items: assos.map((asso) => ({ ...pick(asso, 'id', 'name', 'logo'), shortDescription: getTranslation(asso.descriptionShortTranslation, (this).language), @@ -246,6 +227,44 @@ Spec.prototype.expectAsso = function (asso: FakeAsso) { }, }); }; +Spec.prototype.expectAssoMembership = function (member: JsonLikeVariant) { + return (this) + .expectStatus(HttpStatus.OK) + .$expectRegexableJson(pick(member, 'id', 'roleId', 'userId', 'startAt', 'endAt')); +}; +Spec.prototype.expectAssoMembershipCreated = function (member: JsonLikeVariant) { + return (this) + .expectStatus(HttpStatus.CREATED) + .$expectRegexableJson(pick(member, 'id', 'roleId', 'userId', 'startAt', 'endAt')); +}; +Spec.prototype.expectAssoMembershipRole = function (role: FakeAssoMembershipRole) { + return (this).expectStatus(HttpStatus.OK).expectJson(pick(role, 'id', 'name', 'position', 'isPresident')); +}; +Spec.prototype.expectAssoMembershipRoleCreated = function (role: FakeAssoMembershipRole) { + return (this) + .expectStatus(HttpStatus.CREATED) + .$expectRegexableJson(pick(role, 'id', 'name', 'position', 'isPresident')); +}; +Spec.prototype.expectAssoMembershipRolesRaw = function (roles: FakeAssoMembershipRole[]) { + return (this).expectStatus(HttpStatus.OK).expectJson({ + roles: roles.map((role) => pick(role, 'id', 'name', 'position', 'isPresident')), + }); +}; +Spec.prototype.expectAssoMembershipRoles = function (members: JsonLikeVariant) { + return (this).expectStatus(HttpStatus.OK).$expectRegexableJson({ + roles: members.map((roleEntry) => ({ + ...pick(roleEntry.role, 'id', 'name', 'position', 'isPresident'), + members: roleEntry.users.map((userEntry) => ({ + ...pick(userEntry.user, 'firstName', 'lastName'), + id: JsonLike.UUID, + userId: userEntry.user.id, + startAt: JsonLike.DATE, + endAt: JsonLike.DATE, + permissions: userEntry.permissions.map((p) => p.id), + })), + })), + }); +}; Spec.prototype.expectCreditCategories = function (creditCategories: FakeUeCreditCategory[]) { return (this).expectStatus(HttpStatus.OK).expectJson(creditCategories); }; @@ -268,5 +287,70 @@ Spec.prototype.expectApplication = function (application: FakeApiApplication) { owner: pick(application.owner, 'id', 'firstName', 'lastName'), } satisfies ApplicationResDto); }; +Spec.prototype.expectPermissions = function (permissions: PermissionManager) { + return (this).expectStatus(HttpStatus.OK).expectJson({ + hardPermissions: permissions.hardPermissions.sort(), + softPermissions: Object.entries(permissions.softPermissions) + .map(([permission, users]) => ({ + permission, + users, + })) + .mappedSort((permission) => permission.permission), + } satisfies PermissionsResDto); +}; export { Spec, JsonLikeVariant, FakeUeWithOfs }; + +// Internal methods below + +Spec.prototype.$expectRegexableJson = function (this: Spec, obj: JsonLikeVariant) { + function wrap(obj: JsonLikeVariant) { + if (obj instanceof RegExp) return regex(obj.source); + if (obj instanceof Date) return obj.toISOString(); + if (typeof obj === 'symbol') { + switch (obj) { + case JsonLike.STRING: + return string(); + case JsonLike.UUID: + return uuid(); + } + } + if (Array.isArray(obj)) return obj.map(wrap); + if (obj === null || typeof obj !== 'object') return obj; + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, wrap(value as JsonLikeVariant)]), + ); + } + return this.expectJsonSchema(generateSchema(obj)).expectJsonMatch(wrap(obj)); +}; + +// Schema should match rules defined here : https://ajv.js.org/json-schema.html +function generateSchema(obj: JsonLikeVariant): object { + if (obj === null) return { type: 'null' }; + if (obj instanceof RegExp) return { type: 'string', pattern: obj.source }; + if (Array.isArray(obj)) + return { + type: 'array', + items: obj.map(generateSchema), + additionalItems: false, + }; + if (obj instanceof Date) return { type: 'string', format: 'date-time' }; + if (typeof obj === 'object') + return { + type: 'object', + required: Object.keys(obj).filter((key) => obj[key] !== undefined), + additionalProperties: false, + properties: Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, generateSchema(value as JsonLikeVariant)]), + ), + }; + if (obj === JsonLike.STRING || obj === JsonLike.UUID) return { type: 'string' }; + switch (typeof obj) { + case 'string': + return { type: 'string' }; + case 'number': + return { type: 'number' }; + case 'boolean': + return { type: 'boolean' }; + } +} diff --git a/test/e2e/assos/add-member.e2e-spec.ts b/test/e2e/assos/add-member.e2e-spec.ts new file mode 100644 index 00000000..8593c3af --- /dev/null +++ b/test/e2e/assos/add-member.e2e-spec.ts @@ -0,0 +1,190 @@ +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { faker } from '@faker-js/faker'; +import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; +import { AuthService } from '../../../src/auth/auth.service'; + +const AddAssoMemberE2ESpec = e2eSuite('POST /assos/:id/members', (app) => { + const userMember = createUser(app); + const userMemberAllowed = createUser(app); + const userTargetFromAllowedUser = createUser(app); + const userTargetFromAssoAccount = createUser(app); + const asso = createAsso(app); + const assoMembershipRole = createAssoMembershipRole(app, { asso }); + const manageMembersPermission = createAssoMembershipPermission(app, { id: 'manage_members' }); + const otherPermission = createAssoMembershipPermission(app, { id: 'other_permission' }); + createAssoMembership(app, { asso, role: assoMembershipRole, user: userMember }); + createAssoMembership(app, { + asso, + role: assoMembershipRole, + user: userMemberAllowed, + permissions: [manageMembersPermission], + }); + + it('should return 403 as user is not authenticated', () => + pactum.spec().post(`/assos/${asso.id}/members`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the id param is not valid', () => + pactum + .spec() + .withBearerToken(userMember.token) + .post('/assos/thisisnotavaliduuid/members') + .withBody({ + userId: userTargetFromAllowedUser.id, + roleId: assoMembershipRole.id, + permissions: [], + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 400 as expiration date is in the past', () => + pactum + .spec() + .withBearerToken(userMember.token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: userTargetFromAllowedUser.id, + roleId: assoMembershipRole.id, + endAt: new Date(), + permissions: [], + }) + .expectAppError(ERROR_CODE.PARAM_PAST_DATE, 'endAt')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userMember.token) + .post(`/assos/${Dummies.UUID}/members`) + .withBody({ + userId: userTargetFromAllowedUser.id, + roleId: assoMembershipRole.id, + permissions: [], + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 404 as role does not exist', () => + pactum + .spec() + .withBearerToken(userMemberAllowed.token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: userTargetFromAllowedUser.id, + roleId: Dummies.UUID, + permissions: [], + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id)); + + it('should return a 404 as user does not exist', () => + pactum + .spec() + .withBearerToken(userMemberAllowed.token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: Dummies.UUID, + roleId: assoMembershipRole.id, + permissions: [], + }) + .expectAppError(ERROR_CODE.NO_SUCH_USER, Dummies.UUID)); + + it('should return a 403 as user has no permission', () => + pactum + .spec() + .withBearerToken(userMember.token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: userTargetFromAllowedUser.id, + roleId: assoMembershipRole.id, + permissions: [], + }) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, manageMembersPermission.id)); + + it('should return a 403 as user grants a permission he does not have', () => + pactum + .spec() + .withBearerToken(userMemberAllowed.token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: userTargetFromAllowedUser.id, + roleId: assoMembershipRole.id, + permissions: [manageMembersPermission.id, otherPermission.id], + }) + .expectAppError( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + asso.id, + [manageMembersPermission.id, otherPermission.id].join(', '), + )); + + it('should fail as user is already in the AssoRole', () => + pactum + .spec() + .withBearerToken(userMemberAllowed.token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: userMember.id, + roleId: assoMembershipRole.id, + permissions: [manageMembersPermission.id], + }) + .expectAppError(ERROR_CODE.USER_ALREADY_ASSO_ROLE_MEMBER, assoMembershipRole.name)); + + it('should add the user to the AssoRole', () => + pactum + .spec() + .withBearerToken(userMemberAllowed.token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: userTargetFromAllowedUser.id, + roleId: assoMembershipRole.id, + permissions: [manageMembersPermission.id], + }) + .expectAssoMembershipCreated({ + id: JsonLike.UUID, + assoId: asso.id, + userId: userTargetFromAllowedUser.id, + roleId: assoMembershipRole.id, + startAt: JsonLike.DATE, + endAt: JsonLike.DATE, + })); + + it('should add the user to the AssoRole with asso account', async () => { + const assoUser = await app() + .get(PrismaService) + .user.findUnique({ where: { login: asso.name } }); + const apiKey = await app() + .get(PrismaService) + .apiKey.create({ + data: { + token: faker.string.alphanumeric(30), + user: { connect: { id: assoUser.id } }, + application: { connect: { id: DEFAULT_APPLICATION.id } }, + }, + }); + const token = await app().get(AuthService).signAuthenticationToken(apiKey.token); + return pactum + .spec() + .withBearerToken(token) + .post(`/assos/${asso.id}/members`) + .withBody({ + userId: userTargetFromAssoAccount.id, + roleId: assoMembershipRole.id, + permissions: [manageMembersPermission.id, otherPermission.id], + }) + .expectAssoMembershipCreated({ + id: JsonLike.UUID, + assoId: asso.id, + userId: userTargetFromAssoAccount.id, + roleId: assoMembershipRole.id, + startAt: JsonLike.DATE, + endAt: JsonLike.DATE, + }); + }); +}); + +export default AddAssoMemberE2ESpec; diff --git a/test/e2e/assos/create-role.e2e-spec.ts b/test/e2e/assos/create-role.e2e-spec.ts new file mode 100644 index 00000000..74741b29 --- /dev/null +++ b/test/e2e/assos/create-role.e2e-spec.ts @@ -0,0 +1,97 @@ +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { faker } from '@faker-js/faker'; +import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; +import { AuthService } from '../../../src/auth/auth.service'; + +const CreateAssoRoleE2ESpec = e2eSuite('POST /assos/:id/roles', (app) => { + const user = createUser(app); + const userAllowed = createUser(app); + const asso = createAsso(app); + const assoMembershipRole = createAssoMembershipRole(app, { asso }); + const permission = createAssoMembershipPermission(app, { id: 'manage_roles' }); + createAssoMembership(app, { asso, role: assoMembershipRole, user: userAllowed, permissions: [permission] }); + const validBody = { + name: 'Bouffeur de carte graphiques', + }; + + it('should return 403 as user is not authenticated', () => + pactum.spec().post(`/assos/${asso.id}/roles`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the id param is not valid', () => + pactum + .spec() + .withBearerToken(user.token) + .post('/assos/thisisnotavaliduuid/roles') + .withBody(validBody) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(user.token) + .post(`/assos/${Dummies.UUID}/roles`) + .withBody(validBody) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 403 as user has no permission', () => + pactum + .spec() + .withBearerToken(user.token) + .post(`/assos/${asso.id}/roles`) + .withBody(validBody) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, permission.id)); + + it('should create the role', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .post(`/assos/${asso.id}/roles`) + .withBody(validBody) + .expectAssoMembershipRoleCreated({ + id: JsonLike.UUID, + isPresident: false, + name: validBody.name, + position: 2, // position 0 is by default the president role and we already have created a role at position 1 + assoId: asso.id, + })); + + it('should create the role with asso account', async () => { + const assoUser = await app() + .get(PrismaService) + .user.findUnique({ where: { login: asso.name } }); + const apiKey = await app() + .get(PrismaService) + .apiKey.create({ + data: { + token: faker.string.alphanumeric(30), + user: { connect: { id: assoUser.id } }, + application: { connect: { id: DEFAULT_APPLICATION.id } }, + }, + }); + const token = await app().get(AuthService).signAuthenticationToken(apiKey.token); + return pactum + .spec() + .withBearerToken(token) + .post(`/assos/${asso.id}/roles`) + .withBody(validBody) + .expectAssoMembershipRoleCreated({ + id: JsonLike.UUID, + isPresident: false, + name: validBody.name, + position: 3, + assoId: asso.id, + }); + }); +}); + +export default CreateAssoRoleE2ESpec; diff --git a/test/e2e/assos/delete-role.e2e-spec.ts b/test/e2e/assos/delete-role.e2e-spec.ts new file mode 100644 index 00000000..cd8d2eb1 --- /dev/null +++ b/test/e2e/assos/delete-role.e2e-spec.ts @@ -0,0 +1,117 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { faker } from '@faker-js/faker'; +import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; +import { AuthService } from '../../../src/auth/auth.service'; + +const DeleteAssoRoleE2ESpec = e2eSuite('DELETE /assos/:id/roles/:id', (app) => { + const user = createUser(app); + const userAllowed = createUser(app); + const asso = createAsso(app); + const asso2 = createAsso(app); + const assoMembershipRole = createAssoMembershipRole(app, { asso }); + const assoMembershipRole2 = createAssoMembershipRole(app, { asso: asso2 }); + const permission = createAssoMembershipPermission(app, { id: 'manage_roles' }); + createAssoMembership(app, { asso, role: assoMembershipRole, user: userAllowed, permissions: [permission] }); + + it('should return 403 as user is not authenticated', () => + pactum.spec().delete(`/assos/${asso.id}/roles/${assoMembershipRole.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the id param is not valid', () => + pactum + .spec() + .withBearerToken(user.token) + .delete(`/assos/thisisnotavaliduuid/roles/${assoMembershipRole.id}`) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(user.token) + .delete(`/assos/${Dummies.UUID}/roles/${assoMembershipRole.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 403 as user has no permission', () => + pactum + .spec() + .withBearerToken(user.token) + .delete(`/assos/${asso.id}/roles/${assoMembershipRole.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, permission.id)); + + it('should return a 404 as role does not exist', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .delete(`/assos/${asso.id}/roles/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id)); + + it('should return a 404 as role is from another asso', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .delete(`/assos/${asso.id}/roles/${assoMembershipRole2.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id)); + + it('should return a 403 as it is the president role', async () => { + const role = await app() + .get(PrismaService) + .assoMembershipRole.findFirstOrThrow({ where: { assoId: asso.id, isPresident: true } }); + return pactum + .spec() + .withBearerToken(userAllowed.token) + .delete(`/assos/${asso.id}/roles/${role.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_ROLE_PERMANENT, role.name); + }); + + it('should delete the role', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .delete(`/assos/${asso.id}/roles/${assoMembershipRole.id}`) + .expectAssoMembershipRole({ + id: assoMembershipRole.id, + isPresident: false, + name: assoMembershipRole.name, + position: assoMembershipRole.position, + assoId: asso.id, + })); + + it('should delete the role with asso account', async () => { + const assoMembershipRole3 = await createAssoMembershipRole(app, { asso }, assoMembershipRole, true); + const assoUser = await app() + .get(PrismaService) + .user.findUnique({ where: { login: asso.name } }); + const apiKey = await app() + .get(PrismaService) + .apiKey.create({ + data: { + token: faker.string.alphanumeric(30), + user: { connect: { id: assoUser.id } }, + application: { connect: { id: DEFAULT_APPLICATION.id } }, + }, + }); + const token = await app().get(AuthService).signAuthenticationToken(apiKey.token); + return pactum + .spec() + .withBearerToken(token) + .delete(`/assos/${asso.id}/roles/${assoMembershipRole3.id}`) + .expectAssoMembershipRole({ + id: assoMembershipRole3.id, + isPresident: false, + name: assoMembershipRole3.name, + position: assoMembershipRole3.position, + assoId: asso.id, + }); + }); +}); + +export default DeleteAssoRoleE2ESpec; diff --git a/test/e2e/assos/index.ts b/test/e2e/assos/index.ts index 5eb6d6e1..5ce31380 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -1,10 +1,24 @@ import { INestApplication } from '@nestjs/common'; import SearchE2ESpec from './search.e2e-spec'; import GetAssoE2ESpec from './get-asso.e2e-spec'; +import GetAssoMembersE2ESpec from './list-members.e2e-spec'; +import AddAssoMemberE2ESpec from './add-member.e2e-spec'; +import KickAssoMemberE2ESpec from './kick-member.e2e-spec'; +import UpdateAssoMemberE2ESpec from './update-member.e2e-spec'; +import CreateAssoRoleE2ESpec from './create-role.e2e-spec'; +import DeleteAssoRoleE2ESpec from './delete-role.e2e-spec'; +import UpdateAssoRoleE2ESpec from './update-role.e2e-spec'; export default function AssoE2ESpec(app: () => INestApplication) { describe('Assos', () => { SearchE2ESpec(app); GetAssoE2ESpec(app); + GetAssoMembersE2ESpec(app); + AddAssoMemberE2ESpec(app); + KickAssoMemberE2ESpec(app); + UpdateAssoMemberE2ESpec(app); + CreateAssoRoleE2ESpec(app); + DeleteAssoRoleE2ESpec(app); + UpdateAssoRoleE2ESpec(app); }); } diff --git a/test/e2e/assos/kick-member.e2e-spec.ts b/test/e2e/assos/kick-member.e2e-spec.ts new file mode 100644 index 00000000..ba7fa630 --- /dev/null +++ b/test/e2e/assos/kick-member.e2e-spec.ts @@ -0,0 +1,129 @@ +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { faker } from '@faker-js/faker'; +import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; +import { AuthService } from '../../../src/auth/auth.service'; + +const KickAssoMemberE2ESpec = e2eSuite('DELETE /assos/:id/members/:id', (app) => { + const userNotAllowed = createUser(app); + const userAllowed = createUser(app); + const userInAsso = createUser(app); + const otherUserInAsso = createUser(app); + const asso = createAsso(app); + const asso2 = createAsso(app); + const assoMembershipRole = createAssoMembershipRole(app, { asso }); + const permission = createAssoMembershipPermission(app, { id: 'manage_members' }); + const userInAssoMembershipInAsso = createAssoMembership(app, { asso, role: assoMembershipRole, user: userInAsso }); + const userInAssoMembershipInAsso2 = createAssoMembership(app, { + asso: asso2, + role: assoMembershipRole, + user: userInAsso, + }); + const otherUserInAssoMembershipInAsso = createAssoMembership(app, { + asso, + role: assoMembershipRole, + user: otherUserInAsso, + }); + createAssoMembership(app, { asso, role: assoMembershipRole, user: userAllowed, permissions: [permission] }); + + it('should return 403 as user is not authenticated', () => + pactum + .spec() + .delete(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the asso id param is not valid', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .delete(`/assos/thisisnotavaliduuid/members/${userInAssoMembershipInAsso.id}`) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 400 as the member id param is not valid', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .delete(`/assos/${asso.id}/members/thisisnotavaliduuid`) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'memberId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .delete(`/assos/${Dummies.UUID}/members/${userInAssoMembershipInAsso.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 404 as member is not found', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .delete(`/assos/${asso.id}/members/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, Dummies.UUID)); + + it('should return a 403 as user has no permission', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .delete(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, permission.id)); + + it('should fail as membership is not part of this Asso', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .delete(`/assos/${asso.id}/members/${userInAssoMembershipInAsso2.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, userInAssoMembershipInAsso2.id)); + + it('should kick the user from the AssoRole', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .delete(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .expectAssoMembership({ + id: userInAssoMembershipInAsso.id, + assoId: asso.id, + userId: userInAsso.id, + roleId: assoMembershipRole.id, + startAt: userInAssoMembershipInAsso.startAt, + endAt: JsonLike.DATE, + })); + + it('should kick the user from the AssoRole with asso account', async () => { + const assoUser = await app() + .get(PrismaService) + .user.findUnique({ where: { login: asso.name } }); + const apiKey = await app() + .get(PrismaService) + .apiKey.create({ + data: { + token: faker.string.alphanumeric(30), + user: { connect: { id: assoUser.id } }, + application: { connect: { id: DEFAULT_APPLICATION.id } }, + }, + }); + const token = await app().get(AuthService).signAuthenticationToken(apiKey.token); + return pactum + .spec() + .withBearerToken(token) + .delete(`/assos/${asso.id}/members/${otherUserInAssoMembershipInAsso.id}`) + .expectAssoMembership({ + id: otherUserInAssoMembershipInAsso.id, + assoId: asso.id, + userId: otherUserInAsso.id, + roleId: assoMembershipRole.id, + startAt: otherUserInAssoMembershipInAsso.startAt, + endAt: JsonLike.DATE, + }); + }); +}); + +export default KickAssoMemberE2ESpec; diff --git a/test/e2e/assos/list-members.e2e-spec.ts b/test/e2e/assos/list-members.e2e-spec.ts new file mode 100644 index 00000000..16239913 --- /dev/null +++ b/test/e2e/assos/list-members.e2e-spec.ts @@ -0,0 +1,57 @@ +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import { createAsso, createAssoMembership, createAssoMembershipRole, createUser } from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; + +const GetAssoMembersE2ESpec = e2eSuite('GET /assos/:id/members', (app) => { + const user = createUser(app); + const asso = createAsso(app); + const assoMembershipRole = createAssoMembershipRole(app, { asso }); + const permissions = []; + createAssoMembership(app, { asso, role: assoMembershipRole, user, permissions }); + + it('should return 403 as user is not authenticated', () => + pactum.spec().get(`/assos/${asso.id}/members`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the id param is not valid', () => + pactum + .spec() + .withBearerToken(user.token) + .get('/assos/thisisnotavaliduuid/members') + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(user.token) + .get(`/assos/${Dummies.UUID}/members`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should successfully return the asso members', () => + pactum + .spec() + .withBearerToken(user.token) + .get(`/assos/${asso.id}/members`) + .expectAssoMembershipRoles([ + { + role: { + id: JsonLike.UUID, + isPresident: true, + name: 'President', + position: 0, + }, + users: [], + }, + { + role: assoMembershipRole, + users: [ + { + user, + permissions, + }, + ], + }, + ])); +}); + +export default GetAssoMembersE2ESpec; diff --git a/test/e2e/assos/update-member.e2e-spec.ts b/test/e2e/assos/update-member.e2e-spec.ts new file mode 100644 index 00000000..3f919d49 --- /dev/null +++ b/test/e2e/assos/update-member.e2e-spec.ts @@ -0,0 +1,239 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { faker } from '@faker-js/faker'; +import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; +import { AuthService } from '../../../src/auth/auth.service'; + +const UpdateAssoMemberE2ESpec = e2eSuite('PATCH /assos/:id/members/:id', (app) => { + const userNotAllowed = createUser(app); + const userAllowed = createUser(app); + const userInAsso = createUser(app); + const otherUserInAsso = createUser(app); + const asso = createAsso(app); + const asso2 = createAsso(app); + const assoMembershipRoleInAsso = createAssoMembershipRole(app, { asso }); + const assoMembershipRole2InAsso = createAssoMembershipRole(app, { asso }); + const manageMembersPermission = createAssoMembershipPermission(app, { id: 'manage_members' }); + const otherPermission = createAssoMembershipPermission(app, { id: 'destroy_etuutt' }); + const userInAssoMembershipInAsso = createAssoMembership(app, { + asso, + role: assoMembershipRoleInAsso, + user: userInAsso, + }); + createAssoMembership(app, { asso, role: assoMembershipRole2InAsso, user: userInAsso }); + const userInAssoMembershipInAsso2 = createAssoMembership(app, { + asso: asso2, + role: assoMembershipRoleInAsso, + user: userInAsso, + }); + const otherUserInAssoMembershipInAsso = createAssoMembership(app, { + asso, + role: assoMembershipRoleInAsso, + user: otherUserInAsso, + }); + createAssoMembership(app, { + asso, + role: assoMembershipRoleInAsso, + user: userAllowed, + permissions: [manageMembersPermission], + }); + + const endAt = new Date(Date.now() + 7 * 24 * 3600 * 1000); + + it('should return 403 as user is not authenticated', () => + pactum + .spec() + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + roleId: assoMembershipRoleInAsso.id, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the asso id param is not valid', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/thisisnotavaliduuid/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + roleId: assoMembershipRoleInAsso.id, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 400 as the member id param is not valid', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/${asso.id}/members/thisisnotavaliduuid`) + .withBody({ + roleId: assoMembershipRoleInAsso.id, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'memberId')); + + it('should return a 400 as expiration date is in the past', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + permissions: [manageMembersPermission.id], + endAt: new Date(), + }) + .expectAppError(ERROR_CODE.PARAM_PAST_DATE, 'endAt')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/${Dummies.UUID}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + roleId: assoMembershipRoleInAsso.id, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 404 as member is not found', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/${asso.id}/members/${Dummies.UUID}`) + .withBody({ + roleId: assoMembershipRoleInAsso.id, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, Dummies.UUID)); + + it('should return a 403 as user has no permission', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + roleId: assoMembershipRoleInAsso.id, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, manageMembersPermission.id)); + + it('should fail as membership is not part of this Asso', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso2.id}`) + .withBody({ + roleId: assoMembershipRoleInAsso.id, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_MEMBERSHIP, userInAssoMembershipInAsso2.id)); + + it('should return a 404 as role does not exist', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + roleId: Dummies.UUID, + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id)); + + it('should return a 403 as user grants a permission he does not have', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + permissions: [manageMembersPermission.id, otherPermission.id], + endAt, + }) + .expectAppError( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + asso.id, + [manageMembersPermission.id, otherPermission.id].join(', '), + )); + + it('should fail as user is already in the AssoRole', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + roleId: assoMembershipRole2InAsso.id, + permissions: [], + endAt, + }) + .expectAppError(ERROR_CODE.USER_ALREADY_ASSO_ROLE_MEMBER, assoMembershipRole2InAsso.name)); + + it('should update the membership', () => { + userInAssoMembershipInAsso.endAt = endAt; + return pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) + .withBody({ + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAssoMembership({ + id: userInAssoMembershipInAsso.id, + assoId: asso.id, + userId: userInAsso.id, + roleId: assoMembershipRoleInAsso.id, + startAt: userInAssoMembershipInAsso.startAt, + endAt: userInAssoMembershipInAsso.endAt, + }); + }); + + it('should update the membership with asso account', async () => { + const assoUser = await app() + .get(PrismaService) + .user.findUnique({ where: { login: asso.name } }); + const apiKey = await app() + .get(PrismaService) + .apiKey.create({ + data: { + token: faker.string.alphanumeric(30), + user: { connect: { id: assoUser.id } }, + application: { connect: { id: DEFAULT_APPLICATION.id } }, + }, + }); + const token = await app().get(AuthService).signAuthenticationToken(apiKey.token); + otherUserInAssoMembershipInAsso.endAt = endAt; + return pactum + .spec() + .withBearerToken(token) + .patch(`/assos/${asso.id}/members/${otherUserInAssoMembershipInAsso.id}`) + .withBody({ + permissions: [manageMembersPermission.id], + endAt, + }) + .expectAssoMembership({ + id: otherUserInAssoMembershipInAsso.id, + assoId: asso.id, + userId: otherUserInAsso.id, + roleId: assoMembershipRoleInAsso.id, + startAt: otherUserInAssoMembershipInAsso.startAt, + endAt: otherUserInAssoMembershipInAsso.endAt, + }); + }); +}); + +export default UpdateAssoMemberE2ESpec; diff --git a/test/e2e/assos/update-role.e2e-spec.ts b/test/e2e/assos/update-role.e2e-spec.ts new file mode 100644 index 00000000..02e2df05 --- /dev/null +++ b/test/e2e/assos/update-role.e2e-spec.ts @@ -0,0 +1,146 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { faker } from '@faker-js/faker'; +import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; +import { AuthService } from '../../../src/auth/auth.service'; + +const UpdateAssoRoleE2ESpec = e2eSuite('PUT /assos/:id/roles/:id', (app) => { + const user = createUser(app); + const userAllowed = createUser(app); + const asso = createAsso(app); + const asso2 = createAsso(app); + const assoMembershipRole = createAssoMembershipRole(app, { asso }); + const assoMembershipRole2 = createAssoMembershipRole(app, { asso: asso2 }); + const permission = createAssoMembershipPermission(app, { id: 'manage_roles' }); + createAssoMembership(app, { asso, role: assoMembershipRole, user: userAllowed, permissions: [permission] }); + + it('should return 403 as user is not authenticated', () => + pactum.spec().put(`/assos/${asso.id}/roles/${assoMembershipRole.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the id param is not valid', () => + pactum + .spec() + .withBearerToken(user.token) + .put(`/assos/thisisnotavaliduuid/roles/${assoMembershipRole.id}`) + .withBody({ + name: 'Yippee', + position: 0, + }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(user.token) + .put(`/assos/${Dummies.UUID}/roles/${assoMembershipRole.id}`) + .withBody({ + name: 'Yippee', + position: 0, + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 403 as user has no permission', () => + pactum + .spec() + .withBearerToken(user.token) + .put(`/assos/${asso.id}/roles/${assoMembershipRole.id}`) + .withBody({ + name: 'Yippee', + position: 0, + }) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, permission.id)); + + it('should return a 404 as role does not exist', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .put(`/assos/${asso.id}/roles/${Dummies.UUID}`) + .withBody({ + name: 'Yippee', + position: 0, + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id)); + + it('should return a 404 as role is from another asso', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .put(`/assos/${asso.id}/roles/${assoMembershipRole2.id}`) + .withBody({ + name: 'Yippee', + position: 0, + }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO_ROLE, asso.id)); + + it('should return a 400 as position is out of range', async () => { + return pactum + .spec() + .withBearerToken(userAllowed.token) + .put(`/assos/${asso.id}/roles/${assoMembershipRole.id}`) + .withBody({ + name: 'Yippee', + position: 128, + }) + .expectAppError(ERROR_CODE.PARAM_TOO_HIGH, 'position'); + }); + + it('should update the role', async () => { + const presRole = await app() + .get(PrismaService) + .assoMembershipRole.findFirstOrThrow({ where: { assoId: asso.id, isPresident: true } }); + assoMembershipRole.position = 0; + assoMembershipRole.name = 'Updated'; + presRole.position = 1; + return pactum + .spec() + .withBearerToken(userAllowed.token) + .put(`/assos/${asso.id}/roles/${assoMembershipRole.id}`) + .withBody({ + name: 'Updated', + position: 0, + }) + .expectAssoMembershipRolesRaw([assoMembershipRole, presRole]); + }); + + it('should update the role with asso account', async () => { + const assoUser = await app() + .get(PrismaService) + .user.findUnique({ where: { login: asso.name } }); + const presRole = await app() + .get(PrismaService) + .assoMembershipRole.findFirstOrThrow({ where: { assoId: asso.id, isPresident: true } }); + const apiKey = await app() + .get(PrismaService) + .apiKey.create({ + data: { + token: faker.string.alphanumeric(30), + user: { connect: { id: assoUser.id } }, + application: { connect: { id: DEFAULT_APPLICATION.id } }, + }, + }); + const token = await app().get(AuthService).signAuthenticationToken(apiKey.token); + assoMembershipRole.position = 1; + assoMembershipRole.name = 'Reverted'; + presRole.position = 0; + return pactum + .spec() + .withBearerToken(token) + .put(`/assos/${asso.id}/roles/${assoMembershipRole.id}`) + .withBody({ + name: 'Reverted', + position: 1, + }) + .expectAssoMembershipRolesRaw([presRole, assoMembershipRole]); + }); +}); + +export default UpdateAssoRoleE2ESpec; diff --git a/test/e2e/auth/application/create-application.e2e-spec.ts b/test/e2e/auth/application/create-application.e2e-spec.ts index d786f34a..03cb8fc7 100644 --- a/test/e2e/auth/application/create-application.e2e-spec.ts +++ b/test/e2e/auth/application/create-application.e2e-spec.ts @@ -1,9 +1,8 @@ -import { e2eSuite } from '../../../utils/test_utils'; +import { e2eSuite, JsonLike } from '../../../utils/test_utils'; import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../../src/exceptions'; import * as fakedb from '../../../utils/fakedb'; import { HttpStatus } from '@nestjs/common'; -import { string } from 'pactum-matchers'; const CreateApplicationE2ESpec = e2eSuite('POST /auth/application', (app) => { const user = fakedb.createUser(app); @@ -23,8 +22,8 @@ const CreateApplicationE2ESpec = e2eSuite('POST /auth/application', (app) => { .withBearerToken(user.token) .withJson(body) .expectStatus(HttpStatus.CREATED) - .expectJsonMatch({ - id: string(), + .$expectRegexableJson({ + id: JsonLike.UUID, name: body.name, owner: { id: user.id, @@ -32,7 +31,7 @@ const CreateApplicationE2ESpec = e2eSuite('POST /auth/application', (app) => { lastName: user.lastName, }, redirectUrl: body.redirectUrl, - clientSecret: string(), + clientSecret: JsonLike.STRING, })); }); diff --git a/test/e2e/auth/application/get-applications-of-user.e2e-spec.ts b/test/e2e/auth/application/get-applications-of-user.e2e-spec.ts index 27cd54ea..a75dfa7b 100644 --- a/test/e2e/auth/application/get-applications-of-user.e2e-spec.ts +++ b/test/e2e/auth/application/get-applications-of-user.e2e-spec.ts @@ -3,11 +3,12 @@ import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../../src/exceptions'; import * as fakedb from '../../../utils/fakedb'; import { Permission } from '@prisma/client'; +import { PermissionManager } from '../../../../src/utils'; const GetApplicationsOfUserE2ESpec = e2eSuite('GET /auth/application/of/:userId', (app) => { const user = fakedb.createUser(app); const unauthorizedUser = fakedb.createUser(app); - const adminUser = fakedb.createUser(app, { permissions: [Permission.USER_SEE_DETAILS] }); + const adminUser = fakedb.createUser(app, { permissions: new PermissionManager().with(Permission.USER_SEE_DETAILS) }); const applications = [fakedb.createApplication(app, { owner: user }), fakedb.createApplication(app, { owner: user })]; it('should return an Unauthorized as user is not logged in', () => diff --git a/test/e2e/auth/application/update-client-secret.e2e-spec.ts b/test/e2e/auth/application/update-client-secret.e2e-spec.ts index 7f0d5774..067937ce 100644 --- a/test/e2e/auth/application/update-client-secret.e2e-spec.ts +++ b/test/e2e/auth/application/update-client-secret.e2e-spec.ts @@ -1,9 +1,8 @@ -import { e2eSuite } from '../../../utils/test_utils'; +import { e2eSuite, JsonLike } from '../../../utils/test_utils'; import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../../src/exceptions'; import * as fakedb from '../../../utils/fakedb'; import { HttpStatus } from '@nestjs/common'; -import { string } from 'pactum-matchers'; const UpdateClientSecretE2ESpec = e2eSuite('PATCH /auth/application/:applicationId/client-secret', (app) => { const user = fakedb.createUser(app); @@ -33,8 +32,8 @@ const UpdateClientSecretE2ESpec = e2eSuite('PATCH /auth/application/:application .patch(`/auth/application/${application.id}/client-secret`) .withBearerToken(user.token) .expectStatus(HttpStatus.OK) - .expectJsonMatch({ - clientSecret: string(), + .$expectRegexableJson({ + clientSecret: JsonLike.STRING, })); }); diff --git a/test/e2e/auth/cas-sign-in.e2e-spec.ts b/test/e2e/auth/cas-sign-in.e2e-spec.ts index 11bdbfb0..2c1098f8 100644 --- a/test/e2e/auth/cas-sign-in.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-in.e2e-spec.ts @@ -1,8 +1,7 @@ -import { e2eSuite } from '../../utils/test_utils'; +import { e2eSuite, JsonLike } from '../../utils/test_utils'; import * as cas from '../../external_services/cas'; import * as fakedb from '../../utils/fakedb'; import * as pactum from 'pactum'; -import { string } from 'pactum-matchers'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../../../src/prisma/prisma.service'; import AuthCasSignInReqDto from '../../../src/auth/dto/req/auth-cas-sign-in-req.dto'; @@ -21,7 +20,7 @@ const CasSignInE2ESpec = e2eSuite('POST /auth/signin/cas', (app) => { .post('/auth/signin/cas') .withBody(body) .expectStatus(HttpStatus.OK) - .expectJsonMatch({ status: 'no_account', token: string() }) + .$expectRegexableJson({ status: 'no_account', token: JsonLike.STRING, redirectUrl: null }) .expect((res) => { const jwt = app().get(JwtService); const data = jwt.decode((res.res.json as { token: string }).token); @@ -37,7 +36,7 @@ const CasSignInE2ESpec = e2eSuite('POST /auth/signin/cas', (app) => { .spec() .post('/auth/signin/cas') .withBody(body) - .expectJsonMatch({ status: 'no_api_key', token: string() }) + .$expectRegexableJson({ status: 'no_api_key', token: JsonLike.STRING, redirectUrl: null }) .expect((res) => { const jwt = app().get(JwtService); const data = jwt.decode((res.res.json as { token: string }).token); @@ -54,7 +53,7 @@ const CasSignInE2ESpec = e2eSuite('POST /auth/signin/cas', (app) => { .spec() .post('/auth/signin/cas') .withBody(body) - .expectJsonMatch({ status: 'ok', token: string() }) + .$expectRegexableJson({ status: 'ok', token: JsonLike.STRING, redirectUrl: null }) .expect(async (res) => { const jwt = app().get(JwtService); const data = jwt.decode((res.res.json as { token: string }).token); diff --git a/test/e2e/auth/cas-sign-up.e2e-spec.ts b/test/e2e/auth/cas-sign-up.e2e-spec.ts index 0b37a199..ec6c34c0 100644 --- a/test/e2e/auth/cas-sign-up.e2e-spec.ts +++ b/test/e2e/auth/cas-sign-up.e2e-spec.ts @@ -1,11 +1,10 @@ -import { e2eSuite } from '../../utils/test_utils'; +import { e2eSuite, JsonLike } from '../../utils/test_utils'; import * as pactum from 'pactum'; import { faker } from '@faker-js/faker'; import { JwtService } from '@nestjs/jwt'; import * as fakedb from '../../utils/fakedb'; import { AuthService } from '../../../src/auth/auth.service'; import { PrismaService } from '../../../src/prisma/prisma.service'; -import { string } from 'pactum-matchers'; import { ERROR_CODE } from '../../../src/exceptions'; import { ConfigModule } from '../../../src/config/config.module'; import { LdapUser } from 'ldap-server-mock'; @@ -83,7 +82,7 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { jpegPhoto: `http://localhost/${login}.jpg`, gidNumber: type === 'student' ? '10000' : type === 'faculty' ? '5000' : '9999', uv: ['PETM6', 'SY16', 'LO17', 'RE02', 'IF03', 'CTC1', 'LG11', 'PEICT', ue.code], - } + }; }; const executeValidSignupRequest = async (personAttributes) => { const firstName = faker.person.firstName(); @@ -95,30 +94,32 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { dn: `uid=${login},ou=people,dc=utt,dc=fr`, attributes: personAttributes, }); - const authService = app() - .get(AuthService); + const authService = app().get(AuthService); await pactum .spec() .post('/auth/signup/cas') .withJson({ - registerToken: await authService - .signRegisterUserToken(login, mail, firstName, lastName, tokenExpiresIn), + registerToken: await authService.signRegisterUserToken(login, mail, firstName, lastName, tokenExpiresIn), }) .expectStatus(HttpStatus.CREATED) - .expectJsonMatch({ token: string() }); + .$expectRegexableJson({ token: JsonLike.STRING }); expect(await app().get(PrismaService).user.count({ where: { login } })).toEqual(1); }; it('should successfully create the user and return a token', async () => { const personAttribute = getPersonAttributes('student'); await executeValidSignupRequest(personAttribute); - await app().get(PrismaService).user.deleteMany({where: {login: personAttribute.uid}}); + await app() + .get(PrismaService) + .user.deleteMany({ where: { login: personAttribute.uid } }); }); it('should successfully create the user and return a token (as a teacher)', async () => { const personAttribute = getPersonAttributes('faculty'); await executeValidSignupRequest(personAttribute); - await app().get(PrismaService).user.deleteMany({where: {login: personAttribute.uid}}); + await app() + .get(PrismaService) + .user.deleteMany({ where: { login: personAttribute.uid } }); }); // Can this happen ? If it does, should we throw an error instead ? // it('should successfully create the user and return a token (as other)', () => executeValidSignupRequest(getPersonAttributes('other'))); @@ -133,7 +134,11 @@ const CasSignUpE2ESpec = e2eSuite('POST /auth/signup/cas', (app) => { mail, gidNumber: '6000', }); - expect(await app().get(PrismaService).asso.count({ where: { name: assoName } })).toEqual(1); + expect( + await app() + .get(PrismaService) + .asso.count({ where: { name: assoName } }), + ).toEqual(1); await app().get(PrismaService).user.deleteMany({ where: { login } }); }); }); diff --git a/test/e2e/auth/create-api-key.e2e-spec.ts b/test/e2e/auth/create-api-key.e2e-spec.ts index 0b297586..7f1c1cc3 100644 --- a/test/e2e/auth/create-api-key.e2e-spec.ts +++ b/test/e2e/auth/create-api-key.e2e-spec.ts @@ -1,9 +1,8 @@ import * as pactum from 'pactum'; -import { e2eSuite } from '../../utils/test_utils'; +import { e2eSuite, JsonLike } from '../../utils/test_utils'; import { AuthService } from '../../../src/auth/auth.service'; import { ERROR_CODE } from '../../../src/exceptions'; import * as fakedb from '../../utils/fakedb'; -import { string } from 'pactum-matchers'; import { pick } from '../../../src/utils'; import { HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../../src/prisma/prisma.service'; @@ -41,7 +40,7 @@ const CreateApiKeyE2ESpec = e2eSuite('POST /auth/api-key', (app) => { .post('/auth/api-key') .withJson({ token: await authService().signRegisterApiKeyToken(otherUser.id, application.id, 99999) }) .expectStatus(HttpStatus.CREATED) - .expectJsonMatch({ redirectUrl: string() }) + .$expectRegexableJson({ redirectUrl: JsonLike.STRING }) .expect((ctx) => { const body = ctx.res.json as { redirectUrl: string }; expect(body.redirectUrl.startsWith(application.redirectUrl)).toBeTruthy(); diff --git a/test/e2e/auth/index.ts b/test/e2e/auth/index.ts index 14b15640..0f4a3260 100644 --- a/test/e2e/auth/index.ts +++ b/test/e2e/auth/index.ts @@ -7,6 +7,7 @@ import CasSignUpE2ESpec from './cas-sign-up.e2e-spec'; import CreateApiKeyE2ESpec from './create-api-key.e2e-spec'; import ApplicationE2ESpec from './application'; import ValidateLoginE2ESpec from './validate-login.e2e-spec'; +import PermissionsE2ESpec from './permissions'; export default function AuthE2ESpec(app: E2EAppProvider) { describe('Auth', () => { @@ -18,5 +19,6 @@ export default function AuthE2ESpec(app: E2EAppProvider) { CreateApiKeyE2ESpec(app); ValidateLoginE2ESpec(app); ApplicationE2ESpec(app); + PermissionsE2ESpec(app); }); } diff --git a/test/e2e/auth/permissions/get-own-permissions.ts b/test/e2e/auth/permissions/get-own-permissions.ts new file mode 100644 index 00000000..6f5988c8 --- /dev/null +++ b/test/e2e/auth/permissions/get-own-permissions.ts @@ -0,0 +1,29 @@ +import { e2eSuite } from '../../../utils/test_utils'; +import * as pactum from 'pactum'; +import * as fakedb from '../../../utils/fakedb'; +import { ERROR_CODE } from '../../../../src/exceptions'; +import { Permission } from '@prisma/client'; +import { PermissionManager } from '../../../../src/utils'; + +const GetOwnPermissionsE2ESpec = e2eSuite('GET /auth/permissions/current', (app) => { + const user = fakedb.createUser(app, { + permissions: new PermissionManager().with(Permission.USER_SEE_DETAILS).with(Permission.API_UPLOAD_ANNALS), + }); + + it('must fail as user is not authenticated', () => + pactum.spec().get('/auth/permissions/current').expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('must return the permission of the API key used to make the request', () => + pactum + .spec() + .withBearerToken(user.token) + .get('/auth/permissions/current') + .expectPermissions( + new PermissionManager() + .with(Permission.API_UPLOAD_ANNALS) + .with(Permission.USER_SEE_DETAILS) + .with(Permission.USER_UPDATE_DETAILS, user.id), + )); +}); + +export default GetOwnPermissionsE2ESpec; diff --git a/test/e2e/auth/permissions/get-permissions.e2e-spec.ts b/test/e2e/auth/permissions/get-permissions.e2e-spec.ts new file mode 100644 index 00000000..a190b3d6 --- /dev/null +++ b/test/e2e/auth/permissions/get-permissions.e2e-spec.ts @@ -0,0 +1,37 @@ +import { e2eSuite } from '../../../utils/test_utils'; +import * as pactum from 'pactum'; +import * as fakedb from '../../../utils/fakedb'; +import { ERROR_CODE } from '../../../../src/exceptions'; +import { Permission } from '@prisma/client'; +import { PermissionManager } from '../../../../src/utils'; + +const GetPermissionsE2ESpec = e2eSuite('GET /auth/permissions/:apiKey', (app) => { + const loggedUser = fakedb.createUser(app); + const user = fakedb.createUser(app, { + permissions: new PermissionManager().with(Permission.USER_SEE_DETAILS).with(Permission.API_UPLOAD_ANNALS), + }); + + it('must fail as user is not authenticated', () => + pactum.spec().get(`/auth/permissions/${user.apiKey.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('must fail as the provided api key does not exist', () => + pactum + .spec() + .withBearerToken(loggedUser.token) + .get(`/auth/permissions/blablabla`) + .expectAppError(ERROR_CODE.NO_SUCH_API_KEY, 'blablabla')); + + it('must return the permission of the given API key', () => + pactum + .spec() + .withBearerToken(loggedUser.token) + .get(`/auth/permissions/${user.apiKey.id}`) + .expectPermissions( + new PermissionManager() + .with(Permission.API_UPLOAD_ANNALS) + .with(Permission.USER_SEE_DETAILS) + .with(Permission.USER_UPDATE_DETAILS, user.id), + )); +}); + +export default GetPermissionsE2ESpec; diff --git a/test/e2e/auth/permissions/index.ts b/test/e2e/auth/permissions/index.ts new file mode 100644 index 00000000..71a90271 --- /dev/null +++ b/test/e2e/auth/permissions/index.ts @@ -0,0 +1,10 @@ +import { E2EAppProvider } from '../../../utils/test_utils'; +import GetPermissionsE2ESpec from './get-permissions.e2e-spec'; +import GetOwnPermissionsE2ESpec from './get-own-permissions'; + +export default function PermissionsE2ESpec(app: E2EAppProvider) { + describe('Permissions', () => { + GetPermissionsE2ESpec(app); + GetOwnPermissionsE2ESpec(app); + }); +} diff --git a/test/e2e/auth/signin-e2e-spec.ts b/test/e2e/auth/signin-e2e-spec.ts index 8437b977..2d646a7f 100644 --- a/test/e2e/auth/signin-e2e-spec.ts +++ b/test/e2e/auth/signin-e2e-spec.ts @@ -1,9 +1,8 @@ import AuthSignInDto from '../../../src/auth/dto/req/auth-sign-in-req.dto'; import * as pactum from 'pactum'; -import { e2eSuite } from '../../utils/test_utils'; +import { e2eSuite, JsonLike } from '../../utils/test_utils'; import * as fakedb from '../../utils/fakedb'; import { ERROR_CODE } from '../../../src/exceptions'; -import { string } from 'pactum-matchers'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../../../src/prisma/prisma.service'; import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; @@ -50,9 +49,9 @@ const SignInE2ESpec = e2eSuite('POST /auth/signin', (app) => { .post('/auth/signin') .withBody(dto) .expectStatus(200) - .expectJsonMatch({ + .$expectRegexableJson({ signedIn: true, - token: string(), + token: JsonLike.STRING, redirectUrl: null, }) .expect(async (ctx) => { @@ -81,10 +80,10 @@ const SignInE2ESpec = e2eSuite('POST /auth/signin', (app) => { .withApplication(application.id) .withBody({ login: userWithApplication.login, password: 'etuutt', tokenExpiresIn: 99999 }) .expectStatus(200) - .expectJsonMatch({ + .$expectRegexableJson({ signedIn: true, token: null, - redirectUrl: string(), + redirectUrl: JsonLike.STRING, }) .expect(async (ctx) => { const redirectUrl = ctx.res.json['redirectUrl'] as string; @@ -104,9 +103,9 @@ const SignInE2ESpec = e2eSuite('POST /auth/signin', (app) => { .withApplication(application.id) .withBody(dto) .expectStatus(200) - .expectJsonMatch({ + .$expectRegexableJson({ signedIn: false, - token: string(), + token: JsonLike.STRING, redirectUrl: null, }) .expect(async (ctx) => { diff --git a/test/e2e/auth/validate-login.e2e-spec.ts b/test/e2e/auth/validate-login.e2e-spec.ts index cc29a470..dd1af59c 100644 --- a/test/e2e/auth/validate-login.e2e-spec.ts +++ b/test/e2e/auth/validate-login.e2e-spec.ts @@ -1,9 +1,8 @@ import * as pactum from 'pactum'; -import { e2eSuite } from '../../utils/test_utils'; +import { e2eSuite, JsonLike } from '../../utils/test_utils'; import { AuthService } from '../../../src/auth/auth.service'; import { ERROR_CODE } from '../../../src/exceptions'; import * as fakedb from '../../utils/fakedb'; -import { string } from 'pactum-matchers'; import { HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../../src/prisma/prisma.service'; import { DEFAULT_APPLICATION } from '../../../prisma/seed/utils'; @@ -51,7 +50,7 @@ const ValidateLoginE2ESpec = e2eSuite('POST /auth/login/validate', (app) => { clientSecret: DEFAULT_APPLICATION.clientSecret, }) .expectStatus(HttpStatus.OK) - .expectJsonMatch({ token: string() }) + .$expectRegexableJson({ token: JsonLike.STRING }) .expect(async (ctx) => { const body = ctx.res.json as { token: string }; const registerData = app().get(JwtService).decode(body.token); diff --git a/test/e2e/branch/get-branches.e2e-spec.ts b/test/e2e/branch/get-branches.e2e-spec.ts index b84a9ec6..18ff1c74 100644 --- a/test/e2e/branch/get-branches.e2e-spec.ts +++ b/test/e2e/branch/get-branches.e2e-spec.ts @@ -14,7 +14,7 @@ export const GetBranchesE2ESpec = e2eSuite('GET /branch', (app) => { .spec() .get('/branch') .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict( + .$expectRegexableJson( [ { code: branch1.code, diff --git a/test/e2e/profile/get-homepage-widgets.e2e.spec.ts b/test/e2e/profile/get-homepage-widgets.e2e.spec.ts index 05ab4fd0..675f10ee 100644 --- a/test/e2e/profile/get-homepage-widgets.e2e.spec.ts +++ b/test/e2e/profile/get-homepage-widgets.e2e.spec.ts @@ -12,7 +12,11 @@ const GetHomepageWidgetsE2ESpec = e2eSuite('GET /profile/homepage', (app) => { }); it('should return a 200 with the widgets if we are logged in', async () => { - return pactum.spec().withBearerToken(user.token).get('/profile/homepage').expectHomepageWidgets(widgets); + return pactum + .spec() + .withBearerToken(user.token) + .get('/profile/homepage') + .expectHomepageWidgets(widgets.mappedSort((w) => w.x)); }); }); diff --git a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts index 885ef0ab..134dfd27 100644 --- a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts +++ b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts @@ -100,7 +100,12 @@ const SetHomepageWidgetsE2ESpec = e2eSuite('PUT /profile/homepage', (app) => { .expectAppError(ERROR_CODE.WIDGET_OVERLAPPING, '0', '1')); it('should successfully set the homepage widgets', async () => { - await pactum.spec().put('/profile/homepage').withBearerToken(user.token).withJson(body).expectHomepageWidgets(body); + await pactum + .spec() + .put('/profile/homepage') + .withBearerToken(user.token) + .withJson(body) + .expectHomepageWidgets(body.mappedSort((w) => w.x)); const prisma = app().get(PrismaService); const widgetsFromDb = await prisma.userHomepageWidget.findMany(); expect(widgetsFromDb).toHaveLength(2); diff --git a/test/e2e/timetable/create-entry.e2e-spec.ts b/test/e2e/timetable/create-entry.e2e-spec.ts index 09cc71ef..94dd1818 100644 --- a/test/e2e/timetable/create-entry.e2e-spec.ts +++ b/test/e2e/timetable/create-entry.e2e-spec.ts @@ -1,8 +1,7 @@ -import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; import * as fakedb from '../../utils/fakedb'; import * as pactum from 'pactum'; import { HttpStatus } from '@nestjs/common'; -import { uuid } from 'pactum-matchers'; import { ERROR_CODE } from '../../../src/exceptions'; const CreateEntryE2ESpec = e2eSuite('POST /timetable/current', (app) => { @@ -28,7 +27,7 @@ const CreateEntryE2ESpec = e2eSuite('POST /timetable/current', (app) => { .withJson({ location: 'In the test ig ?', duration: 3, - firstRepetitionDate: new Date(0).toISOString(), + firstRepetitionDate: new Date(0), repetitionFrequency: 10, repetitions: 4, groups: [], @@ -43,7 +42,7 @@ const CreateEntryE2ESpec = e2eSuite('POST /timetable/current', (app) => { .withJson({ location: 'In the test ig ?', duration: 3, - firstRepetitionDate: new Date(0).toISOString(), + firstRepetitionDate: new Date(0), repetitionFrequency: 10, repetitions: 4, groups: ['abcdef'], @@ -58,7 +57,7 @@ const CreateEntryE2ESpec = e2eSuite('POST /timetable/current', (app) => { .withJson({ location: 'In the test ig ?', duration: 3, - firstRepetitionDate: new Date(0).toISOString(), + firstRepetitionDate: new Date(0), repetitionFrequency: 10, repetitions: 4, groups: [Dummies.UUID], @@ -73,7 +72,7 @@ const CreateEntryE2ESpec = e2eSuite('POST /timetable/current', (app) => { .withJson({ location: 'In the test ig ?', duration: 3, - firstRepetitionDate: new Date(0).toISOString(), + firstRepetitionDate: new Date(0), repetitionFrequency: 10, repetitions: 4, groups: [randomGroup.id], @@ -88,18 +87,18 @@ const CreateEntryE2ESpec = e2eSuite('POST /timetable/current', (app) => { .withJson({ location: 'In the test ig ?', duration: 3, - firstRepetitionDate: new Date(0).toISOString(), + firstRepetitionDate: new Date(0), repetitionFrequency: 10, repetitions: 4, groups: [userGroup.id], }) .expectStatus(HttpStatus.CREATED) - .expectJsonMatchStrict({ - id: uuid(), + .$expectRegexableJson({ + id: JsonLike.UUID, location: 'In the test ig ?', duration: 3, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(30).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(30), repetitionFrequency: 10, repetitions: 4, groups: [userGroup.id], diff --git a/test/e2e/timetable/delete-occurrences.e2e-spec.ts b/test/e2e/timetable/delete-occurrences.e2e-spec.ts index e5d09b27..d93fac60 100644 --- a/test/e2e/timetable/delete-occurrences.e2e-spec.ts +++ b/test/e2e/timetable/delete-occurrences.e2e-spec.ts @@ -98,12 +98,12 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) for: [userGroup.id, userOtherGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(20), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], @@ -111,8 +111,8 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) { id: override.id, location: override.location, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -142,20 +142,20 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) for: [userGroup.id, userOtherGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(20), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], overrides: [ { id: override.id, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -182,12 +182,12 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) for: [userGroup.id, userOtherGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(20), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], @@ -195,8 +195,8 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) { id: uuid(), location: null, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(10).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(10), firstOccurrenceOverride: 0, lastOccurrenceOverride: 1, overrideFrequency: 1, @@ -206,8 +206,8 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) { id: override.id, location: override.location, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -233,12 +233,12 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) for: [userGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(20), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], @@ -246,8 +246,8 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) { id: override.id, location: override.location, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -257,8 +257,8 @@ const DeleteEntryE2ESpec = e2eSuite('DELETE /timetable/current/:entryId', (app) { id: uuid(), location: null, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, diff --git a/test/e2e/timetable/get-daily-timetable-e2e-spec.ts b/test/e2e/timetable/get-daily-timetable-e2e-spec.ts index 4ddd896a..83f0561f 100644 --- a/test/e2e/timetable/get-daily-timetable-e2e-spec.ts +++ b/test/e2e/timetable/get-daily-timetable-e2e-spec.ts @@ -58,17 +58,17 @@ const GetDailyTimetableE2ESpec = e2eSuite('GET /timetable/current/daily/:day/:mo .get(`/timetable/current/daily/${date}/${month}/${year}`) .withBearerToken(user.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict([ + .$expectRegexableJson([ { id: `0@${timetableEntry.id}`, - start: new Date(0).toISOString(), - end: new Date(3_600_000).toISOString(), + start: new Date(0), + end: new Date(3_600_000), location: timetableEntry.location, }, { id: `1@${timetableEntry.id}`, - start: new Date(12 * 3_600_000).toISOString(), - end: new Date(13 * 3_600_000).toISOString(), + start: new Date(12 * 3_600_000), + end: new Date(13 * 3_600_000), location: timetableEntry.location, }, ]); diff --git a/test/e2e/timetable/get-entry-details.e2e-spec.ts b/test/e2e/timetable/get-entry-details.e2e-spec.ts index d7f3a0c4..8866f105 100644 --- a/test/e2e/timetable/get-entry-details.e2e-spec.ts +++ b/test/e2e/timetable/get-entry-details.e2e-spec.ts @@ -22,10 +22,8 @@ const GetEntryDetailsE2ESpec = e2eSuite('GET /timetable/:entryId', (app) => { id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: entry.eventStart.toISOString(), - lastRepetitionDate: new Date( - entry.eventStart.getTime() + (entry.occurrencesCount - 1) * entry.repeatEvery, - ).toISOString(), + firstRepetitionDate: entry.eventStart, + lastRepetitionDate: new Date(entry.eventStart.getTime() + (entry.occurrencesCount - 1) * entry.repeatEvery), repetitionFrequency: entry.repeatEvery, repetitions: entry.occurrencesCount, groups: [user1Group.id], @@ -33,8 +31,8 @@ const GetEntryDetailsE2ESpec = e2eSuite('GET /timetable/:entryId', (app) => { { id: override2.id, location: override2.location, - firstRepetitionDate: entry.eventStart.toISOString(), - lastRepetitionDate: entry.eventStart.toISOString(), + firstRepetitionDate: entry.eventStart, + lastRepetitionDate: entry.eventStart, firstOccurrenceOverride: override2.applyFrom, lastOccurrenceOverride: override2.applyUntil, overrideFrequency: override2.repeatEvery, @@ -44,8 +42,8 @@ const GetEntryDetailsE2ESpec = e2eSuite('GET /timetable/:entryId', (app) => { { id: override1.id, location: override1.location, - firstRepetitionDate: entry.eventStart.toISOString(), - lastRepetitionDate: entry.eventStart.toISOString(), + firstRepetitionDate: entry.eventStart, + lastRepetitionDate: entry.eventStart, firstOccurrenceOverride: override1.applyFrom, lastOccurrenceOverride: override1.applyUntil, overrideFrequency: override1.repeatEvery, @@ -81,7 +79,7 @@ const GetEntryDetailsE2ESpec = e2eSuite('GET /timetable/:entryId', (app) => { .get(`/timetable/0@${entry.id}`) .withBearerToken(user1.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict(entryDetails)); + .$expectRegexableJson(entryDetails)); it('should return the details of the entry, which is an override', () => pactum @@ -89,7 +87,7 @@ const GetEntryDetailsE2ESpec = e2eSuite('GET /timetable/:entryId', (app) => { .get(`/timetable/0@${override1.id}`) .withBearerToken(user1.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict(entryDetails)); + .$expectRegexableJson(entryDetails)); }); export default GetEntryDetailsE2ESpec; diff --git a/test/e2e/timetable/get-groups.e2e-spec.ts b/test/e2e/timetable/get-groups.e2e-spec.ts index aee513eb..b8c8e5ee 100644 --- a/test/e2e/timetable/get-groups.e2e-spec.ts +++ b/test/e2e/timetable/get-groups.e2e-spec.ts @@ -25,7 +25,7 @@ const GetGroupsE2ESpec = e2eSuite('GET /timetable/current/groups', (app) => { .get('/timetable/current/groups') .withBearerToken(user1.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict([ + .$expectRegexableJson([ { id: user1And2Group.id, name: user1And2Group.name, priority: 1 }, { id: user1Group.id, name: user1Group.name, priority: 2 }, ])); diff --git a/test/e2e/timetable/get-timetable.e2e-spec.ts b/test/e2e/timetable/get-timetable.e2e-spec.ts index 22d76926..694c2e8c 100644 --- a/test/e2e/timetable/get-timetable.e2e-spec.ts +++ b/test/e2e/timetable/get-timetable.e2e-spec.ts @@ -55,17 +55,17 @@ const GetTimetableE2ESpec = e2eSuite('GET /timetable/current/:daysCount/:day/:mo .get(`/timetable/current/2/${date}/${month}/${year}`) .withBearerToken(user.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict([ + .$expectRegexableJson([ { id: `0@${timetableEntry.id}`, - start: new Date(0).toISOString(), - end: new Date(1).toISOString(), + start: new Date(0), + end: new Date(1), location: timetableEntry.location, }, { id: `1@${timetableEntry.id}`, - start: new Date(24 * 3_600_000).toISOString(), - end: new Date(24 * 3_600_000 + 1).toISOString(), + start: new Date(24 * 3_600_000), + end: new Date(24 * 3_600_000 + 1), location: timetableEntry.location, }, ]); diff --git a/test/e2e/timetable/update-entry.e2e-spec.ts b/test/e2e/timetable/update-entry.e2e-spec.ts index a29f808c..ac07951a 100644 --- a/test/e2e/timetable/update-entry.e2e-spec.ts +++ b/test/e2e/timetable/update-entry.e2e-spec.ts @@ -102,12 +102,12 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = for: [userGroup.id, userOtherGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: newLocation, duration: 100, - firstRepetitionDate: new Date(1000).toISOString(), - lastRepetitionDate: new Date(20 + 1000).toISOString(), + firstRepetitionDate: new Date(1000), + lastRepetitionDate: new Date(20 + 1000), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], @@ -115,8 +115,8 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = { id: override.id, location: override.location, - firstRepetitionDate: new Date(10 + 1000).toISOString(), - lastRepetitionDate: new Date(20 + 1000).toISOString(), + firstRepetitionDate: new Date(10 + 1000), + lastRepetitionDate: new Date(20 + 1000), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -147,12 +147,12 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = for: [userGroup.id, userOtherGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(20), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], @@ -160,8 +160,8 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = { id: uuid(), location: 'Somewhere else', - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(10).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(10), firstOccurrenceOverride: 0, lastOccurrenceOverride: 1, overrideFrequency: 1, @@ -171,8 +171,8 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = { id: override.id, location: override.location, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -200,12 +200,12 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = for: [userGroup.id, userOtherGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(20), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], @@ -213,8 +213,8 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = { id: override.id, location: newLocation, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -240,12 +240,12 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = for: [userGroup.id], }) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ + .$expectRegexableJson({ id: entry.id, location: entry.location, duration: entry.occurrenceDuration, - firstRepetitionDate: new Date(0).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(0), + lastRepetitionDate: new Date(20), repetitionFrequency: 10, repetitions: 3, groups: [userOtherGroup.id, userGroup.id], @@ -253,8 +253,8 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = { id: override.id, location: override.location, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, @@ -264,8 +264,8 @@ const UpdateEntryE2ESpec = e2eSuite('PATCH /timetable/current/:entryId', (app) = { id: uuid(), location: newLocation, - firstRepetitionDate: new Date(10).toISOString(), - lastRepetitionDate: new Date(20).toISOString(), + firstRepetitionDate: new Date(10), + lastRepetitionDate: new Date(20), firstOccurrenceOverride: 1, lastOccurrenceOverride: 2, overrideFrequency: 1, diff --git a/test/e2e/ue/annals/delete-annal.e2e-spec.ts b/test/e2e/ue/annals/delete-annal.e2e-spec.ts index 01096384..6411b1f0 100644 --- a/test/e2e/ue/annals/delete-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/delete-annal.e2e-spec.ts @@ -13,13 +13,17 @@ import { import { Dummies, JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; -import { pick } from '../../../../src/utils'; +import { PermissionManager, pick } from '../../../../src/utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const DeleteAnnal = e2eSuite('DELETE /ue/annals/{annalId}', (app) => { - const senderUser = createUser(app, { permissions: ['API_UPLOAD_ANNALS'] }); - const nonUeUser = createUser(app, { login: 'user2', studentId: 2, permissions: ['API_UPLOAD_ANNALS'] }); + const senderUser = createUser(app, { permissions: new PermissionManager().with('API_UPLOAD_ANNALS') }); + const nonUeUser = createUser(app, { + login: 'user2', + studentId: 2, + permissions: new PermissionManager().with('API_UPLOAD_ANNALS'), + }); const userNoPermission = createUser(app); const annalType = createAnnalType(app); const semester = createSemester(app); @@ -67,8 +71,8 @@ const DeleteAnnal = e2eSuite('DELETE /ue/annals/{annalId}', (app) => { type: annalType, status: AnnalStatus.DELETED | AnnalStatus.VALIDATED, sender: pick(senderUser, 'id', 'firstName', 'lastName'), - createdAt: annal_validated.createdAt.toISOString(), - updatedAt: JsonLike.ANY_DATE, + createdAt: annal_validated.createdAt, + updatedAt: JsonLike.DATE, }); return app() .get(PrismaService) diff --git a/test/e2e/ue/annals/get-annal-file.e2e-spec.ts b/test/e2e/ue/annals/get-annal-file.e2e-spec.ts index d2e19804..3bbc6d82 100644 --- a/test/e2e/ue/annals/get-annal-file.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annal-file.e2e-spec.ts @@ -13,11 +13,16 @@ import { import { Dummies, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; +import { PermissionManager } from '../../../../src/utils'; const GetAnnalFile = e2eSuite('GET /ue/annals/{annalId}', (app) => { - const senderUser = createUser(app, { permissions: ['API_SEE_ANNALS'] }); - const nonUeUser = createUser(app, { login: 'user2', studentId: 2, permissions: ['API_SEE_ANNALS'] }); - // const moderator = createUser(app, { login: 'user3', studentId: 3, permissions: ['annalModerator'] }); + const senderUser = createUser(app, { permissions: new PermissionManager().with('API_SEE_ANNALS') }); + const nonUeUser = createUser(app, { + login: 'user2', + studentId: 2, + permissions: new PermissionManager().with('API_SEE_ANNALS'), + }); + // const moderator = createUser(app, { login: 'user3', studentId: 3, permissions: new PermissionManager().add('annalModerator') }); const userNoPermission = createUser(app); const annalType = createAnnalType(app); const semester = createSemester(app); diff --git a/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts b/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts index 838048f5..46ebba87 100644 --- a/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annal-metadata.e2e-spec.ts @@ -12,14 +12,19 @@ import { import { e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; import { Permission } from '@prisma/client'; +import { PermissionManager } from '../../../../src/utils'; const GetAnnalMetadata = e2eSuite('GET /ue/annals/metadata', (app) => { - const ueUser = createUser(app, { permissions: [Permission.API_SEE_ANNALS] }); - const nonUeUser = createUser(app, { login: 'user2', studentId: 3, permissions: [Permission.API_SEE_ANNALS] }); + const ueUser = createUser(app, { permissions: new PermissionManager().with(Permission.API_SEE_ANNALS) }); + const nonUeUser = createUser(app, { + login: 'user2', + studentId: 3, + permissions: new PermissionManager().with(Permission.API_SEE_ANNALS), + }); const uploader = createUser(app, { login: 'user3', studentId: 4, - permissions: [Permission.API_SEE_ANNALS, Permission.API_UPLOAD_ANNALS], + permissions: new PermissionManager().with(Permission.API_SEE_ANNALS).with(Permission.API_UPLOAD_ANNALS), }); const userNoPermission = createUser(app); const annalType = createAnnalType(app); diff --git a/test/e2e/ue/annals/get-annals.e2e-spec.ts b/test/e2e/ue/annals/get-annals.e2e-spec.ts index a225e849..ff190f34 100644 --- a/test/e2e/ue/annals/get-annals.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annals.e2e-spec.ts @@ -14,16 +14,20 @@ import { e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; import { AnnalStatus, UeAnnalFile } from '../../../../src/ue/annals/interfaces/annal.interface'; import { JsonLikeVariant } from 'test/declarations'; -import { pick } from '../../../../src/utils'; +import { PermissionManager, pick } from '../../../../src/utils'; import { CommentStatus } from '../../../../src/ue/comments/interfaces/comment.interface'; const GetAnnal = e2eSuite('GET /ue/annals', (app) => { - const senderUser = createUser(app, { permissions: ['API_SEE_ANNALS'] }); - const nonUeUser = createUser(app, { login: 'user2', studentId: 2, permissions: ['API_SEE_ANNALS'] }); + const senderUser = createUser(app, { permissions: new PermissionManager().with('API_SEE_ANNALS') }); + const nonUeUser = createUser(app, { + login: 'user2', + studentId: 2, + permissions: new PermissionManager().with('API_SEE_ANNALS'), + }); const moderator = createUser(app, { login: 'user3', studentId: 3, - permissions: ['API_SEE_ANNALS', 'API_MODERATE_ANNALS'], + permissions: new PermissionManager().with('API_SEE_ANNALS').with('API_MODERATE_ANNALS'), }); const userNoPermission = createUser(app); const annalType = createAnnalType(app); @@ -113,8 +117,8 @@ const GetAnnal = e2eSuite('GET /ue/annals', (app) => { const formatAnnalFile = (from: Partial): JsonLikeVariant => { return { ...pick(from, 'id', 'semesterId', 'status', 'sender', 'type', 'ueof'), - createdAt: from.createdAt?.toISOString(), - updatedAt: from.updatedAt?.toISOString(), + createdAt: from.createdAt, + updatedAt: from.updatedAt, }; }; }); diff --git a/test/e2e/ue/annals/patch-annal.e2e-spec.ts b/test/e2e/ue/annals/patch-annal.e2e-spec.ts index 8a5f338c..990f7b61 100644 --- a/test/e2e/ue/annals/patch-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/patch-annal.e2e-spec.ts @@ -12,12 +12,16 @@ import { } from '../../../utils/fakedb'; import { Dummies, JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { pick } from '../../../../src/utils'; +import { PermissionManager, pick } from '../../../../src/utils'; import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { - const senderUser = createUser(app, { permissions: ['API_UPLOAD_ANNALS'] }); - const nonUeUser = createUser(app, { login: 'user2', studentId: 2, permissions: ['API_UPLOAD_ANNALS'] }); + const senderUser = createUser(app, { permissions: new PermissionManager().with('API_UPLOAD_ANNALS') }); + const nonUeUser = createUser(app, { + login: 'user2', + studentId: 2, + permissions: new PermissionManager().with('API_UPLOAD_ANNALS'), + }); const userNoPermission = createUser(app); const annalType = createAnnalType(app); const semester = createSemester(app); @@ -105,8 +109,8 @@ const EditAnnal = e2eSuite('PATCH /ue/annals/{annalId}', (app) => { status: AnnalStatus.VALIDATED, sender: pick(senderUser, 'id', 'firstName', 'lastName'), id: annal_validated.id, - createdAt: annal_validated.createdAt.toISOString(), - updatedAt: JsonLike.ANY_DATE, + createdAt: annal_validated.createdAt, + updatedAt: JsonLike.DATE, }); }); }); diff --git a/test/e2e/ue/annals/upload-annal.e2e-spec.ts b/test/e2e/ue/annals/upload-annal.e2e-spec.ts index 4873fdcb..d8bb775e 100644 --- a/test/e2e/ue/annals/upload-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/upload-annal.e2e-spec.ts @@ -12,15 +12,21 @@ import { import { JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; import { ConfigModule } from '../../../../src/config/config.module'; -import { pick } from '../../../../src/utils'; +import { PermissionManager, pick } from '../../../../src/utils'; import { mkdirSync, rmSync } from 'fs'; import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { - const senderUser = createUser(app, { permissions: ['API_UPLOAD_ANNALS'] }); - const nonUeUser = createUser(app, { login: 'user2', studentId: 2, permissions: ['API_UPLOAD_ANNALS'] }); + const senderUser = createUser(app, { permissions: new PermissionManager().with('API_UPLOAD_ANNALS') }); + const nonUeUser = createUser(app, { + login: 'user2', + studentId: 2, + permissions: new PermissionManager().with('API_UPLOAD_ANNALS'), + }); const userNoPermission = createUser(app); - const userModerator = createUser(app, { permissions: ['API_UPLOAD_ANNALS', 'API_MODERATE_ANNALS'] }); + const userModerator = createUser(app, { + permissions: new PermissionManager().with('API_UPLOAD_ANNALS').with('API_MODERATE_ANNALS'), + }); const annalType = createAnnalType(app); const semester = createSemester(app); const otherRandomSemester = createSemester(app); @@ -131,9 +137,9 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { }) .expectUeAnnal( { - id: JsonLike.ANY_UUID, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + id: JsonLike.UUID, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, status: AnnalStatus.PROCESSING, @@ -148,7 +154,7 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { .put(`/ue/annals/${ueAnnalFile.id}?rotate=${rotation}`) .withFile('file', `test/e2e/ue/annals/artifacts/annal.${fileExt}`) .expectUeAnnal({ - ...pick(ueAnnalFile, 'id', 'semesterId', 'type', 'status', 'sender', 'createdAt', 'createdAt'), + ...pick(ueAnnalFile, 'id', 'semesterId', 'type', 'status', 'sender', 'createdAt', 'updatedAt'), }); }; it('from a pdf', testFunction('pdf', 0)); @@ -169,9 +175,9 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { }) .expectUeAnnal( { - id: JsonLike.ANY_UUID, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + id: JsonLike.UUID, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, status: AnnalStatus.PROCESSING, @@ -203,9 +209,9 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { }) .expectUeAnnal( { - id: JsonLike.ANY_UUID, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + id: JsonLike.UUID, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, status: AnnalStatus.PROCESSING, @@ -237,9 +243,9 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { }) .expectUeAnnal( { - id: JsonLike.ANY_UUID, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + id: JsonLike.UUID, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, status: AnnalStatus.PROCESSING, @@ -267,9 +273,9 @@ const PostAnnal = e2eSuite('POST-PUT /ue/annals', (app) => { }) .expectUeAnnal( { - id: JsonLike.ANY_UUID, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + id: JsonLike.UUID, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semesterId: semester.code, type: annalType, status: AnnalStatus.PROCESSING, diff --git a/test/e2e/ue/comments/delete-comment.e2e-spec.ts b/test/e2e/ue/comments/delete-comment.e2e-spec.ts index 66be641a..a7b1f32d 100644 --- a/test/e2e/ue/comments/delete-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-comment.e2e-spec.ts @@ -13,10 +13,14 @@ import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../../src/exceptions'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { PermissionManager } from '../../../../src/utils'; const DeleteComment = e2eSuite('DELETE /ue/comments/:commentId', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNotAuthor = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -73,13 +77,15 @@ const DeleteComment = e2eSuite('DELETE /ue/comments/:commentId', (app) => { id: user.id, firstName: user.firstName, lastName: user.lastName, + studentId: user.studentId, }, - createdAt: comment1.createdAt.toISOString(), - updatedAt: comment1.updatedAt.toISOString(), + createdAt: comment1.createdAt, + updatedAt: comment1.updatedAt, semester: semester.code, isAnonymous: comment1.isAnonymous, body: comment1.body, answers: [], + reports: [], upvotes: 1, upvoted: true, status: CommentStatus.DELETED, diff --git a/test/e2e/ue/comments/delete-reply.e2e-spec.ts b/test/e2e/ue/comments/delete-reply.e2e-spec.ts index 2ed0cce5..77789a88 100644 --- a/test/e2e/ue/comments/delete-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-reply.e2e-spec.ts @@ -13,10 +13,14 @@ import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../../src/exceptions'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { PermissionManager } from '../../../../src/utils'; const DeleteCommentReply = e2eSuite('DELETE /ue/comments/reply/{replyId}', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNotAuthor = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -78,10 +82,11 @@ const DeleteCommentReply = e2eSuite('DELETE /ue/comments/reply/{replyId}', (app) firstName: user.firstName, lastName: user.lastName, }, - createdAt: reply.createdAt.toISOString(), - updatedAt: reply.updatedAt.toISOString(), + createdAt: reply.createdAt, + updatedAt: reply.updatedAt, body: reply.body, status: CommentStatus.DELETED, + reports: [] }); await app() .get(PrismaService) diff --git a/test/e2e/ue/comments/delete-upvote.e2e-spec.ts b/test/e2e/ue/comments/delete-upvote.e2e-spec.ts index 6f40094e..4dae5dda 100644 --- a/test/e2e/ue/comments/delete-upvote.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-upvote.e2e-spec.ts @@ -13,10 +13,14 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { HttpStatus } from '@nestjs/common'; import { Dummies, e2eSuite } from '../../../utils/test_utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { PermissionManager } from '../../../../src/utils'; const DeleteUpvote = e2eSuite('DELETE /ue/comments/{commentId}/upvote', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNotAuthor = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -72,7 +76,7 @@ const DeleteUpvote = e2eSuite('DELETE /ue/comments/{commentId}/upvote', (app) => .withBearerToken(userNotAuthor.token) .delete(`/ue/comments/${comment1.id}/upvote`) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ upvoted: false }); + .$expectRegexableJson({ upvoted: false }); return createCommentUpvote(app, { user: userNotAuthor, comment: comment1 }, upvote, true); }); diff --git a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts index f49f1d2e..0aa25295 100644 --- a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts @@ -3,19 +3,22 @@ import * as fakedb from '../../../utils/fakedb'; import { e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from 'src/exceptions'; import { faker } from '@faker-js/faker'; -import { omit } from '../../../../src/utils'; +import { omit, PermissionManager } from '../../../../src/utils'; import { FakeComment } from '../../../utils/fakedb'; const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => { - const user = fakedb.createUser(app, { permissions: ['API_SEE_OPINIONS_UE'] }); - const userNotAuthor = fakedb.createUser(app, { login: 'user2', permissions: ['API_SEE_OPINIONS_UE'] }); + const user = fakedb.createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE') }); + const userNotAuthor = fakedb.createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE'), + }); const userNoPermission = fakedb.createUser(app); const semester = fakedb.createSemester(app); const branch = fakedb.createBranch(app); const branchOption = fakedb.createBranchOption(app, { branch }); const ue = fakedb.createUe(app); const ueof = fakedb.createUeof(app, { branchOptions: [branchOption], semesters: [semester], ue }); - const comment = fakedb.createComment(app, { user, ueof, semester }); + const comment = fakedb.createComment(app, { user, ueof, semester }, { isAnonymous: true }); fakedb.createCommentUpvote(app, { user: userNotAuthor, comment }); const reply = fakedb.createCommentReply(app, { user, comment }, { body: 'HelloWorld' }); @@ -72,12 +75,19 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => firstName: user.firstName, lastName: user.lastName, }, - createdAt: reply.createdAt.toISOString(), - updatedAt: reply.updatedAt.toISOString(), + createdAt: reply.createdAt, + updatedAt: reply.updatedAt, + reports: [] }, ], - updatedAt: comment.updatedAt.toISOString(), - createdAt: comment.createdAt.toISOString(), + author: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + studentId: user.studentId, + }, + updatedAt: comment.updatedAt, + createdAt: comment.createdAt, semester: semester.code, upvotes: 1, reports: [], @@ -100,12 +110,19 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => answers: [ { ...omit(reply, 'authorId', 'deletedAt', 'commentId'), - createdAt: reply.createdAt.toISOString(), - updatedAt: reply.updatedAt.toISOString(), + createdAt: reply.createdAt, + updatedAt: reply.updatedAt, + author: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + }, + reports: [], }, ], - updatedAt: comment.updatedAt.toISOString(), - createdAt: comment.createdAt.toISOString(), + author: null, + updatedAt: comment.updatedAt, + createdAt: comment.createdAt, semester: semester.code, upvotes: 1, reports: [], diff --git a/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts index 40de953f..38ec5efd 100644 --- a/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts @@ -2,9 +2,10 @@ import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; import { createCommentReportReason, createUser } from '../../../../test/utils/fakedb'; import { e2eSuite } from '../../../../test/utils/test_utils'; +import { PermissionManager } from '../../../../src/utils'; const GetCommentReportReason = e2eSuite('GET /ue/comments/reports/reasons', (app) => { - const user = createUser(app, { permissions: ['API_SEE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE') }); const userNoPermission = createUser(app); createCommentReportReason(app, { name: 'meh' }); createCommentReportReason(app, { name: 'bad' }); diff --git a/test/e2e/ue/comments/get-comment.e2e-spec.ts b/test/e2e/ue/comments/get-comment.e2e-spec.ts index e02f8097..281a0ebc 100644 --- a/test/e2e/ue/comments/get-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment.e2e-spec.ts @@ -15,14 +15,15 @@ import { e2eSuite } from '../../../utils/test_utils'; import { ConfigModule } from '../../../../src/config/config.module'; import { ERROR_CODE } from 'src/exceptions'; import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { PermissionManager } from '../../../../src/utils'; const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { - const user = createUser(app, { permissions: ['API_SEE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE') }); const userNoPermission = createUser(app, { login: 'user2', studentId: 2 }); const moderator = createUser(app, { login: 'user3', studentId: 3, - permissions: ['API_MODERATE_COMMENTS', 'API_SEE_OPINIONS_UE'], + permissions: new PermissionManager().with('API_MODERATE_COMMENTS').with('API_SEE_OPINIONS_UE'), }); const semester = createSemester(app); const branch = createBranch(app); @@ -176,7 +177,7 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { ? (b.createdAt).getTime() - (a.createdAt).getTime() : b.upvotes - a.upvotes, ) - .map((comment) => ({ ...comment, ue })) + .map((comment) => ({ ...comment, ue, })) .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), itemCount: comments.length, itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, diff --git a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts index 67dfbe15..0467034e 100644 --- a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts +++ b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts @@ -19,11 +19,12 @@ import { } from '../../../utils/fakedb'; import { e2eSuite } from '../../../utils/test_utils'; import { Prisma } from '@prisma/client'; +import { PermissionManager } from '../../../../src/utils'; const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { const userModerator = createUser(app, { login: 'user2', - permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE', 'API_MODERATE_COMMENTS'], + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE').with('API_MODERATE_COMMENTS'), }); const semester = createSemester(app); const branch = createBranch(app); @@ -69,7 +70,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { const userNoPermission = await createUser(app, {}, true); const userNotModerator = await createUser( app, - { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }, + { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }, true, ); await pactum diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts index ecddfd01..07c24e23 100644 --- a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -1,3 +1,4 @@ +import { PermissionManager } from '../../../../src/utils'; import { createBranch, createBranchOption, @@ -9,16 +10,16 @@ import { createUeof, createUser, } from '../../../utils/fakedb'; -import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import { Dummies, e2eSuite, JsonLike } from '../../../utils/test_utils'; import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', (app) => { - const commentAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); - const replyAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); + const commentAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); + const replyAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); const userNotAuthor = createUser(app, { login: 'user2', - permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'], + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), }); const userNoPermission = createUser(app); const semester = createSemester(app); @@ -104,8 +105,10 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', reason: reportReason.name, }) .expectUeCommentReport({ + id: JsonLike.UUID, body: "it's offensive", reason: reportReason.name, + createdAt: JsonLike.DATE, reportedBody: reply.body, mitigated: false, user: { diff --git a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts index e9eec6d7..23cfb754 100644 --- a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts @@ -1,3 +1,4 @@ +import { PermissionManager } from '../../../../src/utils'; import { createBranch, createBranchOption, @@ -8,13 +9,13 @@ import { createUeof, createUser, } from '../../../utils/fakedb'; -import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import { Dummies, e2eSuite, JsonLike } from '../../../utils/test_utils'; import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => { - const user = createUser(app, { permissions: ['API_SEE_OPINIONS_UE','API_GIVE_OPINIONS_UE'] }); - const userNotAuthor = createUser(app, { login: 'user2', permissions: ['API_SEE_OPINIONS_UE','API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); + const userNotAuthor = createUser(app, { login: 'user2', permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -95,9 +96,11 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => body: "it's offensive", reason: reportReason.name, }).expectUeCommentReport({ + id: JsonLike.UUID, body: "it's offensive", reason: reportReason.name, reportedBody: comment.body, + createdAt: JsonLike.DATE, mitigated: false, user: { id: userNotAuthor.id, diff --git a/test/e2e/ue/comments/post-comment.e2e-spec.ts b/test/e2e/ue/comments/post-comment.e2e-spec.ts index 71a24383..0c8598f3 100644 --- a/test/e2e/ue/comments/post-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment.e2e-spec.ts @@ -13,10 +13,14 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { e2eSuite, JsonLike } from '../../../utils/test_utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; +import { PermissionManager } from '../../../../src/utils'; const PostCommment = e2eSuite('POST /ue/comments', (app) => { - const userNotDoneUe = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userDidUe = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const userNotDoneUe = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userDidUe = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -110,19 +114,21 @@ const PostCommment = e2eSuite('POST /ue/comments', (app) => { }) .expectUeComment( { - id: JsonLike.ANY_UUID, + id: JsonLike.UUID, ueof, author: { id: userDidUe.id, firstName: userDidUe.firstName, lastName: userDidUe.lastName, + studentId: userDidUe.studentId, }, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semester: semester.code, isAnonymous: true, body: 'Cette UE est troooop bien', answers: [], + reports: [], upvotes: 0, upvoted: false, status: CommentStatus.ACTIVE, @@ -158,18 +164,20 @@ const PostCommment = e2eSuite('POST /ue/comments', (app) => { .expectUeComment( { ueof, - id: JsonLike.ANY_UUID, + id: JsonLike.UUID, author: { id: userDidUe.id, firstName: userDidUe.firstName, lastName: userDidUe.lastName, + studentId: userDidUe.studentId, }, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semester: semester.code, isAnonymous: false, body: 'Cette UE est troooop bien', answers: [], + reports: [], upvotes: 0, upvoted: false, status: CommentStatus.ACTIVE, diff --git a/test/e2e/ue/comments/post-reply.e2e-spec.ts b/test/e2e/ue/comments/post-reply.e2e-spec.ts index a1ad5736..0e998805 100644 --- a/test/e2e/ue/comments/post-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/post-reply.e2e-spec.ts @@ -13,9 +13,10 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { Dummies, e2eSuite, JsonLike } from '../../../utils/test_utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; +import { PermissionManager } from '../../../../src/utils'; const PostCommmentReply = e2eSuite('POST /ue/comments/{commentId}/reply', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -110,16 +111,17 @@ const PostCommmentReply = e2eSuite('POST /ue/comments/{commentId}/reply', (app) }) .expectUeCommentReply( { - id: JsonLike.ANY_UUID, + id: JsonLike.UUID, author: { id: user.id, lastName: user.lastName, firstName: user.firstName, }, body: 'heyhey', - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, status: CommentStatus.ACTIVE, + reports: [], }, true, ); diff --git a/test/e2e/ue/comments/post-upvote.e2e-spec.ts b/test/e2e/ue/comments/post-upvote.e2e-spec.ts index d79d9015..6daafcde 100644 --- a/test/e2e/ue/comments/post-upvote.e2e-spec.ts +++ b/test/e2e/ue/comments/post-upvote.e2e-spec.ts @@ -13,10 +13,14 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { HttpStatus } from '@nestjs/common'; import { Dummies, e2eSuite } from '../../../utils/test_utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; +import { PermissionManager } from '../../../../src/utils'; const PostUpvote = e2eSuite('POST /ue/comments/{commentId}/upvote', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNotAuthor = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -71,7 +75,7 @@ const PostUpvote = e2eSuite('POST /ue/comments/{commentId}/upvote', (app) => { .withBearerToken(userNotAuthor.token) .post(`/ue/comments/${comment.id}/upvote`) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict({ upvoted: true }); + .$expectRegexableJson({ upvoted: true }); return app().get(PrismaService).ueCommentUpvote.deleteMany(); }); diff --git a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts index 486af977..d8fa6cb6 100644 --- a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts @@ -7,7 +7,6 @@ import { createComment, createCommentReply, createCommentReplyReport, - createCommentReport, createCommentReportReason, createSemester, createUe, @@ -15,11 +14,12 @@ import { createUser, } from '../../../utils/fakedb'; import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import { PermissionManager } from '../../../../src/utils'; const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{reportId}', (app) => { - const commentAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); - const replyAuthor = createUser(app, { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }); - const moderator = createUser(app, { permissions: ['API_MODERATE_COMMENTS'] }); + const commentAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE')}); + const replyAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); + const moderator = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS')}); const semester = createSemester(app); const branch = createBranch(app); const branchOption = createBranchOption(app, { branch }); @@ -38,7 +38,7 @@ const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{r const userNoPermission = await createUser(app, {}, true); const userNotModerator = await createUser( app, - { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }, + { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }, true, ); await pactum @@ -77,7 +77,7 @@ const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{r .expectUeCommentReport({ ...report, mitigated: true, - createdAt: report.createdAt.toISOString(), + createdAt: report.createdAt, }); await app() .get(PrismaService) diff --git a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts index f1550e52..7d154b30 100644 --- a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts @@ -13,9 +13,10 @@ import { createUser, } from '../../../utils/fakedb'; import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import { PermissionManager } from '../../../../src/utils'; const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', (app) => { - const user = createUser(app, { permissions: ['API_MODERATE_COMMENTS'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS')}); const semester = createSemester(app); const branch = createBranch(app); const branchOption = createBranchOption(app, { branch }); @@ -33,7 +34,7 @@ const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', const userNoPermission = await createUser(app, {}, true); const userNotModerator = await createUser( app, - { permissions: ['API_SEE_OPINIONS_UE', 'API_GIVE_OPINIONS_UE'] }, + { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }, true, ); await pactum @@ -72,7 +73,6 @@ const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', .expectUeCommentReport({ ...report, mitigated: true, - createdAt: report.createdAt.toISOString(), }); await app() .get(PrismaService) diff --git a/test/e2e/ue/comments/update-comment.e2e-spec.ts b/test/e2e/ue/comments/update-comment.e2e-spec.ts index 3b815b9c..fdc77ebb 100644 --- a/test/e2e/ue/comments/update-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment.e2e-spec.ts @@ -13,10 +13,14 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { Dummies, e2eSuite, JsonLike } from '../../../utils/test_utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; +import { PermissionManager } from '../../../../src/utils'; const UpdateComment = e2eSuite('PATCH /ue/comments/:commentId', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNotCommentAuthor = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNotCommentAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -114,18 +118,20 @@ const UpdateComment = e2eSuite('PATCH /ue/comments/:commentId', (app) => { }) .expectUeComment({ ueof, - id: JsonLike.ANY_UUID, + id: JsonLike.UUID, author: { id: user.id, firstName: user.firstName, lastName: user.lastName, + studentId: user.studentId, }, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semester: semester.code, isAnonymous: true, body: 'Cette UE est troooop bien', answers: [], + reports: [], upvotes: 1, upvoted: false, status: CommentStatus.ACTIVE, @@ -145,18 +151,20 @@ const UpdateComment = e2eSuite('PATCH /ue/comments/:commentId', (app) => { }) .expectUeComment({ ueof, - id: JsonLike.ANY_UUID, + id: JsonLike.UUID, author: { id: user.id, firstName: user.firstName, lastName: user.lastName, + studentId: user.studentId, }, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, semester: semester.code, isAnonymous: false, body: comment.body, answers: [], + reports: [], upvotes: 1, upvoted: false, status: CommentStatus.ACTIVE, diff --git a/test/e2e/ue/comments/update-reply.e2e-spec.ts b/test/e2e/ue/comments/update-reply.e2e-spec.ts index 555b6684..8b4b460f 100644 --- a/test/e2e/ue/comments/update-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/update-reply.e2e-spec.ts @@ -13,10 +13,14 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { Dummies, e2eSuite, JsonLike } from '../../../utils/test_utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; +import { PermissionManager } from '../../../../src/utils'; const UpdateCommentReply = e2eSuite('PATCH /ue/comments/reply/{replyId}', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNotAuthor = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -112,14 +116,14 @@ const UpdateCommentReply = e2eSuite('PATCH /ue/comments/reply/{replyId}', (app) body: "Je m'appelle Alban Ichou et j'approuve ce commentaire", }) .expectUeCommentReply({ - id: JsonLike.ANY_UUID, + id: JsonLike.UUID, author: { id: user.id, firstName: user.firstName, lastName: user.lastName, }, - createdAt: JsonLike.ANY_DATE, - updatedAt: JsonLike.ANY_DATE, + createdAt: JsonLike.DATE, + updatedAt: JsonLike.DATE, body: "Je m'appelle Alban Ichou et j'approuve ce commentaire", status: CommentStatus.ACTIVE, reports: [] diff --git a/test/e2e/ue/delete-rate.e2e-spec.ts b/test/e2e/ue/delete-rate.e2e-spec.ts index 46e8df0d..d5fe32e1 100644 --- a/test/e2e/ue/delete-rate.e2e-spec.ts +++ b/test/e2e/ue/delete-rate.e2e-spec.ts @@ -13,10 +13,14 @@ import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; import { Dummies, e2eSuite } from '../../utils/test_utils'; import { faker } from '@faker-js/faker'; +import { PermissionManager } from '../../../src/utils'; const DeleteRate = e2eSuite('DELETE /ue/ueof/{ueofCode}/rate/{critetionId}', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNotRated = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNotRated = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermissions = createUser(app); const semester = createSemester(app); const branch = createBranch(app); diff --git a/test/e2e/ue/get-rate-criteria.e2e-spec.ts b/test/e2e/ue/get-rate-criteria.e2e-spec.ts index 6aed69ef..04702edd 100644 --- a/test/e2e/ue/get-rate-criteria.e2e-spec.ts +++ b/test/e2e/ue/get-rate-criteria.e2e-spec.ts @@ -1,4 +1,4 @@ -import { omit } from '../../../src/utils'; +import { omit, PermissionManager } from '../../../src/utils'; import { createUser, createCriterion, FakeUeStarCriterion } from '../../utils/fakedb'; import { e2eSuite } from '../../utils/test_utils'; import * as pactum from 'pactum'; @@ -6,7 +6,7 @@ import { ERROR_CODE } from '../../../src/exceptions'; const GetRateCriteria = e2eSuite('GET /ue/rate/criteria', (app) => { const userNoPermission = createUser(app); - const user = createUser(app, { permissions: ['API_SEE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE') }); const criteria: FakeUeStarCriterion[] = []; for (let i = 0; i < 30; i++) criteria.push(createCriterion(app)); diff --git a/test/e2e/ue/get-ue-rate.e2e-spec.ts b/test/e2e/ue/get-ue-rate.e2e-spec.ts index d0486bca..efc6d96e 100644 --- a/test/e2e/ue/get-ue-rate.e2e-spec.ts +++ b/test/e2e/ue/get-ue-rate.e2e-spec.ts @@ -11,11 +11,15 @@ import { import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; import { e2eSuite } from '../../utils/test_utils'; +import { PermissionManager } from '../../../src/utils'; const GetRateE2ESpec = e2eSuite('GET /ue/:ueCode/rate', (app) => { const userNoPermission = createUser(app); - const userFullRating = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userPartialRating = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const userFullRating = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userPartialRating = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const semester = createSemester(app); const branch = createBranch(app); const branchOption = createBranchOption(app, { branch }); diff --git a/test/e2e/ue/put-rate.e2e-spec.ts b/test/e2e/ue/put-rate.e2e-spec.ts index d5cf8a99..01db5d87 100644 --- a/test/e2e/ue/put-rate.e2e-spec.ts +++ b/test/e2e/ue/put-rate.e2e-spec.ts @@ -13,10 +13,14 @@ import { ERROR_CODE } from 'src/exceptions'; import { e2eSuite } from '../../utils/test_utils'; import { PrismaService } from '../../../src/prisma/prisma.service'; import { faker } from '@faker-js/faker'; +import { PermissionManager } from '../../../src/utils'; const PutRate = e2eSuite('PUT /ue/ueof/{ueofCode}/rate', (app) => { - const user = createUser(app, { permissions: ['API_GIVE_OPINIONS_UE'] }); - const userNoUe = createUser(app, { login: 'user2', permissions: ['API_GIVE_OPINIONS_UE'] }); + const user = createUser(app, { permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE') }); + const userNoUe = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); diff --git a/test/e2e/users/get-current-user-e2e-spec.ts b/test/e2e/users/get-current-user-e2e-spec.ts index b09e65e1..46e8adde 100644 --- a/test/e2e/users/get-current-user-e2e-spec.ts +++ b/test/e2e/users/get-current-user-e2e-spec.ts @@ -4,7 +4,6 @@ import * as pactum from 'pactum'; import { HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../../src/prisma/prisma.service'; import { pick } from '../../../src/utils'; -import { deepDateToString } from '../../declarations'; const GetCurrentUserE2ESpec = e2eSuite('GET /users/current', (app) => { const user = createUser(app); @@ -22,7 +21,7 @@ const GetCurrentUserE2ESpec = e2eSuite('GET /users/current', (app) => { const branch = userFromDb.branchSubscriptions.find( (branch) => branch.semester.start >= new Date() && branch.semester.end <= new Date(), ); - const expectedBody = deepDateToString({ + const expectedBody = { id: userFromDb.id, firstName: userFromDb.firstName, lastName: userFromDb.lastName, @@ -57,14 +56,14 @@ const GetCurrentUserE2ESpec = e2eSuite('GET /users/current', (app) => { displayDiscord: userFromDb.privacy.discord, displayTimetable: userFromDb.privacy.timetable, }, - }); + }; return pactum .spec() .get(`/users/current`) .withBearerToken(user.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict( + .$expectRegexableJson( Object.fromEntries(Object.entries(expectedBody).filter(([, value]) => value !== undefined)), ); }); diff --git a/test/e2e/users/get-todays-birthdays.e2e-spec.ts b/test/e2e/users/get-todays-birthdays.e2e-spec.ts index 2e763a9c..3801753f 100644 --- a/test/e2e/users/get-todays-birthdays.e2e-spec.ts +++ b/test/e2e/users/get-todays-birthdays.e2e-spec.ts @@ -8,7 +8,7 @@ const GetTodaysBirthdaysE2ESpec = e2eSuite('GET /users/birthday/today', (app) => const user = createUser(app, { infos: { birthday: new Date(now.getTime() - 3_600_000 * 24) } }); const otherUser = createUser(app, { infos: { - birthday: new Date(now.getUTCFullYear() - 15, now.getUTCMonth(), now.getUTCDate()), + birthday: new Date(now.getFullYear() - 15, now.getMonth(), now.getDate()), }, }); // Bro you are 15 years old wtf gaudry like @@ -21,7 +21,7 @@ const GetTodaysBirthdaysE2ESpec = e2eSuite('GET /users/birthday/today', (app) => .withBearerToken(user.token) .get('/users/birthdays/today') .expectStatus(200) - .expectJsonMatchStrict([ + .$expectRegexableJson([ { id: otherUser.id, firstName: otherUser.firstName, diff --git a/test/e2e/users/get-user-e2e-spec.ts b/test/e2e/users/get-user-e2e-spec.ts index 0ea77371..391044b9 100644 --- a/test/e2e/users/get-user-e2e-spec.ts +++ b/test/e2e/users/get-user-e2e-spec.ts @@ -75,7 +75,7 @@ const GetUserE2ESpec = e2eSuite('GET /users/:userId', (app) => { .get(`/users/${userFromDb.id}`) .withBearerToken(user.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict( + .$expectRegexableJson( Object.fromEntries(Object.entries(expectedBody).filter(([, value]) => value !== undefined)), ); }); diff --git a/test/e2e/users/get-user_assos-e2e-spec.ts b/test/e2e/users/get-user_assos-e2e-spec.ts index 3853c651..ab1bbce3 100644 --- a/test/e2e/users/get-user_assos-e2e-spec.ts +++ b/test/e2e/users/get-user_assos-e2e-spec.ts @@ -54,8 +54,8 @@ const GetUserAssociationE2ESpec = e2eSuite('GET /users/:userId/associations', (a ).map((membership) => ({ ...omit(membership, 'role', 'endAt', 'startAt', 'asso'), role: membership.role.name, - endAt: membership.endAt.toISOString(), - startAt: membership.startAt.toISOString(), + endAt: membership.endAt, + startAt: membership.startAt, asso: { ...omit(membership.asso, 'descriptionShortTranslation'), shortDescription: membership.asso.descriptionShortTranslation.fr, @@ -67,7 +67,7 @@ const GetUserAssociationE2ESpec = e2eSuite('GET /users/:userId/associations', (a .get(`/users/${user.id}/associations`) .withBearerToken(user.token) .expectStatus(HttpStatus.OK) - .expectJsonMatchStrict(assoMembershipFromDb.filter((value) => value !== undefined)); + .$expectRegexableJson(assoMembershipFromDb.filter((value) => value !== undefined)); }); }); diff --git a/test/e2e/users/update-profile-e2e-spec.ts b/test/e2e/users/update-profile-e2e-spec.ts index b0305307..56fba51b 100644 --- a/test/e2e/users/update-profile-e2e-spec.ts +++ b/test/e2e/users/update-profile-e2e-spec.ts @@ -31,9 +31,9 @@ const UpdateProfile = e2eSuite('PATCH /users/current', (app) => { facebook: 'fbProfile', displayAddress: 'ALL_PUBLIC', }) - .expectJsonMatchStrict({ + .$expectRegexableJson({ avatar: user.infos.avatar, - birthday: user.infos.birthday.toISOString(), + birthday: user.infos.birthday, discord: user.socialNetwork.discord, facebook: 'fbProfile', firstName: user.firstName, diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index 11341cdc..22432bb8 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -2,6 +2,7 @@ import { RawAsso, RawAssoMembership, RawAssoMembershipRole, + RawAssoMembershipPermission, RawBranch, RawBranchOption, RawCreditCategory, @@ -43,7 +44,7 @@ import { AppProvider } from './test_utils'; import { Permission, Sex, TimetableEntryType, UeCommentReportReason, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; import { AnnalStatus, UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; -import { omit, pick, translationSelect } from '../../src/utils'; +import { omit, PermissionManager, pick, translationSelect } from '../../src/utils'; import { DEFAULT_APPLICATION } from '../../prisma/seed/utils'; /** @@ -52,7 +53,7 @@ import { DEFAULT_APPLICATION } from '../../prisma/seed/utils'; */ export type FakeUser = Partial & { infos?: Partial; - permissions?: Permission[]; + permissions?: PermissionManager; mailsPhones?: Partial; addresses?: Array>; socialNetwork?: Partial; @@ -77,6 +78,7 @@ export type FakeAssoMembershipRole = Partial; export type FakeAssoMembership = Partial & { role?: Partial; }; +export type FakeAssoMembershipPermission = RawAssoMembershipPermission; export type FakeAsso = Partial< RawAsso & { descriptionShortTranslation: Partial; @@ -129,7 +131,16 @@ export interface FakeEntityMap { assoMembership: { entity: FakeAssoMembership; params: CreateAssoMembershipParameters; - deps: { asso: FakeAsso; user: FakeUser; role: FakeAssoMembershipRole }; + deps: { + asso: FakeAsso; + user: FakeUser; + role: FakeAssoMembershipRole; + permissions?: FakeAssoMembershipPermission[]; + }; + }; + assoMembershipPermission: { + entity: FakeAssoMembershipPermission; + params: { id: string }; }; assoMembershipRole: { entity: FakeAssoMembershipRole; @@ -288,7 +299,7 @@ export const createUser = entityFaker( }, ], branchSubscriptions: [], - permissions: [], + permissions: () => new PermissionManager(), privacy: {}, }, async (app, params) => { @@ -355,6 +366,7 @@ export const createUser = entityFaker( privacy: true, }, }); + params.permissions.with(Permission.USER_SEE_DETAILS, user.id).with(Permission.USER_UPDATE_DETAILS, user.id); const apiKey = await app() .get(PrismaService) .apiKey.create({ @@ -364,35 +376,28 @@ export const createUser = entityFaker( application: { connect: { id: DEFAULT_APPLICATION.id } }, apiKeyPermissions: { create: [ - ...(params.permissions.includes(Permission.USER_SEE_DETAILS) - ? [] - : [ - { - permission: Permission.USER_SEE_DETAILS, - user: { connect: { id: user.id } }, - granter: { connect: { id: user.id } }, - }, - ]), - ...(params.permissions.includes(Permission.USER_UPDATE_DETAILS) - ? [] - : [ - { - permission: Permission.USER_UPDATE_DETAILS, - user: { connect: { id: user.id } }, - granter: { connect: { id: user.id } }, - }, - ]), - ...params.permissions.map((permission) => ({ + ...params.permissions.hardPermissions.map((permission) => ({ permission, granter: { connect: { id: user.id } }, })), + ...Object.entries(params.permissions.softPermissions).reduce( + (previous, [permission, users]) => [ + ...previous, + ...users.map((permissionUser) => ({ + permission, + user: { connect: { id: permissionUser } }, + granter: { connect: { id: user.id } }, + })), + ], + [], + ), ], }, }, }); return { ...user, - permissions: [], + permissions: params.permissions, token: await app().get(AuthService).signAuthenticationToken(apiKey.token), apiKey, }; @@ -441,7 +446,7 @@ export const createAssoMembership = entityFaker( 'assoMembership', { startAt: new Date(0), - endAt: new Date(0), + endAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // One year from now createdAt: new Date(0), }, async (app, dependencies, params) => @@ -465,6 +470,9 @@ export const createAssoMembership = entityFaker( id: dependencies.role.id, }, }, + permissions: { + connect: dependencies.permissions, + }, }, include: { role: true, @@ -472,6 +480,15 @@ export const createAssoMembership = entityFaker( }), ); +export const createAssoMembershipPermission = entityFaker( + 'assoMembershipPermission', + { id: faker.word.noun }, + (app, { id }) => + app().get(PrismaService).assoMembershipPermission.create({ + data: { id }, + }), +); + export type CreateAssoParameters = FakeAsso; /** * Creates an association in the database. diff --git a/test/utils/test_utils.ts b/test/utils/test_utils.ts index 12cb6418..d338062d 100644 --- a/test/utils/test_utils.ts +++ b/test/utils/test_utils.ts @@ -65,11 +65,11 @@ export const e2eSuite = suite; */ export const unitSuite = suite; -/** Utilities to use in {@link Spec.expectJsonLike} to match database-generated values */ +/** Utilities to use in {@link Spec.$expectJsonRegexable} to match database-generated values */ export const JsonLike = { - STRING: "typeof $V === 'string'", - ANY_UUID: /[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/, - ANY_DATE: /\d{4}-\d{2}-\d{2}T(\d{2}:){2}\d{2}.\d{3}Z/, + STRING: Symbol('string'), + UUID: Symbol('uuid'), + DATE: /^\d{4}-[01]\d-[0-3]\d(?:T[0-2]\d:[0-5]\d:[0-5]\d[.,]\d+Z)?$/, // dateTime from pactum-matchers doesn't ms }; export const Dummies = { From 5aafd72f2619e671ad5ec679ed7f473c2a99e7f7 Mon Sep 17 00:00:00 2001 From: Cookky Date: Tue, 28 Oct 2025 16:09:10 +0100 Subject: [PATCH 12/19] Sorry linter --- migration/etuutt_old/make-migration.ts | 1 - src/ue/comments/comments.controller.ts | 4 +--- src/ue/comments/comments.service.ts | 9 ++++----- src/ue/comments/interfaces/comment.interface.ts | 2 +- test/e2e/ue/annals/delete-annal.e2e-spec.ts | 1 - test/e2e/ue/annals/get-annals.e2e-spec.ts | 1 - 6 files changed, 6 insertions(+), 12 deletions(-) diff --git a/migration/etuutt_old/make-migration.ts b/migration/etuutt_old/make-migration.ts index 7e667607..a778981e 100644 --- a/migration/etuutt_old/make-migration.ts +++ b/migration/etuutt_old/make-migration.ts @@ -289,7 +289,6 @@ const prisma = _prisma.$extends({ body, createdAt, updatedAt, - isValid, ue, semesterCode, }: { diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 5b9969ae..70b18507 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -323,15 +323,13 @@ export class CommentsController { @GetUser() user: User, @UUIDParam('replyId') replyId: string, @Body() body: UeCommentReportReqDto, - @GetPermissions() permissions: PermissionManager, ) { - const commentModerator = permissions.can('API_MODERATE_COMMENTS'); if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); if (await this.commentsService.isUserCommentReplyAuthor(user.id, replyId)) throw new AppException(ERROR_CODE.IS_COMMENT_AUTHOR); if (!(await this.commentsService.doesReportReasonExist(body.reason))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT_REASON); - return this.commentsService.reportCommentReply(user.id, body, replyId, commentModerator); + return this.commentsService.reportCommentReply(user.id, body, replyId); } @Patch(':commentId/:reportId') diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index 1d361a02..a5ac9e08 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -257,7 +257,7 @@ export class CommentsService { userId: string, isModerator: boolean, ): Promise { - const previousComment = await this.prisma.normalize.ueComment.findUnique({ + await this.prisma.normalize.ueComment.findUnique({ args: { userId, includeHiddenComments: isModerator, @@ -489,7 +489,7 @@ export class CommentsService { * @param reportId the id of the report * @returns true if it exists */ - async doesCommentReportExist(reportId: string): Promise { + async doesCommentReportExist(reportId: string): Promise { return (await this.prisma.ueCommentReport.count({ where: { id: reportId } })) == 1; } @@ -498,7 +498,7 @@ export class CommentsService { * @param reportId the id of the report * @returns true if it exists */ - async doesCommentReplyReportExist(reportId: string): Promise { + async doesCommentReplyReportExist(reportId: string): Promise { return (await this.prisma.ueCommentReplyReport.count({ where: { id: reportId } })) == 1; } @@ -507,7 +507,7 @@ export class CommentsService { * @param reasonName the name of the report reason * @returns true if it exists */ - async doesReportReasonExist(reasonName: string): Promise { + async doesReportReasonExist(reasonName: string): Promise { return (await this.prisma.ueCommentReportReason.count({ where: { name: reasonName } })) == 1; } @@ -559,7 +559,6 @@ export class CommentsService { userId: string, body: UeCommentReportReqDto, replyId: string, - isModerator: boolean, ): Promise { const reply = await this.getReplyFromId(replyId); const report = await this.prisma.ueCommentReplyReport.create({ diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index eeb0ffd1..75ae0368 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -153,7 +153,7 @@ export function formatComment(prisma: PrismaClient, comment: UnformattedUeCommen answers: comment.answers .filter((answer) => args.includeDeleted || answer.deletedAt === null) .map((answer) => { - let anwser = formatReply(prisma, answer); + const anwser = formatReply(prisma, answer); if (!includeReports) anwser.reports = []; return anwser; }), diff --git a/test/e2e/ue/annals/delete-annal.e2e-spec.ts b/test/e2e/ue/annals/delete-annal.e2e-spec.ts index 6411b1f0..1af3c933 100644 --- a/test/e2e/ue/annals/delete-annal.e2e-spec.ts +++ b/test/e2e/ue/annals/delete-annal.e2e-spec.ts @@ -12,7 +12,6 @@ import { } from '../../../utils/fakedb'; import { Dummies, JsonLike, e2eSuite } from '../../../utils/test_utils'; import { ERROR_CODE } from '../../../../src/exceptions'; -import { CommentStatus } from 'src/ue/comments/interfaces/comment.interface'; import { PermissionManager, pick } from '../../../../src/utils'; import { PrismaService } from '../../../../src/prisma/prisma.service'; import { AnnalStatus } from 'src/ue/annals/interfaces/annal.interface'; diff --git a/test/e2e/ue/annals/get-annals.e2e-spec.ts b/test/e2e/ue/annals/get-annals.e2e-spec.ts index ff190f34..e456d142 100644 --- a/test/e2e/ue/annals/get-annals.e2e-spec.ts +++ b/test/e2e/ue/annals/get-annals.e2e-spec.ts @@ -15,7 +15,6 @@ import { ERROR_CODE } from '../../../../src/exceptions'; import { AnnalStatus, UeAnnalFile } from '../../../../src/ue/annals/interfaces/annal.interface'; import { JsonLikeVariant } from 'test/declarations'; import { PermissionManager, pick } from '../../../../src/utils'; -import { CommentStatus } from '../../../../src/ue/comments/interfaces/comment.interface'; const GetAnnal = e2eSuite('GET /ue/annals', (app) => { const senderUser = createUser(app, { permissions: new PermissionManager().with('API_SEE_ANNALS') }); From 22773056f89125c4f6865a9c2beaa1bd4d216161 Mon Sep 17 00:00:00 2001 From: Cookky Date: Thu, 6 Nov 2025 13:44:43 +0100 Subject: [PATCH 13/19] Runned prettier --- migration/etuutt_old/make-migration.ts | 7 +--- src/exceptions.ts | 2 +- .../req/ue-get-reported-comments-req.dto.ts | 6 +-- .../dto/res/ue-comment-report-res.dto.ts | 18 ++++----- test/declarations.ts | 2 +- test/e2e/ue/comments/delete-reply.e2e-spec.ts | 2 +- .../comments/get-comment-from-id.e2e-spec.ts | 16 ++------ test/e2e/ue/comments/get-comment.e2e-spec.ts | 4 +- .../get-reported-comments.e2e-spec.ts | 7 +++- .../post-comment-reply-report.e2e-spec.ts | 8 +++- .../comments/post-comment-report.e2e-spec.ts | 40 +++++++++++-------- .../update-comment-reply-report.e2e-spec.ts | 10 +++-- .../update-comment-report.e2e-spec.ts | 2 +- test/e2e/ue/comments/update-reply.e2e-spec.ts | 2 +- 14 files changed, 64 insertions(+), 62 deletions(-) diff --git a/migration/etuutt_old/make-migration.ts b/migration/etuutt_old/make-migration.ts index a778981e..7474b4be 100644 --- a/migration/etuutt_old/make-migration.ts +++ b/migration/etuutt_old/make-migration.ts @@ -322,14 +322,11 @@ const prisma = _prisma.$extends({ operation: 'created', }; } - if ( - comment.body !== body || - comment.updatedAt !== updatedAt - ) { + if (comment.body !== body || comment.updatedAt !== updatedAt) { return { data: await _prisma.ueComment.update({ where: { id: comment.id }, - data: { body, updatedAt}, + data: { body, updatedAt }, }), operation: 'updated', }; diff --git a/src/exceptions.ts b/src/exceptions.ts index 3170a2f3..08af2b97 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -385,7 +385,7 @@ export const ErrorData = Object.freeze({ httpCode: HttpStatus.NOT_FOUND, }, [ERROR_CODE.NO_SUCH_REPORT]: { - message: 'The report does not exist', + message: 'The report does not exist', httpCode: HttpStatus.NOT_FOUND, }, [ERROR_CODE.NO_SUCH_REPORT_REASON]: { diff --git a/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts b/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts index c4924b41..5699a657 100644 --- a/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts +++ b/src/ue/comments/dto/req/ue-get-reported-comments-req.dto.ts @@ -1,9 +1,5 @@ import { Type } from 'class-transformer'; -import { - IsInt, - IsOptional, - IsPositive, -} from 'class-validator'; +import { IsInt, IsOptional, IsPositive } from 'class-validator'; /** * Query parameters to get reported comments. diff --git a/src/ue/comments/dto/res/ue-comment-report-res.dto.ts b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts index ffaf015e..e1e02bac 100644 --- a/src/ue/comments/dto/res/ue-comment-report-res.dto.ts +++ b/src/ue/comments/dto/res/ue-comment-report-res.dto.ts @@ -1,11 +1,11 @@ -import UeCommentAuthorResDto from "./ue-comment-author-res.dto"; +import UeCommentAuthorResDto from './ue-comment-author-res.dto'; export default class UeCommentReportResDto { - id: string; - body: string; - createdAt: Date; - mitigated: boolean; - reportedBody: string; - user: UeCommentAuthorResDto; - reason: string; -} \ No newline at end of file + id: string; + body: string; + createdAt: Date; + mitigated: boolean; + reportedBody: string; + user: UeCommentAuthorResDto; + reason: string; +} diff --git a/test/declarations.ts b/test/declarations.ts index 47163d8e..3da28400 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -171,7 +171,7 @@ Spec.prototype.expectUeComments = function (this: Spec, obj) { itemCount: obj.itemCount, itemsPerPage: obj.itemsPerPage, items: obj.items.map((comment) => ({ - ...omit(comment, 'ueof','ue'), + ...omit(comment, 'ueof', 'ue'), ueof: { code: comment.ueof.code, info: { diff --git a/test/e2e/ue/comments/delete-reply.e2e-spec.ts b/test/e2e/ue/comments/delete-reply.e2e-spec.ts index 77789a88..7969b86e 100644 --- a/test/e2e/ue/comments/delete-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/delete-reply.e2e-spec.ts @@ -86,7 +86,7 @@ const DeleteCommentReply = e2eSuite('DELETE /ue/comments/reply/{replyId}', (app) updatedAt: reply.updatedAt, body: reply.body, status: CommentStatus.DELETED, - reports: [] + reports: [], }); await app() .get(PrismaService) diff --git a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts index 0aa25295..f6ea7512 100644 --- a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts @@ -61,12 +61,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => .get(`/ue/comments/${comment.id}`) .expectUeComment({ ueof, - ...(omit( - comment, - 'semesterId', - 'authorId', - 'deletedAt', - ) as Required), + ...(omit(comment, 'semesterId', 'authorId', 'deletedAt') as Required), answers: [ { ...omit(reply, 'authorId', 'deletedAt', 'commentId'), @@ -77,7 +72,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => }, createdAt: reply.createdAt, updatedAt: reply.updatedAt, - reports: [] + reports: [], }, ], author: { @@ -101,12 +96,7 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => .get(`/ue/comments/${comment.id}`) .expectUeComment({ ueof, - ...(omit( - comment, - 'semesterId', - 'authorId', - 'deletedAt', - ) as Required), + ...(omit(comment, 'semesterId', 'authorId', 'deletedAt') as Required), answers: [ { ...omit(reply, 'authorId', 'deletedAt', 'commentId'), diff --git a/test/e2e/ue/comments/get-comment.e2e-spec.ts b/test/e2e/ue/comments/get-comment.e2e-spec.ts index 281a0ebc..fc473912 100644 --- a/test/e2e/ue/comments/get-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment.e2e-spec.ts @@ -167,7 +167,7 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { includeDeleted: true, includeHiddenComments: true, includeReports: true, - bypassAnonymousData: true + bypassAnonymousData: true, }, }); const commentsFiltered = { @@ -177,7 +177,7 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { ? (b.createdAt).getTime() - (a.createdAt).getTime() : b.upvotes - a.upvotes, ) - .map((comment) => ({ ...comment, ue, })) + .map((comment) => ({ ...comment, ue })) .slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), itemCount: comments.length, itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, diff --git a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts index 0467034e..1a6a8795 100644 --- a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts +++ b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts @@ -24,7 +24,10 @@ import { PermissionManager } from '../../../../src/utils'; const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { const userModerator = createUser(app, { login: 'user2', - permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE').with('API_MODERATE_COMMENTS'), + permissions: new PermissionManager() + .with('API_SEE_OPINIONS_UE') + .with('API_GIVE_OPINIONS_UE') + .with('API_MODERATE_COMMENTS'), }); const semester = createSemester(app); const branch = createBranch(app); @@ -180,7 +183,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { }); const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; const commentsFiltered = { - items: JSON.parse(JSON.stringify(comments)).slice(0,PAGINATION_PAGE_SIZE), + items: JSON.parse(JSON.stringify(comments)).slice(0, PAGINATION_PAGE_SIZE), itemCount: comments.length, itemsPerPage: PAGINATION_PAGE_SIZE, }; diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts index 07c24e23..09814771 100644 --- a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -15,8 +15,12 @@ import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', (app) => { - const commentAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); - const replyAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); + const commentAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const replyAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); const userNotAuthor = createUser(app, { login: 'user2', permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), diff --git a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts index 23cfb754..87e0ff94 100644 --- a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts @@ -14,8 +14,13 @@ import * as pactum from 'pactum'; import { ERROR_CODE } from 'src/exceptions'; const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => { - const user = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); - const userNotAuthor = createUser(app, { login: 'user2', permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); + const user = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const userNotAuthor = createUser(app, { + login: 'user2', + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); const userNoPermission = createUser(app); const semester = createSemester(app); const branch = createBranch(app); @@ -45,18 +50,20 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => return await pactum .spec() .withBearerToken(userNotAuthor.token) - .post(`/ue/comments/notauuid/report`).withBody({ + .post(`/ue/comments/notauuid/report`) + .withBody({ body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.PARAM_NOT_UUID,'commentId'); + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'commentId'); }); it('should return 404 because comment does not exist', async () => { return await pactum .spec() .withBearerToken(userNotAuthor.token) - .post(`/ue/comments/${Dummies.UUID}/report`).withBody({ + .post(`/ue/comments/${Dummies.UUID}/report`) + .withBody({ body: "it's offensive", reason: reportReason.name, }) @@ -73,7 +80,7 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => reason: reportReason.name, }) .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR); - }) + }); it('should return 404 because report reason does not exist', async () => { return await pactum @@ -82,12 +89,12 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => .post(`/ue/comments/${comment.id}/report`) .withBody({ body: "it's offensive", - reason: "idontexist", + reason: 'idontexist', }) .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON); - }) + }); - it('should return a report', async ()=> { + it('should return a report', async () => { return await pactum .spec() .withBearerToken(userNotAuthor.token) @@ -95,7 +102,8 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => .withBody({ body: "it's offensive", reason: reportReason.name, - }).expectUeCommentReport({ + }) + .expectUeCommentReport({ id: JsonLike.UUID, body: "it's offensive", reason: reportReason.name, @@ -103,12 +111,12 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => createdAt: JsonLike.DATE, mitigated: false, user: { - id: userNotAuthor.id, - firstName: userNotAuthor.firstName, - lastName: userNotAuthor.lastName, - } - }) - }) + id: userNotAuthor.id, + firstName: userNotAuthor.firstName, + lastName: userNotAuthor.lastName, + }, + }); + }); }); export default ReportComment; diff --git a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts index d8fa6cb6..8abdc7d4 100644 --- a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts @@ -17,9 +17,13 @@ import { Dummies, e2eSuite } from '../../../utils/test_utils'; import { PermissionManager } from '../../../../src/utils'; const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{reportId}', (app) => { - const commentAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE')}); - const replyAuthor = createUser(app, { permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE') }); - const moderator = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS')}); + const commentAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const replyAuthor = createUser(app, { + permissions: new PermissionManager().with('API_SEE_OPINIONS_UE').with('API_GIVE_OPINIONS_UE'), + }); + const moderator = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS') }); const semester = createSemester(app); const branch = createBranch(app); const branchOption = createBranchOption(app, { branch }); diff --git a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts index 7d154b30..3b24103f 100644 --- a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts @@ -16,7 +16,7 @@ import { Dummies, e2eSuite } from '../../../utils/test_utils'; import { PermissionManager } from '../../../../src/utils'; const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', (app) => { - const user = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS')}); + const user = createUser(app, { permissions: new PermissionManager().with('API_MODERATE_COMMENTS') }); const semester = createSemester(app); const branch = createBranch(app); const branchOption = createBranchOption(app, { branch }); diff --git a/test/e2e/ue/comments/update-reply.e2e-spec.ts b/test/e2e/ue/comments/update-reply.e2e-spec.ts index 8b4b460f..5715fc14 100644 --- a/test/e2e/ue/comments/update-reply.e2e-spec.ts +++ b/test/e2e/ue/comments/update-reply.e2e-spec.ts @@ -126,7 +126,7 @@ const UpdateCommentReply = e2eSuite('PATCH /ue/comments/reply/{replyId}', (app) updatedAt: JsonLike.DATE, body: "Je m'appelle Alban Ichou et j'approuve ce commentaire", status: CommentStatus.ACTIVE, - reports: [] + reports: [], }); return app().get(PrismaService).ueCommentReply.deleteMany(); }); From 8ef3d831a4bf30295713ee73aff6cc1ecd5b441f Mon Sep 17 00:00:00 2001 From: Cookky Date: Thu, 6 Nov 2025 14:35:23 +0100 Subject: [PATCH 14/19] removed author field when anonymous and cleaned test files --- src/ue/comments/comments.service.ts | 34 +++----------- .../dto/req/ue-comment-report-req.dto.ts | 3 +- .../res/ue-comment-report-reason-res.dto.ts | 2 +- .../comments/interfaces/comment.interface.ts | 7 ++- src/ue/ue.service.ts | 24 ++++++++++ .../comments/get-comment-from-id.e2e-spec.ts | 1 - .../get-comment-report-reasons.e2e-spec.ts | 4 +- .../get-reported-comments.e2e-spec.ts | 12 ++--- .../post-comment-reply-report.e2e-spec.ts | 47 ++++++++----------- .../comments/post-comment-report.e2e-spec.ts | 47 ++++++++----------- .../update-comment-reply-report.e2e-spec.ts | 19 ++++---- .../update-comment-report.e2e-spec.ts | 19 ++++---- 12 files changed, 101 insertions(+), 118 deletions(-) diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index a5ac9e08..e68ff7f6 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -13,12 +13,14 @@ import GetReportedCommentsReqDto from './dto/req/ue-get-reported-comments-req.dt import UeCommentReportResDto from './dto/res/ue-comment-report-res.dto'; import { UeCommentReply } from './interfaces/comment-reply.interface'; import { UeComment } from './interfaces/comment.interface'; +import { UeService } from '../ue.service'; @Injectable() export class CommentsService { constructor( readonly prisma: PrismaService, readonly config: ConfigModule, + readonly ueService: UeService, ) {} /** @@ -153,30 +155,6 @@ export class CommentsService { ); } - //TODO: This function may belongs to another service (users or ue) - /** - * Retrieves the last semester done by a user for a given ue - * @remarks The user must not be null - * @param userId the user to retrieve semesters of - * @param ueCode the code of the UE - * @returns the last semester done by the {@link user} for the {@link ueCode | ue} - */ - private async getLastUserSubscription(userId: string, ueCode: string): Promise { - return this.prisma.userUeSubscription.findFirst({ - where: { - ueof: { - ueId: ueCode, - }, - userId, - }, - orderBy: { - semester: { - end: 'desc', - }, - }, - }); - } - /** * Checks whether a user has already posted a comment for an ue * @remarks The user must not be null and UE must exist @@ -215,7 +193,7 @@ export class CommentsService { */ async createComment(body: UeCommentPostReqDto, userId: string): Promise { // Use last semester done when creating the comment - const lastSemester = await this.getLastUserSubscription(userId, body.ueCode); + const lastSemester = await this.ueService.getLastUserSubscription(userId, body.ueCode); return this.prisma.normalize.ueComment.create({ args: { userId, @@ -485,7 +463,7 @@ export class CommentsService { } /** - * Check if a report exist + * Check if a comment report exists * @param reportId the id of the report * @returns true if it exists */ @@ -494,7 +472,7 @@ export class CommentsService { } /** - * Check if a report exist + * Check if a comment reply report exists * @param reportId the id of the report * @returns true if it exists */ @@ -625,7 +603,7 @@ export class CommentsService { (rr) => { return { name: rr.name, - descriptionTranslation: omit(rr.descriptionTranslation, 'id'), + description: omit(rr.descriptionTranslation, 'id'), }; }, ); diff --git a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts index 453128eb..e835bf25 100644 --- a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts +++ b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts @@ -2,7 +2,8 @@ import { IsNotEmpty, IsString, MinLength } from 'class-validator'; /** * Query parameters to get reported comments. - * @property page The page number to get. Defaults to 1 (Starting at 1). + * @property body The user message associated with the report + * @property reason The report reason */ export default class UeCommentReportReqDto { @IsString() diff --git a/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts b/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts index bf080834..b74aff0b 100644 --- a/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts +++ b/src/ue/comments/dto/res/ue-comment-report-reason-res.dto.ts @@ -1,6 +1,6 @@ export default class UeCommentReportReasonResDto { name: string; - descriptionTranslation: { + description: { fr: string; en: string; es: string; diff --git a/src/ue/comments/interfaces/comment.interface.ts b/src/ue/comments/interfaces/comment.interface.ts index 75ae0368..da13ea49 100644 --- a/src/ue/comments/interfaces/comment.interface.ts +++ b/src/ue/comments/interfaces/comment.interface.ts @@ -148,8 +148,11 @@ export function formatComment(prisma: PrismaClient, comment: UnformattedUeCommen const bypassAnonymousData = !!args.bypassAnonymousData; const includeReports = !!args.includeReports; return { - ...omit(comment, 'deletedAt'), - author: !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? comment.author : null, + ...omit( + comment, + 'deletedAt', + !comment.isAnonymous || bypassAnonymousData || args.userId == comment.author.id ? undefined : 'author', + ), answers: comment.answers .filter((answer) => args.includeDeleted || answer.deletedAt === null) .map((answer) => { diff --git a/src/ue/ue.service.ts b/src/ue/ue.service.ts index 29e6ff4e..4477e796 100644 --- a/src/ue/ue.service.ts +++ b/src/ue/ue.service.ts @@ -8,6 +8,7 @@ import { UeRating } from './interfaces/rate.interface'; import { ConfigModule } from '../config/config.module'; import { Language, Prisma } from '@prisma/client'; import { SemesterService } from '../semester/semester.service'; +import { RawUserUeSubscription } from 'src/prisma/types'; @Injectable() export class UeService { @@ -224,6 +225,29 @@ export class UeService { }); } + /** + * Retrieves the last semester done by a user for a given ue + * @remarks The user must not be null + * @param userId the user to retrieve semesters of + * @param ueCode the code of the UE + * @returns the last semester done by the {@link user} for the {@link ueCode | ue} + */ + async getLastUserSubscription(userId: string, ueCode: string): Promise { + return this.prisma.userUeSubscription.findFirst({ + where: { + ueof: { + ueId: ueCode, + }, + userId, + }, + orderBy: { + semester: { + end: 'desc', + }, + }, + }); + } + /** * Checks whether a criterion exists * @param criterionId the id of the criterion to check diff --git a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts index f6ea7512..ea2ecc2d 100644 --- a/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-from-id.e2e-spec.ts @@ -110,7 +110,6 @@ const GetCommentFromIdE2ESpec = e2eSuite('GET /ue/comments/:commentId', (app) => reports: [], }, ], - author: null, updatedAt: comment.updatedAt, createdAt: comment.createdAt, semester: semester.code, diff --git a/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts index 38ec5efd..a79cfc6a 100644 --- a/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment-report-reasons.e2e-spec.ts @@ -29,11 +29,11 @@ const GetCommentReportReason = e2eSuite('GET /ue/comments/reports/reasons', (app .expectJsonLike([ { name: 'bad', - descriptionTranslation: 'bonjour', + description: 'bonjour', }, { name: 'meh', - descriptionTranslation: 'bonjour', + description: 'bonjour', }, ])); }); diff --git a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts index 1a6a8795..4550ad48 100644 --- a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts +++ b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts @@ -65,9 +65,8 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { ], }; - it('should return a 401 as user is not authenticated', () => { - return pactum.spec().get('/ue/comments/reports').expectAppError(ERROR_CODE.NOT_LOGGED_IN); - }); + it('should return a 401 as user is not authenticated', () => + pactum.spec().get('/ue/comments/reports').expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 403 as user does not have permission to moderate comments', async () => { const userNoPermission = await createUser(app, {}, true); @@ -88,16 +87,15 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); }); - it('should return a 403 as user uses a wrong page', () => { - return pactum + it('should return a 403 as user uses a wrong page', () => + pactum .spec() .withBearerToken(userModerator.token) .get('/ue/comments/reports') .withQueryParams({ page: -1, }) - .expectAppError(ERROR_CODE.PARAM_NOT_POSITIVE, 'page'); - }); + .expectAppError(ERROR_CODE.PARAM_NOT_POSITIVE, 'page')); it('should return the first page of reported comments', async () => { const comments = await app() diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts index 09814771..17e342ce 100644 --- a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -35,12 +35,11 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', const reply = createCommentReply(app, { user: replyAuthor, comment }); const reportReason = createCommentReportReason(app, { name: 'meh' }); - it('should return a 401 as user is not authenticated', async () => { - return await pactum.spec().post(`/ue/comments/reply/${reply.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); - }); + it('should return a 401 as user is not authenticated', () => + pactum.spec().post(`/ue/comments/reply/${reply.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); - it('should fail as the user does not have the required permissions', async () => { - return await pactum + it('should fail as the user does not have the required permissions', () => + pactum .spec() .withBearerToken(userNoPermission.token) .post(`/ue/comments/reply/${reply.id}/report`) @@ -48,11 +47,10 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE'); - }); + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE')); - it('should return 400 because reply id is not a valid UUID', async () => { - return await pactum + it('should return 400 because reply id is not a valid UUID', async () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/reply/notauuid/report`) @@ -60,11 +58,10 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'replyId'); - }); + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'replyId')); - it('should return 404 because reply does not exist', async () => { - return await pactum + it('should return 404 because reply does not exist', () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/reply/${Dummies.UUID}/report`) @@ -72,11 +69,10 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.NO_SUCH_REPLY); - }); + .expectAppError(ERROR_CODE.NO_SUCH_REPLY)); - it('should return 403 because user is reply author', async () => { - return await pactum + it('should return 403 because user is reply author', () => + pactum .spec() .withBearerToken(replyAuthor.token) .post(`/ue/comments/reply/${reply.id}/report`) @@ -84,11 +80,10 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR); - }); + .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR)); - it('should return 404 because report reason does not exist', async () => { - return await pactum + it('should return 404 because report reason does not exist', () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/reply/${reply.id}/report`) @@ -96,11 +91,10 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', body: "it's offensive", reason: 'idontexist', }) - .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON); - }); + .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON)); - it('should return a report', async () => { - return await pactum + it('should return a report', () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/reply/${reply.id}/report`) @@ -120,8 +114,7 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, }, - }); - }); + })); }); export default ReportCommentReply; diff --git a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts index 87e0ff94..f226c1bf 100644 --- a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts @@ -30,12 +30,11 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => const comment = createComment(app, { ueof, user, semester }); const reportReason = createCommentReportReason(app, { name: 'meh' }); - it('should return a 401 as user is not authenticated', async () => { - return await pactum.spec().post(`/ue/comments/${comment.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); - }); + it('should return a 401 as user is not authenticated', () => + pactum.spec().post(`/ue/comments/${comment.id}/report`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); - it('should fail as the user does not have the required permissions', async () => { - return await pactum + it('should fail as the user does not have the required permissions', () => + pactum .spec() .withBearerToken(userNoPermission.token) .post(`/ue/comments/${comment.id}/report`) @@ -43,11 +42,10 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE'); - }); + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_SEE_OPINIONS_UE')); - it('should return 400 because comment id is not a valid UUID', async () => { - return await pactum + it('should return 400 because comment id is not a valid UUID', () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/notauuid/report`) @@ -55,11 +53,10 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'commentId'); - }); + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'commentId')); - it('should return 404 because comment does not exist', async () => { - return await pactum + it('should return 404 because comment does not exist', () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/${Dummies.UUID}/report`) @@ -67,11 +64,10 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.NO_SUCH_COMMENT); - }); + .expectAppError(ERROR_CODE.NO_SUCH_COMMENT)); - it('should return 403 because user is comment author', async () => { - return await pactum + it('should return 403 because user is comment author', () => + pactum .spec() .withBearerToken(user.token) .post(`/ue/comments/${comment.id}/report`) @@ -79,11 +75,10 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => body: "it's offensive", reason: reportReason.name, }) - .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR); - }); + .expectAppError(ERROR_CODE.IS_COMMENT_AUTHOR)); - it('should return 404 because report reason does not exist', async () => { - return await pactum + it('should return 404 because report reason does not exist', () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/${comment.id}/report`) @@ -91,11 +86,10 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => body: "it's offensive", reason: 'idontexist', }) - .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON); - }); + .expectAppError(ERROR_CODE.NO_SUCH_REPORT_REASON)); - it('should return a report', async () => { - return await pactum + it('should return a report', () => + pactum .spec() .withBearerToken(userNotAuthor.token) .post(`/ue/comments/${comment.id}/report`) @@ -115,8 +109,7 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, }, - }); - }); + })); }); export default ReportComment; diff --git a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts index 8abdc7d4..9d8a363d 100644 --- a/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment-reply-report.e2e-spec.ts @@ -34,9 +34,8 @@ const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{r const reply = createCommentReply(app, { user: replyAuthor, comment }); const report = createCommentReplyReport(app, { reply, reason, user: commentAuthor }); - it('should return a 401 as user is not authenticated', () => { - return pactum.spec().patch(`/ue/comments/reply/${reply.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); - }); + it('should return a 401 as user is not authenticated', () => + pactum.spec().patch(`/ue/comments/reply/${reply.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 403 as user does not have permission to moderate comments', async () => { const userNoPermission = await createUser(app, {}, true); @@ -57,21 +56,19 @@ const UpdateCommentReplyReport = e2eSuite('PATCH /ue/comments/reply/{replyId}/{r .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); }); - it('should return 404 as replyId is invalid', async () => { - await pactum + it('should return 404 as replyId is invalid', () => + pactum .spec() .withBearerToken(moderator.token) .patch(`/ue/comments/reply/${Dummies.UUID}/${report.id}`) - .expectAppError(ERROR_CODE.NO_SUCH_REPLY); - }); + .expectAppError(ERROR_CODE.NO_SUCH_REPLY)); - it('should return 404 as reportId is invalid', async () => { - await pactum + it('should return 404 as reportId is invalid', () => + pactum .spec() .withBearerToken(moderator.token) .patch(`/ue/comments/reply/${reply.id}/${Dummies.UUID}`) - .expectAppError(ERROR_CODE.NO_SUCH_REPORT); - }); + .expectAppError(ERROR_CODE.NO_SUCH_REPORT)); it('should return the updated report', async () => { await pactum diff --git a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts index 3b24103f..202dabdb 100644 --- a/test/e2e/ue/comments/update-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/update-comment-report.e2e-spec.ts @@ -26,9 +26,8 @@ const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', const comment = createComment(app, { user, ueof, semester }); const report = createCommentReport(app, { comment, reason, user }); - it('should return a 401 as user is not authenticated', () => { - return pactum.spec().patch(`/ue/comments/${comment.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); - }); + it('should return a 401 as user is not authenticated', () => + pactum.spec().patch(`/ue/comments/${comment.id}/${report.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 403 as user does not have permission to moderate comments', async () => { const userNoPermission = await createUser(app, {}, true); @@ -49,21 +48,19 @@ const UpdateCommentReport = e2eSuite('PATCH /ue/comments/:commentId/:reportId', .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODERATE_COMMENTS'); }); - it('should return 404 as commentId is invalid', async () => { - await pactum + it('should return 404 as commentId is invalid', () => + pactum .spec() .withBearerToken(user.token) .patch(`/ue/comments/${Dummies.UUID}/${report.id}`) - .expectAppError(ERROR_CODE.NO_SUCH_COMMENT); - }); + .expectAppError(ERROR_CODE.NO_SUCH_COMMENT)); - it('should return 404 as reportId is invalid', async () => { - await pactum + it('should return 404 as reportId is invalid', () => + pactum .spec() .withBearerToken(user.token) .patch(`/ue/comments/${comment.id}/${Dummies.UUID}`) - .expectAppError(ERROR_CODE.NO_SUCH_REPORT); - }); + .expectAppError(ERROR_CODE.NO_SUCH_REPORT)); it('should return the updated report', async () => { await pactum From ab57db42081719e0739d70cc9c1e930245902035 Mon Sep 17 00:00:00 2001 From: Cookky Date: Thu, 6 Nov 2025 14:54:41 +0100 Subject: [PATCH 15/19] Changed http code to 201 and used $expectRegexableJson --- src/ue/comments/comments.controller.ts | 2 -- test/declarations.d.ts | 2 +- .../ue/comments/get-reported-comments.e2e-spec.ts | 12 ++++++------ .../comments/post-comment-reply-report.e2e-spec.ts | 2 +- test/e2e/ue/comments/post-comment-report.e2e-spec.ts | 2 +- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 70b18507..2b2ca774 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -290,7 +290,6 @@ export class CommentsController { @Post(':commentId/report') @RequireApiPermission('API_SEE_OPINIONS_UE') @ApiOperation({ description: 'Report a comment' }) - @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: UeCommentReportResDto }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_COMMENT, 'there is no comment with the provided commentId') @ApiAppErrorResponse(ERROR_CODE.IS_COMMENT_AUTHOR, 'thrown when the user is the comment author') @@ -314,7 +313,6 @@ export class CommentsController { @Post('reply/:replyId/report') @RequireApiPermission('API_SEE_OPINIONS_UE') @ApiOperation({ description: 'Report a comment reply' }) - @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: UeCommentReportResDto }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_REPLY, 'there is no comment reply with the provided replyId') @ApiAppErrorResponse(ERROR_CODE.IS_COMMENT_AUTHOR, 'thrown when the user is the comment author') diff --git a/test/declarations.d.ts b/test/declarations.d.ts index d62256b9..6505128b 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -76,7 +76,7 @@ declare module './declarations' { */ expectUeCommentReply(reply: JsonLikeVariant, created = false): this; /** expects to return the given {@link UeCommentReport} */ - expectUeCommentReport(report: JsonLikeVariant): this; + expectUeCommentReport(report: JsonLikeVariant, created = false): this; /** expects to return the given {@link criterion} list */ expectUeCriteria(criterion: JsonLikeVariant): this; /** expects to return the given {@link rate} */ diff --git a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts index 4550ad48..958429c9 100644 --- a/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts +++ b/test/e2e/ue/comments/get-reported-comments.e2e-spec.ts @@ -112,7 +112,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { }); const commentsFiltered = { - items: JSON.parse(JSON.stringify(comments)).slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), + items: comments.slice(0, app().get(ConfigModule).PAGINATION_PAGE_SIZE), itemCount: comments.length, itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, }; @@ -120,7 +120,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { .spec() .withBearerToken(userModerator.token) .get('/ue/comments/reports') - .expectJsonMatch(commentsFiltered); + .$expectRegexableJson(commentsFiltered); }); it('should return the second page of reported comments', async () => { @@ -138,7 +138,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { }); const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; const commentsFiltered = { - items: JSON.parse(JSON.stringify(comments)).slice(PAGINATION_PAGE_SIZE, 2 * PAGINATION_PAGE_SIZE), + items: comments.slice(PAGINATION_PAGE_SIZE, 2 * PAGINATION_PAGE_SIZE), itemCount: comments.length, itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, }; @@ -147,7 +147,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { .withBearerToken(userModerator.token) .get('/ue/comments/reports') .withQueryParams({ page: 2 }) - .expectJsonMatch(commentsFiltered); + .$expectRegexableJson(commentsFiltered); }); it('should include comments with reported replies', async () => { @@ -181,7 +181,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { }); const PAGINATION_PAGE_SIZE = app().get(ConfigModule).PAGINATION_PAGE_SIZE; const commentsFiltered = { - items: JSON.parse(JSON.stringify(comments)).slice(0, PAGINATION_PAGE_SIZE), + items: comments.slice(0, PAGINATION_PAGE_SIZE), itemCount: comments.length, itemsPerPage: PAGINATION_PAGE_SIZE, }; @@ -192,7 +192,7 @@ const GetReportedComments = e2eSuite('GET /ue/comments/reports', (app) => { .spec() .withBearerToken(userModerator.token) .get('/ue/comments/reports') - .expectJsonMatch(commentsFiltered); + .$expectRegexableJson(commentsFiltered); }); }); diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts index 17e342ce..fc14e280 100644 --- a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -114,7 +114,7 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, }, - })); + }, true)); }); export default ReportCommentReply; diff --git a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts index f226c1bf..62098a23 100644 --- a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts @@ -109,7 +109,7 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, }, - })); + },true)); }); export default ReportComment; From 4709ba444256aff7ac650a500f8c241f6befe0c2 Mon Sep 17 00:00:00 2001 From: Cookky Date: Thu, 6 Nov 2025 15:05:52 +0100 Subject: [PATCH 16/19] small fixes --- src/ue/comments/dto/req/ue-comment-report-req.dto.ts | 4 ++-- test/e2e/profile/set-homepage-widgets.e2e-spec.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts index e835bf25..505e91fb 100644 --- a/src/ue/comments/dto/req/ue-comment-report-req.dto.ts +++ b/src/ue/comments/dto/req/ue-comment-report-req.dto.ts @@ -1,8 +1,8 @@ import { IsNotEmpty, IsString, MinLength } from 'class-validator'; /** - * Query parameters to get reported comments. - * @property body The user message associated with the report + * Body data required to report a comment + * @property body The user message associated with the report. Must be at least 5 characters long * @property reason The report reason */ export default class UeCommentReportReqDto { diff --git a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts index 134dfd27..b88f6d66 100644 --- a/test/e2e/profile/set-homepage-widgets.e2e-spec.ts +++ b/test/e2e/profile/set-homepage-widgets.e2e-spec.ts @@ -26,8 +26,8 @@ const SetHomepageWidgetsE2ESpec = e2eSuite('PUT /profile/homepage', (app) => { }, ] as HomepageWidgetsUpdateElement[]; - it('should fail as user is not connected', async () => - await pactum.spec().put('/profile/homepage').withJson(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + it('should fail as user is not connected', () => + pactum.spec().put('/profile/homepage').withJson(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should fail as body is not valid', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -91,8 +91,8 @@ const SetHomepageWidgetsE2ESpec = e2eSuite('PUT /profile/homepage', (app) => { .expectAppError(ERROR_CODE.PARAM_NOT_POSITIVE, 'height'); }); - it('should fail as the widgets are overlapping', async () => - await pactum + it('should fail as the widgets are overlapping', () => + pactum .spec() .put('/profile/homepage') .withBearerToken(user.token) From 54ffff4aa1be7ec4d43e7725d6b75dbd23e7ea2b Mon Sep 17 00:00:00 2001 From: Cookky Date: Thu, 6 Nov 2025 15:11:56 +0100 Subject: [PATCH 17/19] removed await in controller --- src/ue/comments/comments.controller.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ue/comments/comments.controller.ts b/src/ue/comments/comments.controller.ts index 2b2ca774..00d212ba 100644 --- a/src/ue/comments/comments.controller.ts +++ b/src/ue/comments/comments.controller.ts @@ -81,11 +81,11 @@ export class CommentsController { ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, "Thrown when the user doesn't have enough permissions", ) - async getReportedComments( + getReportedComments( @GetUser() user: User, @Query() dto: GetReportedCommentsReqDto, ): Promise> { - return await this.commentsService.getCommentsWithReports(user.id, dto); + return this.commentsService.getCommentsWithReports(user.id, dto); } @Get('/reports/reasons') @@ -96,8 +96,8 @@ export class CommentsController { ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, "Thrown when the user doesn't have enough permissions", ) - async getReportReasons(): Promise { - return await this.commentsService.getCommentReportReason(); + getReportReasons(): Promise { + return this.commentsService.getCommentReportReason(); } // TODO : en vrai la route GET /ue/comments renvoie les mêmes infos nan ? :sweat_smile: @@ -343,7 +343,7 @@ export class CommentsController { throw new AppException(ERROR_CODE.NO_SUCH_COMMENT); if (!(await this.commentsService.doesCommentReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); - return await this.commentsService.mitigateCommentReport(commentId, reportId); + return this.commentsService.mitigateCommentReport(commentId, reportId); } @Patch('/reply/:replyId/:reportId') @@ -356,6 +356,6 @@ export class CommentsController { if (!(await this.commentsService.doesReplyExist(replyId))) throw new AppException(ERROR_CODE.NO_SUCH_REPLY); if (!(await this.commentsService.doesCommentReplyReportExist(reportId))) throw new AppException(ERROR_CODE.NO_SUCH_REPORT); - return await this.commentsService.mitigateCommentReplyReport(replyId, reportId); + return this.commentsService.mitigateCommentReplyReport(replyId, reportId); } } From c3e4fa377d0f33f564ca2bb807f20ece1aabeb11 Mon Sep 17 00:00:00 2001 From: Cookky Date: Thu, 6 Nov 2025 21:57:00 +0100 Subject: [PATCH 18/19] the linter never forgets --- src/ue/comments/comments.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ue/comments/comments.service.ts b/src/ue/comments/comments.service.ts index e68ff7f6..6ed7920f 100644 --- a/src/ue/comments/comments.service.ts +++ b/src/ue/comments/comments.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { RawUserUeSubscription } from 'src/prisma/types'; import { ConfigModule } from '../../config/config.module'; import { PrismaService } from '../../prisma/prisma.service'; import { omit, pick } from '../../utils'; From 36155f0dca5e6b365536a4977719c0fe664a2087 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Wed, 18 Mar 2026 01:07:03 +0100 Subject: [PATCH 19/19] Fix tests --- test/declarations.d.ts | 2 +- test/declarations.ts | 4 +++- test/e2e/ue/comments/get-comment.e2e-spec.ts | 6 +++--- test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts | 3 ++- test/e2e/ue/comments/post-comment-report.e2e-spec.ts | 3 ++- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/test/declarations.d.ts b/test/declarations.d.ts index e88c6d2b..cf5052f6 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -81,7 +81,7 @@ declare module './declarations' { /** expects to return the given {@link reply} */ expectUeCommentReply(reply: JsonLikeVariant): this; /** expects to return the given {@link UeCommentReport} */ - expectUeCommentReport(report: JsonLikeVariant, created = false): this; + expectUeCommentReport(report: JsonLikeVariant): this; /** expects to return the given {@link criterion} list */ expectUeCriteria(criterion: JsonLikeVariant): this; /** expects to return the given {@link rate} */ diff --git a/test/declarations.ts b/test/declarations.ts index 6da8b0a5..bebcf14e 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -27,6 +27,7 @@ import { Language } from '../src/prisma/types'; import { DEFAULT_APPLICATION } from '../prisma/seed/utils'; import ApplicationResDto from '../src/auth/application/dto/res/application-res.dto'; import PermissionsResDto from '../src/auth/permissions/dto/res/permissions.dto'; +import UeCommentResDto from 'src/ue/comments/dto/res/ue-comment-res.dto'; function ueOverviewExpectation(ue: FakeUeWithOfs, spec: Spec) { return { @@ -200,9 +201,10 @@ Spec.prototype.expectUeComments = function (this: Spec, obj) { ...pick(answer, 'author', 'body', 'id', 'status'), createdAt: answer.createdAt, updatedAt: answer.updatedAt, + reports: [] })), })), - } satisfies JsonLikeVariant>); + } satisfies JsonLikeVariant>); }; Spec.prototype.expectUeCommentReply = $expectRegexableJson; Spec.prototype.expectUeCommentReport = $expectRegexableJson; diff --git a/test/e2e/ue/comments/get-comment.e2e-spec.ts b/test/e2e/ue/comments/get-comment.e2e-spec.ts index 8e075609..b9634f9c 100644 --- a/test/e2e/ue/comments/get-comment.e2e-spec.ts +++ b/test/e2e/ue/comments/get-comment.e2e-spec.ts @@ -87,7 +87,7 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { return pactum .spec() .withBearerToken(user.token) - .get(`/ue/comments`) + .get('/ue/comments') .withQueryParams({ ueCode: ue.code.slice(0, ue.code.length - 1), }) @@ -117,7 +117,7 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { return pactum .spec() .withBearerToken(user.token) - .get(`/ue/comments`) + .get('/ue/comments') .withQueryParams({ ueCode: ue.code, }) @@ -135,7 +135,7 @@ const GetCommentsE2ESpec = e2eSuite('GET /ue/comments', (app) => { return pactum .spec() .withBearerToken(user.token) - .get(`/ue/comments`) + .get('/ue/comments') .withQueryParams({ page: 2, ueCode: ue.code, diff --git a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts index fc14e280..41d7d08b 100644 --- a/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-reply-report.e2e-spec.ts @@ -102,6 +102,7 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', body: "it's offensive", reason: reportReason.name, }) + .created() .expectUeCommentReport({ id: JsonLike.UUID, body: "it's offensive", @@ -114,7 +115,7 @@ const ReportCommentReply = e2eSuite('POST /ue/comments/reply/{replyId}/report', firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, }, - }, true)); + })); }); export default ReportCommentReply; diff --git a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts index 62098a23..30791038 100644 --- a/test/e2e/ue/comments/post-comment-report.e2e-spec.ts +++ b/test/e2e/ue/comments/post-comment-report.e2e-spec.ts @@ -97,6 +97,7 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => body: "it's offensive", reason: reportReason.name, }) + .created() .expectUeCommentReport({ id: JsonLike.UUID, body: "it's offensive", @@ -109,7 +110,7 @@ const ReportComment = e2eSuite('POST /ue/comments/{commentId}/report', (app) => firstName: userNotAuthor.firstName, lastName: userNotAuthor.lastName, }, - },true)); + })); }); export default ReportComment;