diff --git a/schema.graphql b/schema.graphql index b84a7ae040..cbc28566c4 100644 --- a/schema.graphql +++ b/schema.graphql @@ -54,6 +54,11 @@ enum ActionItemsOrderByInput { dueDate_DESC } +input AddPeopleToUserTagInput { + tagId: ID! + userIds: [ID!]! +} + type Address { city: String countryCode: String @@ -1099,6 +1104,7 @@ type Mutation { addLanguageTranslation(data: LanguageInput!): Language! addOrganizationCustomField(name: String!, organizationId: ID!, type: String!): OrganizationCustomField! addOrganizationImage(file: String!, organizationId: String!): Organization! + addPeopleToUserTag(input: AddPeopleToUserTagInput!): UserTag addPledgeToFundraisingCampaign(campaignId: ID!, pledgeId: ID!): FundraisingCampaignPledge! addUserCustomData(dataName: String!, dataValue: Any!, organizationId: ID!): UserCustomData! addUserImage(file: String!): User! @@ -1961,6 +1967,12 @@ type UserTag { to. """ usersAssignedTo(after: String, before: String, first: PositiveInt, last: PositiveInt): UsersConnection + + """ + A connection field to traverse a list of Users this UserTag is not assigned + to, to see and select among them and assign this tag. + """ + usersToAssignTo(after: String, before: String, first: PositiveInt, last: PositiveInt): UsersConnection } """A default connection on the UserTag type.""" diff --git a/src/resolvers/Mutation/addPeopleToUserTag.ts b/src/resolvers/Mutation/addPeopleToUserTag.ts new file mode 100644 index 0000000000..77ad67bae3 --- /dev/null +++ b/src/resolvers/Mutation/addPeopleToUserTag.ts @@ -0,0 +1,202 @@ +import { Types } from "mongoose"; +import { + TAG_NOT_FOUND, + USER_DOES_NOT_BELONG_TO_TAGS_ORGANIZATION, + USER_NOT_AUTHORIZED_ERROR, + USER_NOT_FOUND_ERROR, +} from "../../constants"; +import { errors, requestContext } from "../../libraries"; +import type { InterfaceAppUserProfile, InterfaceUser } from "../../models"; +import { + AppUserProfile, + OrganizationTagUser, + TagUser, + User, +} from "../../models"; +import { cacheAppUserProfile } from "../../services/AppUserProfileCache/cacheAppUserProfile"; +import { findAppUserProfileCache } from "../../services/AppUserProfileCache/findAppUserProfileCache"; +import { cacheUsers } from "../../services/UserCache/cacheUser"; +import { findUserInCache } from "../../services/UserCache/findUserInCache"; +import type { MutationResolvers } from "../../types/generatedGraphQLTypes"; + +/** + * This function enables an admin to assign a tag to multiple users. + * @param _parent - parent of current request + * @param args - payload provided with the request + * @param context - context of entire application + * @remarks The following checks are done: + * 1. If the current user exists and has a profile. + * 2. If the tag object exists. + * 3. If the current user is an admin for the organization of the tag. + * 4. If each user to be assigned the tag exists and belongs to the tag's organization. + * 5. Assign the tag only to users who do not already have it. + * @returns Array of users to whom the tag was assigned. + */ + +export const addPeopleToUserTag: MutationResolvers["addPeopleToUserTag"] = + async (_parent, args, context) => { + let currentUser: InterfaceUser | null; + const userFoundInCache = await findUserInCache([context.userId]); + currentUser = userFoundInCache[0]; + if (currentUser === null) { + currentUser = await User.findOne({ + _id: context.userId, + }).lean(); + if (currentUser !== null) { + await cacheUsers([currentUser]); + } + } + + // Checks whether the currentUser exists. + if (!currentUser) { + throw new errors.NotFoundError( + requestContext.translate(USER_NOT_FOUND_ERROR.MESSAGE), + USER_NOT_FOUND_ERROR.CODE, + USER_NOT_FOUND_ERROR.PARAM, + ); + } + + // Get the tag object + const tag = await OrganizationTagUser.findOne({ + _id: args.input.tagId, + }).lean(); + + if (!tag) { + throw new errors.NotFoundError( + requestContext.translate(TAG_NOT_FOUND.MESSAGE), + TAG_NOT_FOUND.CODE, + TAG_NOT_FOUND.PARAM, + ); + } + + let currentUserAppProfile: InterfaceAppUserProfile | null; + + const appUserProfileFoundInCache = await findAppUserProfileCache([ + currentUser.appUserProfileId.toString(), + ]); + + currentUserAppProfile = appUserProfileFoundInCache[0]; + if (currentUserAppProfile === null) { + currentUserAppProfile = await AppUserProfile.findOne({ + userId: currentUser._id, + }).lean(); + if (currentUserAppProfile !== null) { + await cacheAppUserProfile([currentUserAppProfile]); + } + } + if (!currentUserAppProfile) { + throw new errors.UnauthorizedError( + requestContext.translate(USER_NOT_AUTHORIZED_ERROR.MESSAGE), + USER_NOT_AUTHORIZED_ERROR.CODE, + USER_NOT_AUTHORIZED_ERROR.PARAM, + ); + } + + // Boolean to determine whether user is an admin of the organization of the tag. + const currentUserIsOrganizationAdmin = currentUserAppProfile.adminFor.some( + (orgId) => + orgId && + new Types.ObjectId(orgId.toString()).equals(tag.organizationId), + ); + //check whether current user can assign tag to users or not + if ( + !(currentUserIsOrganizationAdmin || currentUserAppProfile.isSuperAdmin) + ) { + throw new errors.UnauthorizedError( + requestContext.translate(USER_NOT_AUTHORIZED_ERROR.MESSAGE), + USER_NOT_AUTHORIZED_ERROR.CODE, + USER_NOT_AUTHORIZED_ERROR.PARAM, + ); + } + + // Check if the request users exist + const requestUsers = await User.find({ + _id: { $in: args.input.userIds }, + }).lean(); + + const requestUserMap = new Map( + requestUsers.map((user) => [user._id.toString(), user]), + ); + + // Validate each user to be assigned the tag + const validRequestUsers = []; + for (const userId of args.input.userIds) { + const requestUser = requestUserMap.get(userId); + + if (!requestUser) { + throw new errors.NotFoundError( + requestContext.translate(USER_NOT_FOUND_ERROR.MESSAGE), + USER_NOT_FOUND_ERROR.CODE, + USER_NOT_FOUND_ERROR.PARAM, + ); + } + + // Check that the user to which the tag is to be assigned is a member of the tag's organization + const requestUserBelongsToTagOrganization = + requestUser.joinedOrganizations.some((orgId) => + orgId.equals(tag.organizationId), + ); + + if (!requestUserBelongsToTagOrganization) { + throw new errors.UnauthorizedError( + requestContext.translate( + USER_DOES_NOT_BELONG_TO_TAGS_ORGANIZATION.MESSAGE, + ), + USER_DOES_NOT_BELONG_TO_TAGS_ORGANIZATION.CODE, + USER_DOES_NOT_BELONG_TO_TAGS_ORGANIZATION.PARAM, + ); + } + + validRequestUsers.push(requestUser); + } + + // Check existing tag assignments + const existingTagAssignments = await TagUser.find({ + userId: { $in: validRequestUsers.map((user) => user._id) }, + tagId: tag._id, + }).lean(); + + const existingAssignmentsMap = existingTagAssignments.map((assign) => + assign.userId.toString(), + ); + + // Filter out users who already have the tag + const newAssignments = validRequestUsers.filter( + (user) => !existingAssignmentsMap.includes(user._id.toString()), + ); + + if (newAssignments.length === 0) { + return tag; // No new assignments to be made + } + + // Assign all the ancestor tags + const allAncestorTags = [tag._id]; + let currentTag = tag; + while (currentTag?.parentTagId) { + const currentParentTag = await OrganizationTagUser.findOne({ + _id: currentTag.parentTagId, + }).lean(); + + if (currentParentTag) { + allAncestorTags.push(currentParentTag?._id); + currentTag = currentParentTag; + } + } + + const tagUserDocs = newAssignments.flatMap((user) => + allAncestorTags.map((tagId) => ({ + updateOne: { + filter: { userId: user._id, tagId }, + update: { $setOnInsert: { userId: user._id, tagId } }, + upsert: true, + setDefaultsOnInsert: true, + }, + })), + ); + + if (tagUserDocs.length > 0) { + await TagUser.bulkWrite(tagUserDocs); + } + + return tag; + }; diff --git a/src/resolvers/Mutation/index.ts b/src/resolvers/Mutation/index.ts index cd707b0854..cbbb216a32 100644 --- a/src/resolvers/Mutation/index.ts +++ b/src/resolvers/Mutation/index.ts @@ -8,6 +8,7 @@ import { addOrganizationImage } from "./addOrganizationImage"; import { addUserCustomData } from "./addUserCustomData"; import { addUserImage } from "./addUserImage"; import { addUserToUserFamily } from "./addUserToUserFamily"; +import { addPeopleToUserTag } from "./addPeopleToUserTag"; import { assignUserTag } from "./assignUserTag"; import { blockPluginCreationBySuperadmin } from "./blockPluginCreationBySuperadmin"; import { blockUser } from "./blockUser"; @@ -127,6 +128,7 @@ export const Mutation: MutationResolvers = { addUserCustomData, addUserImage, addUserToUserFamily, + addPeopleToUserTag, removeUserFamily, removeUserFromUserFamily, createUserFamily, diff --git a/src/resolvers/UserTag/index.ts b/src/resolvers/UserTag/index.ts index ee65404610..e2f5fcd824 100644 --- a/src/resolvers/UserTag/index.ts +++ b/src/resolvers/UserTag/index.ts @@ -3,10 +3,12 @@ import { childTags } from "./childTags"; import { organization } from "./organization"; import { parentTag } from "./parentTag"; import { usersAssignedTo } from "./usersAssignedTo"; +import { usersToAssignTo } from "./usersToAssignTo"; export const UserTag: UserTagResolvers = { childTags, organization, parentTag, usersAssignedTo, + usersToAssignTo, }; diff --git a/src/resolvers/UserTag/usersToAssignTo.ts b/src/resolvers/UserTag/usersToAssignTo.ts new file mode 100644 index 0000000000..79cad7d573 --- /dev/null +++ b/src/resolvers/UserTag/usersToAssignTo.ts @@ -0,0 +1,219 @@ +import type { UserTagResolvers } from "../../types/generatedGraphQLTypes"; +import type { InterfaceUser } from "../../models"; +import { User } from "../../models"; +import type { + DefaultGraphQLArgumentError, + GraphQLConnectionTraversalDirection, + ParseGraphQLConnectionCursorArguments, + ParseGraphQLConnectionCursorResult, +} from "../../utilities/graphQLConnection"; + +import { + getCommonGraphQLConnectionSort, + parseGraphQLConnectionArguments, + transformToDefaultGraphQLConnection, +} from "../../utilities/graphQLConnection"; + +import { GraphQLError } from "graphql"; +import { MAXIMUM_FETCH_LIMIT } from "../../constants"; +import { Types } from "mongoose"; + +/** + * Resolver function for the `usersToAssignTo` field of a `UserTag`. + * + * @param parent - The parent object representing the user tag. It contains information about the user tag, including the ID of the user tag. + * @param args - The arguments provided to the field. These arguments are used to filter, sort, and paginate the users assigned to the user tag. + * @returns A promise that resolves to a connection object containing the users assigned to the user tag. + * + * @see User - The User model used to interact with the users collection in the database. + * @see parseGraphQLConnectionArguments - The function used to parse the GraphQL connection arguments (filter, sort, pagination). + * @see transformToDefaultGraphQLConnection - The function used to transform the list of users assigned to the user tag into a connection object. + * @see getGraphQLConnectionFilter - The function used to get the filter object for the GraphQL connection. + * @see getCommonGraphQLConnectionSort - The function used to get the common sort object for the GraphQL connection. + * @see MAXIMUM_FETCH_LIMIT - The maximum number of users that can be fetched in a single request. + * @see GraphQLError - The error class used to throw GraphQL errors. + * @see UserResolvers - The type definition for the resolvers of the UserTag fields. + * + */ +export const usersToAssignTo: UserTagResolvers["usersToAssignTo"] = async ( + parent, + args, +) => { + const parseGraphQLConnectionArgumentsResult = + await parseGraphQLConnectionArguments({ + args, + parseCursor: (args) => + parseCursor({ + ...args, + }), + maximumLimit: MAXIMUM_FETCH_LIMIT, + }); + + if (!parseGraphQLConnectionArgumentsResult.isSuccessful) { + throw new GraphQLError("Invalid arguments provided.", { + extensions: { + code: "INVALID_ARGUMENTS", + errors: parseGraphQLConnectionArgumentsResult.errors, + }, + }); + } + + const { parsedArgs } = parseGraphQLConnectionArgumentsResult; + + const filter = getGraphQLConnectionFilter({ + cursor: parsedArgs.cursor, + direction: parsedArgs.direction, + }); + + const sort = getCommonGraphQLConnectionSort({ + direction: parsedArgs.direction, + }); + + const commonPipeline = [ + // Step 1: Match users whose joinedOrgs contains the orgId + { + $match: { + ...filter, + joinedOrganizations: parent.organizationId, + }, + }, + // Step 2: Perform a left join with TagUser collection on userId + { + $lookup: { + from: "tagusers", // Name of the collection holding TagUser documents + localField: "_id", + foreignField: "userId", + as: "tagUsers", + }, + }, + // Step 3: Filter out users that have a tagUser document with the specified tagId + { + $match: { + tagUsers: { + $not: { + $elemMatch: { tagId: parent._id }, + }, + }, + }, + }, + ]; + + // Execute the queries using the common pipeline + const [objectList, totalCountResult] = await Promise.all([ + // First aggregation to get the user list + User.aggregate([ + ...commonPipeline, + { + $sort: { ...sort }, + }, + { $limit: parsedArgs.limit }, + ]), + // Second aggregation to count total users + User.aggregate([...commonPipeline, { $count: "totalCount" }]), + ]); + + const totalCount = + totalCountResult.length > 0 ? totalCountResult[0].totalCount : 0; + + // The users and totalCount are now ready for use + + return transformToDefaultGraphQLConnection< + ParsedCursor, + InterfaceUser, + InterfaceUser + >({ + objectList, + parsedArgs, + totalCount, + }); +}; + +/* +This is typescript type of the parsed cursor for this connection resolver. +*/ +type ParsedCursor = string; + +/** + * Parses the cursor value for the `usersToAssignTo` connection resolver. + * + * This function is used to parse the cursor value provided to the `usersToAssignTo` connection resolver. + * + * @param cursorValue - The cursor value to be parsed. + * @param cursorName - The name of the cursor argument. + * @param cursorPath - The path of the cursor argument in the GraphQL query. + * @returns An object containing the parsed cursor value or an array of errors if the cursor value is invalid. + * + * @see User - The User model used to interact with the users collection in the database. + * @see DefaultGraphQLArgumentError - The type definition for the default GraphQL argument error. + * @see ParseGraphQLConnectionCursorArguments - The type definition for the arguments provided to the parseCursor function. + * @see ParseGraphQLConnectionCursorResult - The type definition for the result of the parseCursor function. + * + */ +export const parseCursor = async ({ + cursorValue, + cursorName, + cursorPath, +}: ParseGraphQLConnectionCursorArguments): ParseGraphQLConnectionCursorResult => { + const errors: DefaultGraphQLArgumentError[] = []; + const user = await User.findOne({ + _id: cursorValue, + }); + + if (!user) { + errors.push({ + message: `Argument ${cursorName} is an invalid cursor.`, + path: cursorPath, + }); + } + + if (errors.length !== 0) { + return { + errors, + isSuccessful: false, + }; + } + + return { + isSuccessful: true, + parsedCursor: cursorValue, + }; +}; + +type GraphQLConnectionFilter = + | { + _id: { + $lt: Types.ObjectId; + }; + } + | { + _id: { + $gt: Types.ObjectId; + }; + } + | Record; + +export const getGraphQLConnectionFilter = ({ + cursor, + direction, +}: { + cursor: string | null; + direction: GraphQLConnectionTraversalDirection; +}): GraphQLConnectionFilter => { + if (cursor !== null) { + if (direction === "BACKWARD") { + return { + _id: { + $gt: new Types.ObjectId(cursor), + }, + }; + } else { + return { + _id: { + $lt: new Types.ObjectId(cursor), + }, + }; + } + } else { + return {}; + } +}; diff --git a/src/typeDefs/inputs.ts b/src/typeDefs/inputs.ts index c4796dcf70..c34e0c22a8 100644 --- a/src/typeDefs/inputs.ts +++ b/src/typeDefs/inputs.ts @@ -456,6 +456,11 @@ export const inputs = gql` tagId: ID! } + input AddPeopleToUserTagInput { + userIds: [ID!]! + tagId: ID! + } + input UpdateActionItemInput { assigneeId: ID assigneeType: String diff --git a/src/typeDefs/mutations.ts b/src/typeDefs/mutations.ts index a43ab8b9dc..cc0e96c66b 100644 --- a/src/typeDefs/mutations.ts +++ b/src/typeDefs/mutations.ts @@ -35,6 +35,10 @@ export const mutations = gql` addUserToUserFamily(userId: ID!, familyId: ID!): UserFamily! @auth + addPeopleToUserTag(input: AddPeopleToUserTagInput!): UserTag + @auth + @role(requires: ADMIN) + removeUserFromUserFamily(userId: ID!, familyId: ID!): UserFamily! @auth removeUserFamily(familyId: ID!): UserFamily! @auth diff --git a/src/typeDefs/types.ts b/src/typeDefs/types.ts index 8ac5db9602..1ffef9d74a 100644 --- a/src/typeDefs/types.ts +++ b/src/typeDefs/types.ts @@ -715,6 +715,17 @@ export const types = gql` first: PositiveInt last: PositiveInt ): UsersConnection + + """ + A connection field to traverse a list of Users this UserTag is not assigned + to, to see and select among them and assign this tag. + """ + usersToAssignTo( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): UsersConnection } """ diff --git a/src/types/generatedGraphQLTypes.ts b/src/types/generatedGraphQLTypes.ts index aad0c6c635..d77cb532ca 100644 --- a/src/types/generatedGraphQLTypes.ts +++ b/src/types/generatedGraphQLTypes.ts @@ -120,6 +120,11 @@ export type ActionItemsOrderByInput = | 'dueDate_ASC' | 'dueDate_DESC'; +export type AddPeopleToUserTagInput = { + tagId: Scalars['ID']['input']; + userIds: Array; +}; + export type Address = { __typename?: 'Address'; city?: Maybe; @@ -1185,6 +1190,7 @@ export type Mutation = { addLanguageTranslation: Language; addOrganizationCustomField: OrganizationCustomField; addOrganizationImage: Organization; + addPeopleToUserTag?: Maybe; addPledgeToFundraisingCampaign: FundraisingCampaignPledge; addUserCustomData: UserCustomData; addUserImage: User; @@ -1335,6 +1341,11 @@ export type MutationAddOrganizationImageArgs = { }; +export type MutationAddPeopleToUserTagArgs = { + input: AddPeopleToUserTagInput; +}; + + export type MutationAddPledgeToFundraisingCampaignArgs = { campaignId: Scalars['ID']['input']; pledgeId: Scalars['ID']['input']; @@ -3063,6 +3074,11 @@ export type UserTag = { * to. */ usersAssignedTo?: Maybe; + /** + * A connection field to traverse a list of Users this UserTag is not assigned + * to, to see and select among them and assign this tag. + */ + usersToAssignTo?: Maybe; }; @@ -3081,6 +3097,14 @@ export type UserTagUsersAssignedToArgs = { last?: InputMaybe; }; + +export type UserTagUsersToAssignToArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + /** A default connection on the UserTag type. */ export type UserTagsConnection = { __typename?: 'UserTagsConnection'; @@ -3335,6 +3359,7 @@ export type ResolversTypes = { ActionItemCategoryWhereInput: ActionItemCategoryWhereInput; ActionItemWhereInput: ActionItemWhereInput; ActionItemsOrderByInput: ActionItemsOrderByInput; + AddPeopleToUserTagInput: AddPeopleToUserTagInput; Address: ResolverTypeWrapper
; AddressInput: AddressInput; Advertisement: ResolverTypeWrapper; @@ -3557,6 +3582,7 @@ export type ResolversParentTypes = { ActionItemCategory: InterfaceActionItemCategoryModel; ActionItemCategoryWhereInput: ActionItemCategoryWhereInput; ActionItemWhereInput: ActionItemWhereInput; + AddPeopleToUserTagInput: AddPeopleToUserTagInput; Address: Address; AddressInput: AddressInput; Advertisement: InterfaceAdvertisementModel; @@ -4341,6 +4367,7 @@ export type MutationResolvers>; addOrganizationCustomField?: Resolver>; addOrganizationImage?: Resolver>; + addPeopleToUserTag?: Resolver, ParentType, ContextType, RequireFields>; addPledgeToFundraisingCampaign?: Resolver>; addUserCustomData?: Resolver>; addUserImage?: Resolver>; @@ -4820,6 +4847,7 @@ export type UserTagResolvers, ParentType, ContextType>; parentTag?: Resolver, ParentType, ContextType>; usersAssignedTo?: Resolver, ParentType, ContextType, Partial>; + usersToAssignTo?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/tests/resolvers/Mutation/addPeopleToUserTag.spec.ts b/tests/resolvers/Mutation/addPeopleToUserTag.spec.ts new file mode 100644 index 0000000000..a8f07277c7 --- /dev/null +++ b/tests/resolvers/Mutation/addPeopleToUserTag.spec.ts @@ -0,0 +1,395 @@ +import "dotenv/config"; +import type mongoose from "mongoose"; +import { Types } from "mongoose"; +import type { MutationAddPeopleToUserTagArgs } from "../../../src/types/generatedGraphQLTypes"; +import { connect, disconnect } from "../../helpers/db"; + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + TAG_NOT_FOUND, + USER_DOES_NOT_BELONG_TO_TAGS_ORGANIZATION, + USER_NOT_AUTHORIZED_ERROR, + USER_NOT_FOUND_ERROR, +} from "../../../src/constants"; +import { AppUserProfile, TagUser, User } from "../../../src/models"; +import type { TestUserTagType } from "../../helpers/tags"; +import { + createRootTagWithOrg, + createTwoLevelTagsWithOrg, +} from "../../helpers/tags"; +import type { + TestOrganizationType, + TestUserType, +} from "../../helpers/userAndOrg"; +import { createTestUser } from "../../helpers/userAndOrg"; + +let MONGOOSE_INSTANCE: typeof mongoose; + +let adminUser: TestUserType; +let adminUser2: TestUserType; +let testTag2: TestUserTagType; +let testTag: TestUserTagType; +let testSubTag1: TestUserTagType; +let testOrg2: TestOrganizationType; +let randomUser: TestUserType; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + [adminUser, , [testTag, testSubTag1]] = await createTwoLevelTagsWithOrg(); + [adminUser2, testOrg2, testTag2] = await createRootTagWithOrg(); + randomUser = await createTestUser(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Mutation -> addPeopleToUserTag", () => { + afterEach(() => { + vi.doUnmock("../../../src/constants"); + vi.resetModules(); + vi.resetAllMocks(); + }); + + it(`throws NotFoundError if no user exists with _id === context.userId `, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser?._id], + tagId: testTag?._id.toString() ?? "", + }, + }; + + const context = { userId: new Types.ObjectId().toString() }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + await addPeopleToUserTagResolver?.({}, args, context); + } catch (error: unknown) { + expect((error as Error).message).toEqual( + `Translated ${USER_NOT_FOUND_ERROR.MESSAGE}`, + ); + expect(spy).toHaveBeenLastCalledWith(USER_NOT_FOUND_ERROR.MESSAGE); + } + }); + + it(`throws NotFoundError if no tag exists with _id === args.input.tagId `, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser?._id], + tagId: new Types.ObjectId().toString(), + }, + }; + + const context = { + userId: adminUser?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + await addPeopleToUserTagResolver?.({}, args, context); + } catch (error: unknown) { + expect(spy).toHaveBeenLastCalledWith(TAG_NOT_FOUND.MESSAGE); + expect((error as Error).message).toEqual( + `Translated ${TAG_NOT_FOUND.MESSAGE}`, + ); + } + }); + + it(`throws Not Authorized Error if the current user is not a superadmin or admin of the organization of the tag being assigned`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser?._id], + tagId: testTag?._id.toString() ?? "", + }, + }; + + const context = { + userId: randomUser?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + await addPeopleToUserTagResolver?.({}, args, context); + } catch (error: unknown) { + expect((error as Error).message).toEqual( + `Translated ${USER_NOT_AUTHORIZED_ERROR.MESSAGE}`, + ); + expect(spy).toHaveBeenLastCalledWith( + `${USER_NOT_AUTHORIZED_ERROR.MESSAGE}`, + ); + } + }); + + it(`throws NotFoundError if one of the requested users doesn't exist`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser?._id, new Types.ObjectId()], + tagId: testTag?._id.toString() ?? "", + }, + }; + + const context = { userId: adminUser?._id }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + await addPeopleToUserTagResolver?.({}, args, context); + } catch (error: unknown) { + expect((error as Error).message).toEqual( + `Translated ${USER_NOT_FOUND_ERROR.MESSAGE}`, + ); + expect(spy).toHaveBeenLastCalledWith(USER_NOT_FOUND_ERROR.MESSAGE); + } + }); + + it(`throws Error if one of the requested users is not a member of organization of the tag being assigned`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser?._id.toString(), randomUser?._id.toString()], + tagId: testTag?._id.toString() ?? "", + }, + }; + + const context = { + userId: adminUser?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + await addPeopleToUserTagResolver?.({}, args, context); + } catch (error: unknown) { + expect((error as Error).message).toEqual( + `Translated ${USER_DOES_NOT_BELONG_TO_TAGS_ORGANIZATION.MESSAGE}`, + ); + expect(spy).toHaveBeenLastCalledWith( + `${USER_DOES_NOT_BELONG_TO_TAGS_ORGANIZATION.MESSAGE}`, + ); + } + }); + + it(`Tag assignment should be successful and the tag is returned`, async () => { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser2?._id.toString()], + tagId: testTag2?._id.toString() ?? "", + }, + }; + + const context = { + userId: adminUser2?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + const payload = await addPeopleToUserTagResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(testTag2?._id.toString()); + + const tagAssigned = await TagUser.exists({ + tagId: args.input.tagId, + userId: adminUser2?._id, + }); + + expect(tagAssigned).toBeTruthy(); + }); + + it(`Tag assignment should be successful and only new assignments are made and the tag is returned`, async () => { + await User.findOneAndUpdate( + { + _id: randomUser?._id, + }, + { + joinedOrganizations: testOrg2?._id, + }, + ); + + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser2?._id.toString(), randomUser?._id.toString()], + tagId: testTag2?._id.toString() ?? "", + }, + }; + + const context = { + userId: adminUser2?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + const payload = await addPeopleToUserTagResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(testTag2?._id.toString()); + + const tagAssigned = await TagUser.exists({ + tagId: args.input.tagId, + userId: adminUser2?._id, + }); + + expect(tagAssigned).toBeTruthy(); + }); + + it(`Returns the tag if there aren't any new assignments to be made and the tag is returned`, async () => { + await User.findOneAndUpdate( + { + _id: randomUser?._id, + }, + { + joinedOrganizations: testOrg2?._id, + }, + ); + + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser2?._id.toString(), randomUser?._id.toString()], + tagId: testTag2?._id.toString() ?? "", + }, + }; + + const context = { + userId: adminUser2?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + const payload = await addPeopleToUserTagResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(testTag2?._id.toString()); + + const tagAssigned = await TagUser.exists({ + tagId: args.input.tagId, + userId: adminUser2?._id, + }); + + expect(tagAssigned).toBeTruthy(); + }); + + it(`Should assign all the ancestor tags and returns the current tag`, async () => { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [adminUser?._id.toString()], + tagId: testSubTag1?._id.toString() ?? "", + }, + }; + const context = { + userId: adminUser?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + const payload = await addPeopleToUserTagResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(testSubTag1?._id.toString()); + + const subTagAssigned = await TagUser.exists({ + tagId: args.input.tagId, + userId: adminUser?._id, + }); + + const ancestorTagAssigned = await TagUser.exists({ + tagId: testTag?._id.toString() ?? "", + userId: adminUser?._id, + }); + + expect(subTagAssigned).toBeTruthy(); + expect(ancestorTagAssigned).toBeTruthy(); + }); + + it("throws error if user does not have appUserProfile", async () => { + const { requestContext } = await import("../../../src/libraries"); + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddPeopleToUserTagArgs = { + input: { + userIds: [randomUser?._id], + tagId: testTag?._id.toString() ?? "", + }, + }; + + const temp = await createTestUser(); + + await AppUserProfile.deleteOne({ + userId: temp?._id, + }); + + const context = { + userId: temp?._id, + }; + + const { addPeopleToUserTag: addPeopleToUserTagResolver } = await import( + "../../../src/resolvers/Mutation/addPeopleToUserTag" + ); + + await addPeopleToUserTagResolver?.({}, args, context); + } catch (error: unknown) { + expect((error as Error).message).toEqual( + `Translated ${USER_NOT_AUTHORIZED_ERROR.MESSAGE}`, + ); + expect(spy).toHaveBeenLastCalledWith( + `${USER_NOT_AUTHORIZED_ERROR.MESSAGE}`, + ); + } + }); +}); diff --git a/tests/resolvers/UserTag/usersToAssignTo.spec.ts b/tests/resolvers/UserTag/usersToAssignTo.spec.ts new file mode 100644 index 0000000000..fdcc04ff2b --- /dev/null +++ b/tests/resolvers/UserTag/usersToAssignTo.spec.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import "dotenv/config"; +import { + parseCursor, + getGraphQLConnectionFilter, + usersToAssignTo as usersToAssignToResolver, +} from "../../../src/resolvers/UserTag/usersToAssignTo"; +import { connect, disconnect } from "../../helpers/db"; +import type mongoose from "mongoose"; +import { beforeAll, afterAll, describe, it, expect } from "vitest"; +import type { TestUserTagType } from "../../helpers/tags"; +import type { TestUserType } from "../../helpers/userAndOrg"; +import { createRootTagWithOrg } from "../../helpers/tags"; +import { GraphQLError } from "graphql"; +import type { DefaultGraphQLArgumentError } from "../../../src/utilities/graphQLConnection"; +import { User, type InterfaceOrganizationTagUser } from "../../../src/models"; +import { Types } from "mongoose"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let testTag: TestUserTagType, testUser: TestUserType; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + [testUser, , testTag] = await createRootTagWithOrg(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("usersToAssignTo resolver", () => { + it(`throws GraphQLError if invalid arguments are provided to the resolver`, async () => { + const parent = testTag as InterfaceOrganizationTagUser; + + try { + await usersToAssignToResolver?.(parent, {}, {}); + } catch (error) { + if (error instanceof GraphQLError) { + expect(error.extensions.code).toEqual("INVALID_ARGUMENTS"); + expect( + (error.extensions.errors as DefaultGraphQLArgumentError[]).length, + ).toBeGreaterThan(0); + } + } + }); + + it(`returns the expected connection object`, async () => { + const parent = testTag as InterfaceOrganizationTagUser; + + const connection = await usersToAssignToResolver?.( + parent, + { + first: 3, + }, + {}, + ); + + const user = await User.findOne({ + _id: testUser?._id, + }).lean(); + + const totalCount = 1; // only one user belonging to the organization + + expect(connection).toEqual({ + edges: [ + { + cursor: testUser?._id.toString(), + node: { + ...user, + _id: user?._id.toString(), + tagUsers: [], + }, + }, + ], + pageInfo: { + endCursor: testUser?._id.toString(), + hasNextPage: false, + hasPreviousPage: false, + startCursor: testUser?._id.toString(), + }, + totalCount, + }); + }); +}); + +describe("parseCursor function", () => { + it("returns failure state if argument cursorValue is an invalid cursor", async () => { + const result = await parseCursor({ + cursorName: "after", + cursorPath: ["after"], + cursorValue: new Types.ObjectId().toString(), + }); + + expect(result.isSuccessful).toEqual(false); + + if (result.isSuccessful === false) { + expect(result.errors.length).toBeGreaterThan(0); + } + }); + + it("returns success state if argument cursorValue is a valid cursor", async () => { + const parserCursorValue = await User.findOne({ + _id: testUser?._id, + }); + + const result = await parseCursor({ + cursorName: "after", + cursorPath: ["after"], + cursorValue: parserCursorValue?._id.toString() as string, + }); + + expect(result.isSuccessful).toEqual(true); + + if (result.isSuccessful === true) { + expect(result.parsedCursor).toEqual(parserCursorValue?._id.toString()); + } + }); +}); + +describe("getGraphQLConnectionFilter function", () => { + it(`when argument cursor is non-null and argument direction corresponds to backward`, async () => { + const cursor = new Types.ObjectId().toString(); + + expect( + getGraphQLConnectionFilter({ + cursor, + direction: "BACKWARD", + }), + ).toEqual({ + _id: { + $gt: new Types.ObjectId(cursor), + }, + }); + }); + + it(`when argument cursor is non-null and argument direction corresponds to forward`, async () => { + const cursor = new Types.ObjectId().toString(); + + expect( + getGraphQLConnectionFilter({ + cursor, + direction: "FORWARD", + }), + ).toEqual({ + _id: { + $lt: new Types.ObjectId(cursor), + }, + }); + }); + + it(`when argument cursor is null`, async () => { + expect( + getGraphQLConnectionFilter({ + cursor: null, + direction: "BACKWARD", + }), + ).toEqual({}); + }); +});