diff --git a/package-lock.json b/package-lock.json index 4eaeb026..acd38884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4298,6 +4298,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", diff --git a/package.json b/package.json index adbdbc66..dc19a155 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dependencies": { "@babel/plugin-proposal-optional-chaining": "^7.12.7", "babel": "^6.23.0", + "base64url": "^3.0.1", "bcrypt": "^5.0.1", "cloudinary": "^1.24.0", "dotenv-flow": "^3.0.0", diff --git a/src/api/middleware/company.js b/src/api/middleware/company.js index 1ef8b949..5ed69a10 100644 --- a/src/api/middleware/company.js +++ b/src/api/middleware/company.js @@ -43,7 +43,7 @@ export const verifyMaxConcurrentOffersOnEdit = async (req, res, next) => { try { - const offer = await (new OfferService()).getOfferById(req.params.offerId, req.user); + const offer = await (new OfferService()).getOfferById(req.params.offerId, req.targetOwner, req.hasAdminPrivileges); if (!offer) throw new APIError(HTTPStatus.NOT_FOUND, ErrorTypes.VALIDATION_ERROR, ValidationReasons.OFFER_NOT_FOUND(req.params.offerId)); diff --git a/src/api/middleware/validators/offer.js b/src/api/middleware/validators/offer.js index 200708b3..8a3af0bf 100644 --- a/src/api/middleware/validators/offer.js +++ b/src/api/middleware/validators/offer.js @@ -312,21 +312,23 @@ const publishEndDateEditableLimit = async (publishEndDateCandidate, { req }) => return true; }; +const existingOfferId = async (offerId, { req }) => { + try { + const offer = await (new OfferService()).getOfferById(offerId, req.targetOwner, req.hasAdminPrivileges); + if (!offer) throw new Error(ValidationReasons.OFFER_NOT_FOUND(offerId)); + } catch (err) { + console.error(err); + throw err; + } + + return true; +}; + export const isExistingOffer = useExpressValidators([ param("offerId", ValidationReasons.DEFAULT) .exists().withMessage(ValidationReasons.REQUIRED).bail() .custom(isObjectId).withMessage(ValidationReasons.OBJECT_ID).bail() - .custom(async (offerId, { req }) => { - try { - const offer = await (new OfferService()).getOfferById(offerId, req.targetOwner, req.hasAdminPrivileges); - if (!offer) throw new Error(ValidationReasons.OFFER_NOT_FOUND(offerId)); - } catch (err) { - console.error(err); - throw err; - } - - return true; - }), + .custom(existingOfferId), ]); export const edit = useExpressValidators([ @@ -440,11 +442,33 @@ export const setDefaultValuesCreate = (req, res, next) => { return next(); }; +const validGetQueryToken = async (queryToken, { req }) => { + try { + const { id, score, value } = (new OfferService()).decodeQueryToken(queryToken); + if (!isObjectId(id)) throw new Error(ValidationReasons.OBJECT_ID); + await existingOfferId(id, { req }); + + if (value) { + if (isNaN(score)) throw new Error(ValidationReasons.NUMBER); + if (score < 0) throw new Error(ValidationReasons.MIN(0)); + } + + if (score && !value) + throw new Error(ValidationReasons.REQUIRED); + + } catch (err) { + console.error(err); + throw new Error(ValidationReasons.INVALID_QUERY_TOKEN); + } + + return true; +}; + export const get = useExpressValidators([ - query("offset", ValidationReasons.DEFAULT) + query("queryToken", ValidationReasons.DEFAULT) .optional() - .isInt({ min: 0 }).withMessage(ValidationReasons.INT) - .toInt(), + .isString().withMessage(ValidationReasons.STRING).bail() + .custom(validGetQueryToken), query("limit") .optional() diff --git a/src/api/middleware/validators/validationReasons.js b/src/api/middleware/validators/validationReasons.js index 135e648d..a3367faf 100644 --- a/src/api/middleware/validators/validationReasons.js +++ b/src/api/middleware/validators/validationReasons.js @@ -19,6 +19,7 @@ const ValidationReasons = Object.freeze({ DATE: "must-be-ISO8601-date", INT: "must-be-int", BOOLEAN: "must-be-boolean", + NUMBER: "must-be-number", IN_ARRAY: (vals, field) => `${field ? `${field}:` : ""}must-be-in:[${vals}]`, ARRAY_SIZE: (min, max) => `size-must-be-between:[${min},${max}]`, OBJECT_ID: "must-be-a-valid-id", @@ -37,6 +38,7 @@ const ValidationReasons = Object.freeze({ OFFER_EXPIRED: (id) => `offer-expired:${id}`, NOT_OFFER_OWNER: (id) => `not-offer-owner:${id}`, OFFER_EDIT_PERIOD_OVER: (value) => `offer-edit-period-over:${value}-hours`, + INVALID_QUERY_TOKEN: "invalid-query-token", JOB_MIN_DURATION_NOT_SPECIFIED: "job-max-duration-requires-job-min-duration", REGISTRATION_FINISHED: "registration-already-finished", REGISTRATION_NOT_FINISHED: "registration-not-finished-yet", diff --git a/src/api/routes/offer.js b/src/api/routes/offer.js index 2f913810..c5c400a8 100644 --- a/src/api/routes/offer.js +++ b/src/api/routes/offer.js @@ -20,12 +20,13 @@ export default (app) => { router.use(offerMiddleware.setTargetOwner); /** - * Gets all currently active offers (without filtering, for now) - * supports offset and limit as query params + * Gets active offers based on passed filters and full-text search. + * Returns the offers found and a queryToken used for pagination. + * Also takes queryToken and limit as query params. */ router.get("/", validators.get, async (req, res, next) => { try { - const offers = await (new OfferService()).get( + const resultsAndQueryToken = await (new OfferService()).get( { ...req.query, showHidden: req?.query?.showHidden && req.hasAdminPrivileges, @@ -33,7 +34,7 @@ export default (app) => { } ); - return res.json(offers); + return res.json(resultsAndQueryToken); } catch (err) { console.error(err); return next(err); diff --git a/src/models/Offer.js b/src/models/Offer.js index 63472fd4..bfa44d19 100644 --- a/src/models/Offer.js +++ b/src/models/Offer.js @@ -146,22 +146,24 @@ function validateDescription(value) { /** * Currently active Offers (publish date was before Date.now and end date is after Date.now) */ +OfferSchema.statics.filterCurrent = () => ({ + publishDate: { + $lte: new Date(Date.now()), + }, + publishEndDate: { + $gt: new Date(Date.now()), + }, +}); OfferSchema.query.current = function() { - return this.where({ - publishDate: { - $lte: new Date(Date.now()), - }, - publishEndDate: { - $gt: new Date(Date.now()), - }, - }); + return this.where(this.model.filterCurrent()); }; /** * Currently active and non-hidden Offers */ +OfferSchema.statics.filterNonHidden = () => ({ isHidden: false }); OfferSchema.query.withoutHidden = function() { - return this.where({ isHidden: false }); + return this.where(this.model.filterNonHidden()); }; const Offer = mongoose.model("Offer", OfferSchema); diff --git a/src/services/offer.js b/src/services/offer.js index 510f7921..3283dd68 100644 --- a/src/services/offer.js +++ b/src/services/offer.js @@ -1,9 +1,13 @@ +import mongoose from "mongoose"; import Company from "../models/Company.js"; import Offer from "../models/Offer.js"; import Account from "../models/Account.js"; import EmailService from "../lib/emailService.js"; import { OFFER_DISABLED_NOTIFICATION } from "../email-templates/companyOfferDisabled.js"; import OfferConstants from "../models/constants/Offer.js"; +import base64url from "base64url"; + +const { ObjectId } = mongoose.Types; class OfferService { // TODO: Use typedi or similar @@ -192,29 +196,102 @@ class OfferService { /** * Fetches offers according to specified options + * Learn more about keyset search here: https://github.com/NIAEFEUP/nijobs-be/issues/129 + * * @param {*} options * value: Text to use in full-text-search - * offset: Point to start looking (and limiting) + * queryToken: Token used to continue the previous search * limit: How many offers to show * jobType: Array of jobTypes allowed */ - get({ value = "", offset = 0, limit = OfferService.MAX_OFFERS_PER_QUERY, showHidden = false, showAdminReason = false, ...filters }) { + async get({ value = "", queryToken = null, limit = OfferService.MAX_OFFERS_PER_QUERY, + showHidden = false, showAdminReason = false, ...filters }) { - const offers = (value ? Offer.find( - { "$and": [this._buildFilterQuery(filters), { "$text": { "$search": value } }] }, { score: { "$meta": "textScore" } } - ) : Offer.find(this._buildFilterQuery(filters))).current(); + let offers, queryValue = value, queryFilters = filters; - if (!showHidden) offers.withoutHidden(); + if (queryToken) { + const { + id: lastOfferId, + score: lastOfferScore, + ...searchInfo + } = this.decodeQueryToken(queryToken); + + [queryValue, queryFilters] = [searchInfo.value, searchInfo.filters]; + + offers = this._buildSearchContinuationQuery(lastOfferId, lastOfferScore, queryValue, + showHidden, showAdminReason, queryFilters); + } else { + offers = this._buildInitialSearchQuery(queryValue, showHidden, showAdminReason, queryFilters); + } - const offersQuery = offers - .sort(value ? { score: { "$meta": "textScore" } } : undefined) - .skip(offset) + const results = await offers + .sort(queryValue ? { score: { "$meta": "textScore" }, _id: 1 } : { _id: 1 }) .limit(limit) ; - return showAdminReason ? offersQuery : offersQuery.select("-adminReason"); + if (results.length > 0) { + const lastOffer = results[results.length - 1]; + return { + results, + queryToken: this.encodeQueryToken( + lastOffer._id, + lastOffer.score || lastOffer._doc?.score, + queryValue, queryFilters + ), + }; + } else { + return { results }; + } + } + + /** + * Builds an initial search query. Cannot be used when loading more offers. + * Otherwise, use _buildSearchContinuationQuery(). + */ + _buildInitialSearchQuery(value, showHidden, showAdminReason, filters) { + const offers = (value ? Offer.find({ "$and": [ + this._buildFilterQuery(filters), + { "$text": { "$search": value } } + ] }, { score: { "$meta": "textScore" } } + + ) : Offer.find(this._buildFilterQuery(filters))); + + return this.selectSearchOffers(offers, showHidden, showAdminReason); + } + + /** + * Builds a search continuation query. Only use this when loading more offers. + * Otherwise, use _buildInitialSearchQuery(). + */ + _buildSearchContinuationQuery(lastOfferId, lastOfferScore, value, showHidden, showAdminReason, filters) { + let offers; + if (value) { + offers = Offer.aggregate([ + { $match: { $text: { $search: value } } }, + { $match: this._buildFilterQuery(filters) }, + { $addFields: { + score: { $meta: "textScore" }, + adminReason: { $cond: [showAdminReason, "$adminReason", "$$REMOVE"] } + } }, + { $match: { "$or": [ + { score: { "$lt": lastOfferScore } }, + { score: lastOfferScore, _id: { "$gt": ObjectId(lastOfferId) } } + ] } }, + { $match: Offer.filterCurrent() }, + { $match: showHidden ? {} : Offer.filterNonHidden() } + ]); + } else { + offers = Offer.find({ "$and": [ + this._buildFilterQuery(filters), + { _id: { "$gt": ObjectId(lastOfferId) } } + ] }); + + this.selectSearchOffers(offers, showHidden, showAdminReason); + } + return offers; } + _buildFilterQuery(filters) { if (!filters || !Object.keys(filters).length) return {}; @@ -262,6 +339,38 @@ class OfferService { return constraints.length ? { "$and": constraints } : {}; } + selectSearchOffers(offers, showHidden, showAdminReason) { + offers.current(); + if (!showHidden) offers.withoutHidden(); + if (!showAdminReason) offers.select("-adminReason"); + + return offers; + } + + /** + * Encodes a query token, by taking the an id and FTS score if present, and encoding them in safe url base64 + * @param {*} id + * @param {*} score + */ + encodeQueryToken(id, score, value, filters) { + return base64url.encode(JSON.stringify({ + id, score, value, filters + })); + } + + /** + * Decodes a query token, extracting the FTS score and remaining offer's information + * @param {*} queryToken + */ + decodeQueryToken(queryToken) { + const tokenInfo = JSON.parse(base64url.decode(queryToken)); + + return { + ...tokenInfo, + score: Number(tokenInfo.score) + }; + } + /** * Checks whether a given offer is visible to a specific userCompanyId. * Unpublished/Unactive offers may still be visible diff --git a/test/end-to-end/offer.js b/test/end-to-end/offer.js index f12e4122..e8c32f01 100644 --- a/test/end-to-end/offer.js +++ b/test/end-to-end/offer.js @@ -873,11 +873,6 @@ describe("Offer endpoint tests", () => { const EndpointValidatorTester = ValidatorTester((params) => request().get("/offers").query(params)); const QueryValidatorTester = EndpointValidatorTester("query"); - describe("offset", () => { - const FieldValidatorTester = QueryValidatorTester("offset"); - FieldValidatorTester.mustBeNumber(); - }); - describe("limit", () => { const FieldValidatorTester = QueryValidatorTester("limit"); FieldValidatorTester.mustBeNumber(); @@ -913,6 +908,9 @@ describe("Offer endpoint tests", () => { let test_company; let test_offer; + const testPublishDate = "2019-11-22T00:00:00.000Z"; + const testPublishEndDate = "2019-11-28T00:00:00.000Z"; + beforeAll(async () => { test_company = await Company.create({ name: "test company", @@ -923,8 +921,8 @@ describe("Offer endpoint tests", () => { test_offer = { ...generateTestOffer({ - "publishDate": "2019-11-22T00:00:00.000Z", - "publishEndDate": "2019-11-28T00:00:00.000Z" + "publishDate": testPublishDate, + "publishEndDate": testPublishEndDate }), owner: test_company._id, ownerName: test_company.name, @@ -946,8 +944,147 @@ describe("Offer endpoint tests", () => { Date.now = RealDateNow; }); + describe("queryToken validation", () => { + test("should fail if queryToken does not contain a valid id", async () => { + const queryToken = (new OfferService()).encodeQueryToken("123"); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.UNPROCESSABLE_ENTITY); + expect(res.body).toHaveProperty("error_code", ErrorTypes.VALIDATION_ERROR); + expect(res.body).toHaveProperty("errors"); + expect(res.body.errors[0]).toHaveProperty("msg", ValidationReasons.INVALID_QUERY_TOKEN); + expect(res.body.errors[0]).toHaveProperty("param", "queryToken"); + expect(res.body.errors[0]).toHaveProperty("location", "query"); + }); + + test("should fail if the queryToken's offer does not exist", async () => { + const queryToken = (new OfferService()).encodeQueryToken("5facf0cdb8bc30016ee58952"); + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.UNPROCESSABLE_ENTITY); + expect(res.body).toHaveProperty("error_code", ErrorTypes.VALIDATION_ERROR); + expect(res.body).toHaveProperty("errors"); + expect(res.body.errors[0]).toHaveProperty("msg", ValidationReasons.INVALID_QUERY_TOKEN); + expect(res.body.errors[0]).toHaveProperty("param", "queryToken"); + expect(res.body.errors[0]).toHaveProperty("location", "query"); + }); + + test("should fail if the queryToken's score is not a number", async () => { + const testOfferId = (await Offer.findOne({}))._id; + const queryToken = (new OfferService()) + .encodeQueryToken(testOfferId, "hello", "test"); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.UNPROCESSABLE_ENTITY); + expect(res.body).toHaveProperty("error_code", ErrorTypes.VALIDATION_ERROR); + expect(res.body).toHaveProperty("errors"); + expect(res.body.errors[0]).toHaveProperty("msg", ValidationReasons.INVALID_QUERY_TOKEN); + expect(res.body.errors[0]).toHaveProperty("param", "queryToken"); + expect(res.body.errors[0]).toHaveProperty("location", "query"); + }); + + test("should fail if the queryToken's score is negative", async () => { + const testOfferId = (await Offer.findOne({}))._id; + const queryToken = (new OfferService()) + .encodeQueryToken(testOfferId, -5, "test"); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.UNPROCESSABLE_ENTITY); + expect(res.body).toHaveProperty("error_code", ErrorTypes.VALIDATION_ERROR); + expect(res.body).toHaveProperty("errors"); + expect(res.body.errors[0]).toHaveProperty("msg", ValidationReasons.INVALID_QUERY_TOKEN); + expect(res.body.errors[0]).toHaveProperty("param", "queryToken"); + expect(res.body.errors[0]).toHaveProperty("location", "query"); + }); + + test("should fail if the queryToken's value is present and score is missing", async () => { + const testOfferId = (await Offer.findOne({}))._id; + const queryToken = (new OfferService()) + .encodeQueryToken(testOfferId, undefined, "test"); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.UNPROCESSABLE_ENTITY); + expect(res.body).toHaveProperty("error_code", ErrorTypes.VALIDATION_ERROR); + expect(res.body).toHaveProperty("errors"); + expect(res.body.errors[0]).toHaveProperty("msg", ValidationReasons.INVALID_QUERY_TOKEN); + expect(res.body.errors[0]).toHaveProperty("param", "queryToken"); + expect(res.body.errors[0]).toHaveProperty("location", "query"); + }); + + test("should fail if the queryToken's score is present and value is missing", async () => { + const testOfferId = (await Offer.findOne({}))._id; + const queryToken = (new OfferService()) + .encodeQueryToken(testOfferId, 5); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.UNPROCESSABLE_ENTITY); + expect(res.body).toHaveProperty("error_code", ErrorTypes.VALIDATION_ERROR); + expect(res.body).toHaveProperty("errors"); + expect(res.body.errors[0]).toHaveProperty("msg", ValidationReasons.INVALID_QUERY_TOKEN); + expect(res.body.errors[0]).toHaveProperty("param", "queryToken"); + expect(res.body.errors[0]).toHaveProperty("location", "query"); + }); + + test("should succeed when the queryToken's value and score are missing", async () => { + const testOfferId = (await Offer.findOne({}))._id; + const queryToken = (new OfferService()) + .encodeQueryToken(testOfferId); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.OK); + }); + + test("should succeed when the queryToken's value is present and score is a valid number", async () => { + const testOfferId = (await Offer.findOne({}))._id; + const queryToken = (new OfferService()) + .encodeQueryToken(testOfferId, 5, "test"); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.OK); + }); + + test("should succeed when value is present and queryToken's score can be parsed as a number", async () => { + const testOfferId = (await Offer.findOne({}))._id; + const queryToken = (new OfferService()) + .encodeQueryToken(testOfferId, "3.5", "test"); + + const res = await request() + .get("/offers") + .query({ queryToken }); + + expect(res.status).toBe(HTTPStatus.OK); + }); + }); + describe("Only current offers are returned", () => { + const expired_test_offer = generateTestOffer({ + "publishDate": (new Date(Date.now() - (2 * DAY_TO_MS))).toISOString(), + "publishEndDate": (new Date(Date.now() - (DAY_TO_MS))).toISOString() + }); const future_test_offer = generateTestOffer({ "publishDate": (new Date(Date.now() + (DAY_TO_MS))).toISOString(), @@ -956,7 +1093,7 @@ describe("Offer endpoint tests", () => { beforeAll(async () => { - [future_test_offer] + [expired_test_offer, future_test_offer] .forEach((offer) => { offer.owner = test_company._id; offer.ownerName = test_company.name; @@ -972,14 +1109,16 @@ describe("Offer endpoint tests", () => { .get("/offers"); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); + expect(res.body?.results).toHaveLength(1); + // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { + const extracted_data = res.body.results.map((elem) => { delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + delete elem["queryToken"]; return elem; }); const prepared_test_offer = { @@ -999,14 +1138,15 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); + expect(res.body?.results).toHaveLength(1); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { + const extracted_data = res.body.results.map((elem) => { delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + delete elem["queryToken"]; return elem; }); const prepared_test_offer = { @@ -1033,11 +1173,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body?.results).toHaveLength(2); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["queryToken"]; return elem; }); @@ -1050,6 +1191,111 @@ describe("Offer endpoint tests", () => { expect(extracted_data).toContainEqual(prepared_test_offer); }); }); + + describe("When queryToken is given", () => { + + beforeAll(async () => { + // Add another offer + await Offer.deleteMany({}); + await Offer.create([test_offer, { ...test_offer, jobType: "FULL-TIME" }, + expired_test_offer, future_test_offer]); + }); + + test("should fetch offers with the id greater than the one provided", async () => { + const res = await request() + .get("/offers") + .query({ limit: 1 }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await request() + .get("/offers") + .query({ queryToken: res.body.queryToken }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + + const offer = res2.body.results[0]; + expect(offer._id).not.toBe(res.body.results[0]._id); + }); + + test("should succeed if there are no more offers after the last one", async () => { + const res = await request() + .get("/offers"); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(2); + + const res2 = await request() + .get("/offers") + .query({ queryToken: res.body.queryToken }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(0); + }); + + test("offers are returned according to filters", async () => { + const res = await request() + .get("/offers") + .query({ + publishDate: testPublishDate, + publishEndDate: testPublishEndDate, + jobType: "FULL-TIME" + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + expect(res.body.results[0].jobType).toBe("FULL-TIME"); + }); + + test("offers are returned according to filters when using queryToken", async () => { + const res = await request() + .get("/offers") + .query({ + publishDate: testPublishDate, + publishEndDate: testPublishEndDate, + fields: ["DEVOPS"], + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + expect(res.body.results[0].fields).toContainEqual("DEVOPS"); + + const res2 = await request() + .get("/offers") + .query({ + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + expect(res2.body.results[0].fields).toContainEqual("DEVOPS"); + + const res3 = await request() + .get("/offers") + .query({ + publishDate: testPublishDate, + publishEndDate: testPublishEndDate, + jobType: "FULL-TIME" + }); + + expect(res3.status).toBe(HTTPStatus.OK); + expect(res3.body?.results).toHaveLength(1); + expect(res3.body.results[0].jobType).toBe("FULL-TIME"); + + const res4 = await request() + .get("/offers") + .query({ + queryToken: res3.body.queryToken + }); + + expect(res4.status).toBe(HTTPStatus.OK); + expect(res4.body?.results).toHaveLength(0); + }); + }); + describe("When showHidden is active", () => { beforeAll(async () => { @@ -1071,11 +1317,12 @@ describe("Offer endpoint tests", () => { .get("/offers"); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); + expect(res.body?.results).toHaveLength(1); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["queryToken"]; return elem; }); @@ -1103,11 +1350,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); + expect(res.body?.results).toHaveLength(1); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["queryToken"]; return elem; }); @@ -1135,11 +1383,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body?.results).toHaveLength(2); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["queryToken"]; return elem; }); @@ -1189,7 +1438,7 @@ describe("Offer endpoint tests", () => { expect(res.status).toBe(HTTPStatus.OK); - const extracted_data = res.body.map((elem) => elem["adminReason"]); + const extracted_data = res.body.results.map((elem) => elem["adminReason"]); const expected_data = ["my_reason", "my_reason", "my_reason", "my_reason", "my_reason"]; @@ -1207,7 +1456,7 @@ describe("Offer endpoint tests", () => { expect(res.status).toBe(HTTPStatus.OK); - const extracted_data = res.body.map((elem) => elem["adminReason"]); + const extracted_data = res.body.results.map((elem) => elem["adminReason"]); const expected_data = ["my_reason", "my_reason", "my_reason", "my_reason", "my_reason"]; @@ -1226,7 +1475,7 @@ describe("Offer endpoint tests", () => { expect(res.status).toBe(HTTPStatus.OK); - const extracted_data = res.body.map((elem) => elem["adminReason"]); + const extracted_data = res.body.results.map((elem) => elem["adminReason"]); const expected_data = []; @@ -1241,7 +1490,7 @@ describe("Offer endpoint tests", () => { expect(res.status).toBe(HTTPStatus.OK); - const extracted_data = res.body.map((elem) => elem["adminReason"]); + const extracted_data = res.body.results.map((elem) => elem["adminReason"]); const expected_data = []; @@ -1261,6 +1510,7 @@ describe("Offer endpoint tests", () => { beforeAll(async () => { portoFrontend = { ...test_offer, + title: "This offer is from Porto", location: "Porto", jobType: "FULL-TIME", fields: ["FRONTEND", "OTHER"], @@ -1298,11 +1548,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body?.results).toHaveLength(2); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1327,11 +1578,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); + expect(res.body?.results).toHaveLength(1); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1353,11 +1605,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body?.results).toHaveLength(2); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1383,11 +1636,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); + expect(res.body?.results).toHaveLength(1); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1410,11 +1664,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body?.results).toHaveLength(2); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1439,11 +1694,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); + expect(res.body?.results).toHaveLength(1); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1466,11 +1722,12 @@ describe("Offer endpoint tests", () => { }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body?.results).toHaveLength(2); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1498,11 +1755,12 @@ describe("Offer endpoint tests", () => { jobMaxDuration: 4 }); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body?.results).toHaveLength(2); // Necessary because jest matchers appear to not be working (expect.any(Number), expect.anthing(), etc) - const extracted_data = res.body.map((elem) => { - delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; delete elem["updatedAt"]; delete elem["score"]; + const extracted_data = res.body.results.map((elem) => { + delete elem["_id"]; delete elem["__v"]; delete elem["createdAt"]; + delete elem["updatedAt"]; delete elem["score"]; delete elem["queryToken"]; return elem; }); @@ -1517,6 +1775,380 @@ describe("Offer endpoint tests", () => { expect(extracted_data).toContainEqual(expected); }); }); + + describe("When queryToken and value are given", () => { + + test("should return next matching offer with lower score", async () => { + const res = await request() + .get("/offers") + .query({ + value: "porto", + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + expect(res.body.results[0].title).toEqual(portoFrontend.title); + + const res2 = await request() + .get("/offers") + .query({ + value: "porto", + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + expect(res2.body.results[0].title).toEqual(portoBackend.title); + }); + + test("should return next matching offer with the same score", async () => { + const res = await request() + .get("/offers") + .query({ + value: "backend", + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await request() + .get("/offers") + .query({ + value: "backend", + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + }); + + describe("With not current offers", () => { + + const expired_test_offer = generateTestOffer({ + "publishDate": (new Date(Date.now() - (2 * DAY_TO_MS))).toISOString(), + "publishEndDate": (new Date(Date.now() - (DAY_TO_MS))).toISOString() + }); + const future_test_offer = generateTestOffer({ + "publishDate": (new Date(Date.now() + (DAY_TO_MS))).toISOString(), + "publishEndDate": (new Date(Date.now() + (2 * DAY_TO_MS))).toISOString() + }); + + beforeAll(async () => { + + [future_test_offer, expired_test_offer] + .forEach((offer) => { + offer.owner = test_company._id; + offer.ownerName = test_company.name; + offer.ownerLogo = test_company.logo; + }); + + await Offer.create([expired_test_offer, future_test_offer]); + }); + + afterAll(async () => { + await Offer.deleteOne(future_test_offer); + await Offer.deleteOne(expired_test_offer); + }); + + test("should provide only current offers", async () => { + const res = await request() + .get("/offers") + .query({ + value: "porto", + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await request() + .get("/offers") + .query({ + value: "porto", + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + + res2.body.results.forEach((offer) => { + expect(offer.publishDate <= new Date(Date.now()).toISOString()).toBeTruthy(); + expect(offer.publishEndDate >= new Date(Date.now()).toISOString()).toBeTruthy(); + }); + }); + }); + + describe("When queryToken and value are provided and showHidden is active", () => { + + beforeAll(async () => { + await Offer.create({ + ...portoFrontend, + isHidden: true, + title: "This offer is hidden" + }); + }); + + afterAll(async () => { + await Offer.deleteOne({ isHidden: true }); + }); + + test("should not return hidden offers by default", async () => { + const res = await request() + .get("/offers") + .query({ + value: "porto", + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await request() + .get("/offers") + .query({ + value: "porto", + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + + res2.body.results.forEach((offer) => { + expect(offer.isHidden).toBeFalsy(); + }); + }); + + test("companies should not see their hidden offers", async () => { + await test_agent + .post("/auth/login") + .send(test_user_company) + .expect(HTTPStatus.OK); + + const res = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await test_agent + .get("/offers") + .query({ + value: "porto", + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + + res2.body.results.forEach((offer) => { + expect(offer.isHidden).toBeFalsy(); + }); + }); + + test("admins should see hidden offers", async () => { + await test_agent + .post("/auth/login") + .send(test_user_admin) + .expect(HTTPStatus.OK); + + const res = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(2); + }); + + test("should see hidden offers if god token is sent", async () => { + const res = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + limit: 1 + }) + .send(withGodToken()); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + queryToken: res.body.queryToken + }) + .send(withGodToken()); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(2); + }); + }); + + describe("When queryToken and value are provided and adminReason is set", () => { + beforeAll(async () => { + await Offer.create({ + ...portoFrontend, + title: "This offer was hidden by an admin", + isHidden: true, + hiddenReason: "ADMIN_REQUEST", + adminReason: "test_reason" + }); + }); + + afterAll(async () => { + await Offer.deleteOne({ isHidden: true }); + }); + + test("should return adminReason if logged in as admin", async () => { + await test_agent + .post("/auth/login") + .send(test_user_admin) + .expect(HTTPStatus.OK); + + const res = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(2); + + res2.body.results.filter((offer) => offer.isHidden).forEach((offer) => { + expect(offer.hiddenReason).toBe("ADMIN_REQUEST"); + expect(offer.adminReason).toBe("test_reason"); + }); + }); + + test("should return adminReason if god token is sent", async () => { + const res = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + limit: 1 + }) + .send(withGodToken()); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + + const res2 = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(2); + + res2.body.results.filter((offer) => offer.isHidden).forEach((offer) => { + expect(offer.hiddenReason).toBe("ADMIN_REQUEST"); + expect(offer.adminReason).toBe("test_reason"); + }); + }); + + test("companies should not see admin reason for their own offers", async () => { + await test_agent + .post("/auth/login") + .send(test_user_company) + .expect(HTTPStatus.OK); + + const res = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + expect(res.body.results[0].adminReason).toBeUndefined(); + + const res2 = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + res2.body.results.forEach((offer) => { + expect(offer.adminReason).toBeUndefined(); + }); + }); + + test("should not return admin reason if not logged in", async () => { + const res = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + limit: 1 + }); + + expect(res.status).toBe(HTTPStatus.OK); + expect(res.body?.results).toHaveLength(1); + expect(res.body.results[0].adminReason).toBeUndefined(); + + const res2 = await test_agent + .get("/offers") + .query({ + value: "porto", + showHidden: true, + queryToken: res.body.queryToken + }); + + expect(res2.status).toBe(HTTPStatus.OK); + expect(res2.body?.results).toHaveLength(1); + res2.body.results.forEach((offer) => { + expect(offer.adminReason).toBeUndefined(); + }); + }); + }); + }); }); describe("Offer requirements", () => { @@ -1532,8 +2164,8 @@ describe("Offer endpoint tests", () => { .get("/offers"); expect(res.status).toBe(HTTPStatus.OK); - expect(res.body).toHaveLength(1); - expect(res.body[0].requirements).toEqual(test_offer.requirements); + expect(res.body?.results).toHaveLength(1); + expect(res.body.results[0].requirements).toEqual(test_offer.requirements); }); }); });