diff --git a/common/chat/conversation.ts b/common/chat/conversation.ts index 5f517dbc6..7598a8b38 100644 --- a/common/chat/conversation.ts +++ b/common/chat/conversation.ts @@ -1,8 +1,8 @@ import dotenv from 'dotenv'; import { v4 as uuidv4 } from 'uuid'; -// eslint-disable-next-line import/no-cycle import { checkResponseStatus, convertConversationInfosToString, createOneOnOneId, userIdToTalkJsId } from './helper'; -import { User } from '../user'; +import { User, userForPupil, userForStudent } from '../user'; +import { getPupil, getStudent } from '../../graphql/util'; import { AllConversations, ChatAccess, ChatType, Conversation, ConversationInfos, SystemMessage, TJConversation } from './types'; import { getLogger } from '../logger/logger'; import assert from 'assert'; @@ -76,6 +76,16 @@ const getConversation = async (conversationId: string): Promise => { + const student = await getStudent(matchees.studentId); + const pupil = await getPupil(matchees.pupilId); + const studentUser = userForStudent(student); + const pupilUser = userForPupil(pupil); + const conversationId = createOneOnOneId(studentUser, pupilUser); + const conversation = await getConversation(conversationId); + return { conversation, conversationId }; +}; + const getAllConversations = async (): Promise => { assert(TALKJS_SECRET_KEY, `No TalkJS secret key found to get all conversations.`); assureChatFeatureActive(); @@ -335,6 +345,7 @@ export { markConversationAsWriteable, sendSystemMessage, getConversation, + getMatcheeConversation, getAllConversations, deleteConversation, markConversationAsReadOnlyForPupils, diff --git a/common/chat/create.ts b/common/chat/create.ts index 3e3f41a73..d24c5436c 100644 --- a/common/chat/create.ts +++ b/common/chat/create.ts @@ -1,12 +1,14 @@ import { User } from '../user'; import { ChatMetaData, ContactReason, Conversation, ConversationInfos, FinishedReason, SystemMessage, TJConversation } from './types'; -import { checkChatMembersAccessRights, convertTJConversation, createOneOnOneId } from './helper'; +import { checkChatMembersAccessRights, convertTJConversation, createOneOnOneId, userIdToTalkJsId } from './helper'; import { createConversation, getConversation, markConversationAsWriteable, sendSystemMessage, updateConversation } from './conversation'; import { getOrCreateChatUser } from './user'; import { prisma } from '../prisma'; import { getMyContacts } from './contacts'; import systemMessages from './localization'; import { getLogger } from '../logger/logger'; +import assert from 'assert'; +import { createHmac } from 'crypto'; const logger = getLogger('Chat'); const getOrCreateOneOnOneConversation = async ( @@ -63,6 +65,15 @@ async function ensureChatUsersExist(participants: [User, User] | User[]): Promis ); } +const createChatSignature = async (user: User): Promise => { + const TALKJS_SECRET_KEY = process.env.TALKJS_API_KEY; + assert(TALKJS_SECRET_KEY, `No TalkJS secret key to create a chat signature for user ${user.userID}.`); + const userId = (await getOrCreateChatUser(user)).id; + const key = TALKJS_SECRET_KEY; + const hash = createHmac('sha256', key).update(userIdToTalkJsId(userId)); + return hash.digest('hex'); +}; + async function handleExistingConversation( conversationId: string, reason: ContactReason, @@ -195,4 +206,4 @@ async function createContactChat(meUser: User, contactUser: User): Promise { return userId.replace('/', '_') as TalkJSUserId; }; @@ -26,13 +17,6 @@ const userIdToTalkJsId = (userId: string): TalkJSUserId => { const talkJsIdToUserId = (userId: string): UserId => { return userId.replace('_', '/') as UserId; }; -const createChatSignature = async (user: User): Promise => { - assert(TALKJS_SECRET_KEY, `No TalkJS secret key to create a chat signature for user ${user.userID}.`); - const userId = (await getOrCreateChatUser(user)).id; - const key = TALKJS_SECRET_KEY; - const hash = createHmac('sha256', key).update(userIdToTalkJsId(userId)); - return hash.digest('hex'); -}; function createOneOnOneId(userA: User, userB: User): string { const userIds = JSON.stringify([userA.userID, userB.userID].sort()); @@ -126,16 +110,6 @@ const getMembersForSubcourseGroupChat = async (subcourse: Subcourse) => { return members; }; -const getMatcheeConversation = async (matchees: { studentId: number; pupilId: number }): Promise<{ conversation: Conversation; conversationId: string }> => { - const student = await getStudent(matchees.studentId); - const pupil = await getPupil(matchees.pupilId); - const studentUser = userForStudent(student); - const pupilUser = userForPupil(pupil); - const conversationId = createOneOnOneId(studentUser, pupilUser); - const conversation = await getConversation(conversationId); - return { conversation, conversationId }; -}; - const countChatParticipants = (conversation: Conversation): number => { return Object.keys(conversation.participants).length; }; @@ -210,11 +184,9 @@ export { talkJsIdToUserId, parseUnderscoreToSlash, checkResponseStatus, - createChatSignature, getMatchByMatchees, createOneOnOneId, countChatParticipants, - getMatcheeConversation, checkChatMembersAccessRights, isSubcourseParticipant, getMembersForSubcourseGroupChat, diff --git a/common/chat/user.ts b/common/chat/user.ts index 875db5ee5..d3b7f67f1 100644 --- a/common/chat/user.ts +++ b/common/chat/user.ts @@ -1,5 +1,4 @@ import dotenv from 'dotenv'; -// eslint-disable-next-line import/no-cycle import { checkResponseStatus, userIdToTalkJsId } from './helper'; import { User as TalkJsUser } from 'talkjs/all'; import { User } from '../user'; diff --git a/common/courses/notifications.ts b/common/courses/notifications.ts index 2fdfe214c..d748ee55c 100644 --- a/common/courses/notifications.ts +++ b/common/courses/notifications.ts @@ -1,13 +1,11 @@ import { prisma } from '../prisma'; import { getLogger } from '../logger/logger'; import * as Notification from '../../common/notification'; -import { getFullName, userForPupil, userForStudent } from '../user'; +import { userForPupil } from '../user'; import * as Prisma from '@prisma/client'; import { getFirstLecture } from './lectures'; import { parseSubjectString } from '../util/subjectsutils'; -// eslint-disable-next-line import/no-cycle -import { getCourseCapacity, getCourseFreePlaces, isParticipant } from './participants'; -import { getCourseImageURL } from './util'; +import { getCourseCapacity, getCourseFreePlaces, getCourseImageURL } from './util'; import { getCourse } from '../../graphql/util'; import { shuffleArray } from '../../common/util/basic'; import { NotificationContext } from '../notification/types'; diff --git a/common/courses/participants.ts b/common/courses/participants.ts index 79f758695..b2a523991 100644 --- a/common/courses/participants.ts +++ b/common/courses/participants.ts @@ -14,7 +14,6 @@ import { addParticipant } from '../chat'; import { ChatType } from '../chat/types'; import { isChatFeatureActive } from '../chat/util'; import { getCourseOfSubcourse, getSubcourseInstructors } from './util'; -// eslint-disable-next-line import/no-cycle import { getNotificationContextForSubcourse } from '../courses/notifications'; const delay = (time: number) => new Promise((res) => setTimeout(res, time)); @@ -319,15 +318,3 @@ export async function fillSubcourse(subcourse: Subcourse) { } } } - -export async function getCourseParticipantCount(subcourse: Subcourse) { - return await prisma.subcourse_participants_pupil.count({ where: { subcourseId: subcourse.id } }); -} - -export async function getCourseCapacity(subcourse: Subcourse) { - return (await getCourseParticipantCount(subcourse)) / (subcourse.maxParticipants || 1); -} - -export async function getCourseFreePlaces(subcourse: Subcourse) { - return Math.max(0, subcourse.maxParticipants - (await getCourseParticipantCount(subcourse))); -} diff --git a/common/courses/util.ts b/common/courses/util.ts index 2fb176c9d..5f5858369 100644 --- a/common/courses/util.ts +++ b/common/courses/util.ts @@ -1,4 +1,4 @@ -import { course as Course, subcourse as Subcourse } from '@prisma/client'; +import { subcourse as Subcourse } from '@prisma/client'; import { accessURLForKey } from '../file-bucket'; import { join } from 'path'; import { prisma } from '../prisma'; @@ -33,3 +33,15 @@ export async function getCourseOfSubcourse(subcourse: Subcourse) { where: { id: subcourse.courseId }, }); } + +export async function getCourseParticipantCount(subcourse: Subcourse) { + return await prisma.subcourse_participants_pupil.count({ where: { subcourseId: subcourse.id } }); +} + +export async function getCourseCapacity(subcourse: Subcourse) { + return (await getCourseParticipantCount(subcourse)) / (subcourse.maxParticipants || 1); +} + +export async function getCourseFreePlaces(subcourse: Subcourse) { + return Math.max(0, subcourse.maxParticipants - (await getCourseParticipantCount(subcourse))); +} diff --git a/common/match/create.ts b/common/match/create.ts index db9ea8bfb..bb35e3f0a 100644 --- a/common/match/create.ts +++ b/common/match/create.ts @@ -4,7 +4,6 @@ import { v4 as generateUUID } from 'uuid'; import { getPupilGradeAsString } from '../pupil'; import * as Notification from '../notification'; import { removeInterest } from './interest'; -// eslint-disable-next-line import/no-cycle import { getJitsiTutoringLink, getMatchHash, getOverlappingSubjects } from './util'; import { getLogger } from '../../common/logger/logger'; import { PrerequisiteError } from '../util/error'; diff --git a/common/match/pool.ts b/common/match/pool.ts index 172cf1e38..dc2e49caf 100644 --- a/common/match/pool.ts +++ b/common/match/pool.ts @@ -2,10 +2,9 @@ import { prisma } from '../prisma'; import type { Prisma, pupil as Pupil, student as Student } from '@prisma/client'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore The matching algorithm is optional, to allow for slim local setups -import type { Helpee, Helper, Settings, SubjectWithGradeRestriction } from 'corona-school-matching'; -// eslint-disable-next-line import/no-cycle +import type { Helpee, Helper, Settings } from 'corona-school-matching'; import { createMatch } from './create'; -import { parseSubjectString, Subject } from '../util/subjectsutils'; +import { parseSubjectString } from '../util/subjectsutils'; import { gradeAsInt } from '../util/gradestrings'; import { assertExists } from '../util/basic'; import { getLogger } from '../logger/logger'; @@ -14,11 +13,7 @@ import { cleanupUnconfirmed, InterestConfirmationStatus, requestInterestConfirma import { userSearch } from '../user/search'; import { addPupilScreening } from '../pupil/screening'; import assert from 'assert'; - -export const DEFAULT_TUTORING_GRADERESTRICTIONS = { - MIN: 1, - MAX: 13, -}; +import { formattedSubjectToSubjectWithGradeRestriction } from './util'; const logger = getLogger('MatchingPool'); @@ -151,16 +146,6 @@ async function pupilToHelpee(pupil: Pupil): Promise { }; } -function formattedSubjectToSubjectWithGradeRestriction(subject: Subject): SubjectWithGradeRestriction { - return { - name: subject.name, - gradeRestriction: { - min: subject.grade?.min ?? DEFAULT_TUTORING_GRADERESTRICTIONS.MIN, //due to a screening tool's bug (or how it is designed), those values may be null (which causes the algorithm to fail) - max: subject.grade?.max ?? DEFAULT_TUTORING_GRADERESTRICTIONS.MAX, - }, - }; -} - const INTEREST_CONFIRMATION_TOGGLES = ['confirmation-success', 'confirmation-pending', 'confirmation-unknown'] as const; type InterestConfirmationToggle = (typeof INTEREST_CONFIRMATION_TOGGLES)[number]; diff --git a/common/match/util.ts b/common/match/util.ts index 8f0ede87d..b9a26278a 100644 --- a/common/match/util.ts +++ b/common/match/util.ts @@ -1,10 +1,26 @@ import { match as Match, pupil as Pupil, student as Student } from '@prisma/client'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore The matching algorithm is optional, to allow for slim local setups +import type { SubjectWithGradeRestriction } from 'corona-school-matching'; import { prisma } from '../prisma'; -import { parseSubjectString } from '../util/subjectsutils'; +import { parseSubjectString, Subject } from '../util/subjectsutils'; import { gradeAsInt } from '../util/gradestrings'; import { hashToken } from '../util/hashing'; -// eslint-disable-next-line import/no-cycle -import { DEFAULT_TUTORING_GRADERESTRICTIONS } from './pool'; + +export const DEFAULT_TUTORING_GRADERESTRICTIONS = { + MIN: 1, + MAX: 13, +}; + +export function formattedSubjectToSubjectWithGradeRestriction(subject: Subject): SubjectWithGradeRestriction { + return { + name: subject.name, + gradeRestriction: { + min: subject.grade?.min ?? DEFAULT_TUTORING_GRADERESTRICTIONS.MIN, //due to a screening tool's bug (or how it is designed), those values may be null (which causes the algorithm to fail) + max: subject.grade?.max ?? DEFAULT_TUTORING_GRADERESTRICTIONS.MAX, + }, + }; +} export function getJitsiTutoringLink(match: Match) { return `https://meet.jit.si/CoronaSchool-${encodeURIComponent(match.uuid)}`; diff --git a/common/notification/channels/mailjet.ts b/common/notification/channels/mailjet.ts index d5913a9f2..7ce5e978a 100644 --- a/common/notification/channels/mailjet.ts +++ b/common/notification/channels/mailjet.ts @@ -4,9 +4,8 @@ import * as assert from 'assert'; import { AttachmentGroup } from '../../attachments'; import { isDev } from '../../util/environment'; import { User } from '../../user'; -// eslint-disable-next-line import/no-cycle -import { createSecretEmailToken } from '../../secret'; import moment from 'moment'; +import { createSecretEmailToken } from '../../secret/emailToken'; // ------------ Mailjet Interface ------------------------------- diff --git a/common/notification/hook.ts b/common/notification/hook.ts index 1dc5d702b..4b7a7b360 100644 --- a/common/notification/hook.ts +++ b/common/notification/hook.ts @@ -5,10 +5,6 @@ import { student as Student, pupil as Pupil } from '@prisma/client'; import { getPupil, getStudent, User } from '../user'; -// eslint-disable-next-line import/no-cycle -import { getNotifications } from './notification'; -import { prisma } from '../prisma'; -import { ActionID, ConcreteNotificationState } from './types'; type NotificationHook = { fn: (user: User) => Promise; description: string }; @@ -40,21 +36,3 @@ export const registerStudentHook = (hookID: string, description: string, hook: ( export const registerPupilHook = (hookID: string, description: string, hook: (pupil: Pupil) => Promise) => registerHook(hookID, description, (user) => getPupil(user).then(hook)); - -// Predicts when a hook will run for a certain user as caused by a certain action -// i.e. 'When will a user by deactivated (hook) due to Certificate of Conduct reminders (action) ?' -// Returns null if no date is known or hook was already triggered -export async function predictedHookActionDate(action: ActionID, hookID: string, user: User): Promise { - const viableNotifications = ((await getNotifications()).get(action)?.toSend ?? []).filter((it) => it.hookID === hookID); - - const possibleTrigger = await prisma.concrete_notification.findFirst({ - where: { - state: ConcreteNotificationState.DELAYED, - userId: user.userID, - notificationID: { in: viableNotifications.map((it) => it.id) }, - }, - select: { sentAt: true }, - }); - - return possibleTrigger?.sentAt; -} diff --git a/common/notification/index.ts b/common/notification/index.ts index aae88d843..9fbf570f0 100644 --- a/common/notification/index.ts +++ b/common/notification/index.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-cycle import { mailjetChannel } from './channels/mailjet'; import { NotificationID, NotificationContext, Context, Notification, ConcreteNotification, ConcreteNotificationState, Channel } from './types'; import { prisma } from '../prisma'; @@ -345,6 +344,26 @@ export async function cancelDraftedAndDelayed(notification: Notification, contex logger.info(`Cancelled ${publishedCount} drafted notifications for Notification(${notification.id})`); } +/* -------------------------------- Hook ----------------------------------------------------------- */ + +// Predicts when a hook will run for a certain user as caused by a certain action +// i.e. 'When will a user by deactivated (hook) due to Certificate of Conduct reminders (action) ?' +// Returns null if no date is known or hook was already triggered +export async function predictedHookActionDate(action: ActionID, hookID: string, user: User): Promise { + const viableNotifications = ((await getNotifications()).get(action)?.toSend ?? []).filter((it) => it.hookID === hookID); + + const possibleTrigger = await prisma.concrete_notification.findFirst({ + where: { + state: ConcreteNotificationState.DELAYED, + userId: user.userID, + notificationID: { in: viableNotifications.map((it) => it.id) }, + }, + select: { sentAt: true }, + }); + + return possibleTrigger?.sentAt; +} + export * from './hook'; /* -------------------------------- Public API exposed to other components ----------------------------------------------------------- */ diff --git a/common/notification/notification.ts b/common/notification/notification.ts index 83c110ba8..2317c06b2 100644 --- a/common/notification/notification.ts +++ b/common/notification/notification.ts @@ -2,13 +2,11 @@ New notifications can be created / modified at runtime. This module contains various utilities to do that */ import { prisma } from '../prisma'; -import { Context, Notification, NotificationID, NotificationMessage, NotificationRecipient } from './types'; +import { Context, Notification, NotificationID, NotificationRecipient } from './types'; import { Prisma } from '@prisma/client'; import { getLogger } from '../../common/logger/logger'; -// eslint-disable-next-line import/no-cycle import { hookExists } from './hook'; -import { ActionID, getNotificationActions, sampleUser } from './actions'; -import { MessageTemplateType } from '../../graphql/types/notificationMessage'; +import { getNotificationActions, sampleUser } from './actions'; import { NotificationUpdateInput } from '../../graphql/generated'; import { USER_APP_DOMAIN } from '../util/environment'; diff --git a/common/notification/types.ts b/common/notification/types.ts index 936b78e3c..5e25ba3a9 100644 --- a/common/notification/types.ts +++ b/common/notification/types.ts @@ -2,7 +2,6 @@ import { concrete_notification as ConcreteNotification, notification as Notification, student as Student, pupil as Pupil } from '.prisma/client'; import { AttachmentGroup } from '../attachments'; import { User } from '../user'; -// eslint-disable-next-line import/no-cycle import { ActionID } from './actions'; export type NotificationID = number; // either our own or we reuse them from Mailjet. Maybe we can structure them a bit better diff --git a/common/secret/emailToken.ts b/common/secret/emailToken.ts new file mode 100644 index 000000000..ee6415ad2 --- /dev/null +++ b/common/secret/emailToken.ts @@ -0,0 +1,33 @@ +import { User } from '../user'; +import { prisma } from '../prisma'; +import { v4 as uuid } from 'uuid'; +import { hashToken } from '../util/hashing'; +import { Moment } from 'moment'; +import { secret_type_enum as SecretType } from '@prisma/client'; +import { getLogger } from '../logger/logger'; + +const logger = getLogger('Token'); + +// The token returned by this function MAY NEVER be persisted and may only be sent to the user by email +// If newEmail ist set, the token MUST be sent to that new email + +// TODO: we should create a dedicated field for newEmail +export async function createSecretEmailToken(user: User, newEmail?: string, expiresAt?: Moment): Promise { + const token = uuid(); + const hash = hashToken(token); + + const result = await prisma.secret.create({ + data: { + type: SecretType.EMAIL_TOKEN, + userId: user.userID, + secret: hash, + expiresAt: expiresAt?.toDate(), + lastUsed: null, + description: newEmail, + }, + }); + + logger.info(`Created a new email token Secret(${result.id}) for User(${user.userID}) with email change ${newEmail ?? '-'}`); + + return token; +} diff --git a/common/secret/index.ts b/common/secret/index.ts index 54d113092..f796dd8d5 100644 --- a/common/secret/index.ts +++ b/common/secret/index.ts @@ -4,8 +4,8 @@ import { getLogger } from '../logger/logger'; import { secret_type_enum as SecretType } from '@prisma/client'; export * from './password'; -// eslint-disable-next-line import/no-cycle export * from './token'; +export * from './emailToken'; const logger = getLogger('Secret'); diff --git a/common/secret/token.ts b/common/secret/token.ts index 1f84a21db..983999932 100644 --- a/common/secret/token.ts +++ b/common/secret/token.ts @@ -2,15 +2,14 @@ import { getUser, updateUser, User } from '../user'; import { prisma } from '../prisma'; import { v4 as uuid } from 'uuid'; import { hashToken } from '../util/hashing'; -// eslint-disable-next-line import/no-cycle import * as Notification from '../notification'; import { getLogger } from '../logger/logger'; import { isDev, isTest, USER_APP_DOMAIN } from '../util/environment'; import { validateEmail } from '../../graphql/validators'; import { Email } from '../notification/types'; -import { Moment } from 'moment'; import { isEmailAvailable } from '../user/email'; import { secret_type_enum as SecretType } from '@prisma/client'; +import { createSecretEmailToken } from './emailToken'; const logger = getLogger('Token'); @@ -105,29 +104,6 @@ export async function requestToken( await Notification.actionTaken(user, action, { token, redirectTo: redirectTo ?? '', overrideReceiverEmail: newEmail as Email }); } -// The token returned by this function MAY NEVER be persisted and may only be sent to the user by email -// If newEmail ist set, the token MUST be sent to that new email -// TODO: we should create a dedicated field for newEmail -export async function createSecretEmailToken(user: User, newEmail?: string, expiresAt?: Moment): Promise { - const token = uuid(); - const hash = hashToken(token); - - const result = await prisma.secret.create({ - data: { - type: SecretType.EMAIL_TOKEN, - userId: user.userID, - secret: hash, - expiresAt: expiresAt?.toDate(), - lastUsed: null, - description: newEmail, - }, - }); - - logger.info(`Created a new email token Secret(${result.id}) for User(${user.userID}) with email change ${newEmail ?? '-'}`); - - return token; -} - export async function loginToken(token: string): Promise { const secret = await prisma.secret.findFirst({ where: { @@ -178,7 +154,7 @@ export async function loginToken(token: string): Promise { await verifyEmail(user); } - return await user; + return user; } export async function verifyEmail(user: User) { diff --git a/graphql/match/mutations.ts b/graphql/match/mutations.ts index 5e26bdfa9..49b286420 100644 --- a/graphql/match/mutations.ts +++ b/graphql/match/mutations.ts @@ -7,8 +7,7 @@ import { dissolveMatch, reactivateMatch } from '../../common/match/dissolve'; import { createMatch } from '../../common/match/create'; import { GraphQLContext } from '../context'; import { ConcreteMatchPool, pools } from '../../common/match/pool'; -import { getMatcheeConversation } from '../../common/chat/helper'; -import { markConversationAsWriteable } from '../../common/chat'; +import { getMatcheeConversation, markConversationAsWriteable } from '../../common/chat'; import { JSONResolver } from 'graphql-scalars'; import { createAdHocMeeting } from '../../common/appointment/create'; import { AuthenticationError } from '../error'; diff --git a/graphql/me/fields.ts b/graphql/me/fields.ts index 2067706aa..3595e6695 100644 --- a/graphql/me/fields.ts +++ b/graphql/me/fields.ts @@ -3,7 +3,7 @@ import { getSessionUser, GraphQLUser } from '../authentication'; import { GraphQLContext } from '../context'; import { Role } from '../authorizations'; import { UserType } from '../types/user'; -import { createChatSignature } from '../../common/chat/helper'; +import { createChatSignature } from '../../common/chat/create'; @Resolver((of) => UserType) export class FieldMeResolver { diff --git a/graphql/me/mutation.ts b/graphql/me/mutation.ts index fb1d5b35e..faa4e326c 100644 --- a/graphql/me/mutation.ts +++ b/graphql/me/mutation.ts @@ -3,116 +3,26 @@ import { Arg, Authorized, Ctx, Field, InputType, Int, Mutation, Resolver } from import { GraphQLContext } from '../context'; import { getSessionPupil, getSessionStudent, isSessionPupil, isSessionStudent, loginAsUser, updateSessionUser } from '../authentication'; import { activatePupil, deactivatePupil } from '../../common/pupil/activation'; -import { - pupil_learninggermansince_enum as LearningGermanSince, - pupil_languages_enum as Language, - pupil_registrationsource_enum as RegistrationSource, - pupil_schooltype_enum as SchoolType, - pupil_state_enum as State, - student_module_enum as TeacherModule, - school as School, -} from '@prisma/client'; +import { pupil_registrationsource_enum as RegistrationSource } from '@prisma/client'; import { MaxLength, ValidateNested } from 'class-validator'; import { RateLimit } from '../rate-limit'; -import { becomeInstructor, BecomeInstructorData, becomeTutor, BecomeTutorData, registerStudent, RegisterStudentData } from '../../common/student/registration'; -import { - becomeStatePupil, - BecomeStatePupilData, - becomeTutee, - BecomeTuteeData, - becomeParticipant, - registerPupil, - RegisterPupilData, -} from '../../common/pupil/registration'; +import { becomeInstructor, BecomeInstructorData, becomeTutor, registerStudent } from '../../common/student/registration'; +import { becomeStatePupil, BecomeStatePupilData, becomeTutee, becomeParticipant, registerPupil } from '../../common/pupil/registration'; import '../types/enums'; -import { Subject } from '../types/subject'; import { PrerequisiteError } from '../../common/util/error'; import { userForStudent, userForPupil } from '../../common/user'; import { evaluatePupilRoles, evaluateStudentRoles } from '../roles'; import { Pupil, Student } from '../generated'; import { UserInputError } from 'apollo-server-express'; import { UserType } from '../types/user'; -// eslint-disable-next-line import/no-cycle import { StudentUpdateInput, updateStudent } from '../student/mutations'; -// eslint-disable-next-line import/no-cycle import { PupilUpdateInput, updatePupil } from '../pupil/mutations'; import { NotificationPreferences } from '../types/preferences'; import { deactivateStudent } from '../../common/student/activation'; import { ValidateEmail } from '../validators'; import { getLogger } from '../../common/logger/logger'; import { GraphQLBoolean } from 'graphql'; - -@InputType() -export class RegisterStudentInput implements RegisterStudentData { - @Field((type) => String) - @MaxLength(100) - firstname: string; - - @Field((type) => String) - @MaxLength(100) - lastname: string; - - @Field((type) => String) - @ValidateEmail() - email: string; - - @Field((type) => Boolean) - newsletter: boolean; - - @Field((type) => RegistrationSource) - registrationSource: RegistrationSource; - - @Field((type) => String, { defaultValue: '' }) - @MaxLength(500) - aboutMe: string; - - /* After registration, the user receives an email to verify their account. - The user is redirected to this URL afterwards to continue with whatever they're registering for */ - @Field((type) => String, { nullable: true }) - redirectTo?: string; - - @Field((type) => String, { nullable: true }) - cooperationTag?: string; -} - -@InputType() -export class RegisterPupilInput implements RegisterPupilData { - @Field((type) => String) - @MaxLength(100) - firstname: string; - - @Field((type) => String) - @MaxLength(100) - lastname: string; - - @Field((type) => String) - @ValidateEmail() - email: string; - - @Field((type) => Boolean) - newsletter: boolean; - - @Field((type) => Int, { nullable: true }) - schoolId?: School['id']; - - @Field((type) => SchoolType, { nullable: true }) - schooltype?: SchoolType; - - @Field((type) => State) - state: State; - - @Field((type) => RegistrationSource) - registrationSource: RegistrationSource; - - @Field((type) => String, { defaultValue: '' }) - @MaxLength(500) - aboutMe: string; - - /* After registration, the user receives an email to verify their account. - The user is redirected to this URL afterwards to continue with whatever they're registering for */ - @Field((type) => String, { nullable: true }) - redirectTo?: string; -} +import { BecomeTuteeInput, BecomeTutorInput, RegisterPupilInput, RegisterStudentInput } from '../types/userInputs'; @InputType() class MeUpdateInput { @@ -146,33 +56,6 @@ class BecomeInstructorInput implements BecomeInstructorData { message?: string; } -@InputType() -export class BecomeTutorInput implements BecomeTutorData { - @Field((type) => [Subject], { nullable: true }) - subjects?: Subject[]; - - @Field((type) => [Language], { nullable: true }) - languages?: Language[]; - - @Field((type) => Boolean, { nullable: true }) - supportsInDaZ?: boolean; -} - -@InputType() -export class BecomeTuteeInput implements BecomeTuteeData { - @Field((type) => [Subject]) - subjects: Subject[]; - - @Field((type) => [Language]) - languages: Language[]; - - @Field((type) => LearningGermanSince, { nullable: true }) - learningGermanSince?: LearningGermanSince; - - @Field((type) => Int) - gradeAsInt: number; -} - @InputType() class BecomeStatePupilInput implements BecomeStatePupilData { @Field((type) => String) diff --git a/graphql/pupil/mutations.ts b/graphql/pupil/mutations.ts index e0068d15c..aa9b3a075 100644 --- a/graphql/pupil/mutations.ts +++ b/graphql/pupil/mutations.ts @@ -24,15 +24,13 @@ import { PrerequisiteError } from '../../common/util/error'; import { toPupilSubjectDatabaseFormat } from '../../common/util/subjectsutils'; import { userForPupil } from '../../common/user'; import { MaxLength } from 'class-validator'; -// eslint-disable-next-line import/no-cycle -import { BecomeTuteeInput, RegisterPupilInput } from '../me/mutation'; import { becomeTutee, registerPupil } from '../../common/pupil/registration'; import { NotificationPreferences } from '../types/preferences'; import { addPupilScreening, updatePupilScreening } from '../../common/pupil/screening'; import { invalidatePupilScreening } from '../../common/pupil/screening'; import { validateEmail, ValidateEmail } from '../validators'; import { getLogger } from '../../common/logger/logger'; -import { JSONResolver } from 'graphql-scalars'; +import { RegisterPupilInput, BecomeTuteeInput } from '../types/userInputs'; const logger = getLogger(`Pupil Mutations`); diff --git a/graphql/student/mutations.ts b/graphql/student/mutations.ts index 824f556f0..f3a5b43ff 100644 --- a/graphql/student/mutations.ts +++ b/graphql/student/mutations.ts @@ -30,11 +30,10 @@ import { createRemissionRequestPDF } from '../../common/remission-request'; import { getFileURL, addFile } from '../files'; import { validateEmail, ValidateEmail } from '../validators'; const log = getLogger(`StudentMutation`); -// eslint-disable-next-line import/no-cycle -import { BecomeTutorInput, RegisterStudentInput } from '../me/mutation'; import { screening_jobstatus_enum } from '../../graphql/generated'; import { createZoomUser, deleteZoomUser } from '../../common/zoom/user'; -import { GraphQLJSON, JSONResolver } from 'graphql-scalars'; +import { GraphQLJSON } from 'graphql-scalars'; +import { BecomeTutorInput, RegisterStudentInput } from '../types/userInputs'; @InputType('Instructor_screeningCreateInput', { isAbstract: true, diff --git a/graphql/subcourse/fields.ts b/graphql/subcourse/fields.ts index c88df3e21..b45ac4ef3 100644 --- a/graphql/subcourse/fields.ts +++ b/graphql/subcourse/fields.ts @@ -1,7 +1,7 @@ import { Prisma, subcourse, course_coursestate_enum as CourseState } from '@prisma/client'; import { canCancel, canEditSubcourse, canPublish } from '../../common/courses/states'; import { Arg, Authorized, Ctx, Field, FieldResolver, Int, ObjectType, Query, Resolver, Root } from 'type-graphql'; -import { canJoinSubcourse, couldJoinSubcourse, getCourseCapacity, isParticipant } from '../../common/courses/participants'; +import { canJoinSubcourse, couldJoinSubcourse, isParticipant } from '../../common/courses/participants'; import { prisma } from '../../common/prisma'; import { getSessionPupil, getSessionStudent, isElevated, isSessionPupil, isSessionStudent } from '../authentication'; import { Role } from '../authorizations'; @@ -16,6 +16,7 @@ import { Deprecated, getCourse } from '../util'; import { gradeAsInt } from '../../common/util/gradestrings'; import { subcourseSearch } from '../../common/courses/search'; import { GraphQLInt } from 'graphql'; +import { getCourseCapacity } from '../../common/courses/util'; @ObjectType() class Participant { diff --git a/graphql/types/userInputs.ts b/graphql/types/userInputs.ts new file mode 100644 index 000000000..82ae0ed22 --- /dev/null +++ b/graphql/types/userInputs.ts @@ -0,0 +1,114 @@ +import { BecomeTuteeData, RegisterPupilData } from '../../common/pupil/registration'; +import { InputType, Field, Int } from 'type-graphql'; +import { Subject } from './subject'; +import { MaxLength } from 'class-validator'; +import { + pupil_learninggermansince_enum as LearningGermanSince, + pupil_languages_enum as Language, + pupil_registrationsource_enum as RegistrationSource, + pupil_schooltype_enum as SchoolType, + pupil_state_enum as State, + student_module_enum as TeacherModule, + school as School, +} from '@prisma/client'; +import { ValidateEmail } from '../validators'; +import { BecomeTutorData, RegisterStudentData } from '../../common/student/registration'; + +@InputType() +export class BecomeTuteeInput implements BecomeTuteeData { + @Field((type) => [Subject]) + subjects: Subject[]; + + @Field((type) => [Language]) + languages: Language[]; + + @Field((type) => LearningGermanSince, { nullable: true }) + learningGermanSince?: LearningGermanSince; + + @Field((type) => Int) + gradeAsInt: number; +} + +@InputType() +export class RegisterPupilInput implements RegisterPupilData { + @Field((type) => String) + @MaxLength(100) + firstname: string; + + @Field((type) => String) + @MaxLength(100) + lastname: string; + + @Field((type) => String) + @ValidateEmail() + email: string; + + @Field((type) => Boolean) + newsletter: boolean; + + @Field((type) => Int, { nullable: true }) + schoolId?: School['id']; + + @Field((type) => SchoolType, { nullable: true }) + schooltype?: SchoolType; + + @Field((type) => State) + state: State; + + @Field((type) => RegistrationSource) + registrationSource: RegistrationSource; + + @Field((type) => String, { defaultValue: '' }) + @MaxLength(500) + aboutMe: string; + + /* After registration, the user receives an email to verify their account. + The user is redirected to this URL afterwards to continue with whatever they're registering for */ + @Field((type) => String, { nullable: true }) + redirectTo?: string; +} + +@InputType() +export class RegisterStudentInput implements RegisterStudentData { + @Field((type) => String) + @MaxLength(100) + firstname: string; + + @Field((type) => String) + @MaxLength(100) + lastname: string; + + @Field((type) => String) + @ValidateEmail() + email: string; + + @Field((type) => Boolean) + newsletter: boolean; + + @Field((type) => RegistrationSource) + registrationSource: RegistrationSource; + + @Field((type) => String, { defaultValue: '' }) + @MaxLength(500) + aboutMe: string; + + /* After registration, the user receives an email to verify their account. + The user is redirected to this URL afterwards to continue with whatever they're registering for */ + @Field((type) => String, { nullable: true }) + redirectTo?: string; + + @Field((type) => String, { nullable: true }) + cooperationTag?: string; +} + +@InputType() +export class BecomeTutorInput implements BecomeTutorData { + @Field((type) => [Subject], { nullable: true }) + subjects?: Subject[]; + + @Field((type) => [Language], { nullable: true }) + languages?: Language[]; + + @Field((type) => Boolean, { nullable: true }) + supportsInDaZ?: boolean; +} diff --git a/integration-tests/01_user.ts b/integration-tests/01_user.ts index 962626728..c15c7d3a8 100644 --- a/integration-tests/01_user.ts +++ b/integration-tests/01_user.ts @@ -1,4 +1,5 @@ -import { test, createUserClient, adminClient } from './base'; +import { test } from './base'; +import { createUserClient, adminClient } from './base/clients'; import assert from 'assert'; import { randomBytes } from 'crypto'; import { assertUserReceivedNotification, createMockNotification } from './base/notifications'; diff --git a/integration-tests/02_screening.ts b/integration-tests/02_screening.ts index 6f8bfd41f..81b4c781b 100644 --- a/integration-tests/02_screening.ts +++ b/integration-tests/02_screening.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; -import { adminClient, createUserClient, test } from './base'; +import { test } from './base'; +import { adminClient, createUserClient } from './base/clients'; import { instructorOne, instructorTwo, pupilOne, studentOne } from './01_user'; const screenerOne = test('Admin can create Screener Account', async () => { @@ -11,7 +12,7 @@ const screenerOne = test('Admin can create Screener Account', async () => { const { screenerCreate: token } = await adminClient.request(` mutation CreateScreenerAccount { - screenerCreate(data: { + screenerCreate(data: { email: "${email}", firstname: "${firstname}", lastname: "${lastname}" @@ -80,7 +81,7 @@ void test('Screener can Query Users to Screen', async () => { const { usersSearch } = await client.request(` query FindUsersToScreen { usersSearch(query: "${instructor.firstname} ${instructor.lastname}", take: 1) { - student { + student { firstname lastname subjectsFormatted { name } @@ -93,8 +94,8 @@ void test('Screener can Query Users to Screen', async () => { dissolvedAt studentFirstMatchRequest } - - tutorScreenings { + + tutorScreenings { success comment jobStatus @@ -102,8 +103,8 @@ void test('Screener can Query Users to Screen', async () => { createdAt screener { firstname lastname } } - - instructorScreenings { + + instructorScreenings { success comment jobStatus @@ -121,8 +122,8 @@ void test('Screener can Query Users to Screen', async () => { const { usersSearch: usersSearch2 } = await client.request(` query FindUsersToScreen { - usersSearch(query: "${pupil.firstname} ${pupil.lastname}", take: 1) { - pupil { + usersSearch(query: "${pupil.firstname} ${pupil.lastname}", take: 1) { + pupil { firstname lastname subjectsFormatted { name } @@ -167,7 +168,7 @@ void test('Screener can Query Users to Screen', async () => { const { pupilsToBeScreened } = await client.request(` query FindPupilsToBeScreened { - pupilsToBeScreened { + pupilsToBeScreened { firstname lastname } @@ -247,7 +248,7 @@ export const screenedTutorOne = test('Screen Tutor One successfully', async () = }); void test('Screen Pupil One', async () => { - const { pupil, client } = await pupilWithScreening; + const { pupil } = await pupilWithScreening; const { client: screenerClient, screener } = await screenerOne; const { pupilsToBeScreened } = await screenerClient.request(` diff --git a/integration-tests/03_matching.ts b/integration-tests/03_matching.ts index 39049d4cb..23cc1d9e1 100644 --- a/integration-tests/03_matching.ts +++ b/integration-tests/03_matching.ts @@ -1,4 +1,5 @@ -import { adminClient, defaultClient, test } from './base'; +import { test } from './base'; +import { adminClient, defaultClient } from './base/clients'; import { pupilOne, studentOne } from './01_user'; import * as assert from 'assert'; import { expectFetch } from './base/mock'; diff --git a/integration-tests/05_auth.ts b/integration-tests/05_auth.ts index 0d48ee8a5..a987b6b1d 100644 --- a/integration-tests/05_auth.ts +++ b/integration-tests/05_auth.ts @@ -1,4 +1,5 @@ -import { adminClient, createUserClient, defaultClient, test } from './base'; +import { test } from './base'; +import { defaultClient, createUserClient, adminClient } from './base/clients'; import { pupilOne } from './01_user'; import assert from 'assert'; import { assertUserReceivedNotification, createMockNotification } from './base/notifications'; diff --git a/integration-tests/07_course.ts b/integration-tests/07_course.ts index 1c33582c4..d3a883ccb 100644 --- a/integration-tests/07_course.ts +++ b/integration-tests/07_course.ts @@ -1,5 +1,6 @@ -import { adminClient, defaultClient, test } from './base'; -import { pupilOne, pupilUpdated } from './01_user'; +import { test } from './base'; +import { adminClient } from './base/clients'; +import { pupilOne } from './01_user'; import * as assert from 'assert'; import { screenedInstructorOne, screenedInstructorTwo } from './02_screening'; import { ChatType } from '../common/chat/types'; diff --git a/integration-tests/10_admin.ts b/integration-tests/10_admin.ts index 546725d54..c31f75362 100644 --- a/integration-tests/10_admin.ts +++ b/integration-tests/10_admin.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; -import { adminClient, test } from './base'; +import { test } from './base'; +import { adminClient } from './base/clients'; import { pupilOne, studentOne } from './01_user'; /* eslint-disable */ @@ -147,7 +148,7 @@ void test('Admin Manage Notifications', async () => { const { notificationCreate: { id }, } = await adminClient.request(`mutation CreateNotification { - notificationCreate(notification: { + notificationCreate(notification: { description: "MOCK" active: false recipient: 0 @@ -181,7 +182,7 @@ void test('Admin Manage Notifications', async () => { await adminClient.request(`mutation Activate { notificationActivate(notificationId: ${id}, active: true)}`); - await adminClient.requestShallFail(`mutation SendOutWithMissingContext { + await adminClient.requestShallFail(`mutation SendOutWithMissingContext { concreteNotificationBulkCreate( startAt: "${new Date(0).toISOString()}" skipDraft: true @@ -191,7 +192,7 @@ void test('Admin Manage Notifications', async () => { ) }`); - await adminClient.request(`mutation SendOut { + await adminClient.request(`mutation SendOut { concreteNotificationBulkCreate( startAt: "${new Date(0).toISOString()}" skipDraft: true @@ -203,13 +204,13 @@ void test('Admin Manage Notifications', async () => { const { me: { concreteNotifications: scheduled }, - } = await pupilClient.request(`query NotificationScheduled { - me { - concreteNotifications(take:100) { + } = await pupilClient.request(`query NotificationScheduled { + me { + concreteNotifications(take:100) { notificationID state sentAt - message { + message { headline body navigateTo @@ -225,13 +226,13 @@ void test('Admin Manage Notifications', async () => { const { me: { concreteNotifications: sent }, - } = await pupilClient.request(`query NotificationSent { - me { - concreteNotifications(take:100) { + } = await pupilClient.request(`query NotificationSent { + me { + concreteNotifications(take:100) { notificationID state sentAt - message { + message { headline body navigateTo @@ -261,13 +262,13 @@ void test('Admin Manage Notifications', async () => { const { me: { concreteNotifications: sent2 }, - } = await pupilClient.request(`query NotificationSent2 { - me { - concreteNotifications(take:100) { + } = await pupilClient.request(`query NotificationSent2 { + me { + concreteNotifications(take:100) { notificationID state sentAt - message { + message { headline body navigateTo diff --git a/integration-tests/11_registerPlusMany.ts b/integration-tests/11_registerPlusMany.ts index be8163061..bc6c85c2a 100644 --- a/integration-tests/11_registerPlusMany.ts +++ b/integration-tests/11_registerPlusMany.ts @@ -1,4 +1,5 @@ -import { adminClient, test } from './base'; +import { test } from './base'; +import { adminClient } from './base/clients'; import { gql } from 'graphql-request'; import { randomBytes } from 'crypto'; import assert from 'assert'; diff --git a/integration-tests/12_notifications.ts b/integration-tests/12_notifications.ts index 8cd5924c9..92e89fb94 100644 --- a/integration-tests/12_notifications.ts +++ b/integration-tests/12_notifications.ts @@ -1,7 +1,8 @@ import assert from 'assert'; import { prisma } from '../common/prisma'; import { pupilOne } from './01_user'; -import { adminClient, test } from './base'; +import { test } from './base'; +import { adminClient } from './base/clients'; import { createMockNotification } from './base/notifications'; void test('Action Notification Timing (Dry Run)', async () => { diff --git a/integration-tests/13_deactivation.ts b/integration-tests/13_deactivation.ts index c7357f2c8..76be996ca 100644 --- a/integration-tests/13_deactivation.ts +++ b/integration-tests/13_deactivation.ts @@ -1,7 +1,7 @@ import { pupilOneWithPassword } from './05_auth'; import { test } from './base'; import { createNewPupil, createNewStudent, createInactivityMockNotification } from './01_user'; -import { adminClient } from './base'; +import { adminClient } from './base/clients'; import { prisma } from '../common/prisma'; import { DEACTIVATE_ACCOUNTS_INACTIVITY_DAYS, deactivateInactiveAccounts } from '../jobs/periodic/redact-inactive-accounts/deactivate-inactive-accounts'; import { sendInactivityNotification, NOTIFY_AFTER_DAYS } from '../jobs/periodic/redact-inactive-accounts/send-inactivity-notification'; diff --git a/integration-tests/14_redaction.ts b/integration-tests/14_redaction.ts index 2fc94e52c..06fc6ee0f 100644 --- a/integration-tests/14_redaction.ts +++ b/integration-tests/14_redaction.ts @@ -5,7 +5,7 @@ import { createNewPupil, createNewStudent } from './01_user'; import moment from 'moment'; import redactInactiveAccounts, { GRACE_PERIOD } from '../jobs/periodic/redact-inactive-accounts'; import { pupil, student } from '@prisma/client'; -import { adminClient } from './base'; +import { adminClient } from './base/clients'; function testUser(shouldBeRedacted: boolean, userOld: pupil | student, userNew: pupil | student) { // TODO: check how to check if other things have been deleted diff --git a/integration-tests/base/clients.ts b/integration-tests/base/clients.ts new file mode 100644 index 000000000..454c34c43 --- /dev/null +++ b/integration-tests/base/clients.ts @@ -0,0 +1,86 @@ +import { randomBytes } from 'crypto'; +import { GraphQLClient } from 'graphql-request'; + +import { getLogger } from '../../common/logger/logger'; + +export const logger = getLogger('TEST'); + +/* -------------- Configuration ------------------- */ + +const ADMIN_TOKEN = process.env.ADMIN_AUTH_TOKEN; +const silent = process.env.INTEGRATION_SILENT === 'true'; +const URL = process.env.INTEGRATION_TARGET ?? `http://localhost:${process.env.PORT ?? 5000}/apollo`; + +/* -------------- Utils --------------------------- */ + +logger.mark(`Backend Integration Tests\n` + ` testing ${URL}\n\n`); + +/* -------------- GraphQL Client Wrapper ------------------ */ + +// This wrapper provides assertions and logging around a GraphQLClient of the graphql-request package +function wrapClient(client: GraphQLClient) { + async function request(query: string) { + const name = query.match(/(mutation|query) [A-Za-z]+/)?.[0] ?? '(unnamed)'; + logger.mark(`+ ${name}`); + if (!silent) { + logger.info(`request: ` + query.trim()); + } + const response = await client.request(query); + if (!silent) { + logger.info(`response: ` + JSON.stringify(response, null, 2)); + } + return response; + } + + async function requestShallFail(query: string): Promise { + const name = query.match(/(mutation|query) [A-Za-z]+/)?.[0] ?? '(unnamed)'; + logger.mark(`+ ${name}`); + + if (!silent) { + logger.info(` request (should fail):` + query.trim()); + } + + try { + await client.request(query); + } catch (error) { + if (!silent) { + logger.info(` successfully failed with ${error.message.split(':')[0]}`); + } + return; + } + + throw new Error(`Request shall fail`); + } + + return { request, requestShallFail }; +} + +/* ----------------- Clients -------------------- + There are different clients for running GraphQL requests: + - defaultClient performs unauthenticated requests + - adminClient performs requests with the Role ADMIN (using Basic auth) + - createUserClient can be used to create a session with a Bearer token + Using a mutation { login...() } one can then associate a user with the session + (see auth.ts for examples) +*/ +export const defaultClient = wrapClient(new GraphQLClient(URL)); + +const adminAuthorization = `Basic ${Buffer.from('admin:' + ADMIN_TOKEN).toString('base64')}`; + +export const adminClient = wrapClient( + new GraphQLClient(URL, { + headers: { + authorization: adminAuthorization, + }, + }) +); + +export function createUserClient() { + return wrapClient( + new GraphQLClient(URL, { + headers: { + authorization: `Bearer ${randomBytes(36).toString('base64')}`, + }, + }) + ); +} diff --git a/integration-tests/base/index.ts b/integration-tests/base/index.ts index c85a8831e..41901ad9c 100644 --- a/integration-tests/base/index.ts +++ b/integration-tests/base/index.ts @@ -1,100 +1,12 @@ -import { randomBytes } from 'crypto'; -import { GraphQLClient } from 'graphql-request'; - import './mock'; import * as WebServer from '../../web'; -// eslint-disable-next-line import/no-cycle import { clearFetchMocks, expectNoFetchMockLeft } from './mock'; -// eslint-disable-next-line import/no-cycle import { cleanupMockedNotifications } from './notifications'; import { getLogger } from '../../common/logger/logger'; export const logger = getLogger('TEST'); -/* -------------- Configuration ------------------- */ - -const APP = 'lernfair-backend-dev'; -const URL = process.env.INTEGRATION_TARGET ?? `http://localhost:${process.env.PORT ?? 5000}/apollo`; -const ADMIN_TOKEN = process.env.ADMIN_AUTH_TOKEN; - -const silent = process.env.INTEGRATION_SILENT === 'true'; - -/* -------------- Utils --------------------------- */ - -logger.mark(`Backend Integration Tests\n` + ` testing ${URL}\n\n`); - -/* -------------- GraphQL Client Wrapper ------------------ */ - -// This wrapper provides assertions and logging around a GraphQLClient of the graphql-request package -function wrapClient(client: GraphQLClient) { - async function request(query: string) { - const name = query.match(/(mutation|query) [A-Za-z]+/)?.[0] ?? '(unnamed)'; - logger.mark(`+ ${name}`); - if (!silent) { - logger.info(`request: ` + query.trim()); - } - const response = await client.request(query); - if (!silent) { - logger.info(`response: ` + JSON.stringify(response, null, 2)); - } - return response; - } - - async function requestShallFail(query: string): Promise { - const name = query.match(/(mutation|query) [A-Za-z]+/)?.[0] ?? '(unnamed)'; - logger.mark(`+ ${name}`); - - if (!silent) { - logger.info(` request (should fail):` + query.trim()); - } - - try { - await client.request(query); - } catch (error) { - if (!silent) { - logger.info(` successfully failed with ${error.message.split(':')[0]}`); - } - return; - } - - throw new Error(`Request shall fail`); - } - - return { request, requestShallFail }; -} - -/* ----------------- Clients -------------------- - There are different clients for running GraphQL requests: - - defaultClient performs unauthenticated requests - - adminClient performs requests with the Role ADMIN (using Basic auth) - - createUserClient can be used to create a session with a Bearer token - Using a mutation { login...() } one can then associate a user with the session - (see auth.ts for examples) -*/ - -export const defaultClient = wrapClient(new GraphQLClient(URL)); - -const adminAuthorization = `Basic ${Buffer.from('admin:' + ADMIN_TOKEN).toString('base64')}`; - -export const adminClient = wrapClient( - new GraphQLClient(URL, { - headers: { - authorization: adminAuthorization, - }, - }) -); - -export function createUserClient() { - return wrapClient( - new GraphQLClient(URL, { - headers: { - authorization: `Bearer ${randomBytes(36).toString('base64')}`, - }, - }) - ); -} - /* -------------- Test Runner ------------------- */ const tests: { name: string; runner: () => Promise; resolve: (value: any) => void; reject: (error: Error) => void }[] = []; diff --git a/integration-tests/base/notifications.ts b/integration-tests/base/notifications.ts index a829f76a6..4288ee193 100644 --- a/integration-tests/base/notifications.ts +++ b/integration-tests/base/notifications.ts @@ -1,6 +1,5 @@ import assert from 'assert'; -// eslint-disable-next-line import/no-cycle -import { adminClient } from '.'; +import { adminClient } from './clients'; interface MockNotification { id: number; @@ -24,7 +23,7 @@ export async function createMockNotification( const { notificationCreate: { id }, } = await adminClient.request(`mutation Create${description} { - notificationCreate(notification: { + notificationCreate(notification: { description: "MOCK ${description}" active: false recipient: 0 @@ -54,8 +53,8 @@ export async function assertUserReceivedNotification(notification: MockNotificat // Sending of concrete notifications happens concurrently to the main flow, so we might get a response back before a notification was sent await new Promise((res) => setTimeout(res, 500)); - const result = await adminClient.request(`query GetNotificationForUser { - concrete_notifications(where: { userId: { equals: "${userID}" } notificationID: { equals: ${notification.id} } state: { equals: 2 } }, take: 2) { + const result = await adminClient.request(`query GetNotificationForUser { + concrete_notifications(where: { userId: { equals: "${userID}" } notificationID: { equals: ${notification.id} } state: { equals: 2 } }, take: 2) { contextID context error