diff --git a/fastify/assets/@types/fastify.d.ts b/fastify/assets/@types/fastify.d.ts index 1a2ea98..a233db4 100644 --- a/fastify/assets/@types/fastify.d.ts +++ b/fastify/assets/@types/fastify.d.ts @@ -1,4 +1,5 @@ import 'fastify'; +import { BrowsingFilter, BrowsingSort } from '../srcs/services/BrowsingService'; declare module 'fastify' { interface FastifyInstance { @@ -104,6 +105,10 @@ declare module 'fastify' { reportUser(reportedId: number, reporterId: number): Promise; }; + browsingService: { + browseUsers(userId: number, limit: number = 5, offset: number = 0, radius: number = 25, filters?: BrowsingFilter, sort?: BrowsingSort): Promise> + }; + nodemailer: any; authenticate(request: any, reply: any): Promise; diff --git a/fastify/assets/srcs/app.ts b/fastify/assets/srcs/app.ts index 167e27d..34aeaf6 100644 --- a/fastify/assets/srcs/app.ts +++ b/fastify/assets/srcs/app.ts @@ -15,6 +15,7 @@ import chatServicePlugin from './services/ChatService' import mailServicePlugin from './services/MailService' import notificationService from './services/NotificationsServices' import reportService from './services/ReportService' +import browsingService from './services/BrowsingService' // import custom plugins import authenticate from './plugins/authenticate' import checkImageConformity from './plugins/checkImageConformity' @@ -73,6 +74,8 @@ export const buildApp = () => { app.register(reportService); + app.register(browsingService); + app.register(pg, { connectionString: process.env.PG }); diff --git a/fastify/assets/srcs/controllers/private/browsing/index.ts b/fastify/assets/srcs/controllers/private/browsing/index.ts new file mode 100644 index 0000000..97b2f32 --- /dev/null +++ b/fastify/assets/srcs/controllers/private/browsing/index.ts @@ -0,0 +1,46 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { AppError, UnauthorizedError } from '../../../utils/error'; +import { BrowsingFilter, BrowsingUser, BrowsingSort } from '../../../services/BrowsingService'; + +export const browseUsersHandler = async (request: FastifyRequest, reply: FastifyReply) => { + try { + const { + minAge, + maxAge, + minFame, + maxFame, + tags, + lat, + lng, + radius, + sortBy, + offset, + limit + } = request.params as { minAge: number, maxAge: number, minFame: number, maxFame: number, tags: string, lat: number, lng: number, radius: number, sortBy: string, offset: number, limit: number }; + + if (!request.user?.id) + throw new UnauthorizedError(); + const tagsArray = tags ? tags.split(',') : []; + let requestFilters: BrowsingFilter = { + age: { min: minAge, max: maxAge }, + fameRate: { min: minFame, max: maxFame }, + tags: tagsArray + }; + if (!(lat < -90 || lat > 90 || lng < -180 || lng > 180)) + requestFilters.location = { latitude: lat, longitude: lng }; + const users: BrowsingUser[] = await request.server.browsingService.browseUsers( + request.user.id, + limit || 20, + offset || 0, + radius, + requestFilters, + (sortBy ? sortBy as BrowsingSort : undefined) + ); + return reply.status(200).send({ users }); + } catch (error) { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ error: error.message }); + } + return reply.status(500).send({ error: 'Internal Server Error' }); + } +} \ No newline at end of file diff --git a/fastify/assets/srcs/controllers/private/me/profile.ts b/fastify/assets/srcs/controllers/private/me/profile.ts index 65cd59e..87e69bd 100644 --- a/fastify/assets/srcs/controllers/private/me/profile.ts +++ b/fastify/assets/srcs/controllers/private/me/profile.ts @@ -5,13 +5,31 @@ export const setProfileHandler = async ( request: FastifyRequest, reply: FastifyReply ) => { - const body = request.body as any; - const updateObject: any = {}; - Object.entries(body).forEach(([key, value]) => { - if (value !== undefined && key !== 'location') { - updateObject[key] = value; - } - }); + const body = request.body as { + bio?: string; + tags?: Array; + gender?: string; + orientation?: string; + bornAt?: string; + location?: { + latitude?: number; + longitude?: number; + }; + }; + + let updateObject: { + bio?: string; + tags?: string[]; + gender?: string; + orientation?: string; + bornAt?: Date; + } = {}; + + if (body.bio !== undefined) updateObject.bio = body.bio; + if (body.tags !== undefined) updateObject.tags = body.tags; + if (body.gender !== undefined) updateObject.gender = body.gender; + if (body.orientation !== undefined) updateObject.orientation = body.orientation; + if (body.bornAt) updateObject.bornAt = new Date(body.bornAt); try { const user = request.user as FastifyRequestUser | undefined; diff --git a/fastify/assets/srcs/routes/private/browsing/index.ts b/fastify/assets/srcs/routes/private/browsing/index.ts new file mode 100644 index 0000000..99a79cd --- /dev/null +++ b/fastify/assets/srcs/routes/private/browsing/index.ts @@ -0,0 +1,65 @@ +import { FastifyInstance } from 'fastify'; +import { browseUsersHandler } from '../../../controllers/private/browsing' + +export default async function browsingRoutes(fastify: FastifyInstance) { + fastify.get('/:minAge/:maxAge/:minFame/:maxFame/:tags/:lat/:lng/:radius/:sortBy', { + schema: { + params: { + type: 'object', + properties: { + minAge: { type: 'number', minimum: 18, maximum: 100 }, + maxAge: { type: 'number', minimum: 18, maximum: 100 }, + minFame: { type: 'number', minimum: 0, maximum: 1000 }, + maxFame: { type: 'number', minimum: 0, maximum: 1000 }, + tags: { type: 'string' }, // comma separated tags + lat: { type: 'number' }, + lng: { type: 'number' }, + radius: { type: 'number', minimum: 0 }, + sortBy: { type: 'string', enum: ['distance', 'age', 'fameRate', 'tags', 'default'] }, + offset: { type: 'number', minimum: 0 }, + limit: { type: 'number', minimum: 1, maximum: 30 } + }, + required: ['minAge', 'maxAge', 'minFame', 'maxFame', 'tags', 'lat', 'lng', 'radius', 'sortBy'], + additionalProperties: false + }, + response: { + 200: { + type: 'object', + properties: { + users: { type: 'array', items: { + type: 'object', + properties: { + id: { type: 'number' }, + firstName: { type: 'string' }, + gender: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + fameRate: { type: 'number' }, + profilePicture: { type: 'string' }, + bornAt: { type: 'string' }, + distance: { type: 'number' } + }, + required: ['id', 'firstName', 'gender', 'tags', 'fameRate', 'profilePicture', 'bornAt', 'distance'], + additionalProperties: false + } } + }, + additionalProperties: false + }, + 400: { + type: 'object', + properties: { + error: { type: 'string', } + }, + additionalProperties: false + }, + 500: { + type: 'object', + properties: { + error: { type: 'string' } + }, + additionalProperties: false + } + } + }, + handler: browseUsersHandler + }); +} \ No newline at end of file diff --git a/fastify/assets/srcs/routes/private/index.ts b/fastify/assets/srcs/routes/private/index.ts index eefbd08..3326b84 100644 --- a/fastify/assets/srcs/routes/private/index.ts +++ b/fastify/assets/srcs/routes/private/index.ts @@ -4,6 +4,7 @@ import userRoutes from './user'; import wsRoutes from './ws'; import notificationsRoutes from './notifications'; import reportRoutes from './report'; +import browsingRoutes from './browsing'; import statics from '@fastify/static'; import path from 'path'; @@ -17,6 +18,7 @@ export default async function privateRoutes(fastify: FastifyInstance, options: F fastify.register(userRoutes, { prefix: '/user' }); fastify.register(notificationsRoutes, { prefix: '/notifications' }); fastify.register(reportRoutes, { prefix: '/report', preHandler: fastify.checkIsCompleted }); + fastify.register(browsingRoutes, { prefix: '/browsing', preHandler: fastify.checkIsCompleted }); fastify.register(wsRoutes, { prefix: '/ws', preHandler: fastify.checkIsCompleted }); fastify.register(statics, { root: path.join(__dirname, '../../../uploads'), diff --git a/fastify/assets/srcs/routes/private/user/me/profile.ts b/fastify/assets/srcs/routes/private/user/me/profile.ts index 156d558..d2abbc8 100644 --- a/fastify/assets/srcs/routes/private/user/me/profile.ts +++ b/fastify/assets/srcs/routes/private/user/me/profile.ts @@ -4,6 +4,7 @@ import { setProfileHandler, getProfileHandler } from "../../../../controllers/pr const profileRoutes = async (fastify: FastifyInstance) => { fastify.put('/', { schema: { + body: { type: 'object', properties: { @@ -18,7 +19,6 @@ const profileRoutes = async (fastify: FastifyInstance) => { latitude: { type: 'number' }, longitude: { type: 'number' } }, - required: ['latitude', 'longitude'], additionalProperties: false } }, diff --git a/fastify/assets/srcs/services/BrowsingService.ts b/fastify/assets/srcs/services/BrowsingService.ts new file mode 100644 index 0000000..4583d09 --- /dev/null +++ b/fastify/assets/srcs/services/BrowsingService.ts @@ -0,0 +1,207 @@ +import PasswordManager from "../utils/password"; +import User from "../classes/User"; +import UserModel from "../models/User"; +import LikeModel from "../models/Like"; +import ViewModel from "../models/View" +// import ChatModel from "../models/Chat"; +import fp from 'fastify-plugin'; +import { FastifyInstance } from 'fastify'; +import { UnauthorizedError, NotFoundError, BadRequestError, InternalServerError, ForbiddenError, ConflictError } from "../utils/error"; + +export type BrowsingFilter = { + age?: { + min: number; + max: number; + }; + location?: { + latitude: number; + longitude: number; + } + fameRate?: { // between 0 and 1000 + min: number; + max: number; + } + tags?: Array; +} + +export type BrowsingSort = 'distance' | 'age' | 'fameRate' | 'tags'; + +export type BrowsingUser = { + id: number; + firstName: string; + gender: string; + tags: Array; + fameRate: number; + profilePicture: string; + bornAt: string; + distance: number; +} + +class BrowsingService { + private fastify: FastifyInstance; + private userModel: UserModel; + private likeModel: LikeModel; + private viewModel: ViewModel + // private chatModel: ChatModel; + UsersCache: Map; + + constructor(fastify: FastifyInstance) { + this.fastify = fastify; + this.userModel = new UserModel(fastify); + this.likeModel = new LikeModel(fastify); + this.viewModel = new ViewModel(fastify); + // this.chatModel = new ChatModel(fastify); + this.UsersCache = new Map(); + } + + private async getUsersFromCoordsAndRadius(userId: number, lat: number, lgn: number, limit: number, offset: number, radius: number, gender?: string, filters?: BrowsingFilter): Promise> { + let parameters: Array> = [lat, lgn, radius, userId, limit, offset]; + if (filters?.tags && filters.tags.length > 0) { + parameters.push(filters.tags); + } + const result = await this.fastify.pg.query( + ` + SELECT u.id, u.first_name, u.gender, u.profile_pictures, u.profile_picture_index, u.born_at, u.tags, u.fame_rate, distances.distance + FROM users u + JOIN + ( + SELECT user_id, 6371 * acos(least(1, greatest(-1, cos(radians(latitude)) * cos(radians($1)) * cos(radians($2) - radians(longitude)) + sin(radians(latitude)) * sin(radians($1))))) AS distance + FROM locations + WHERE 6371 * acos(least(1, greatest(-1, cos(radians(latitude)) * cos(radians($1)) * cos(radians($2) - radians(longitude)) + sin(radians(latitude)) * sin(radians($1))))) < $3 + AND user_id != $4 + ORDER BY distance + ) AS distances ON u.id = distances.user_id + WHERE u.is_profile_completed = TRUE + AND u.profile_picture_index IS NOT NULL + ${filters?.age ? `AND (CURRENT_DATE - u.born_at) / 365 +BETWEEN ${filters.age.min} AND ${filters.age.max} + ` : ''} + ${filters?.fameRate ? `AND u.fame_rate BETWEEN ${filters.fameRate.min} AND ${filters.fameRate.max}` : ''} + ${filters?.tags && filters.tags.length > 0 ? `AND u.tags @> $7::text[]` : ''} + ${gender ? `AND u.gender = '${gender}'` : ''} + LIMIT $5 OFFSET $6 + `, + parameters + ); + return result.rows.map((row: { + id: number; + first_name: string; + gender: string; + tags: Array; + fame_rate: number; + profile_pictures: Array; + profile_picture_index: number; + born_at: string; + distance: number; + }) => { + const user = { + id: row.id as number, + firstName: row.first_name as string, + gender: row.gender as string, + tags: row.tags as Array, + fameRate: row.fame_rate as number, + profilePicture: row.profile_pictures[row.profile_picture_index] ?? '', + bornAt: row.born_at as string, + distance: row.distance as number + } + return user; + }); + } + + private getSimilarTagsCount(userTags: Array, otherUserTags: Array): number { + let count = 0; + + for (const tag of userTags) { + if (otherUserTags.includes(tag)) { + count++; + } + } + return count; + } + + private sortByDistance(userRows: Array): Array { + return userRows.sort((a, b) => a.distance - b.distance); + } + + private sortByAge(userRows: Array, bornAt: Date): Array { + return userRows.sort((a, b) => Math.abs(bornAt.getTime() - new Date(a.bornAt).getTime()) - Math.abs(bornAt.getTime() - new Date(b.bornAt).getTime())); + } + + private sortByFameRate(userRows: Array): Array { + return userRows.sort((a, b) => b.fameRate - a.fameRate); + } + + private sortByTags(userRows: Array, userTags: Array): Array { + return userRows.sort((a, b) => this.getSimilarTagsCount(userTags, b.tags || []) - this.getSimilarTagsCount(userTags, a.tags || [])); + } + + private sortByAll(userRows: Array, bornAt: Date, userTags: Array, fameRate: number): Array { + const ageWeight = 0.3; + const tagsWeight = 0.2; + const fameRateWeight = 0.2; + const distanceWeight = 0.3; + const maxAgeDiff = 10; // years + const maxFameDiff = 400; + const maxDistance = 100; + + let scoreMap = new Map(); // index to score + + userRows.forEach(user => { + const ageDiff = Math.abs(bornAt.getFullYear() - new Date(user.bornAt).getFullYear()); + + const ageScore = 100 - (Math.min(ageDiff, maxAgeDiff) / maxAgeDiff) * 100; + + const similarTagsCount = this.getSimilarTagsCount(userTags, user.tags || []); + const tagsScore = userTags.length > 0 ? (similarTagsCount / userTags.length) * 100 : 0; + + const fameRateDiff = Math.abs(fameRate - user.fameRate); + const fameRateScore = 100 - (fameRateDiff / maxFameDiff) * maxFameDiff; + + const distanceScore = 100 - (Math.min(user.distance, maxDistance) / maxDistance) * 100; + + const totalScore = (ageScore * ageWeight) + (tagsScore * tagsWeight) + (fameRateScore * fameRateWeight) + (distanceScore * distanceWeight); + // console.log(`User ${user.id} - Age Score: ${ageScore.toFixed(2)}, Tags Score: ${tagsScore.toFixed(2)}, Fame Rate Score: ${fameRateScore.toFixed(2)}, Total Score: ${totalScore.toFixed(2)}, Distance Score: ${distanceScore.toFixed(2)}`); + scoreMap.set(user.id, totalScore); + }) + return userRows.sort((a, b) => (scoreMap.get(b.id) || 0) - (scoreMap.get(a.id) || 0)); + } + + public async browseUsers(userId: number, limit: number = 5, offset: number = 0, radius: number = 25, filters?: BrowsingFilter, sort?: BrowsingSort): Promise> { + const user = await this.fastify.userService.getMe(userId); + const lat = filters?.location?.latitude ?? user.location?.latitude; + const lng = filters?.location?.longitude ?? user.location?.longitude; + if (filters?.tags && filters.tags.length === 0) { + delete filters.tags; + } + const bornAt = user.bornAt; + const fameRate = user.fameRate; + const tags = user.tags; + + if (lat === undefined || lng === undefined) + throw new BadRequestError(); + let gender: string | undefined = undefined; + if (user.orientation === 'heterosexual') { + gender = user.gender === 'men' ? 'women' : 'men'; + } else if (user.orientation === 'homosexual') { + gender = user.gender; + } + const userRows = await this.getUsersFromCoordsAndRadius(userId, lat, lng, limit, offset, radius, gender, filters); + switch (sort) { + case 'distance': + return this.sortByDistance(userRows); + case 'age': + return this.sortByAge(userRows, bornAt); + case 'fameRate': + return this.sortByFameRate(userRows); + case 'tags': + return this.sortByTags(userRows, tags); + default: + return this.sortByAll(userRows, bornAt, tags, fameRate ?? 0); + } + } +} + +export default fp(async (fastify: FastifyInstance) => { + const browsingService = new BrowsingService(fastify); + fastify.decorate('browsingService', browsingService); +}); \ No newline at end of file diff --git a/fastify/assets/test/integration/fixtures/auth.fixtures.ts b/fastify/assets/test/integration/fixtures/auth.fixtures.ts index 8b618df..58b6af2 100644 --- a/fastify/assets/test/integration/fixtures/auth.fixtures.ts +++ b/fastify/assets/test/integration/fixtures/auth.fixtures.ts @@ -94,7 +94,7 @@ export const quickUser = async (app: FastifyInstance): Promise<{ userData: UserD bio: `Nice description for quick user ${concat}`, tags: ['quick', 'test', 'user'], bornAt: '2000-01-01', - orientation: 'heterosexual', + orientation: 'bisexual', gender: 'men' }; const token = await signUpAndGetToken(app, userData); diff --git a/fastify/assets/test/integration/utils/browsing.ts b/fastify/assets/test/integration/utils/browsing.ts new file mode 100644 index 0000000..de3212c --- /dev/null +++ b/fastify/assets/test/integration/utils/browsing.ts @@ -0,0 +1,73 @@ +import { FastifyInstance } from "fastify"; + +export const setTags = async (app: FastifyInstance, token: string, tags: Array) => { + await app.inject({ + method: 'PUT', + url: `/private/user/me/profile`, + headers: { + 'Cookie': `jwt=${token}` + }, + payload: { + tags: tags + } + }); +} + +export const viewUser = async (app: FastifyInstance, token: string, likedUserId: number) => { + await app.inject({ + method: 'GET', + url: `/private/user/view/${likedUserId}`, + headers: { + 'Cookie': `jwt=${token}` + } + }); +}; + +export const likeUser = async (app: FastifyInstance, token: string, likedUserId: number) => { + await app.inject({ + method: 'POST', + url: `/private/user/like/${likedUserId}`, + headers: { + 'Cookie': `jwt=${token}` + } + }); +}; + +export const setLocalisation = async (app: FastifyInstance, token: string, lat: number, lgn: number) => { + await app.inject({ + method: 'PUT', + url: `/private/user/me/profile`, + headers: { + 'Cookie': `jwt=${token}` + }, + payload: { + location: { + latitude: lat, + longitude: lgn + } + } + }); +}; + +export const setBirthDate = async (app: FastifyInstance, token: string, birthdate: string) => { + const response = await app.inject({ + method: 'PUT', + url: `/private/user/me/profile`, + headers: { + 'Cookie': `jwt=${token}` + }, + payload: { + bornAt: new Date(birthdate).toISOString() + } + }); +} + +export const getAgeDifference = (birthdate1: string, birthdate2: string): number => { + const date1 = new Date(birthdate1); + const date2 = new Date(birthdate2); + + const age1 = new Date().getTime() - date1.getTime(); + const age2 = new Date().getTime() - date2.getTime(); + + return Math.abs(age1 - age2); +} \ No newline at end of file diff --git a/fastify/assets/test/integration/ws/browsing.filtersort.test.ts b/fastify/assets/test/integration/ws/browsing.filtersort.test.ts new file mode 100644 index 0000000..b4c5e7f --- /dev/null +++ b/fastify/assets/test/integration/ws/browsing.filtersort.test.ts @@ -0,0 +1,372 @@ +import chai from 'chai'; +import { expect } from 'chai'; +import { buildApp } from '../../../srcs/app'; +import { FastifyInstance } from 'fastify'; + +// import fixtures +import { quickUser } from '../fixtures/auth.fixtures'; + +// import utils +import { setTags, setLocalisation, setBirthDate, likeUser, viewUser, getAgeDifference } from '../utils/browsing'; + +describe('Browsing filters and sorting', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = buildApp(); + await app.ready(); + }); + + it('should be able get near users', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + + await setLocalisation(app, token1, 48.8566, 2.3522); + + await setLocalisation(app, token2, 48.94705, 2.3522); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50); + + expect(rows.length).to.be.greaterThan(0); + }); + + it('should be able to sort users by distance', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 48.8566, 2.3522); + + await setLocalisation(app, token2, 48.94705, 2.3522); + + await setLocalisation(app, token3, 48.8516, 2.4525); + + await setLocalisation(app, token4, 48.8566, 2.3622); + + await setLocalisation(app, token5, 48.8566, 2.5522); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, undefined, 'distance'); + + for (let i = 0; i < rows.length - 1; i++) { + expect(rows[i].distance).to.be.lessThanOrEqual(rows[i + 1].distance); + } + }); + + it('should be able to sort users by age', async function (this: any) { + this.timeout(5000); + + const userBirthdate = '1992-06-15'; + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 48.8566, 2.3522); + await setLocalisation(app, token2, 48.94705, 2.3522); + await setLocalisation(app, token3, 48.8566, 2.3525); + await setLocalisation(app, token4, 48.8566, 2.3622); + await setLocalisation(app, token5, 48.8566, 2.5522); + + await setBirthDate(app, token1, userBirthdate); + await setBirthDate(app, token2, '1987-02-02'); + await setBirthDate(app, token3, '1994-03-03'); + await setBirthDate(app, token4, '1996-04-04'); + await setBirthDate(app, token5, '1980-05-05'); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, undefined, 'age'); + + for (let i = 0; i < rows.length - 1; i++) { + expect(getAgeDifference(userBirthdate, rows[i].bornAt)).to.be.lessThanOrEqual(getAgeDifference(userBirthdate, rows[i + 1].bornAt)); + } + }); + + it('should be able to sort users by fame rate', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 34.3453, 2.325); + await setLocalisation(app, token2, 34.3453, 2.323); + await setLocalisation(app, token3, 34.3453, 2.3212); + await setLocalisation(app, token4, 34.3453, 2.3232); + await setLocalisation(app, token5, 34.3453, 2.321); + + await viewUser(app, token1, data2.id as number); + await viewUser(app, token1, data3.id as number); + await viewUser(app, token1, data4.id as number); + await viewUser(app, token1, data5.id as number); + await viewUser(app, token2, data3.id as number); + await viewUser(app, token2, data4.id as number); + await viewUser(app, token2, data5.id as number); + await viewUser(app, token3, data2.id as number); + await viewUser(app, token3, data4.id as number); + await viewUser(app, token3, data5.id as number); + await viewUser(app, token4, data2.id as number); + await viewUser(app, token4, data3.id as number); + await viewUser(app, token4, data5.id as number); + await viewUser(app, token5, data2.id as number); + await viewUser(app, token5, data3.id as number); + await viewUser(app, token5, data4.id as number); + + await likeUser(app, token1, data2.id as number); + await likeUser(app, token1, data3.id as number); + await likeUser(app, token2, data4.id as number); + await likeUser(app, token3, data4.id as number); + await likeUser(app, token3, data5.id as number); + await likeUser(app, token4, data5.id as number); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, undefined, 'fameRate'); + + for (let i = 0; i < rows.length - 1; i++) { + expect(rows[i].fameRate).to.be.greaterThanOrEqual(rows[i + 1].fameRate); + } + }); + + it('should be able to sort users by fame rate', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 34.3453, 2.325); + await setLocalisation(app, token2, 34.3453, 2.323); + await setLocalisation(app, token3, 34.3453, 2.3212); + await setLocalisation(app, token4, 34.3453, 2.3232); + await setLocalisation(app, token5, 34.3453, 2.321); + + await setTags(app, token1, ['music', 'sports', 'art']); + await setTags(app, token2, ['music', 'sports', 'art']); + await setTags(app, token3, ['music', 'sports', 'reading']); + await setTags(app, token4, ['gambling', 'cooking', 'gaming']); + await setTags(app, token5, ['gaming', 'photography', 'art']); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, undefined, 'tags'); + + for (let i = 0; i < rows.length - 1; i++) { + const countCurrent = rows[i].tags.filter((tag: string) => ['music', 'sports', 'art'].includes(tag)).length; + const countNext = rows[i + 1].tags.filter((tag: string) => ['music', 'sports', 'art'].includes(tag)).length; + expect(countCurrent).to.be.greaterThanOrEqual(countNext); + } + }); + + it('should be able to filter users by tags', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 34.3453, 2.325); + await setLocalisation(app, token2, 34.3453, 2.323); + await setLocalisation(app, token3, 34.3453, 2.3212); + await setLocalisation(app, token4, 34.3453, 2.3232); + await setLocalisation(app, token5, 34.3453, 2.321); + + await setTags(app, token1, ['music', 'sports', 'art']); + await setTags(app, token2, ['music', 'sports', 'art']); + await setTags(app, token3, ['music', 'sports', 'reading']); + await setTags(app, token4, ['music', 'cooking', 'gaming']); + await setTags(app, token5, ['gaming', 'photography', 'gambling']); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, { tags: ['music'] }); + + for (let i = 0; i < rows.length - 1; i++) { + expect(rows[i].tags).to.include('music'); + } + }); + + it('should be able to filter users by age', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 38.3853, 2.325); + await setLocalisation(app, token2, 38.3853, 2.323); + await setLocalisation(app, token3, 38.3853, 2.3212); + await setLocalisation(app, token4, 38.3853, 2.3232); + await setLocalisation(app, token5, 38.3853, 2.321); + + const less40YearsAgo = new Date(); + less40YearsAgo.setFullYear(less40YearsAgo.getFullYear() - 40); + await setBirthDate(app, token2, less40YearsAgo.toISOString()); + + const less37YearsAgo = new Date(); + less37YearsAgo.setFullYear(less37YearsAgo.getFullYear() - 37); + await setBirthDate(app, token3, less37YearsAgo.toISOString()); + + const less35YearsAgo = new Date(); + less35YearsAgo.setFullYear(less35YearsAgo.getFullYear() - 35); + await setBirthDate(app, token4, less35YearsAgo.toISOString()); + + const less33YearsAgo = new Date(); + less33YearsAgo.setFullYear(less33YearsAgo.getFullYear() - 33); + await setBirthDate(app, token5, less33YearsAgo.toISOString()); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, { age: { min: 34, max: 39 } }); + + for (let i = 0; i < rows.length - 1; i++) { + const age = new Date().getFullYear() - new Date(rows[i].bornAt).getFullYear(); + expect(age).to.be.at.least(34); + expect(age).to.be.at.most(39); + } + }); + + it('should be able to filter users by age', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 38.3853, 2.325); + await setLocalisation(app, token2, 38.3853, 2.323); + await setLocalisation(app, token3, 38.3853, 2.3212); + await setLocalisation(app, token4, 38.3853, 2.3232); + await setLocalisation(app, token5, 38.3853, 2.321); + + const less40YearsAgo = new Date(); + less40YearsAgo.setFullYear(less40YearsAgo.getFullYear() - 40); + await setBirthDate(app, token2, less40YearsAgo.toISOString()); + + const less37YearsAgo = new Date(); + less37YearsAgo.setFullYear(less37YearsAgo.getFullYear() - 37); + await setBirthDate(app, token3, less37YearsAgo.toISOString()); + + const less35YearsAgo = new Date(); + less35YearsAgo.setFullYear(less35YearsAgo.getFullYear() - 35); + await setBirthDate(app, token4, less35YearsAgo.toISOString()); + + const less33YearsAgo = new Date(); + less33YearsAgo.setFullYear(less33YearsAgo.getFullYear() - 33); + await setBirthDate(app, token5, less33YearsAgo.toISOString()); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, { age: { min: 34, max: 39 } }); + + for (let i = 0; i < rows.length - 1; i++) { + const age = new Date().getFullYear() - new Date(rows[i].bornAt).getFullYear(); + expect(age).to.be.at.least(34); + expect(age).to.be.at.most(39); + } + }); + + it('should be able to filter users by fame rate', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 40.4053, 2.325); + await setLocalisation(app, token2, 40.4053, 2.323); + await setLocalisation(app, token3, 40.4053, 2.3212); + await setLocalisation(app, token4, 40.4053, 2.3232); + await setLocalisation(app, token5, 40.4053, 2.321); + + await viewUser(app, token1, data2.id as number); + await viewUser(app, token1, data3.id as number); + await viewUser(app, token1, data4.id as number); + await viewUser(app, token1, data5.id as number); + await viewUser(app, token2, data3.id as number); + await viewUser(app, token2, data4.id as number); + await viewUser(app, token2, data5.id as number); + await viewUser(app, token3, data2.id as number); + await viewUser(app, token3, data4.id as number); + await viewUser(app, token3, data5.id as number); + await viewUser(app, token4, data2.id as number); + await viewUser(app, token4, data3.id as number); + await viewUser(app, token4, data5.id as number); + await viewUser(app, token5, data2.id as number); + await viewUser(app, token5, data3.id as number); + await viewUser(app, token5, data4.id as number); + + await likeUser(app, token1, data2.id as number); + await likeUser(app, token1, data3.id as number); + await likeUser(app, token2, data4.id as number); + await likeUser(app, token3, data4.id as number); + await likeUser(app, token3, data5.id as number); + await likeUser(app, token4, data5.id as number); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, { fameRate: { min: 300, max: 800 } }); + + for (let i = 0; i < rows.length - 1; i++) { + expect(rows[i].fameRate).to.be.at.least(300); + expect(rows[i].fameRate).to.be.at.most(800); + } + + const rows1 = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, { fameRate: { min: 600, max: 1000 } }); + for (let i = 0; i < rows1.length - 1; i++) { + expect(rows1[i].fameRate).to.be.at.least(600); + expect(rows1[i].fameRate).to.be.at.most(1000); + } + + const rows2 = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, { fameRate: { min: 0, max: 0 } }); + for (let i = 0; i < rows2.length - 1; i++) { + expect(rows2[i].fameRate).to.be.at.least(0); + expect(rows2[i].fameRate).to.be.at.most(0); + } + }); + + it('should be able to filter users by location', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + const { userData: data3, token: token3 } = await quickUser(app); + const { userData: data4, token: token4 } = await quickUser(app); + const { userData: data5, token: token5 } = await quickUser(app); + + await setLocalisation(app, token1, 1.4053, 1.325); + await setLocalisation(app, token2, 40.4053, 2.323); + await setLocalisation(app, token3, 40.4053, 2.3212); + await setLocalisation(app, token4, 40.4053, 2.3232); + await setLocalisation(app, token5, 40.4053, 2.321); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50, { + location: { + latitude: 40.4053, + longitude: 2.325 + } + , }, 'distance'); + expect(rows.length).to.be.greaterThan(0); + + for (let i = 0; i < rows.length - 1; i++) { + expect(rows[i].distance).to.be.at.most(50); + } + + const rows1 = await app.browsingService.browseUsers(data1.id as number, 10, 0, 25, { + location: { + latitude: 40.4053, + longitude: 2.325 + } + , }, 'distance'); + expect(rows1.length).to.be.greaterThan(0); + + for (let i = 0; i < rows1.length - 1; i++) { + expect(rows1[i].distance).to.be.at.most(25); + } + }); +}); \ No newline at end of file diff --git a/fastify/assets/test/integration/ws/browsing.test.ts b/fastify/assets/test/integration/ws/browsing.test.ts new file mode 100644 index 0000000..713773d --- /dev/null +++ b/fastify/assets/test/integration/ws/browsing.test.ts @@ -0,0 +1,140 @@ +import chai from 'chai'; +import { expect } from 'chai'; +import { buildApp } from '../../../srcs/app'; +import { FastifyInstance } from 'fastify'; + +// import fixtures +import { quickUser, signUpAndGetToken } from '../fixtures/auth.fixtures'; + +// import utils +import { setTags, setLocalisation, setBirthDate, likeUser, viewUser, getAgeDifference } from '../utils/browsing'; +import path from 'node:path'; +import fs from 'node:fs'; +import FormData from 'form-data'; +import { create } from 'node:domain'; + +async function browseUsers(app: FastifyInstance, token: string, params: any) { + const url = `/private/browsing/${params.minAge || 18}/${params.maxAge || 100}/${params.minFame || 0}/${params.maxFame || 1000}/${(params.tags || []).join(',')}/${params.lat || 1000}/${params.lng || 1000}/${params.radius || 30}/${params.sortBy || 'default'}`; + const response = await app.inject({ + method: 'GET', + headers: { + Cookie: `jwt=${token}` + }, + url: url, + }); + return JSON.parse(response.body).users; +} + +async function createUserWithProfile(app: FastifyInstance, username: string, email: string, password: string, firstName: string, lastName: string, bio: string, tags: string[], bornAt: string, orientation: string, gender: string): Promise { + const token = await signUpAndGetToken(app, { + username: username, + email: email, + password: password, + firstName: firstName, + lastName: lastName, + bio: bio, + tags: tags, + bornAt: bornAt, + orientation: orientation, + gender: gender + }); + if (!token) throw new Error('Failed to create browsing test user 1'); + await setLocalisation(app, token, 69.8566, 2.3522); + + const form = new FormData(); + const filePath = path.join(__dirname, '../../files/test.jpg'); + form.append('file', fs.createReadStream(filePath), { + filename: 'test.jpg', + contentType: 'image/jpeg' + }); + + const headers = form.getHeaders(); + headers['Cookie'] = `jwt=${token}`; + + await app.inject({ + method: 'POST', + url: '/private/user/me/profile-picture', + headers, + payload: form + }); + + return token; +} + +describe('Browsing filters and sorting', async () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = buildApp(); + await app.ready(); + }); + + it('should be able get near users', async function (this: any) { + this.timeout(5000); + + const { userData: data1, token: token1 } = await quickUser(app); + const { userData: data2, token: token2 } = await quickUser(app); + + await setLocalisation(app, token1, 48.8566, 2.3522); + + await setLocalisation(app, token2, 48.94705, 2.3522); + + const rows = await app.browsingService.browseUsers(data1.id as number, 10, 0, 50); + + expect(rows.length).to.be.greaterThan(0); + }); + + it('should be able to give the best corresponding users', async function (this: any) { + this.timeout(5000); + const token1 = await createUserWithProfile(app, 'browsingtestuser1', 'browsingtestuser1@gmail.com', 'Test@1234!fjfsfas', 'Browsing', 'TestUser1', 'I am browsing test user 1', ['music', 'sport', 'travel', 'art'], '1995-06-15', 'heterosexual', 'women'); + + // Build corresponding users + const { userData: data2, token: token2 } = await quickUser(app); // #1 Perfect match + await setTags(app, token2, ['music', 'sport', 'travel']); + await setBirthDate(app, token2, '1994-08-20'); + await setLocalisation(app, token2, 69.8566, 2.3622); + + const { userData: data3, token: token3 } = await quickUser(app); // #2 Almost perfect match + await setTags(app, token3, ['music', 'art']); + await setBirthDate(app, token3, '1994-08-20'); + await setLocalisation(app, token3, 69.854, 2.5522); + + const { userData: data4, token: token4 } = await quickUser(app); // #3 Less tags + await setTags(app, token4, ['music', 'dshsh', 'sdgfgd']); + await setBirthDate(app, token4, '1996-11-05'); + await setLocalisation(app, token4, 69.9566, 2.3522); + + const { userData: data5, token: token5 } = await quickUser(app); // #7 Too old + await setTags(app, token5, ['music', 'sport', 'travel', 'art']); + await setBirthDate(app, token5, '1970-03-22'); + await setLocalisation(app, token5, 69.8577, 2.3522); + + const { userData: data6, token: token6 } = await quickUser(app); // #4 Too far + await setTags(app, token6, ['cooking', 'reading', 'music']); + await setBirthDate(app, token6, '1995-05-30'); + await setLocalisation(app, token6, 68.8566, 2.955); + + const { userData: data7, token: token7 } = await quickUser(app); // #5 No matching tags + far + old + await setTags(app, token7, ['cooking', 'reading', 'drug', 'gambling', 'alcohol']); + await setBirthDate(app, token7, '1960-03-22'); + await setLocalisation(app, token7, 69.86, 2.3522); + + const users = await browseUsers(app, token1, { + minAge: 18, + maxAge: 100, + minFame: 0, + maxFame: 1000, + lat: 69.8566, + lng: 2.3522, + radius: 300, + sortBy: 'default' + }); + + expect(users[0].id).to.equal(data2.id); + expect(users[1].id).to.equal(data3.id); + expect(users[2].id).to.equal(data4.id); + expect(users[3].id).to.equal(data5.id); + expect(users[4].id).to.equal(data6.id); + expect(users[5].id).to.equal(data7.id); + }); +}); \ No newline at end of file