diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..b2f71611 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,32 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Jest Tests", + "type": "node", + "request": "launch", + "runtimeArgs": [ + "--inspect-brk", + "${workspaceRoot}/node_modules/.bin/jest", + "--runInBand" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + // { + // "type": "node", + // "request": "launch", + // "name": "Launch Program", + // "skipFiles": [ + // "/**" + // ], + // "program": "${workspaceFolder}/backend/api/tests/unit/get-profiles.unit.test.ts", + // "outFiles": [ + // "${workspaceFolder}/**/*.js" + // ] + // } + ] +} \ No newline at end of file diff --git a/backend/api/.eslintrc.js b/backend/api/.eslintrc.js index dd39bc3d..34ee6972 100644 --- a/backend/api/.eslintrc.js +++ b/backend/api/.eslintrc.js @@ -13,7 +13,7 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], + project: ['./tsconfig.json', './tsconfig.test.json'], }, rules: { '@typescript-eslint/ban-types': [ diff --git a/backend/api/README.md b/backend/api/README.md index 9f4b3371..3cc0e531 100644 --- a/backend/api/README.md +++ b/backend/api/README.md @@ -168,3 +168,6 @@ docker rmi -f $(docker images -aq) ### Documentation The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts). + +### Todo (Tests) +- [ ] Finish get-supabase-token unit test when endpoint is implemented \ No newline at end of file diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index 58ada3f8..30c1a837 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -10,26 +10,26 @@ export type profileQueryType = { after?: string | undefined, // Search and filter parameters name?: string | undefined, - genders?: String[] | undefined, - education_levels?: String[] | undefined, - pref_gender?: String[] | undefined, + genders?: string[] | undefined, + education_levels?: string[] | undefined, + pref_gender?: string[] | undefined, pref_age_min?: number | undefined, pref_age_max?: number | undefined, drinks_min?: number | undefined, drinks_max?: number | undefined, - pref_relation_styles?: String[] | undefined, - pref_romantic_styles?: String[] | undefined, - diet?: String[] | undefined, - political_beliefs?: String[] | undefined, - mbti?: String[] | undefined, - relationship_status?: String[] | undefined, - languages?: String[] | undefined, - religion?: String[] | undefined, + pref_relation_styles?: string[] | undefined, + pref_romantic_styles?: string[] | undefined, + diet?: string[] | undefined, + political_beliefs?: string[] | undefined, + mbti?: string[] | undefined, + relationship_status?: string[] | undefined, + languages?: string[] | undefined, + religion?: string[] | undefined, wants_kids_strength?: number | undefined, has_kids?: number | undefined, is_smoker?: boolean | undefined, shortBio?: boolean | undefined, - geodbCityIds?: String[] | undefined, + geodbCityIds?: string[] | undefined, lat?: number | undefined, lon?: number | undefined, radius?: number | undefined, diff --git a/backend/api/src/get-user.ts b/backend/api/src/get-user.ts index a7b999c4..a550268c 100644 --- a/backend/api/src/get-user.ts +++ b/backend/api/src/get-user.ts @@ -1,8 +1,7 @@ import { toUserAPIResponse } from 'common/api/user-types' -import { convertUser, displayUserColumns } from 'common/supabase/users' +import { convertUser } from 'common/supabase/users' import { createSupabaseDirectClient } from 'shared/supabase/init' import { APIError } from 'common/api/utils' -import { removeNullOrUndefinedProps } from 'common/util/object' export const getUser = async (props: { id: string } | { username: string }) => { const pg = createSupabaseDirectClient() diff --git a/backend/api/src/report.ts b/backend/api/src/report.ts index 6f699713..7d27148c 100644 --- a/backend/api/src/report.ts +++ b/backend/api/src/report.ts @@ -52,7 +52,7 @@ export const report: APIHandler<'report'> = async (body, auth) => { console.error('Failed to get reported user for report', userError) return } - let message: string = ` + const message: string = ` 🚨 **New Report** 🚨 **Type:** ${contentType} **Content ID:** ${contentId} diff --git a/backend/api/tests/unit/ban-user.unit.test.ts b/backend/api/tests/unit/ban-user.unit.test.ts new file mode 100644 index 00000000..4b4cd653 --- /dev/null +++ b/backend/api/tests/unit/ban-user.unit.test.ts @@ -0,0 +1,115 @@ +jest.mock('shared/supabase/init') +jest.mock('shared/helpers/auth') +jest.mock('common/envs/constants') +jest.mock('shared/supabase/users') +jest.mock('shared/analytics') +jest.mock('shared/utils') + +import { banUser } from "api/ban-user"; +import * as supabaseInit from "shared/supabase/init"; +import { throwErrorIfNotMod } from "shared/helpers/auth"; +import * as constants from "common/envs/constants"; +import * as supabaseUsers from "shared/supabase/users"; +import * as sharedAnalytics from "shared/analytics"; +import { } from "shared/helpers/auth"; +import { APIError, AuthedUser } from "api/helpers/endpoint" + + +describe('banUser', () => { + const mockPg = {} as any; + + beforeEach(() => { + jest.resetAllMocks(); + + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('ban a user successfully', async () => { + const mockUser = { + userId: '123', + unban: false + }; + const mockAuth = {uid: '321'} as AuthedUser; + const mockReq = {} as any; + + (constants.isAdminId as jest.Mock).mockReturnValue(false); + + await banUser(mockUser, mockAuth, mockReq); + + expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid); + expect(constants.isAdminId).toBeCalledWith(mockUser.userId); + expect(sharedAnalytics.trackPublicEvent) + .toBeCalledWith(mockAuth.uid, 'ban user', {userId: mockUser.userId}); + expect(supabaseUsers.updateUser) + .toBeCalledWith(mockPg, mockUser.userId, {isBannedFromPosting: true}); + }); + + it('unban a user successfully', async () => { + const mockUser = { + userId: '123', + unban: true + }; + const mockAuth = {uid: '321'} as AuthedUser; + const mockReq = {} as any; + + (constants.isAdminId as jest.Mock).mockReturnValue(false); + + await banUser(mockUser, mockAuth, mockReq); + + expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid); + expect(constants.isAdminId).toBeCalledWith(mockUser.userId); + expect(sharedAnalytics.trackPublicEvent) + .toBeCalledWith(mockAuth.uid, 'ban user', {userId: mockUser.userId}); + expect(supabaseUsers.updateUser) + .toBeCalledWith(mockPg, mockUser.userId, {isBannedFromPosting: false}); + }); + + it('throw and error if the ban requester is not a mod or admin', async () => { + const mockUser = { + userId: '123', + unban: false + }; + const mockAuth = {uid: '321'} as AuthedUser; + const mockReq = {} as any; + + (throwErrorIfNotMod as jest.Mock).mockRejectedValue( + new APIError( + 403, + `User ${mockAuth.uid} must be an admin or trusted to perform this action.` + ) + ); + + await expect(banUser(mockUser, mockAuth, mockReq)) + .rejects + .toThrowError(`User ${mockAuth.uid} must be an admin or trusted to perform this action.`); + expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid); + expect(sharedAnalytics.trackPublicEvent).toBeCalledTimes(0); + expect(supabaseUsers.updateUser).toBeCalledTimes(0); + }); + + it('throw an error if the ban target is an admin', async () => { + const mockUser = { + userId: '123', + unban: false + }; + const mockAuth = {uid: '321'} as AuthedUser; + const mockReq = {} as any; + + (constants.isAdminId as jest.Mock).mockReturnValue(true); + + await expect(banUser(mockUser, mockAuth, mockReq)) + .rejects + .toThrowError('Cannot ban admin'); + expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid); + expect(constants.isAdminId).toBeCalledWith(mockUser.userId); + expect(sharedAnalytics.trackPublicEvent).toBeCalledTimes(0); + expect(supabaseUsers.updateUser).toBeCalledTimes(0); + }); + }); +}); \ No newline at end of file diff --git a/backend/api/tests/unit/block-user.unit.test.ts b/backend/api/tests/unit/block-user.unit.test.ts new file mode 100644 index 00000000..f46ce959 --- /dev/null +++ b/backend/api/tests/unit/block-user.unit.test.ts @@ -0,0 +1,119 @@ +jest.mock('shared/supabase/init') +jest.mock('shared/supabase/users') +jest.mock('shared/supabase/utils') + +import * as blockUserModule from "api/block-user"; +import { AuthedUser } from "api/helpers/endpoint"; +import * as supabaseInit from "shared/supabase/init"; +import * as supabaseUsers from "shared/supabase/users"; +import * as supabaseUtils from "shared/supabase/utils"; + +describe('blockUser', () => { + let mockPg: any; + + beforeEach(() => { + jest.resetAllMocks() + mockPg = { + tx: jest.fn(async (cb) => { + const mockTx = {}; + await cb(mockTx); + }), + }; + + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('block the user successfully', async () => { + const mockParams = { id: '123' } + const mockAuth = {uid: '321'} as AuthedUser; + const mockReq = {} as any; + + (supabaseUsers.updatePrivateUser as jest.Mock).mockResolvedValue(null); + + await blockUserModule.blockUser(mockParams, mockAuth, mockReq) + + expect(mockPg.tx).toHaveBeenCalledTimes(1) + + expect(supabaseUsers.updatePrivateUser) + .toHaveBeenCalledWith( + expect.any(Object), + mockAuth.uid, + { blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockParams.id)} + ); + expect(supabaseUsers.updatePrivateUser) + .toHaveBeenCalledWith( + expect.any(Object), + mockParams.id, + { blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockAuth.uid)} + ); + }); + + it('throw an error if the user tries to block themselves', async () => { + const mockParams = { id: '123' } + const mockAuth = {uid: '123'} as AuthedUser; + const mockReq = {} as any; + + expect(blockUserModule.blockUser(mockParams, mockAuth, mockReq)) + .rejects + .toThrowError('You cannot block yourself') + + expect(mockPg.tx).toHaveBeenCalledTimes(0) + }); + }); + +}); + +describe('unblockUser', () => { + let mockPg: any; + + beforeEach(() => { + jest.resetAllMocks() + mockPg = { + tx: jest.fn(async (cb) => { + const mockTx = {}; + await cb(mockTx); + }), + }; + + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('block the user successfully', async () => { + const mockParams = { id: '123' } + const mockAuth = {uid: '321'} as AuthedUser; + const mockReq = {} as any; + + (supabaseUsers.updatePrivateUser as jest.Mock).mockResolvedValue(null); + + await blockUserModule.unblockUser(mockParams, mockAuth, mockReq) + + expect(mockPg.tx).toHaveBeenCalledTimes(1) + + expect(supabaseUsers.updatePrivateUser) + .toHaveBeenCalledWith( + expect.any(Object), + mockAuth.uid, + { blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockParams.id)} + ); + expect(supabaseUsers.updatePrivateUser) + .toHaveBeenCalledWith( + expect.any(Object), + mockParams.id, + { blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockAuth.uid)} + ); + }); + }); + +}); \ No newline at end of file diff --git a/backend/api/tests/unit/compatible-profiles.unit.test.ts b/backend/api/tests/unit/compatible-profiles.unit.test.ts new file mode 100644 index 00000000..bc2ee249 --- /dev/null +++ b/backend/api/tests/unit/compatible-profiles.unit.test.ts @@ -0,0 +1,32 @@ +import * as supabaseInit from "shared/supabase/init"; +import {getCompatibleProfiles} from "api/compatible-profiles"; + +jest.mock('shared/supabase/init') + +describe('getCompatibleProfiles', () => { + beforeEach(() => { + jest.resetAllMocks(); + const mockPg = { + none: jest.fn().mockResolvedValue(null), + one: jest.fn().mockResolvedValue(null), + oneOrNone: jest.fn().mockResolvedValue(null), + any: jest.fn().mockResolvedValue([]), + map: jest.fn().mockResolvedValue([["abc", {score: 0.69}]]), + } as any; + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('successfully get compatible profiles when supplied with a valid user Id', async () => { + const results = await getCompatibleProfiles("123"); + expect(results.status).toEqual('success'); + expect(results.profileCompatibilityScores).toEqual({"abc": {score: 0.69}}); + }); + + }); +}); diff --git a/backend/api/tests/unit/contact.unit.test.ts b/backend/api/tests/unit/contact.unit.test.ts new file mode 100644 index 00000000..b134f5e8 --- /dev/null +++ b/backend/api/tests/unit/contact.unit.test.ts @@ -0,0 +1,114 @@ +jest.mock('common/discord/core'); +jest.mock('shared/supabase/utils'); +jest.mock('shared/supabase/init'); +jest.mock('common/util/try-catch'); + +import { contact } from "api/contact"; +import * as supabaseInit from "shared/supabase/init"; +import * as supabaseUtils from "shared/supabase/utils"; +import { tryCatch } from "common/util/try-catch"; +import { sendDiscordMessage } from "common/discord/core"; +import { AuthedUser } from "api/helpers/endpoint"; + +describe('contact', () => { + let mockPg: any; + beforeEach(() => { + jest.resetAllMocks(); + + mockPg = { + oneOrNone: jest.fn(), + }; + + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('send a discord message to the user', async () => { + const mockProps = { + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Error test message' + } + ] + } + ] + }, + userId: '123' + }; + const mockAuth = { uid: '321' } as AuthedUser; + const mockReq = {} as any; + const mockDbUser = { name: 'Humphrey Mocker' }; + const mockReturnData = {} as any; + + (tryCatch as jest.Mock).mockResolvedValue({ data: mockReturnData, error: null }); + (mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockDbUser); + (sendDiscordMessage as jest.Mock).mockResolvedValue(null); + + const results = await contact(mockProps, mockAuth, mockReq); + expect(supabaseUtils.insert).toBeCalledTimes(1) + expect(supabaseUtils.insert).toBeCalledWith( + mockPg, + 'contact', + { + user_id: mockProps.userId, + content: JSON.stringify(mockProps.content) + } + ); + expect(results.success).toBe(true); + await results.continue(); + expect(sendDiscordMessage).toBeCalledWith( + expect.stringContaining(`New message from ${mockDbUser.name}`), + 'contact' + ) + expect(sendDiscordMessage).toBeCalledTimes(1); + }); + + it('throw an error if the inser function fails', async () => { + const mockProps = { + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Error test message' + } + ] + } + ] + }, + userId: '123' + }; + const mockAuth = { uid: '321' } as AuthedUser; + const mockReq = {} as any; + + (tryCatch as jest.Mock).mockResolvedValue({ data: null, error: Error }); + + expect(contact(mockProps, mockAuth, mockReq)) + .rejects + .toThrowError('Failed to submit contact message'); + expect(supabaseUtils.insert).toBeCalledTimes(1) + expect(supabaseUtils.insert).toBeCalledWith( + mockPg, + 'contact', + { + user_id: mockProps.userId, + content: JSON.stringify(mockProps.content) + } + ); + }); + }); +}); \ No newline at end of file diff --git a/backend/api/tests/unit/create-bookmarked-search.unit.test.ts b/backend/api/tests/unit/create-bookmarked-search.unit.test.ts new file mode 100644 index 00000000..1108a6a0 --- /dev/null +++ b/backend/api/tests/unit/create-bookmarked-search.unit.test.ts @@ -0,0 +1,47 @@ +jest.mock('shared/supabase/init'); + +import { createBookmarkedSearch } from "api/create-bookmarked-search"; +import { AuthedUser } from "api/helpers/endpoint"; +import * as supabaseInit from "shared/supabase/init"; + +describe('createBookmarkedSearch', () => { + let mockPg: any; + beforeEach(() => { + jest.resetAllMocks(); + + mockPg = { + one: jest.fn(), + }; + + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('insert a bookmarked search into the database', async () => { + const mockProps = { + search_filters: 'mock_search_filters', + location: 'mock_location', + search_name: 'mock_search_name' + }; + const mockAuth = { uid: '321' } as AuthedUser; + const mockReq = {} as any; + + await createBookmarkedSearch(mockProps, mockAuth, mockReq) + expect(mockPg.one).toBeCalledTimes(1) + expect(mockPg.one).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO bookmarked_searches'), + [ + mockAuth.uid, + mockProps.search_filters, + mockProps.location, + mockProps.search_name + ] + ); + }); + }); +}); \ No newline at end of file diff --git a/backend/api/tests/unit/create-comment.unit.test.ts b/backend/api/tests/unit/create-comment.unit.test.ts new file mode 100644 index 00000000..bed6cac3 --- /dev/null +++ b/backend/api/tests/unit/create-comment.unit.test.ts @@ -0,0 +1,56 @@ +jest.mock('shared/supabase/init'); + +import * as supabaseInit from "shared/supabase/init"; +import { AuthedUser } from "api/helpers/endpoint"; + +describe('createComment', () => { + let mockPg: any; + beforeEach(() => { + jest.resetAllMocks(); + + mockPg = { + one: jest.fn() + }; + + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('successfully create a comment with information provided', async () => { + const mockUserId = {userId: '123'} + const mockOnUser = {id: '123'} + const mockCreator = { + id: '123', + name: 'Mock Creator', + username: 'mock.creator.username', + avatarUrl: 'mock.creator.avatarurl' + } + const mockContent = { + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'This is the comment text' + } + ] + } + ] + }, + userId: '123' + }; + const mockAuth = { uid: '321' } as AuthedUser; + const mockReplyToCommentId = {} as any; + + + }); + }); +}); \ No newline at end of file diff --git a/backend/api/tests/unit/get-profiles.unit.test.ts b/backend/api/tests/unit/get-profiles.unit.test.ts new file mode 100644 index 00000000..364eca8e --- /dev/null +++ b/backend/api/tests/unit/get-profiles.unit.test.ts @@ -0,0 +1,333 @@ +import * as profilesModule from "api/get-profiles"; +import { Profile } from "common/profiles/profile"; +import * as supabaseInit from "shared/supabase/init"; + +describe('getProfiles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should fetch the user profiles', () => { + it('successfully', async ()=> { + const mockProfiles = [ + { + diet: ['Jonathon Hammon'], + has_kids: 0 + }, + { + diet: ['Joseph Hammon'], + has_kids: 1 + }, + { + diet: ['Jolene Hammon'], + has_kids: 2, + } + ] as Profile []; + + jest.spyOn(profilesModule, 'loadProfiles').mockResolvedValue({profiles: mockProfiles, count: 3}); + + const props = { + limit: 2, + orderBy: "last_online_time" as const, + }; + const mockReq = {} as any; + const results = await profilesModule.getProfiles(props, mockReq, mockReq); + + if('continue' in results) { + throw new Error('Expected direct response') + }; + + expect(results.status).toEqual('success'); + expect(results.profiles).toEqual(mockProfiles); + expect(results.profiles[0]).toEqual(mockProfiles[0]); + expect(profilesModule.loadProfiles).toHaveBeenCalledWith(props); + expect(profilesModule.loadProfiles).toHaveBeenCalledTimes(1); + }); + + it('unsuccessfully', async () => { + jest.spyOn(profilesModule, 'loadProfiles').mockRejectedValue(null); + + const props = { + limit: 2, + orderBy: "last_online_time" as const, + }; + const mockReq = {} as any; + const results = await profilesModule.getProfiles(props, mockReq, mockReq); + + if('continue' in results) { + throw new Error('Expected direct response') + }; + + expect(results.status).toEqual('fail'); + expect(results.profiles).toEqual([]); + expect(profilesModule.loadProfiles).toHaveBeenCalledWith(props); + expect(profilesModule.loadProfiles).toHaveBeenCalledTimes(1); + }); + + }); +}); + +describe('loadProfiles', () => { + let mockPg: any; + + describe('should call pg.map with an SQL query', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPg = { + map: jest.fn().mockResolvedValue([]), + one: jest.fn().mockResolvedValue(1), + }; + + jest.spyOn(supabaseInit, 'createSupabaseDirectClient') + .mockReturnValue(mockPg); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('successfully', async () => { + await profilesModule.loadProfiles({ + limit: 10, + name: 'John', + is_smoker: true, + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain('select'); + expect(query).toContain('from profiles'); + expect(query).toContain('where'); + expect(query).toContain('limit 10'); + expect(query).toContain(`John`); + expect(query).toContain(`is_smoker`); + expect(query).not.toContain(`gender`); + expect(query).not.toContain(`education_level`); + expect(query).not.toContain(`pref_gender`); + expect(query).not.toContain(`age`); + expect(query).not.toContain(`drinks_per_month`); + expect(query).not.toContain(`pref_relation_styles`); + expect(query).not.toContain(`pref_romantic_styles`); + expect(query).not.toContain(`diet`); + expect(query).not.toContain(`political_beliefs`); + expect(query).not.toContain(`religion`); + expect(query).not.toContain(`has_kids`); + }); + + it('that contains a gender filter', async () => { + await profilesModule.loadProfiles({ + genders: ['Electrical_gender'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`gender`); + expect(query).toContain(`Electrical_gender`); + }); + + it('that contains a education level filter', async () => { + await profilesModule.loadProfiles({ + education_levels: ['High School'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`education_level`); + expect(query).toContain(`High School`); + }); + + it('that contains a prefer gender filter', async () => { + await profilesModule.loadProfiles({ + pref_gender: ['female'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + console.log(query); + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`pref_gender`); + expect(query).toContain(`female`); + }); + + it('that contains a minimum age filter', async () => { + await profilesModule.loadProfiles({ + pref_age_min: 20, + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`age`); + expect(query).toContain(`>= 20`); + }); + + it('that contains a maximum age filter', async () => { + await profilesModule.loadProfiles({ + pref_age_max: 40, + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`age`); + expect(query).toContain(`<= 40`); + }); + + it('that contains a minimum drinks per month filter', async () => { + await profilesModule.loadProfiles({ + drinks_min: 4, + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`drinks_per_month`); + expect(query).toContain('4'); + }); + + it('that contains a maximum drinks per month filter', async () => { + await profilesModule.loadProfiles({ + drinks_max: 20, + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`drinks_per_month`); + expect(query).toContain('20'); + }); + + it('that contains a relationship style filter', async () => { + await profilesModule.loadProfiles({ + pref_relation_styles: ['Chill and relaxing'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`pref_relation_styles`); + expect(query).toContain('Chill and relaxing'); + }); + + it('that contains a romantic style filter', async () => { + await profilesModule.loadProfiles({ + pref_romantic_styles: ['Sexy'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`pref_romantic_styles`); + expect(query).toContain('Sexy'); + }); + + it('that contains a diet filter', async () => { + await profilesModule.loadProfiles({ + diet: ['Glutton'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`diet`); + expect(query).toContain('Glutton'); + }); + + it('that contains a political beliefs filter', async () => { + await profilesModule.loadProfiles({ + political_beliefs: ['For the people'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`political_beliefs`); + expect(query).toContain('For the people'); + }); + + it('that contains a religion filter', async () => { + await profilesModule.loadProfiles({ + religion: ['The blood god'], + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`religion`); + expect(query).toContain('The blood god'); + }); + + it('that contains a has kids filter', async () => { + await profilesModule.loadProfiles({ + has_kids: 3, + }); + + const [query, values, cb] = mockPg.map.mock.calls[0] + + expect(mockPg.map.mock.calls).toHaveLength(1) + expect(query).toContain(`has_kids`); + expect(query).toContain('> 0'); + }); + }); + + describe('should', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPg = { + map: jest.fn(), + one: jest.fn().mockResolvedValue(1), + }; + + jest.spyOn(supabaseInit, 'createSupabaseDirectClient') + .mockReturnValue(mockPg) + + + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('return profiles from the database', async () => { + const mockProfiles = [ + { + diet: ['Jonathon Hammon'], + is_smoker: true, + has_kids: 0 + }, + { + diet: ['Joseph Hammon'], + is_smoker: false, + has_kids: 1 + }, + { + diet: ['Jolene Hammon'], + is_smoker: true, + has_kids: 2, + } + ] as Profile []; + + mockPg.map.mockResolvedValue(mockProfiles); + const props = {} as any; + const results = await profilesModule.loadProfiles(props); + + expect(results).toEqual({profiles: mockProfiles, count: 1}); + }); + + it('throw an error if there is no compatability', async () => { + const props = { + orderBy: 'compatibility_score' + } + expect(profilesModule.loadProfiles(props)) + .rejects + .toThrowError('Incompatible with user ID') + }); + }) +}) \ No newline at end of file diff --git a/backend/api/tests/unit/get-supabase-token.unit.test.ts b/backend/api/tests/unit/get-supabase-token.unit.test.ts new file mode 100644 index 00000000..a695ba4c --- /dev/null +++ b/backend/api/tests/unit/get-supabase-token.unit.test.ts @@ -0,0 +1,49 @@ +jest.mock('jsonwebtoken'); + + +describe.skip('getSupabaseToken', () => { + // const originalSupabaseJwtSecret = process.env.SUPABASE_JWT_SECRET + // const originalInstanceId = constants.ENV_CONFIG.supabaseInstanceId + // const originalProjectId = constants.ENV_CONFIG.firebaseConfig.projectId + + // describe('should', () => { + // beforeEach(() => { + // jest.resetAllMocks(); + + // process.env.SUPABASE_JWT_SECRET = 'test-jwt-secret-123'; + // constants.ENV_CONFIG.supabaseInstanceId = 'test-instance-id'; + // constants.ENV_CONFIG.firebaseConfig.projectId = 'test-project-id'; + + // (jsonWebtokenModules.sign as jest.Mock).mockReturnValue('fake-jwt-token-abc123'); + // }); + + // afterEach(() => { + // if (originalSupabaseJwtSecret === undefined) { + // delete process.env.SUPABASE_JWT_SECRET; + // } else { + // process.env.SUPABASE_JWT_SECRET = originalSupabaseJwtSecret; + // } + // constants.ENV_CONFIG.supabaseInstanceId = originalInstanceId; + // constants.ENV_CONFIG.firebaseConfig.projectId = originalProjectId; + + // jest.restoreAllMocks(); + // }); + + // it('successfully generate a JTW token with correct parameters', async () => { + // const mockParams = {} as any; + // const mockAuth = {uid: '321'} as AuthedUser; + // const result = await getSupabaseToken(mockParams, mockAuth, mockParams) + + // expect(result).toEqual({ + // jwt: 'fake-jwt-token-abc123' + // }) + // }) + // }); +}); + +describe('getCompatibleProfiles', () => { + it('skip', async () => { + console.log('This needs tests'); + + }) +}) \ No newline at end of file diff --git a/backend/api/tests/unit/get-users.unit.test.ts b/backend/api/tests/unit/get-users.unit.test.ts index f1deb7b5..27a15e7c 100644 --- a/backend/api/tests/unit/get-users.unit.test.ts +++ b/backend/api/tests/unit/get-users.unit.test.ts @@ -1,12 +1,15 @@ +jest.mock("shared/supabase/init"); + import { getUser } from "api/get-user"; import { createSupabaseDirectClient } from "shared/supabase/init"; import { toUserAPIResponse } from "common/api/user-types"; import { convertUser } from "common/supabase/users"; -import { APIError } from "common/src/api/utils"; +import { APIError } from "common/api/utils"; + + +jest.spyOn(require("common/supabase/users"), 'convertUser') +jest.spyOn(require("common/api/user-types"), 'toUserAPIResponse') -jest.mock("shared/supabase/init"); -jest.mock("common/supabase/users"); -jest.mock("common/api/utils"); describe('getUser', () =>{ let mockPg: any; @@ -19,41 +22,142 @@ describe('getUser', () =>{ jest.clearAllMocks(); }); - it('should fetch user successfully by id', async () => { - const mockDbUser = { - created_time: '2025-11-11T16:42:05.188Z', - data: { link: {}, avatarUrl: "", isBannedFromPosting: false }, - id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', - name: 'Franklin Buckridge', - name_username_vector: "'buckridg':2,4 'franklin':1,3", - username: 'Franky_Buck' - }; - const mockConvertedUser = { - created_time: new Date(), - id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', - name: 'Franklin Buckridge', - name_username_vector: "'buckridg':2,4 'franklin':1,3", - username: 'Franky_Buck' + describe('when fetching by id', () => { + it('should fetch user successfully by id', async () => { + const mockDbUser = { + created_time: '2025-11-11T16:42:05.188Z', + data: { link: {}, avatarUrl: "", isBannedFromPosting: false }, + id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', + name: 'Franklin Buckridge', + name_username_vector: "'buckridg':2,4 'franklin':1,3", + username: 'Franky_Buck' + }; + const mockConvertedUser = { + created_time: new Date(), + id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', + name: 'Franklin Buckridge', + name_username_vector: "'buckridg':2,4 'franklin':1,3", + username: 'Franky_Buck' + + }; + const mockApiResponse = { + created_time: '2025-11-11T16:42:05.188Z', + data: { link: {}, avatarUrl: "", isBannedFromPosting: false }, + id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', + name: 'Franklin Buckridge', + username: 'Franky_Buck' + }; + + mockPg.oneOrNone.mockImplementation((query: string, values: any[], cb: (value: any) => any) => { + const result = cb(mockDbUser); + return Promise.resolve(result); + }); + + (convertUser as jest.Mock).mockReturnValue(mockConvertedUser); + ( toUserAPIResponse as jest.Mock).mockReturnValue(mockApiResponse); + + const result = await getUser({id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP'}) + + expect(mockPg.oneOrNone).toHaveBeenCalledWith( + expect.stringContaining('where id = $1'), + ['feUaIfcxVmJZHJOVVfawLTTPgZiP'], + expect.any(Function) + ); + + expect(convertUser).toHaveBeenCalledWith(mockDbUser); + expect(toUserAPIResponse).toHaveBeenCalledWith(mockConvertedUser); + + expect(result).toEqual(mockApiResponse); + + }); - }; - const mockApiResponse = { - created_time: '2025-11-11T16:42:05.188Z', - data: { link: {}, avatarUrl: "", isBannedFromPosting: false }, - id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', - name: 'Franklin Buckridge', - username: 'Franky_Buck' - }; + it('should throw 404 when user is not found by id', async () => { + mockPg.oneOrNone.mockImplementation((query: string, values: any[], cb: (value: any) => any) => { + return Promise.resolve(null); + }); - // mockPg.oneOrNone.mockImplementation((query: any, params: any, callback: any) => { - // return Promise.resolve(callback(mockDbUser)) - // }) + (convertUser as jest.Mock).mockReturnValue(null) + + try { + await getUser({id: '3333'}); + fail('Should have thrown'); + } catch (error) { + const apiError = error as APIError; + expect(apiError.code).toBe(404) + expect(apiError.message).toBe('User not found') + expect(apiError.details).toBeUndefined() + expect(apiError.name).toBe('APIError') + } + }) + + }) - // (convertUser as jest.Mock).mockReturnValue(mockConvertedUser); - // ( toUserAPIResponse as jest.Mock).mockReturnValue(mockApiResponse); + describe('when fetching by username', () => { + it('should fetch user successfully by username', async () => { + const mockDbUser = { + created_time: '2025-11-11T16:42:05.188Z', + data: { link: {}, avatarUrl: "", isBannedFromPosting: false }, + id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', + name: 'Franklin Buckridge', + name_username_vector: "'buckridg':2,4 'franklin':1,3", + username: 'Franky_Buck' + }; + const mockConvertedUser = { + created_time: new Date(), + id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', + name: 'Franklin Buckridge', + name_username_vector: "'buckridg':2,4 'franklin':1,3", + username: 'Franky_Buck' + + }; + const mockApiResponse = { + created_time: '2025-11-11T16:42:05.188Z', + data: { link: {}, avatarUrl: "", isBannedFromPosting: false }, + id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP', + name: 'Franklin Buckridge', + username: 'Franky_Buck' + }; + + mockPg.oneOrNone.mockImplementation((query: string, values: any[], cb: (value: any) => any) => { + const result = cb(mockDbUser); + return Promise.resolve(result); + }); + + (convertUser as jest.Mock).mockReturnValue(mockConvertedUser); + (toUserAPIResponse as jest.Mock).mockReturnValue(mockApiResponse); + + const result = await getUser({username: 'Franky_Buck'}) + + expect(mockPg.oneOrNone).toHaveBeenCalledWith( + expect.stringContaining('where username = $1'), + ['Franky_Buck'], + expect.any(Function) + ); + + expect(convertUser).toHaveBeenCalledWith(mockDbUser); + expect(toUserAPIResponse).toHaveBeenCalledWith(mockConvertedUser); + + expect(result).toEqual(mockApiResponse); + + }); - // const result = await getUser({id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP'}) + it('should throw 404 when user is not found by id', async () => { + mockPg.oneOrNone.mockImplementation((query: string, values: any[], cb: (value: any) => any) => { + return Promise.resolve(null); + }); - // console.log(result); - + (convertUser as jest.Mock).mockReturnValue(null) + + try { + await getUser({username: '3333'}); + fail('Should have thrown'); + } catch (error) { + const apiError = error as APIError; + expect(apiError.code).toBe(404) + expect(apiError.message).toBe('User not found') + expect(apiError.details).toBeUndefined() + expect(apiError.name).toBe('APIError') + } + }) }) }) \ No newline at end of file diff --git a/backend/api/tests/unit/set-last-online-time.unit.test.ts b/backend/api/tests/unit/set-last-online-time.unit.test.ts new file mode 100644 index 00000000..61e83522 --- /dev/null +++ b/backend/api/tests/unit/set-last-online-time.unit.test.ts @@ -0,0 +1,34 @@ +jest.mock('shared/supabase/init'); + +import * as setLastTimeOnlineModule from "api/set-last-online-time"; +import * as supabaseInit from "shared/supabase/init"; + +describe('Should', () => { + let mockPg: any; + + beforeEach(() => { + mockPg = { + none: jest.fn(), + }; + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + + jest.clearAllMocks(); + }); + + it('change the users last online time', async () => { + const mockProfile = {user_id: 'Jonathon'}; + + await setLastTimeOnlineModule.setLastOnlineTimeUser(mockProfile.user_id); + + expect(mockPg.none).toBeCalledTimes(1); + + const [query, userId] = mockPg.none.mock.calls[0]; + + expect(userId).toContain(mockProfile.user_id); + expect(query).toContain("VALUES ($1, now())") + expect(query).toContain("ON CONFLICT (user_id)") + expect(query).toContain("DO UPDATE") + expect(query).toContain("user_activity.last_online_time < now() - interval '1 minute'") + }); +}) \ No newline at end of file diff --git a/backend/api/tests/unit/update-profile.unit.test.ts b/backend/api/tests/unit/update-profile.unit.test.ts new file mode 100644 index 00000000..6225fe4e --- /dev/null +++ b/backend/api/tests/unit/update-profile.unit.test.ts @@ -0,0 +1,86 @@ +jest.mock("shared/supabase/init"); +jest.mock("shared/supabase/utils"); + +import { AuthedUser } from "api/helpers/endpoint"; +import { updateProfile } from "api/update-profile"; +import * as supabaseInit from "shared/supabase/init"; +import * as supabaseUtils from "shared/supabase/utils"; + +describe('updateProfiles', () => { + let mockPg: any; + + beforeEach(() => { + mockPg = { + oneOrNone: jest.fn(), + }; + + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + + jest.clearAllMocks(); + }); + describe('should', () => { + it('update an existing profile when provided the user id', async () => { + const mockUserProfile = { + user_id: '234', + diet: 'Nothing', + gender: 'female', + is_smoker: true, + } + const mockUpdateMade = { + gender: 'male' + } + const mockUpdatedProfile = { + user_id: '234', + diet: 'Nothing', + gender: 'male', + is_smoker: true, + } + const mockParams = {} as any; + const mockAuth = { + uid: '234' + } + + mockPg.oneOrNone.mockResolvedValue(mockUserProfile); + (supabaseUtils.update as jest.Mock).mockResolvedValue(mockUpdatedProfile); + + const result = await updateProfile( + mockUpdateMade, + mockAuth as AuthedUser, + mockParams + ); + + expect(mockPg.oneOrNone.mock.calls.length).toBe(1); + expect(mockPg.oneOrNone.mock.calls[0][1]).toEqual([mockAuth.uid]); + expect(result).toEqual(mockUpdatedProfile); + }); + + it('throw an error if a profile is not found', async () => { + mockPg.oneOrNone.mockResolvedValue(null); + expect(updateProfile({} as any, {} as any, {} as any,)) + .rejects + .toThrowError('Profile not found'); + }); + + it('throw an error if unable to update the profile', async () => { + const mockUserProfile = { + user_id: '234', + diet: 'Nothing', + gender: 'female', + is_smoker: true, + } + const data = null; + const error = true; + const mockError = { + data, + error + } + mockPg.oneOrNone.mockResolvedValue(mockUserProfile); + (supabaseUtils.update as jest.Mock).mockRejectedValue(mockError); + expect(updateProfile({} as any, {} as any, {} as any,)) + .rejects + .toThrowError('Error updating profile'); + + }); + }); +}); \ No newline at end of file diff --git a/backend/shared/.eslintrc.js b/backend/shared/.eslintrc.js index db48f75f..3c825132 100644 --- a/backend/shared/.eslintrc.js +++ b/backend/shared/.eslintrc.js @@ -13,7 +13,7 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], + project: ['./tsconfig.json', './tsconfig.test.json'], }, rules: { '@typescript-eslint/ban-types': [ diff --git a/backend/shared/src/compatibility/compute-scores.ts b/backend/shared/src/compatibility/compute-scores.ts index 2f13c1c7..bc698a42 100644 --- a/backend/shared/src/compatibility/compute-scores.ts +++ b/backend/shared/src/compatibility/compute-scores.ts @@ -1,12 +1,14 @@ -import {SupabaseDirectClient} from 'shared/supabase/init' -import {Row as RowFor} from 'common/supabase/utils' +import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init' import {getCompatibilityScore, hasAnsweredQuestions} from 'common/profiles/compatibility-score' -import {getCompatibilityAnswers, getGenderCompatibleProfiles, getProfile} from "shared/profiles/supabase" +import { + getAnswersForUser, + getCompatibilityAnswers, + getGenderCompatibleProfiles, + getProfile +} from "shared/profiles/supabase" import {groupBy} from "lodash" import {hrtime} from "node:process" -type AnswerRow = RowFor<'compatibility_answers'> - // Canonicalize pair ordering (user_id_1 < user_id_2 lexicographically) function canonicalPair(a: string, b: string) { return a < b ? [a, b] as const : [b, a] as const @@ -14,15 +16,16 @@ function canonicalPair(a: string, b: string) { export async function recomputeCompatibilityScoresForUser( userId: string, - pg: SupabaseDirectClient, + client?: SupabaseDirectClient, ) { + const pg = client ?? createSupabaseDirectClient() const startTs = hrtime.bigint() + const profile = await getProfile(userId) + if (!profile) throw new Error(`Profile not found for user ${userId}`) + // Load all answers for the target user - const answersSelf = await pg.manyOrNone( - 'select * from compatibility_answers where creator_id = $1', - [userId] - ) + const answersSelf = await getAnswersForUser(userId); // If the user has no answered questions, set the score to null if (!hasAnsweredQuestions(answersSelf)) { @@ -35,10 +38,7 @@ export async function recomputeCompatibilityScoresForUser( ) return } - - const profile = await getProfile(userId, pg) - if (!profile) throw new Error(`Profile not found for user ${userId}`) - let profiles = await getGenderCompatibleProfiles(profile) + const profiles = await getGenderCompatibleProfiles(profile) const otherUserIds = profiles.map((l) => l.user_id) const profileAnswers = await getCompatibilityAnswers([userId, ...otherUserIds]) const answersByUser = groupBy(profileAnswers, 'creator_id') @@ -96,4 +96,6 @@ export async function recomputeCompatibilityScoresForUser( const dt = Number(hrtime.bigint() - startTs) / 1e9 console.log(`Done recomputing compatibility scores for user ${userId} (${dt.toFixed(1)}s).`) + + return rows } diff --git a/backend/shared/src/create-profile-notification.ts b/backend/shared/src/create-profile-notification.ts index f3317fd5..85e23a6f 100644 --- a/backend/shared/src/create-profile-notification.ts +++ b/backend/shared/src/create-profile-notification.ts @@ -11,7 +11,7 @@ export const createProfileLikeNotification = async (like: Row<'profile_likes'>) const pg = createSupabaseDirectClient() const targetPrivateUser = await getPrivateUser(target_id) - const profile = await getProfile(creator_id, pg) + const profile = await getProfile(creator_id) if (!targetPrivateUser || !profile) return @@ -49,7 +49,7 @@ export const createProfileShipNotification = async ( const creator = await getUser(creator_id) const targetPrivateUser = await getPrivateUser(recipientId) const pg = createSupabaseDirectClient() - const profile = await getProfile(otherTargetId, pg) + const profile = await getProfile(otherTargetId) if (!creator || !targetPrivateUser || !profile) { console.error('Could not load user object', { diff --git a/backend/shared/src/profiles/supabase.ts b/backend/shared/src/profiles/supabase.ts index 7d78b7df..0d8d6339 100644 --- a/backend/shared/src/profiles/supabase.ts +++ b/backend/shared/src/profiles/supabase.ts @@ -2,7 +2,7 @@ import {areGenderCompatible} from 'common/profiles/compatibility-util' import {type Profile, type ProfileRow} from 'common/profiles/profile' import {type User} from 'common/user' import {Row} from 'common/supabase/utils' -import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init' +import {createSupabaseDirectClient} from 'shared/supabase/init' export type ProfileAndUserRow = ProfileRow & { name: string @@ -26,8 +26,8 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null { const PROFILE_COLS = 'profiles.*, name, username, users.data as user' -export const getProfile = async (userId: string, client?: SupabaseDirectClient) => { - const pg = client ?? createSupabaseDirectClient() +export const getProfile = async (userId: string) => { + const pg = createSupabaseDirectClient() return await pg.oneOrNone( ` select ${PROFILE_COLS} @@ -122,3 +122,14 @@ export const getCompatibilityAnswers = async (userIds: string[]) => { [userIds] ) } + +type AnswerRow = Row<'compatibility_answers'> + +export async function getAnswersForUser(userId: string) { + const pg = createSupabaseDirectClient() + const answersSelf = await pg.manyOrNone( + 'select * from compatibility_answers where creator_id = $1', + [userId] + ) + return answersSelf +} diff --git a/backend/shared/tests/unit/.keep b/backend/shared/tests/unit/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/shared/tests/unit/compute-score.unit.test.ts b/backend/shared/tests/unit/compute-score.unit.test.ts new file mode 100644 index 00000000..0befbc37 --- /dev/null +++ b/backend/shared/tests/unit/compute-score.unit.test.ts @@ -0,0 +1,140 @@ +import {recomputeCompatibilityScoresForUser} from "api/compatibility/compute-scores"; +import * as supabaseInit from "shared/supabase/init"; +import * as profilesSupabaseModules from "shared/profiles/supabase"; +import * as compatibilityScoreModules from "common/profiles/compatibility-score"; +import {Profile} from "common/profiles/profile"; + +jest.mock('shared/profiles/supabase') +jest.mock('shared/supabase/init') +jest.mock('common/profiles/compatibility-score') + + +describe('recomputeCompatibilityScoresForUser', () => { + beforeEach(() => { + jest.resetAllMocks(); + const mockPg = { + none: jest.fn().mockResolvedValue(null), + one: jest.fn().mockResolvedValue(null), + oneOrNone: jest.fn().mockResolvedValue(null), + any: jest.fn().mockResolvedValue([]), + } as any; + (supabaseInit.createSupabaseDirectClient as jest.Mock) + .mockReturnValue(mockPg); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('should', () => { + it('successfully get compute score when supplied with a valid user Id', async () => { + const mockUser = {userId: "123"}; + const mockUserProfile = { + id: 1, + user_id: '123', + user: { + username: "Mockuser.getProfile" + }, + created_time: "10:30", + explanation: "mockExplanation3", + importance: 3, + }; + const mockGenderCompatibleProfiles = [ + { + age: 20, + user_id: "1", + company: 'Mock Texan Roadhouse', + drinks_per_month: 3, + city: 'Mockingdale' + }, + { + age: 23, + user_id: "2", + company: 'Chicken fried goose', + drinks_per_month: 2, + city: 'Mockingdale' + }, + { + age: 40, + user_id: "3", + company: 'World Peace', + drinks_per_month: 10, + city: 'Velvet Suite' + }, + ] as Partial []; + const mockProfileCompatibilityAnswers = [ + { + created_time: "10:30", + creator_id: "3", + explanation: "mockExplanation3", + id: 3, + importance: 3 + }, + { + created_time: "10:20", + creator_id: "2", + explanation: "mockExplanation2", + id: 2, + importance: 2 + }, + { + created_time: "10:10", + creator_id: "1", + explanation: "mockExplanation", + id: 1, + importance: 1 + }, + ]; + const mockCompatibilityScore = { + score: 4, + confidence: "low" + }; + const mockAnswersForUser = [{ + created_time: "", + creator_id: mockUser.userId, + explanation: "", + id: 1, + importance: 1, + multiple_choice: 0, + pref_choices: [0, 1], + question_id: 1, + }]; + + (profilesSupabaseModules.getProfile as jest.Mock) + .mockResolvedValue(mockUserProfile); + (profilesSupabaseModules.getGenderCompatibleProfiles as jest.Mock) + .mockResolvedValue(mockGenderCompatibleProfiles); + (profilesSupabaseModules.getCompatibilityAnswers as jest.Mock) + .mockResolvedValue(mockProfileCompatibilityAnswers); + (profilesSupabaseModules.getAnswersForUser as jest.Mock) + .mockResolvedValue(mockAnswersForUser); + (compatibilityScoreModules.getCompatibilityScore as jest.Mock) + .mockReturnValue(mockCompatibilityScore); + (compatibilityScoreModules.hasAnsweredQuestions as jest.Mock) + .mockReturnValue(true); + + const results = await recomputeCompatibilityScoresForUser(mockUser.userId); + expect(profilesSupabaseModules.getProfile).toBeCalledWith(mockUser.userId); + expect(profilesSupabaseModules.getProfile).toBeCalledTimes(1); + expect(profilesSupabaseModules.getGenderCompatibleProfiles).toBeCalledWith(mockUserProfile); + expect(profilesSupabaseModules.getGenderCompatibleProfiles).toBeCalledTimes(1); + expect(compatibilityScoreModules.getCompatibilityScore).toBeCalledTimes(mockGenderCompatibleProfiles.length) + // expect(results.profile).toEqual(mockUserProfile); + // expect(results.compatibleProfiles).toContain(mockGenderCompatibleProfiles[0]); + expect(results?.[0][0]).toEqual("1"); + expect(results?.[0][1]).toEqual("123"); + expect(results?.[0][2]).toBeCloseTo(mockCompatibilityScore.score, 2); + }); + + it('throw an error if there is no profile matching the user Id', async () => { + const mockUser = {userId: "123"}; + + expect(recomputeCompatibilityScoresForUser(mockUser.userId)) + .rejects + .toThrowError('Profile not found'); + expect(profilesSupabaseModules.getProfile).toBeCalledWith(mockUser.userId); + expect(profilesSupabaseModules.getProfile).toBeCalledTimes(1); + }); + + }); +}); diff --git a/scripts/userCreation.ts b/scripts/userCreation.ts index d0ba3416..f187445d 100644 --- a/scripts/userCreation.ts +++ b/scripts/userCreation.ts @@ -2,107 +2,11 @@ // export ENVIRONMENT=DEV && ./scripts/build_api.sh && npx tsx ./scripts/userCreation.ts import {createSupabaseDirectClient} from "../backend/shared/lib/supabase/init"; -import {insert} from "../backend/shared/lib/supabase/utils"; -import {PrivateUser} from "../common/lib/user"; -import {getDefaultNotificationPreferences} from "../common/lib/user-notification-preferences"; -import {randomString} from "../common/lib/util/random"; import UserAccountInformation from "../tests/e2e/backend/utils/userInformation"; +import { seedDatabase } from "../tests/e2e/utils/seedDatabase"; type ProfileType = 'basic' | 'medium' | 'full' -/** - * Function used to populate the database with profiles. - * - * @param pg - Supabase client used to access the database. - * @param userInfo - Class object containing information to create a user account generated by `fakerjs`. - * @param profileType - Optional param used to signify how much information is used in the account generation. - */ -async function seedDatabase (pg: any, userInfo: UserAccountInformation, profileType?: string) { - - const userId = userInfo.user_id - const deviceToken = randomString() - const bio = { - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { - "text": userInfo.bio, - "type": "text" - } - ] - } - ] - } - const basicProfile = { - user_id: userId, - bio_length: userInfo.bio.length, - bio: bio, - age: userInfo.age, - born_in_location: userInfo.born_in_location, - company: userInfo.company, - } - - const mediumProfile = { - ...basicProfile, - drinks_per_month: userInfo.drinks_per_month, - diet: [userInfo.randomElement(userInfo.diet)], - education_level: userInfo.randomElement(userInfo.education_level), - ethnicity: [userInfo.randomElement(userInfo.ethnicity)], - gender: userInfo.randomElement(userInfo.gender), - height_in_inches: userInfo.height_in_inches, - pref_gender: [userInfo.randomElement(userInfo.pref_gender)], - pref_age_min: userInfo.pref_age.min, - pref_age_max: userInfo.pref_age.max, - } - - const fullProfile = { - ...mediumProfile, - occupation_title: userInfo.occupation_title, - political_beliefs: [userInfo.randomElement(userInfo.political_beliefs)], - pref_relation_styles: [userInfo.randomElement(userInfo.pref_relation_styles)], - religion: [userInfo.randomElement(userInfo.religion)], - } - - const profileData = profileType === 'basic' ? basicProfile - : profileType === 'medium' ? mediumProfile - : fullProfile - - const user = { - // avatarUrl, - isBannedFromPosting: false, - link: {}, - } - - const privateUser: PrivateUser = { - id: userId, - email: userInfo.email, - initialIpAddress: userInfo.ip, - initialDeviceToken: deviceToken, - notificationPreferences: getDefaultNotificationPreferences(), - blockedUserIds: [], - blockedByUserIds: [], - } - - await pg.tx(async (tx:any) => { - - await insert(tx, 'users', { - id: userId, - name: userInfo.name, - username: userInfo.name, - data: user, - }) - - await insert(tx, 'private_users', { - id: userId, - data: privateUser, - }) - - await insert(tx, 'profiles', profileData ) - - }) -} (async () => { const pg = createSupabaseDirectClient() diff --git a/tests/e2e/utils/.keep b/tests/e2e/utils/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/e2e/utils/seedDatabase.ts b/tests/e2e/utils/seedDatabase.ts new file mode 100644 index 00000000..801bc154 --- /dev/null +++ b/tests/e2e/utils/seedDatabase.ts @@ -0,0 +1,99 @@ +import {insert} from "../../../backend/shared/lib/supabase/utils"; +import {PrivateUser} from "../../../common/lib/user"; +import {getDefaultNotificationPreferences} from "../../../common/lib/user-notification-preferences"; +import {randomString} from "../../../common/lib/util/random"; +import UserAccountInformation from "../backend/utils/userInformation"; + +/** + * Function used to populate the database with profiles. + * + * @param pg - Supabase client used to access the database. + * @param userInfo - Class object containing information to create a user account generated by `fakerjs`. + * @param profileType - Optional param used to signify how much information is used in the account generation. + */ +export async function seedDatabase (pg: any, userInfo: UserAccountInformation, profileType?: string) { + + const userId = userInfo.user_id + const deviceToken = randomString() + const bio = { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "text": userInfo.bio, + "type": "text" + } + ] + } + ] + } + const basicProfile = { + user_id: userId, + bio_length: userInfo.bio.length, + bio: bio, + age: userInfo.age, + born_in_location: userInfo.born_in_location, + company: userInfo.company, + } + + const mediumProfile = { + ...basicProfile, + drinks_per_month: userInfo.drinks_per_month, + diet: [userInfo.randomElement(userInfo.diet)], + education_level: userInfo.randomElement(userInfo.education_level), + ethnicity: [userInfo.randomElement(userInfo.ethnicity)], + gender: userInfo.randomElement(userInfo.gender), + height_in_inches: userInfo.height_in_inches, + pref_gender: [userInfo.randomElement(userInfo.pref_gender)], + pref_age_min: userInfo.pref_age.min, + pref_age_max: userInfo.pref_age.max, + } + + const fullProfile = { + ...mediumProfile, + occupation_title: userInfo.occupation_title, + political_beliefs: [userInfo.randomElement(userInfo.political_beliefs)], + pref_relation_styles: [userInfo.randomElement(userInfo.pref_relation_styles)], + religion: [userInfo.randomElement(userInfo.religion)], + } + + const profileData = profileType === 'basic' ? basicProfile + : profileType === 'medium' ? mediumProfile + : fullProfile + + const user = { + // avatarUrl, + isBannedFromPosting: false, + link: {}, + } + + const privateUser: PrivateUser = { + id: userId, + email: userInfo.email, + initialIpAddress: userInfo.ip, + initialDeviceToken: deviceToken, + notificationPreferences: getDefaultNotificationPreferences(), + blockedUserIds: [], + blockedByUserIds: [], + } + + await pg.tx(async (tx:any) => { + + await insert(tx, 'users', { + id: userId, + name: userInfo.name, + username: userInfo.name, + data: user, + }) + + await insert(tx, 'private_users', { + id: userId, + data: privateUser, + }) + + await insert(tx, 'profiles', profileData ) + + }) +}; \ No newline at end of file