From 2e63530fbb3ed7263f166240703ab5f7c7297eb3 Mon Sep 17 00:00:00 2001 From: Moroine Bentefrit Date: Tue, 26 Nov 2024 11:55:31 +0100 Subject: [PATCH] chore: suppression des routes /v2/jobs (#1661) --- .talismanrc | 10 +- .../controllers/v2/jobs.controller.v2.test.ts | 543 ----------- .../http/controllers/v2/jobs.controller.v2.ts | 36 +- .../v3/jobs/jobs.controller.v3.test.ts | 90 +- .../controllers/v3/jobs/jobs.controller.v3.ts | 9 +- .../jobOpportunity.service.test.ts.snap | 382 ++++---- .../jobOpportunity.service.test.ts | 459 ++++++---- .../jobOpportunity/jobOpportunity.service.ts | 342 ++++--- .../generateOpenapi.test.ts.snap | 8 + shared/models/jobPartners.model.test.ts | 79 +- .../jobPartners/jobPartners.model.test.ts | 214 ----- shared/models/jobsPartners.model.ts | 77 -- shared/routes/index.ts | 1 - shared/routes/jobOpportunity.routes.ts | 50 -- shared/routes/jobs.routes.v2.ts | 48 - shared/routes/jobs/jobs.routes.v2.test.ts | 55 -- .../v3/jobs/jobs.routes.v3.model.test.ts | 842 ++++++++---------- shared/routes/v3/jobs/jobs.routes.v3.model.ts | 318 +++---- shared/routes/v3/jobs/jobs.routes.v3.ts | 7 +- 19 files changed, 1273 insertions(+), 2297 deletions(-) delete mode 100644 server/src/http/controllers/v2/jobs.controller.v2.test.ts delete mode 100644 shared/models/jobPartners/jobPartners.model.test.ts delete mode 100644 shared/routes/jobOpportunity.routes.ts delete mode 100644 shared/routes/jobs/jobs.routes.v2.test.ts diff --git a/.talismanrc b/.talismanrc index 2213b6449b..b0c360ccf1 100644 --- a/.talismanrc +++ b/.talismanrc @@ -48,7 +48,7 @@ fileignoreconfig: - filename: server/src/http/controllers/v2/jobs.controller.v2.test.ts checksum: a99bfdcb5a8f4c66e9d055b44160adbc7ec2bcaf87209ff76fa9a9f674b9ca86 - filename: server/src/http/controllers/v3/jobs/jobs.controller.v3.test.ts - checksum: 0f1e48378ed0b1690cd802f43638db4a6353bbd4d4e980a2c4252098e9c410a8 + checksum: a48869d28ff084575d26350a410a22f4c1bdd4196ad9bf5f6ac6a611a479f271 - filename: server/src/http/routes/appointmentRequest.controller.ts checksum: d2770daa97ae332eec0b66497fdb717229895583ac3bfd48af1a830b36504968 - filename: server/src/http/routes/auth/password.controller.ts @@ -139,6 +139,8 @@ fileignoreconfig: checksum: 27b6e1af0a5e079720d525534486bb02552431fe4cff9560ebf2cc9e16468b7b - filename: shared/constants/recruteur.ts checksum: af4631fe998b78b13691dbc821a8ffa71ffc2f4cb6967361cb5ef19965dc95cb +- filename: shared/fixtures/application.fixture.ts + checksum: 4c4590586b8c5683ce7aebb32d2286af3265bb9d3d39581f6d7a1f8ed1a2850d - filename: shared/fixtures/appointment.fixture.ts checksum: 8b894b4059d8f7531e26f249901b83dbec433d4e660c0eca193c8e61572b3ae0 - filename: shared/fixtures/recruiter.fixture.ts @@ -172,7 +174,7 @@ fileignoreconfig: - filename: shared/routes/v1Jobs.routes.ts checksum: aa0fb2458520f24921a48af03ad05c3f4a92052374182851f24a3afa7421a5b8 - filename: shared/routes/v3/jobs/jobs.routes.v3.model.test.ts - checksum: 17cd6781921e55e0f14ddfdfb48bfafad53021a211a89f89989b5d9050a5d968 + checksum: aba2650704ad4267506ab7e1eccfdff9faa7ef1d7cc482a104eddf36c07fbd58 - filename: shared/utils/objectUtils.ts checksum: cfd0e48e8762b43c6431dad873727f6ac091ca6f5c7555e37a8ee3fba03184d1 - filename: ui/common/hooks/useAuth.ts @@ -241,10 +243,6 @@ fileignoreconfig: checksum: 324cd501354cfff65447c2599c4cc8966aa8aac30dda7854623dd6f7f7b0d34e - filename: yarn.lock checksum: 21c8c4f9064194196622ad215768893c1756eec1cbc175efffcb1428d8821c9f -- filename: server/src/jobs/offrePartenaire/importHelloWork.test.input.xml - checksum: 6f28aee7664d1ec1e4b4da34b96b63e23781538b3fe3e8d50dddbdad3884fa3f -- filename: shared/fixtures/application.fixture.ts - checksum: 4c4590586b8c5683ce7aebb32d2286af3265bb9d3d39581f6d7a1f8ed1a2850d scopeconfig: - scope: node custom_patterns: diff --git a/server/src/http/controllers/v2/jobs.controller.v2.test.ts b/server/src/http/controllers/v2/jobs.controller.v2.test.ts deleted file mode 100644 index 09427dbae7..0000000000 --- a/server/src/http/controllers/v2/jobs.controller.v2.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { getApiApprentissageTestingToken, getApiApprentissageTestingTokenFromInvalidPrivateKey } from "@tests/utils/jwt.test.utils" -import { useMongo } from "@tests/utils/mongo.test.utils" -import { useServer } from "@tests/utils/server.test.utils" -import { ObjectId } from "mongodb" -import nock from "nock" -import { generateJobsPartnersOfferPrivate } from "shared/fixtures/jobPartners.fixture" -import { generateLbaCompanyFixture } from "shared/fixtures/recruteurLba.fixture" -import { clichyFixture, generateReferentielCommuneFixtures, levalloisFixture, marseilleFixture, parisFixture } from "shared/fixtures/referentiel/commune.fixture" -import { IGeoPoint } from "shared/models" -import { IJobsPartnersOfferPrivate, IJobsPartnersWritableApiInput } from "shared/models/jobsPartners.model" -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" - -import { getEtablissementFromGouvSafe } from "@/common/apis/apiEntreprise/apiEntreprise.client" -import { apiEntrepriseEtablissementFixture } from "@/common/apis/apiEntreprise/apiEntreprise.client.fixture" -import { searchForFtJobs } from "@/common/apis/franceTravail/franceTravail.client" -import { getDbCollection } from "@/common/utils/mongodbUtils" -import { certificationFixtures } from "@/services/external/api-alternance/certification.fixture" - -vi.mock("@/common/apis/franceTravail/franceTravail.client") -vi.mock("@/common/apis/apiEntreprise/apiEntreprise.client") - -const httpClient = useServer() - -const token = getApiApprentissageTestingToken({ - email: "test@test.fr", - organisation: "Un super Partenaire", - habilitations: { "applications:write": false, "appointments:write": false, "jobs:write": true }, -}) - -const fakeToken = getApiApprentissageTestingTokenFromInvalidPrivateKey({ - email: "mail@mail.com", - organisation: "Un super Partenaire", - habilitations: { "applications:write": false, "appointments:write": false, "jobs:write": true }, -}) - -const rome = ["D1214", "D1212", "D1211"] -const rncpQuery = "RNCP37098" -const certification = certificationFixtures["RNCP37098-46T31203"] - -const porteDeClichy: IGeoPoint = { - type: "Point", - coordinates: [2.313262, 48.894891], -} -const romesQuery = rome.join(",") -const [longitude, latitude] = porteDeClichy.coordinates -const recruteurLba = generateLbaCompanyFixture({ rome_codes: rome, geopoint: clichyFixture.centre, siret: "58006820882692", email: "email@mail.com", website: "http://site.fr" }) -const jobPartnerOffer: IJobsPartnersOfferPrivate = generateJobsPartnersOfferPrivate({ - offer_rome_codes: ["D1214"], - workplace_geopoint: parisFixture.centre, - workplace_website: "http://site.fr", -}) - -const mockData = async () => { - await getDbCollection("recruteurslba").insertOne(recruteurLba) -} - -useMongo(mockData) - -beforeAll(async () => { - nock.disableNetConnect() - - return () => { - nock.enableNetConnect() - } -}) - -afterEach(() => { - nock.cleanAll() -}) - -describe("GET /jobs/search", () => { - beforeEach(async () => { - await getDbCollection("referentiel.communes").insertMany(generateReferentielCommuneFixtures([parisFixture, clichyFixture, levalloisFixture, marseilleFixture])) - await getDbCollection("jobs_partners").insertOne(jobPartnerOffer) - vi.mocked(searchForFtJobs).mockResolvedValue(null) - }) - - it("should return 401 if no api key provided", async () => { - const response = await httpClient().inject({ method: "GET", path: "/api/v2/jobs/search" }) - expect(response.statusCode).toBe(401) - expect(response.json()).toEqual({ statusCode: 401, error: "Unauthorized", message: "Unable to parse token missing-bearer" }) - }) - - it("should return 401 if api key is invalid", async () => { - const response = await httpClient().inject({ method: "GET", path: "/api/v2/jobs/search", headers: { authorization: `Bearer ${fakeToken}` } }) - expect.soft(response.statusCode).toBe(401) - expect(response.json()).toEqual({ statusCode: 401, error: "Unauthorized", message: "Unable to parse token invalid-signature" }) - }) - - it("should throw ZOD error if ROME is not formatted correctly", async () => { - const romesQuery = "D4354,D864,F67" - const response = await httpClient().inject({ - method: "GET", - path: `/api/v2/jobs/search?romes=${romesQuery}&latitude=${latitude}&longitude=${longitude}`, - headers: { authorization: `Bearer ${token}` }, - }) - const data = response.json() - expect(response.statusCode).toBe(400) - expect(data).toEqual({ - data: { - validationError: { - _errors: [], - romes: { - _errors: ["One or more ROME codes are invalid. Expected format is 'D1234'."], - }, - }, - }, - error: "Bad Request", - message: "Request validation failed", - statusCode: 400, - }) - }) - - it("should throw ZOD error if GEOLOCATION is not formatted correctly", async () => { - const [latitude, longitude] = [300, 200] - const response = await httpClient().inject({ - method: "GET", - path: `/api/v2/jobs/search?romes=${romesQuery}&latitude=${latitude}&longitude=${longitude}`, - headers: { authorization: `Bearer ${token}` }, - }) - const data = response.json() - expect(response.statusCode).toBe(400) - expect(data).toEqual({ - data: { - validationError: { - _errors: [], - latitude: { - _errors: ["Latitude doit être comprise entre -90 et 90"], - }, - longitude: { - _errors: ["Longitude doit être comprise entre -180 et 180"], - }, - }, - }, - error: "Bad Request", - message: "Request validation failed", - statusCode: 400, - }) - }) - - it("should perform search and return data", async () => { - const response = await httpClient().inject({ - method: "GET", - path: `/api/v2/jobs/search?romes=${romesQuery}&latitude=${latitude}&longitude=${longitude}`, - headers: { authorization: `Bearer ${token}` }, - }) - const data = response.json() - expect(response.statusCode).toBe(200) - expect(data.jobs).toHaveLength(1) - expect(data.recruiters).toHaveLength(1) - - expect(Object.keys(data.jobs[0]).toSorted()).toEqual([ - "_id", - "apply_phone", - "apply_url", - "contract_duration", - "contract_remote", - "contract_start", - "contract_type", - "offer_access_conditions", - "offer_creation", - "offer_description", - "offer_desired_skills", - "offer_expiration", - "offer_opening_count", - "offer_rome_codes", - "offer_status", - "offer_target_diploma", - "offer_title", - "offer_to_be_acquired_skills", - "partner_job_id", - "partner_label", - "workplace_address_label", - "workplace_brand", - "workplace_description", - "workplace_geopoint", - "workplace_idcc", - "workplace_legal_name", - "workplace_naf_code", - "workplace_naf_label", - "workplace_name", - "workplace_opco", - "workplace_siret", - "workplace_size", - "workplace_website", - ]) - - expect(Object.keys(data.recruiters[0]).toSorted()).toEqual([ - "_id", - "apply_phone", - "apply_url", - "workplace_address_label", - "workplace_brand", - "workplace_description", - "workplace_geopoint", - "workplace_idcc", - "workplace_legal_name", - "workplace_naf_code", - "workplace_naf_label", - "workplace_name", - "workplace_opco", - "workplace_siret", - "workplace_size", - "workplace_website", - ]) - }) - - it("should support rncp param", async () => { - const scope = nock("https://api.apprentissage.beta.gouv.fr").get(`/api/certification/v1`).query({ "identifiant.rncp": rncpQuery }).reply(200, [certification]) - - const response = await httpClient().inject({ - method: "GET", - path: `/api/v2/jobs/search?rncp=${rncpQuery}&latitude=${latitude}&longitude=${longitude}`, - headers: { authorization: `Bearer ${token}` }, - }) - - const data = response.json() - expect(response.statusCode).toBe(200) - expect(data.jobs).toHaveLength(1) - expect(data.recruiters).toHaveLength(1) - expect(data.warnings).toEqual([]) - expect(scope.isDone()).toBe(true) - }) - - it("should require latitude when longitude is provided", async () => { - const response = await httpClient().inject({ - method: "GET", - path: `/api/v2/jobs/search?longitude=${longitude}`, - headers: { authorization: `Bearer ${token}` }, - }) - const data = response.json() - expect(response.statusCode).toBe(400) - expect(data).toEqual({ - data: { - validationError: { - _errors: [], - latitude: { - _errors: ["latitude is required when longitude is provided"], - }, - }, - }, - error: "Bad Request", - message: "Request validation failed", - statusCode: 400, - }) - }) - - it("should require longitude when latitude is provided", async () => { - const response = await httpClient().inject({ - method: "GET", - path: `/api/v2/jobs/search?latitude=${latitude}`, - headers: { authorization: `Bearer ${token}` }, - }) - const data = response.json() - expect(response.statusCode).toBe(400) - expect(data).toEqual({ - data: { - validationError: { - _errors: [], - longitude: { - _errors: ["longitude is required when latitude is provided"], - }, - }, - }, - error: "Bad Request", - message: "Request validation failed", - statusCode: 400, - }) - }) - - it("should all params be optional", async () => { - const response = await httpClient().inject({ - method: "GET", - path: `/api/v2/jobs/search`, - headers: { authorization: `Bearer ${token}` }, - }) - - const data = response.json() - expect(response.statusCode).toBe(200) - expect(data.jobs).toHaveLength(1) - expect(data.recruiters).toHaveLength(1) - expect(data.warnings).toEqual([]) - }) -}) - -describe("POST /jobs", async () => { - const now = new Date("2024-06-18T00:00:00.000Z") - const inSept = new Date("2024-09-01T00:00:00.000Z") - - const data: IJobsPartnersWritableApiInput = { - contract_start: inSept.toJSON(), - - offer_title: "Apprentis en développement web", - offer_rome_codes: ["M1602"], - offer_description: "Envie de devenir développeur web ? Rejoignez-nous !", - - apply_email: "mail@mail.com", - - workplace_siret: apiEntrepriseEtablissementFixture.dinum.data.siret, - } - - beforeEach(async () => { - // Do not mock nextTick - vi.useFakeTimers({ toFake: ["Date"] }) - vi.setSystemTime(now) - - vi.mocked(getEtablissementFromGouvSafe).mockResolvedValue(apiEntrepriseEtablissementFixture.dinum) - - nock("https://api-adresse.data.gouv.fr:443") - .get("/search") - .query({ q: "20 AVENUE DE SEGUR, 75007 PARIS", limit: "1" }) - .reply(200, { - features: [{ geometry: parisFixture.centre }], - }) - - await getDbCollection("opcos").insertOne({ - _id: new ObjectId(), - siren: "130025265", - opco: "AKTO / Opco entreprises et salariés des services à forte intensité de main d'oeuvre", - opco_short_name: "AKTO", - idcc: 1459, - url: null, - }) - - return () => { - vi.useRealTimers() - } - }) - - it("should return 403 if no token is not signed by API", async () => { - const response = await httpClient().inject({ - method: "POST", - path: `/api/v2/jobs`, - body: data, - headers: { authorization: `Bearer ${fakeToken}` }, - }) - - expect.soft(response.statusCode).toBe(401) - expect(response.json()).toEqual({ statusCode: 401, error: "Unauthorized", message: "Unable to parse token invalid-signature" }) - expect(await getDbCollection("jobs_partners").countDocuments({})).toBe(0) - }) - - it('should return 403 if user does not have "jobs:write" permission', async () => { - const restrictedToken = getApiApprentissageTestingToken({ - email: "mail@mail.com", - organisation: "Un super Partenaire", - habilitations: { "applications:write": false, "appointments:write": false, "jobs:write": false }, - }) - - const response = await httpClient().inject({ - method: "POST", - path: `/api/v2/jobs`, - body: data, - headers: { authorization: `Bearer ${restrictedToken}` }, - }) - - expect.soft(response.statusCode).toBe(403) - expect(response.json()).toEqual({ error: "Forbidden", message: "Unauthorized", statusCode: 403 }) - }) - - it("should create a new job offer", async () => { - const response = await httpClient().inject({ - method: "POST", - path: `/api/v2/jobs`, - body: data, - headers: { authorization: `Bearer ${token}` }, - }) - - expect.soft(response.statusCode).toBe(201) - const responseJson = response.json() - expect(responseJson).toEqual({ id: expect.any(String) }) - expect(await getDbCollection("jobs_partners").countDocuments({ _id: new ObjectId(responseJson.id as string) })).toBe(1) - const doc = await getDbCollection("jobs_partners").findOne({ _id: new ObjectId(responseJson.id as string) }) - - // Ensure that the job offer is associated to the correct permission - expect(doc?.partner_label).toBe("Un super Partenaire") - }) - - it("should apply method be defined", async () => { - const response = await httpClient().inject({ - method: "POST", - path: `/api/v2/jobs`, - body: { - ...data, - apply_email: null, - apply_phone: null, - apply_url: null, - }, - headers: { authorization: `Bearer ${token}` }, - }) - - expect.soft(response.statusCode).toBe(400) - const responseJson = response.json() - expect(responseJson).toEqual({ - data: { - validationError: { - _errors: [], - apply_email: { - _errors: ["At least one of apply_url, apply_email, or apply_phone is required"], - }, - apply_phone: { - _errors: ["At least one of apply_url, apply_email, or apply_phone is required"], - }, - apply_url: { - _errors: ["At least one of apply_url, apply_email, or apply_phone is required"], - }, - }, - }, - error: "Bad Request", - message: "Request validation failed", - statusCode: 400, - }) - expect(await getDbCollection("jobs_partners").countDocuments({})).toBe(0) - }) -}) - -describe("PUT /jobs/:id", async () => { - const id = new ObjectId() - const now = new Date("2024-06-18T00:00:00.000Z") - const inSept = new Date("2024-09-01T00:00:00.000Z") - - const originalJob = generateJobsPartnersOfferPrivate({ _id: id, offer_title: "Old title", partner_label: "Un super Partenaire" }) - - const data: IJobsPartnersWritableApiInput = { - contract_start: inSept.toJSON(), - - offer_title: "Apprentis en développement web", - offer_rome_codes: ["M1602"], - offer_description: "Envie de devenir développeur web ? Rejoignez-nous !", - - apply_email: "mail@mail.com", - - workplace_siret: apiEntrepriseEtablissementFixture.dinum.data.siret, - } - - beforeEach(async () => { - // Do not mock nextTick - vi.useFakeTimers({ shouldAdvanceTime: true }) - vi.setSystemTime(now) - - vi.mocked(getEtablissementFromGouvSafe).mockResolvedValue(apiEntrepriseEtablissementFixture.dinum) - - nock("https://api-adresse.data.gouv.fr:443") - .get("/search") - .query({ q: "20 AVENUE DE SEGUR, 75007 PARIS", limit: "1" }) - .reply(200, { - features: [{ geometry: parisFixture.centre }], - }) - - await getDbCollection("opcos").insertOne({ - _id: new ObjectId(), - siren: "130025265", - opco: "AKTO / Opco entreprises et salariés des services à forte intensité de main d'oeuvre", - opco_short_name: "AKTO", - idcc: 1459, - url: null, - }) - - await getDbCollection("jobs_partners").insertOne(originalJob) - - return () => { - vi.useRealTimers() - } - }) - - it("should return 401 if no token is not signed by API", async () => { - const response = await httpClient().inject({ - method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, - body: data, - headers: { authorization: `Bearer ${fakeToken}` }, - }) - - expect.soft(response.statusCode).toBe(401) - expect(response.json()).toEqual({ error: "Unauthorized", message: "Unable to parse token invalid-signature", statusCode: 401 }) - expect(await getDbCollection("jobs_partners").findOne({ _id: id })).toEqual(originalJob) - }) - - it("should update a job offer", async () => { - const response = await httpClient().inject({ - method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, - body: data, - headers: { authorization: `Bearer ${token}` }, - }) - - expect.soft(response.statusCode).toBe(204) - expect(await getDbCollection("jobs_partners").countDocuments({ _id: id })).toBe(1) - const doc = await getDbCollection("jobs_partners").findOne({ _id: id }) - - // Ensure that the job offer is associated to the correct permission - expect(doc?.offer_title).toBe(data.offer_title) - expect(doc).not.toEqual(originalJob) - }) - - it('should return 404 on "job does not exist"', async () => { - const response = await httpClient().inject({ - method: "PUT", - path: `/api/v2/jobs/${new ObjectId().toString()}`, - body: data, - headers: { authorization: `Bearer ${token}` }, - }) - - expect.soft(response.statusCode).toBe(404) - expect(response.json()).toEqual({ error: "Not Found", message: "Job offer not found", statusCode: 404 }) - }) - - it('should return 403 if user does not have "jobs:write" permission', async () => { - const restrictedToken = getApiApprentissageTestingToken({ - email: "mail@mail.com", - organisation: "Un super Partenaire", - habilitations: { "applications:write": false, "appointments:write": false, "jobs:write": false }, - }) - - const response = await httpClient().inject({ - method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, - body: data, - headers: { authorization: `Bearer ${restrictedToken}` }, - }) - - expect.soft(response.statusCode).toBe(403) - expect(response.json()).toEqual({ error: "Forbidden", message: "Unauthorized", statusCode: 403 }) - }) - - it("should return 403 if user is trying to edit other partner_label job", async () => { - const restrictedToken = getApiApprentissageTestingToken({ - email: "mail@mail.com", - organisation: "Un autre", - habilitations: { "applications:write": false, "appointments:write": false, "jobs:write": true }, - }) - - const response = await httpClient().inject({ - method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, - body: data, - headers: { authorization: `Bearer ${restrictedToken}` }, - }) - - expect.soft(response.statusCode).toBe(403) - expect(response.json()).toEqual({ error: "Forbidden", message: "Unauthorized", statusCode: 403 }) - }) -}) diff --git a/server/src/http/controllers/v2/jobs.controller.v2.ts b/server/src/http/controllers/v2/jobs.controller.v2.ts index b4656fa7d7..fea986c4be 100644 --- a/server/src/http/controllers/v2/jobs.controller.v2.ts +++ b/server/src/http/controllers/v2/jobs.controller.v2.ts @@ -4,7 +4,6 @@ import { LBA_ITEM_TYPE } from "shared/constants/lbaitem" import { getDbCollection } from "@/common/utils/mongodbUtils" import { getUserFromRequest } from "@/security/authenticationService" -import { JobOpportunityRequestContext } from "@/services/jobs/jobOpportunity/JobOpportunityRequestContext" import { s3SignedUrl } from "../../../common/utils/awsUtils" import { trackApiCall } from "../../../common/utils/sendTrackingEvent" @@ -12,7 +11,7 @@ import { sentryCaptureException } from "../../../common/utils/sentryUtils" import dayjs from "../../../services/dayjs.service" import { addExpirationPeriod, getFormulaires } from "../../../services/formulaire.service" import { getFtJobFromIdV2 } from "../../../services/ftjob.service" -import { createJobOffer, findJobsOpportunities, getJobsQuery, updateJobOffer } from "../../../services/jobs/jobOpportunity/jobOpportunity.service" +import { getJobsQuery } from "../../../services/jobs/jobOpportunity/jobOpportunity.service" import { addOffreDetailView, getLbaJobByIdV2 } from "../../../services/lbajob.service" import { getCompanyFromSiret } from "../../../services/recruteurLba.service" import { Server } from "../../server" @@ -67,34 +66,6 @@ export default (server: Server) => { } ) - server.post( - "/jobs", - { - schema: zRoutes.post["/jobs"], - onRequest: server.auth(zRoutes.post["/jobs"]), - config, - }, - async (req, res) => { - const user = getUserFromRequest(req, zRoutes.post["/jobs"]).value - const id = await createJobOffer(user, req.body) - return res.status(201).send({ id }) - } - ) - - server.put( - "/jobs/:id", - { - schema: zRoutes.put["/jobs/:id"], - onRequest: server.auth(zRoutes.put["/jobs/:id"]), - config, - }, - async (req, res) => { - const user = getUserFromRequest(req, zRoutes.put["/jobs/:id"]).value - await updateJobOffer(req.params.id, user, req.body) - return res.status(204).send() - } - ) - server.post( "/jobs/provided/:id", { @@ -298,9 +269,4 @@ export default (server: Server) => { } } ) - - server.get("/jobs/search", { schema: zRoutes.get["/jobs/search"], onRequest: server.auth(zRoutes.get["/jobs/search"]) }, async (req, res) => { - const result = await findJobsOpportunities(req.query, new JobOpportunityRequestContext(zRoutes.get["/jobs/search"], "api-apprentissage")) - return res.send(result) - }) } diff --git a/server/src/http/controllers/v3/jobs/jobs.controller.v3.test.ts b/server/src/http/controllers/v3/jobs/jobs.controller.v3.test.ts index 380f9ee8da..a95c3d675f 100644 --- a/server/src/http/controllers/v3/jobs/jobs.controller.v3.test.ts +++ b/server/src/http/controllers/v3/jobs/jobs.controller.v3.test.ts @@ -7,7 +7,8 @@ import { generateJobsPartnersOfferPrivate } from "shared/fixtures/jobPartners.fi import { generateLbaCompanyFixture } from "shared/fixtures/recruteurLba.fixture" import { clichyFixture, generateReferentielCommuneFixtures, levalloisFixture, marseilleFixture, parisFixture } from "shared/fixtures/referentiel/commune.fixture" import { IGeoPoint } from "shared/models" -import { IJobsPartnersOfferPrivate, IJobsPartnersWritableApiInput } from "shared/models/jobsPartners.model" +import { IJobsPartnersOfferPrivate } from "shared/models/jobsPartners.model" +import type { IJobOfferApiWriteV3Input } from "shared/routes/v3/jobs/jobs.routes.v3.model" import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" import { getEtablissementFromGouvSafe } from "@/common/apis/apiEntreprise/apiEntreprise.client" @@ -232,16 +233,20 @@ describe("POST /jobs", async () => { const now = new Date("2024-06-18T00:00:00.000Z") const inSept = new Date("2024-09-01T00:00:00.000Z") - const data: IJobsPartnersWritableApiInput = { - contract_start: inSept.toJSON(), + const data: IJobOfferApiWriteV3Input = { + contract: { start: inSept.toJSON() }, - offer_title: "Apprentis en développement web", - offer_rome_codes: ["M1602"], - offer_description: "Envie de devenir développeur web ? Rejoignez-nous !", + offer: { + title: "Apprentis en développement web", + rome_codes: ["M1602"], + description: "Envie de devenir développeur web ? Rejoignez-nous !", + }, - apply_email: "mail@mail.com", + apply: { email: "mail@mail.com" }, - workplace_siret: apiEntrepriseEtablissementFixture.dinum.data.siret, + workplace: { + siret: apiEntrepriseEtablissementFixture.dinum.data.siret, + }, } beforeEach(async () => { @@ -275,7 +280,7 @@ describe("POST /jobs", async () => { it("should return 403 if no token is not signed by API", async () => { const response = await httpClient().inject({ method: "POST", - path: `/api/v2/jobs`, + path: `/api/v3/jobs`, body: data, headers: { authorization: `Bearer ${fakeToken}` }, }) @@ -294,7 +299,7 @@ describe("POST /jobs", async () => { const response = await httpClient().inject({ method: "POST", - path: `/api/v2/jobs`, + path: `/api/v3/jobs`, body: data, headers: { authorization: `Bearer ${restrictedToken}` }, }) @@ -306,12 +311,12 @@ describe("POST /jobs", async () => { it("should create a new job offer", async () => { const response = await httpClient().inject({ method: "POST", - path: `/api/v2/jobs`, + path: `/api/v3/jobs`, body: data, headers: { authorization: `Bearer ${token}` }, }) - expect.soft(response.statusCode).toBe(201) + expect.soft(response.statusCode).toBe(200) const responseJson = response.json() expect(responseJson).toEqual({ id: expect.any(String) }) expect(await getDbCollection("jobs_partners").countDocuments({ _id: new ObjectId(responseJson.id as string) })).toBe(1) @@ -324,12 +329,14 @@ describe("POST /jobs", async () => { it("should apply method be defined", async () => { const response = await httpClient().inject({ method: "POST", - path: `/api/v2/jobs`, + path: `/api/v3/jobs`, body: { ...data, - apply_email: null, - apply_phone: null, - apply_url: null, + apply: { + email: null, + phone: null, + url: null, + }, }, headers: { authorization: `Bearer ${token}` }, }) @@ -340,14 +347,17 @@ describe("POST /jobs", async () => { data: { validationError: { _errors: [], - apply_email: { - _errors: ["At least one of apply_url, apply_email, or apply_phone is required"], - }, - apply_phone: { - _errors: ["At least one of apply_url, apply_email, or apply_phone is required"], - }, - apply_url: { - _errors: ["At least one of apply_url, apply_email, or apply_phone is required"], + apply: { + _errors: [], + email: { + _errors: ["At least one of url, email, or phone is required"], + }, + phone: { + _errors: ["At least one of url, email, or phone is required"], + }, + url: { + _errors: ["At least one of url, email, or phone is required"], + }, }, }, }, @@ -366,16 +376,22 @@ describe("PUT /jobs/:id", async () => { const originalJob = generateJobsPartnersOfferPrivate({ _id: id, offer_title: "Old title", partner_label: "Un super Partenaire" }) - const data: IJobsPartnersWritableApiInput = { - contract_start: inSept.toJSON(), + const data: IJobOfferApiWriteV3Input = { + contract: { start: inSept.toJSON() }, - offer_title: "Apprentis en développement web", - offer_rome_codes: ["M1602"], - offer_description: "Envie de devenir développeur web ? Rejoignez-nous !", + offer: { + title: "Apprentis en développement web", + rome_codes: ["M1602"], + description: "Envie de devenir développeur web ? Rejoignez-nous !", + }, - apply_email: "mail@mail.com", + apply: { + email: "mail@mail.com", + }, - workplace_siret: apiEntrepriseEtablissementFixture.dinum.data.siret, + workplace: { + siret: apiEntrepriseEtablissementFixture.dinum.data.siret, + }, } beforeEach(async () => { @@ -411,7 +427,7 @@ describe("PUT /jobs/:id", async () => { it("should return 401 if no token is not signed by API", async () => { const response = await httpClient().inject({ method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, + path: `/api/v3/jobs/${id.toString()}`, body: data, headers: { authorization: `Bearer ${fakeToken}` }, }) @@ -424,7 +440,7 @@ describe("PUT /jobs/:id", async () => { it("should update a job offer", async () => { const response = await httpClient().inject({ method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, + path: `/api/v3/jobs/${id.toString()}`, body: data, headers: { authorization: `Bearer ${token}` }, }) @@ -434,14 +450,14 @@ describe("PUT /jobs/:id", async () => { const doc = await getDbCollection("jobs_partners").findOne({ _id: id }) // Ensure that the job offer is associated to the correct permission - expect(doc?.offer_title).toBe(data.offer_title) + expect(doc?.offer_title).toBe(data.offer.title) expect(doc).not.toEqual(originalJob) }) it('should return 404 on "job does not exist"', async () => { const response = await httpClient().inject({ method: "PUT", - path: `/api/v2/jobs/${new ObjectId().toString()}`, + path: `/api/v3/jobs/${new ObjectId().toString()}`, body: data, headers: { authorization: `Bearer ${token}` }, }) @@ -459,7 +475,7 @@ describe("PUT /jobs/:id", async () => { const response = await httpClient().inject({ method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, + path: `/api/v3/jobs/${id.toString()}`, body: data, headers: { authorization: `Bearer ${restrictedToken}` }, }) @@ -477,7 +493,7 @@ describe("PUT /jobs/:id", async () => { const response = await httpClient().inject({ method: "PUT", - path: `/api/v2/jobs/${id.toString()}`, + path: `/api/v3/jobs/${id.toString()}`, body: data, headers: { authorization: `Bearer ${restrictedToken}` }, }) diff --git a/server/src/http/controllers/v3/jobs/jobs.controller.v3.ts b/server/src/http/controllers/v3/jobs/jobs.controller.v3.ts index 0b54dc5602..1080501e6b 100644 --- a/server/src/http/controllers/v3/jobs/jobs.controller.v3.ts +++ b/server/src/http/controllers/v3/jobs/jobs.controller.v3.ts @@ -1,5 +1,4 @@ import { zRoutes } from "shared" -import { jobsRouteApiv3Converters } from "shared/routes/v3/jobs/jobs.routes.v3.model" import { getUserFromRequest } from "@/security/authenticationService" import { JobOpportunityRequestContext } from "@/services/jobs/jobOpportunity/JobOpportunityRequestContext" @@ -24,8 +23,7 @@ export const jobsApiV3Routes = (server: Server) => { }, async (req, res) => { const user = getUserFromRequest(req, zRoutes.post["/v3/jobs"]).value - const offer = jobsRouteApiv3Converters.convertToJobsPartnersWritableApi(req.body) - const id = await createJobOffer(user, offer) + const id = await createJobOffer(user, req.body) return res.status(200).send({ id }) } ) @@ -39,14 +37,13 @@ export const jobsApiV3Routes = (server: Server) => { }, async (req, res) => { const user = getUserFromRequest(req, zRoutes.put["/v3/jobs/:id"]).value - const offer = jobsRouteApiv3Converters.convertToJobsPartnersWritableApi(req.body) - await updateJobOffer(req.params.id, user, offer) + await updateJobOffer(req.params.id, user, req.body) return res.status(204).send() } ) server.get("/v3/jobs/search", { schema: zRoutes.get["/v3/jobs/search"], onRequest: server.auth(zRoutes.get["/v3/jobs/search"]) }, async (req, res) => { const result = await findJobsOpportunities(req.query, new JobOpportunityRequestContext(zRoutes.get["/v3/jobs/search"], "api-apprentissage")) - return res.send(jobsRouteApiv3Converters.convertToJobSearchApiV3(result)) + return res.send(result) }) } diff --git a/server/src/services/jobs/jobOpportunity/__snapshots__/jobOpportunity.service.test.ts.snap b/server/src/services/jobs/jobOpportunity/__snapshots__/jobOpportunity.service.test.ts.snap index 290ca37054..438c3a8594 100644 --- a/server/src/services/jobs/jobOpportunity/__snapshots__/jobOpportunity.service.test.ts.snap +++ b/server/src/services/jobs/jobOpportunity/__snapshots__/jobOpportunity.service.test.ts.snap @@ -4,7 +4,7 @@ exports[`createJobOffer > should create a job offer with the minimal data 1`] = { "_id": Any, "apply_email": null, - "apply_phone": null, + "apply_phone": "0600000000", "apply_url": null, "contract_duration": null, "contract_remote": null, @@ -57,156 +57,201 @@ exports[`createJobOffer > should create a job offer with the minimal data 1`] = exports[`findJobsOpportunities > should execute query 1`] = ` [ { - "apply_phone": "0300000000", - "contract_duration": null, - "contract_remote": null, - "contract_start": 2021-01-28T15:00:00.000Z, - "contract_type": [ - "Apprentissage", - ], - "offer_access_conditions": [ - "Ce métier est accessible avec un diplôme de fin d'études secondaires (brevet des collèges) à Bac (professionnel, Brevet Professionnel, ...) dans le secteur tertiaire. Il est également accessible avec une expérience professionnelle sans diplôme particulier. La maîtrise de l'outil bureautique (traitement de texte, tableur, ...) peut être requise.", - ], - "offer_creation": 2021-01-01T00:00:00.000Z, - "offer_description": "Exécute des travaux administratifs courants (vérification de documents, frappe et mise en forme de courriers pré-établis, suivi de dossier administratifs, ...) selon l'organisation de la structure ou du service. Peut être en charge d'activités de reprographie et d'archivage. Peut réaliser l'accueil de la structure.", - "offer_desired_skills": [ - "Faire preuve de rigueur et de précision", - "Organiser son travail selon les priorités et les objectifs", - "Être à l'écoute, faire preuve d'empathie", - ], - "offer_expiration": 2050-01-01T00:00:00.000Z, - "offer_opening_count": 1, - "offer_rome_codes": [ - "M1602", - ], - "offer_status": "Active", - "offer_target_diploma": null, - "offer_title": "Opérations administratives", - "offer_to_be_acquired_skills": [ - "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", - "Production, Fabrication: Réaliser des travaux de reprographie", - "Gestion des stocks: Contrôler l'état des stocks", - "Gestion des stocks: Définir des besoins en approvisionnement", - "Logistique: Organiser le traitement des commandes", - "Relation client: Accueillir, orienter, informer une personne", - "Organisation: Contrôler la conformité des données ou des documents", - "Organisation: Corriger et mettre en forme un document", - "Organisation: Numériser des documents, médias ou supports techniques", - "Organisation: Utiliser les outils bureautiques", - "Organisation: Établir, mettre à jour un dossier, une base de données", - ], - "partner_job_id": null, - "partner_label": "La bonne alternance", - "workplace_address_label": "Paris", - "workplace_brand": null, - "workplace_description": null, - "workplace_geopoint": { - "coordinates": [ - 2.347, - 48.8589, + "apply": { + "phone": "0300000000", + "url": "", + }, + "contract": { + "duration": null, + "remote": null, + "start": 2021-01-28T15:00:00.000Z, + "type": [ + "Apprentissage", + ], + }, + "identifier": { + "id": "", + "partner_job_id": null, + "partner_label": "La bonne alternance", + }, + "offer": { + "access_conditions": [ + "Ce métier est accessible avec un diplôme de fin d'études secondaires (brevet des collèges) à Bac (professionnel, Brevet Professionnel, ...) dans le secteur tertiaire. Il est également accessible avec une expérience professionnelle sans diplôme particulier. La maîtrise de l'outil bureautique (traitement de texte, tableur, ...) peut être requise.", + ], + "description": "Exécute des travaux administratifs courants (vérification de documents, frappe et mise en forme de courriers pré-établis, suivi de dossier administratifs, ...) selon l'organisation de la structure ou du service. Peut être en charge d'activités de reprographie et d'archivage. Peut réaliser l'accueil de la structure.", + "desired_skills": [ + "Faire preuve de rigueur et de précision", + "Organiser son travail selon les priorités et les objectifs", + "Être à l'écoute, faire preuve d'empathie", + ], + "opening_count": 1, + "publication": { + "creation": 2021-01-01T00:00:00.000Z, + "expiration": 2050-01-01T00:00:00.000Z, + }, + "rome_codes": [ + "M1602", ], - "type": "Point", + "status": "Active", + "target_diploma": null, + "title": "Opérations administratives", + "to_be_acquired_skills": [ + "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", + "Production, Fabrication: Réaliser des travaux de reprographie", + "Gestion des stocks: Contrôler l'état des stocks", + "Gestion des stocks: Définir des besoins en approvisionnement", + "Logistique: Organiser le traitement des commandes", + "Relation client: Accueillir, orienter, informer une personne", + "Organisation: Contrôler la conformité des données ou des documents", + "Organisation: Corriger et mettre en forme un document", + "Organisation: Numériser des documents, médias ou supports techniques", + "Organisation: Utiliser les outils bureautiques", + "Organisation: Établir, mettre à jour un dossier, une base de données", + ], + }, + "workplace": { + "brand": null, + "description": null, + "domain": { + "idcc": null, + "naf": null, + "opco": null, + }, + "legal_name": "ASSEMBLEE NATIONALE", + "location": { + "address": "Paris", + "geopoint": { + "coordinates": [ + 2.347, + 48.8589, + ], + "type": "Point", + }, + }, + "name": "ASSEMBLEE NATIONALE", + "siret": "11000001500013", + "size": null, + "website": null, }, - "workplace_idcc": null, - "workplace_legal_name": "ASSEMBLEE NATIONALE", - "workplace_naf_code": null, - "workplace_naf_label": null, - "workplace_name": "ASSEMBLEE NATIONALE", - "workplace_opco": null, - "workplace_siret": "11000001500013", - "workplace_size": null, - "workplace_website": null, }, { - "apply_phone": null, - "contract_duration": null, - "contract_remote": null, - "contract_start": null, - "contract_type": [], - "offer_access_conditions": [], - "offer_creation": 2024-01-01T00:00:00.000Z, - "offer_description": "Attention il te faut une super motivation pour ce job", - "offer_desired_skills": [], - "offer_expiration": null, - "offer_opening_count": 1, - "offer_rome_codes": [ - "M1602", - ], - "offer_status": "Active", - "offer_target_diploma": null, - "offer_title": "Super offre d'apprentissage", - "offer_to_be_acquired_skills": [], - "partner_job_id": "1", - "partner_label": "France Travail", - "workplace_address_label": "Paris", - "workplace_brand": null, - "workplace_description": "", - "workplace_geopoint": { - "coordinates": [ - 2.347, - 48.8589, + "apply": { + "phone": null, + "url": "", + }, + "contract": { + "duration": null, + "remote": null, + "start": null, + "type": [], + }, + "identifier": { + "id": "", + "partner_job_id": "1", + "partner_label": "France Travail", + }, + "offer": { + "access_conditions": [], + "description": "Attention il te faut une super motivation pour ce job", + "desired_skills": [], + "opening_count": 1, + "publication": { + "creation": 2024-01-01T00:00:00.000Z, + "expiration": null, + }, + "rome_codes": [ + "M1602", ], - "type": "Point", + "status": "Active", + "target_diploma": null, + "title": "Super offre d'apprentissage", + "to_be_acquired_skills": [], + }, + "workplace": { + "brand": null, + "description": "", + "domain": { + "idcc": null, + "naf": null, + "opco": null, + }, + "legal_name": null, + "location": { + "address": "Paris", + "geopoint": { + "coordinates": [ + 2.347, + 48.8589, + ], + "type": "Point", + }, + }, + "name": "", + "siret": null, + "size": null, + "website": null, }, - "workplace_idcc": null, - "workplace_legal_name": null, - "workplace_naf_code": null, - "workplace_naf_label": null, - "workplace_name": "", - "workplace_opco": null, - "workplace_siret": null, - "workplace_size": null, - "workplace_website": null, }, { - "apply_email": null, - "apply_phone": null, - "contract_duration": null, - "contract_remote": null, - "contract_start": null, - "contract_type": [ - "Apprentissage", - "Professionnalisation", - ], - "created_at": 2021-01-28T15:00:00.000Z, - "distance": 0, - "offer_access_conditions": [], - "offer_creation": 2021-01-01T00:00:00.000Z, - "offer_description": "Attention il te faut une super motivation pour ce job", - "offer_desired_skills": [], - "offer_expiration": null, - "offer_multicast": true, - "offer_opening_count": 1, - "offer_origin": null, - "offer_rome_codes": [ - "M1602", - ], - "offer_status": "Active", - "offer_target_diploma": null, - "offer_title": "Une super offre d'alternance", - "offer_to_be_acquired_skills": [], - "partner_job_id": "job-id-1", - "partner_label": "Hello work", - "updated_at": 2021-01-28T15:00:00.000Z, - "workplace_address_label": "126 RUE DE L'UNIVERSITE 75007 PARIS", - "workplace_brand": null, - "workplace_description": null, - "workplace_geopoint": { - "coordinates": [ - 2.347, - 48.8589, + "apply": { + "phone": null, + "url": "", + }, + "contract": { + "duration": null, + "remote": null, + "start": null, + "type": [ + "Apprentissage", + "Professionnalisation", ], - "type": "Point", }, - "workplace_idcc": null, - "workplace_legal_name": null, - "workplace_naf_code": null, - "workplace_naf_label": null, - "workplace_name": null, - "workplace_opco": null, - "workplace_siret": null, - "workplace_size": null, - "workplace_website": null, + "identifier": { + "id": "", + "partner_job_id": "job-id-1", + "partner_label": "Hello work", + }, + "offer": { + "access_conditions": [], + "description": "Attention il te faut une super motivation pour ce job", + "desired_skills": [], + "opening_count": 1, + "publication": { + "creation": 2021-01-01T00:00:00.000Z, + "expiration": null, + }, + "rome_codes": [ + "M1602", + ], + "status": "Active", + "target_diploma": null, + "title": "Une super offre d'alternance", + "to_be_acquired_skills": [], + }, + "workplace": { + "brand": null, + "description": null, + "domain": { + "idcc": null, + "naf": null, + "opco": null, + }, + "legal_name": null, + "location": { + "address": "126 RUE DE L'UNIVERSITE 75007 PARIS", + "geopoint": { + "coordinates": [ + 2.347, + 48.8589, + ], + "type": "Point", + }, + }, + "name": null, + "siret": null, + "size": null, + "website": null, + }, }, ] `; @@ -214,27 +259,40 @@ exports[`findJobsOpportunities > should execute query 1`] = ` exports[`findJobsOpportunities > should execute query 2`] = ` [ { - "apply_phone": "0100000000", - "apply_url": "http://localhost:3000/recherche-apprentissage?type=lba&itemId=11000001500013", - "workplace_address_label": "null null null null", - "workplace_brand": "ASSEMBLEE NATIONALE - La vraie", - "workplace_description": null, - "workplace_geopoint": { - "coordinates": [ - 2.347, - 48.8589, - ], - "type": "Point", + "apply": { + "phone": "0100000000", + "url": "http://localhost:3000/recherche-apprentissage?type=lba&itemId=11000001500013", + }, + "identifier": { + "id": "000000000000000000000000", + }, + "workplace": { + "brand": "ASSEMBLEE NATIONALE - La vraie", + "description": null, + "domain": { + "idcc": null, + "naf": { + "code": "8411Z", + "label": "Administration publique générale", + }, + "opco": null, + }, + "legal_name": "ASSEMBLEE NATIONALE", + "location": { + "address": "null null null null", + "geopoint": { + "coordinates": [ + 2.347, + 48.8589, + ], + "type": "Point", + }, + }, + "name": "ASSEMBLEE NATIONALE - La vraie", + "siret": "11000001500013", + "size": null, + "website": null, }, - "workplace_idcc": null, - "workplace_legal_name": "ASSEMBLEE NATIONALE", - "workplace_naf_code": "8411Z", - "workplace_naf_label": "Administration publique générale", - "workplace_name": "ASSEMBLEE NATIONALE - La vraie", - "workplace_opco": null, - "workplace_siret": "11000001500013", - "workplace_size": null, - "workplace_website": null, }, ] `; @@ -243,7 +301,7 @@ exports[`updateJobOffer > should update a job offer with the minimal data 1`] = { "_id": Any, "apply_email": null, - "apply_phone": null, + "apply_phone": "0600000000", "apply_url": null, "contract_duration": null, "contract_remote": null, diff --git a/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts b/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts index 3ffa21e471..3c872407ac 100644 --- a/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts +++ b/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts @@ -11,9 +11,9 @@ import { generateLbaCompanyFixture } from "shared/fixtures/recruteurLba.fixture" import { clichyFixture, generateReferentielCommuneFixtures, levalloisFixture, marseilleFixture, parisFixture } from "shared/fixtures/referentiel/commune.fixture" import { generateReferentielRome } from "shared/fixtures/rome.fixture" import { generateUserWithAccountFixture } from "shared/fixtures/userWithAccount.fixture" -import { ILbaCompany, IRecruiter, IReferentielRome, JOB_STATUS, JOB_STATUS_ENGLISH } from "shared/models" -import { IJobsPartnersOfferPrivate, IJobsPartnersWritableApi, INiveauDiplomeEuropeen } from "shared/models/jobsPartners.model" -import { ZJobsOpportunityResponse } from "shared/routes/jobOpportunity.routes" +import { ILbaCompany, IRecruiter, IReferentielRome, JOB_STATUS } from "shared/models" +import { IJobsPartnersOfferPrivate, INiveauDiplomeEuropeen } from "shared/models/jobsPartners.model" +import { zJobOfferApiWriteV3, zJobSearchApiV3Response, type IJobOfferApiWriteV3, type IJobOfferApiWriteV3Input } from "shared/routes/v3/jobs/jobs.routes.v3.model" import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" import { getEtablissementFromGouvSafe } from "@/common/apis/apiEntreprise/apiEntreprise.client" @@ -212,24 +212,32 @@ describe("findJobsOpportunities", () => { expect(results).toEqual({ jobs: [ expect.objectContaining({ - _id: lbaJobs[0].jobs[0]._id.toString(), - workplace_geopoint: lbaJobs[0].geopoint, + identifier: { id: lbaJobs[0].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: lbaJobs[0].geopoint, + }), + }), }), expect.objectContaining({ - _id: null, - partner_job_id: ftJobs[0].id, - partner_label: "France Travail", + identifier: { id: null, partner_job_id: ftJobs[0].id, partner_label: "France Travail" }, }), expect.objectContaining({ - _id: partnerJobs[0]._id, - workplace_geopoint: partnerJobs[0].workplace_geopoint, + identifier: { id: partnerJobs[0]._id, partner_job_id: partnerJobs[0].partner_job_id, partner_label: partnerJobs[0].partner_label }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: partnerJobs[0].workplace_geopoint, + }), + }), }), ], recruiters: [ expect.objectContaining({ - _id: recruiters[0]._id, - workplace_geopoint: recruiters[0].geopoint, - workplace_name: recruiters[0].enseigne, + identifier: { id: recruiters[0]._id }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ geopoint: recruiters[0].geopoint }), + name: recruiters[0].enseigne, + }), }), ], warnings: [], @@ -250,13 +258,17 @@ describe("findJobsOpportunities", () => { ) expect( - results.jobs.map(({ _id, apply_url, ...j }) => { + results.jobs.map((j) => { + j.identifier.id = "" + j.apply.url = "" + return j }) ).toMatchSnapshot() expect( - results.recruiters.map(({ _id, ...j }) => { - return j + results.recruiters.map((r) => { + r.identifier.id = new ObjectId("000000000000000000000000") + return r }) ).toMatchSnapshot() }) @@ -275,43 +287,61 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results).toEqual({ jobs: [ expect.objectContaining({ - _id: lbaJobs[0].jobs[0]._id.toString(), - workplace_geopoint: lbaJobs[0].geopoint, + identifier: { id: lbaJobs[0].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: lbaJobs[0].geopoint, + }), + }), }), expect.objectContaining({ - _id: lbaJobs[2].jobs[0]._id.toString(), - workplace_geopoint: lbaJobs[2].geopoint, + identifier: { id: lbaJobs[2].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: lbaJobs[2].geopoint, + }), + }), }), expect.objectContaining({ - _id: null, - partner_job_id: ftJobs[0].id, - partner_label: "France Travail", + identifier: { id: null, partner_job_id: ftJobs[0].id, partner_label: "France Travail" }, }), expect.objectContaining({ - _id: partnerJobs[0]._id, - workplace_geopoint: partnerJobs[0].workplace_geopoint, + identifier: { id: partnerJobs[0]._id, partner_job_id: partnerJobs[0].partner_job_id, partner_label: partnerJobs[0].partner_label }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: partnerJobs[0].workplace_geopoint, + }), + }), }), expect.objectContaining({ - _id: partnerJobs[2]._id, - workplace_geopoint: partnerJobs[2].workplace_geopoint, + identifier: { id: partnerJobs[2]._id, partner_job_id: partnerJobs[2].partner_job_id, partner_label: partnerJobs[2].partner_label }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: partnerJobs[2].workplace_geopoint, + }), + }), }), ], recruiters: [ expect.objectContaining({ - _id: recruiters[0]._id, - workplace_geopoint: recruiters[0].geopoint, - workplace_name: recruiters[0].enseigne, + identifier: { id: recruiters[0]._id }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ geopoint: recruiters[0].geopoint }), + name: recruiters[0].enseigne, + }), }), expect.objectContaining({ - _id: recruiters[2]._id, - workplace_geopoint: recruiters[2].geopoint, - workplace_name: recruiters[2].enseigne, + identifier: { id: recruiters[2]._id }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ geopoint: recruiters[2].geopoint }), + name: recruiters[2].enseigne, + }), }), ], warnings: [], @@ -344,7 +374,7 @@ describe("findJobsOpportunities", () => { }, new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() @@ -352,31 +382,33 @@ describe("findJobsOpportunities", () => { expect(results).toEqual({ jobs: [ expect.objectContaining({ - _id: lbaJobs[1].jobs[0]._id.toString(), + identifier: { id: lbaJobs[1].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, }), expect.objectContaining({ - _id: lbaJobs[0].jobs[0]._id.toString(), + identifier: { id: lbaJobs[0].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, }), expect.objectContaining({ - _id: null, - partner_job_id: ftJobs[0].id, - partner_label: "France Travail", + identifier: { id: null, partner_job_id: ftJobs[0].id, partner_label: "France Travail" }, }), expect.objectContaining({ - _id: partnerJobs[1]._id, + identifier: { id: partnerJobs[1]._id, partner_job_id: partnerJobs[1].partner_job_id, partner_label: partnerJobs[1].partner_label }, }), expect.objectContaining({ - _id: partnerJobs[0]._id, + identifier: { id: partnerJobs[0]._id, partner_job_id: partnerJobs[0].partner_job_id, partner_label: partnerJobs[0].partner_label }, }), ], recruiters: [ expect.objectContaining({ - _id: recruiters[1]._id, - workplace_name: recruiters[1].enseigne, + identifier: { id: recruiters[1]._id }, + workplace: expect.objectContaining({ + name: recruiters[1].enseigne, + }), }), expect.objectContaining({ - _id: recruiters[0]._id, - workplace_name: recruiters[0].enseigne, + identifier: { id: recruiters[0]._id }, + workplace: expect.objectContaining({ + name: recruiters[0].enseigne, + }), }), ], warnings: [], @@ -414,26 +446,38 @@ describe("findJobsOpportunities", () => { }, new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results).toEqual({ jobs: [ expect.objectContaining({ - _id: lbaJobs[2].jobs[0]._id.toString(), - workplace_geopoint: lbaJobs[2].geopoint, + identifier: { id: lbaJobs[2].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: lbaJobs[2].geopoint, + }), + }), }), expect.objectContaining({ - _id: partnerJobs[2]._id, - workplace_geopoint: partnerJobs[2].workplace_geopoint, + identifier: { id: partnerJobs[2]._id, partner_job_id: partnerJobs[2].partner_job_id, partner_label: partnerJobs[2].partner_label }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: partnerJobs[2].workplace_geopoint, + }), + }), }), ], recruiters: [ expect.objectContaining({ - _id: recruiters[2]._id, - workplace_geopoint: recruiters[2].geopoint, - workplace_name: recruiters[2].enseigne, + identifier: { id: recruiters[2]._id }, + workplace: expect.objectContaining({ + name: recruiters[2].enseigne, + location: expect.objectContaining({ + geopoint: recruiters[2].geopoint, + }), + }), }), ], warnings: [], @@ -542,25 +586,37 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results).toEqual({ jobs: [ expect.objectContaining({ - _id: lbaJobs[2].jobs[0]._id.toString(), - workplace_geopoint: lbaJobs[2].geopoint, + identifier: { id: lbaJobs[2].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: lbaJobs[2].geopoint, + }), + }), }), expect.objectContaining({ - _id: partnerJobs[2]._id, - workplace_geopoint: partnerJobs[2].workplace_geopoint, + identifier: { id: partnerJobs[2]._id, partner_job_id: partnerJobs[2].partner_job_id, partner_label: partnerJobs[2].partner_label }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: partnerJobs[2].workplace_geopoint, + }), + }), }), ], recruiters: [ expect.objectContaining({ - _id: recruiters[2]._id, - workplace_geopoint: recruiters[2].geopoint, - workplace_name: recruiters[2].enseigne, + identifier: { id: recruiters[2]._id }, + workplace: expect.objectContaining({ + name: recruiters[2].enseigne, + location: expect.objectContaining({ + geopoint: recruiters[2].geopoint, + }), + }), }), ], warnings: [], @@ -602,43 +658,61 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results).toEqual({ jobs: [ expect.objectContaining({ - _id: lbaJobs[0].jobs[0]._id.toString(), - workplace_geopoint: lbaJobs[0].geopoint, + identifier: { id: lbaJobs[0].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: lbaJobs[0].geopoint, + }), + }), }), expect.objectContaining({ - _id: lbaJobs[2].jobs[0]._id.toString(), - workplace_geopoint: lbaJobs[2].geopoint, + identifier: { id: lbaJobs[2].jobs[0]._id, partner_job_id: null, partner_label: "La bonne alternance" }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: lbaJobs[2].geopoint, + }), + }), }), expect.objectContaining({ - _id: null, - partner_job_id: ftJobs[0].id, - partner_label: "France Travail", + identifier: { id: null, partner_job_id: ftJobs[0].id, partner_label: "France Travail" }, }), expect.objectContaining({ - _id: partnerJobs[0]._id, - workplace_geopoint: partnerJobs[0].workplace_geopoint, + identifier: { id: partnerJobs[0]._id, partner_job_id: partnerJobs[0].partner_job_id, partner_label: partnerJobs[0].partner_label }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: partnerJobs[0].workplace_geopoint, + }), + }), }), expect.objectContaining({ - _id: partnerJobs[2]._id, - workplace_geopoint: partnerJobs[2].workplace_geopoint, + identifier: { id: partnerJobs[2]._id, partner_job_id: partnerJobs[2].partner_job_id, partner_label: partnerJobs[2].partner_label }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ + geopoint: partnerJobs[2].workplace_geopoint, + }), + }), }), ], recruiters: [ expect.objectContaining({ - _id: recruiters[0]._id, - workplace_geopoint: recruiters[0].geopoint, - workplace_name: recruiters[0].enseigne, + identifier: { id: recruiters[0]._id }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ geopoint: recruiters[0].geopoint }), + name: recruiters[0].enseigne, + }), }), expect.objectContaining({ - _id: recruiters[2]._id, - workplace_geopoint: recruiters[2].geopoint, - workplace_name: recruiters[2].enseigne, + identifier: { id: recruiters[2]._id }, + workplace: expect.objectContaining({ + location: expect.objectContaining({ geopoint: recruiters[2].geopoint }), + name: recruiters[2].enseigne, + }), }), ], warnings: [], @@ -687,7 +761,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.recruiters).toHaveLength(150) @@ -769,7 +843,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(1) @@ -837,7 +911,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(2) @@ -885,11 +959,11 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect.soft(results.jobs).toHaveLength(2) - expect.soft(results.jobs.map((j) => j.offer_target_diploma)).toEqual( + expect.soft(results.jobs.map((j) => j.offer.target_diploma)).toEqual( expect.arrayContaining([ null, { @@ -939,7 +1013,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(150) @@ -979,7 +1053,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(0) @@ -997,7 +1071,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(0) @@ -1037,7 +1111,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(2) @@ -1100,33 +1174,33 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(3) expect( results.jobs.map((j) => ({ - _id: j._id, - workspace_siret: j.workplace_siret, - workplace_geopoint: j.workplace_geopoint, - apply_phone: j.apply_phone, + _id: j.identifier.id, + workspace_siret: j.workplace.siret, + workplace_geopoint: j.workplace.location.geopoint, + apply_phone: j.apply.phone, })) ).toEqual( expect.arrayContaining([ { - _id: lbaJobs[0].jobs[0]._id.toString(), + _id: lbaJobs[0].jobs[0]._id, workplace_geopoint: lbaJobs[0].geopoint, workspace_siret: lbaJobs[0].establishment_siret, apply_phone: lbaJobs[0].phone, }, { - _id: delegatedLbaJob.jobs[0]._id.toString(), + _id: delegatedLbaJob.jobs[0]._id, workplace_geopoint: delegatedLbaJob.geopoint, workspace_siret: cfa.siret, apply_phone: userWithAccount.phone, }, { - _id: delegatedLbaJob.jobs[1]._id.toString(), + _id: delegatedLbaJob.jobs[1]._id, workplace_geopoint: delegatedLbaJob.geopoint, workspace_siret: cfa.siret, apply_phone: userWithAccount.phone, @@ -1168,11 +1242,11 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(1) - expect(results.jobs[0]._id).toBe(lbaJobs[0].jobs[0]._id.toString()) + expect(results.jobs[0].identifier.id).toEqual(lbaJobs[0].jobs[0]._id) }) it("should ignore recruiters without geopoint", async () => { @@ -1207,11 +1281,11 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(1) - expect(results.jobs[0]._id).toBe(lbaJobs[0].jobs[0]._id.toString()) + expect(results.jobs[0].identifier.id).toEqual(lbaJobs[0].jobs[0]._id) }) }) @@ -1241,7 +1315,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(150) @@ -1261,7 +1335,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(0) @@ -1289,7 +1363,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(1) @@ -1327,11 +1401,11 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(2) - expect(results.jobs.map((j) => j.offer_target_diploma)).toEqual([null, { european: "3", label: "CAP, BEP, autres formations niveau (CAP)" }]) + expect(results.jobs.map((j) => j.offer.target_diploma)).toEqual([null, { european: "3", label: "CAP, BEP, autres formations niveau (CAP)" }]) }) }) }) @@ -1357,7 +1431,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(0) @@ -1384,7 +1458,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(0) @@ -1426,7 +1500,7 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(0) @@ -1494,12 +1568,12 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect(results.jobs).toHaveLength(1) expect(results.warnings).toHaveLength(0) - expect(results.jobs[0].partner_job_id).toEqual(ftJobs[0].id) + expect(results.jobs[0].identifier.partner_job_id).toEqual(ftJobs[0].id) }) }) @@ -1592,38 +1666,43 @@ describe("findJobsOpportunities", () => { new JobOpportunityRequestContext({ path: "/api/route" }, "api-alternance") ) - const parseResult = ZJobsOpportunityResponse.safeParse(results) + const parseResult = zJobSearchApiV3Response.safeParse(results) expect.soft(parseResult.success).toBeTruthy() expect(parseResult.error).toBeUndefined() expect({ - jobs: results.jobs.map((j) => ({ _id: j._id, partner_job_id: j.partner_job_id, partner_label: j.partner_label, workplace_legal_name: j.workplace_legal_name })), - recruiters: results.recruiters.map((j) => ({ _id: j._id, workplace_legal_name: j.workplace_legal_name })), + jobs: results.jobs.map((j) => ({ + _id: j.identifier.id, + partner_job_id: j.identifier.partner_job_id, + partner_label: j.identifier.partner_label, + workplace_legal_name: j.workplace.legal_name, + })), + recruiters: results.recruiters.map((j) => ({ _id: j.identifier.id, workplace_legal_name: j.workplace.legal_name })), }).toEqual({ jobs: [ { // Paris - _id: lbaJobs[0].jobs[0]._id.toString(), + _id: lbaJobs[0].jobs[0]._id, partner_label: "La bonne alternance", partner_job_id: null, workplace_legal_name: lbaJobs[0].establishment_raison_sociale, }, { // Levallois - 2024-01-01 - _id: extraLbaJob.jobs[1]._id.toString(), + _id: extraLbaJob.jobs[1]._id, partner_label: "La bonne alternance", partner_job_id: null, workplace_legal_name: extraLbaJob.establishment_raison_sociale, }, { // Levallois - 2023-01-01 - _id: lbaJobs[2].jobs[0]._id.toString(), + _id: lbaJobs[2].jobs[0]._id, partner_label: "La bonne alternance", partner_job_id: null, workplace_legal_name: lbaJobs[2].establishment_raison_sociale, }, { // Levallois - 2021-01-01 - _id: extraLbaJob.jobs[0]._id.toString(), + _id: extraLbaJob.jobs[0]._id, partner_label: "La bonne alternance", partner_job_id: null, workplace_legal_name: extraLbaJob.establishment_raison_sociale, @@ -1703,6 +1782,10 @@ describe("findJobsOpportunities", () => { }) }) +function generateJobOfferApiWriteV3(input: IJobOfferApiWriteV3Input): IJobOfferApiWriteV3 { + return zJobOfferApiWriteV3.parse(input) +} + describe("createJobOffer", () => { const identity = { email: "mail@mailType.com", @@ -1714,37 +1797,24 @@ describe("createJobOffer", () => { const in2Month = new Date("2024-08-17T22:00:00.000Z") const inSept = new Date("2024-09-01T00:00:00.000Z") - const minimalData: IJobsPartnersWritableApi = { - partner_job_id: null, - - contract_start: inSept, - contract_duration: null, - contract_type: ["Apprentissage", "Professionnalisation"], - contract_remote: null, - - offer_title: "Apprentis en développement web", - offer_rome_codes: ["M1602"], - offer_desired_skills: [], - offer_to_be_acquired_skills: [], - offer_access_conditions: [], - offer_creation: null, - offer_expiration: null, - offer_opening_count: 1, - offer_origin: null, - offer_multicast: true, - offer_description: "Envie de devenir développeur web ? Rejoignez-nous !", - offer_target_diploma_european: null, - offer_status: JOB_STATUS_ENGLISH.ACTIVE, - - apply_url: null, - apply_email: null, - apply_phone: null, - - workplace_siret: apiEntrepriseEtablissementFixture.dinum.data.siret, - workplace_address_label: null, - workplace_description: null, - workplace_website: null, - workplace_name: null, + const minimalData: IJobOfferApiWriteV3Input = { + contract: { + start: inSept.toJSON(), + }, + + offer: { + title: "Apprentis en développement web", + rome_codes: ["M1602"], + description: "Envie de devenir développeur web ? Rejoignez-nous !", + }, + + apply: { + phone: "0600000000", + }, + + workplace: { + siret: apiEntrepriseEtablissementFixture.dinum.data.siret, + }, } beforeEach(async () => { @@ -1775,7 +1845,9 @@ describe("createJobOffer", () => { }) it("should create a job offer with the minimal data", async () => { - const result = await createJobOffer(identity, { ...minimalData, partner_job_id: "job-id-b" }) + const data = generateJobOfferApiWriteV3({ ...minimalData, identifier: { partner_job_id: "job-id-b" } }) + + const result = await createJobOffer(identity, data) expect(result).toBeInstanceOf(ObjectId) const job = await getDbCollection("jobs_partners").findOne({ _id: result }) @@ -1799,7 +1871,8 @@ describe("createJobOffer", () => { it("should get default rome from ROMEO", async () => { vi.mocked(getRomeoPredictions).mockResolvedValue(franceTravailRomeoFixture["Software Engineer"]) - const result = await createJobOffer(identity, { ...minimalData, offer_rome_codes: [] }) + const data = generateJobOfferApiWriteV3({ ...minimalData, offer: { ...minimalData.offer, rome_codes: [] } }) + const result = await createJobOffer(identity, data) expect(result).toBeInstanceOf(ObjectId) const job = await getDbCollection("jobs_partners").findOne({ _id: result }) @@ -1815,7 +1888,16 @@ describe("createJobOffer", () => { features: [{ geometry: clichyFixture.centre }], }) - const result = await createJobOffer(identity, { ...minimalData, workplace_address_label: "1T impasse Passoir Clichy" }) + const data = generateJobOfferApiWriteV3({ + ...minimalData, + workplace: { + ...minimalData.workplace, + location: { + address: "1T impasse Passoir Clichy", + }, + }, + }) + const result = await createJobOffer(identity, data) expect(result).toBeInstanceOf(ObjectId) const job = await getDbCollection("jobs_partners").findOne({ _id: result }) @@ -1846,37 +1928,24 @@ describe("updateJobOffer", () => { offer_expiration: originalCreatedAtPlus2Months, }) - const minimalData: IJobsPartnersWritableApi = { - partner_job_id: null, - - contract_start: inSept, - contract_duration: null, - contract_type: ["Apprentissage", "Professionnalisation"], - contract_remote: null, - - offer_title: "Apprentis en développement web", - offer_rome_codes: ["M1602"], - offer_desired_skills: [], - offer_to_be_acquired_skills: [], - offer_access_conditions: [], - offer_creation: null, - offer_expiration: null, - offer_opening_count: 1, - offer_origin: null, - offer_multicast: true, - offer_description: "Envie de devenir développeur web ? Rejoignez-nous !", - offer_target_diploma_european: null, - offer_status: JOB_STATUS_ENGLISH.ACTIVE, - - apply_url: null, - apply_email: null, - apply_phone: null, - - workplace_siret: apiEntrepriseEtablissementFixture.dinum.data.siret, - workplace_address_label: null, - workplace_description: null, - workplace_website: null, - workplace_name: null, + const minimalData: IJobOfferApiWriteV3Input = { + contract: { + start: inSept.toJSON(), + }, + + offer: { + title: "Apprentis en développement web", + rome_codes: ["M1602"], + description: "Envie de devenir développeur web ? Rejoignez-nous !", + }, + + apply: { + phone: "0600000000", + }, + + workplace: { + siret: apiEntrepriseEtablissementFixture.dinum.data.siret, + }, } beforeEach(async () => { @@ -1909,7 +1978,8 @@ describe("updateJobOffer", () => { }) it("should update a job offer with the minimal data", async () => { - await updateJobOffer(_id, identity, { ...minimalData, partner_job_id: "job-id-9" }) + const data = generateJobOfferApiWriteV3({ ...minimalData, identifier: { ...minimalData.identifier, partner_job_id: "job-id-9" } }) + await updateJobOffer(_id, identity, data) const job = await getDbCollection("jobs_partners").findOne({ _id }) expect(job?.created_at).toEqual(originalCreatedAt) @@ -1933,7 +2003,15 @@ describe("updateJobOffer", () => { it("should get default rome from ROMEO", async () => { vi.mocked(getRomeoPredictions).mockResolvedValue(franceTravailRomeoFixture["Software Engineer"]) - await updateJobOffer(_id, identity, { ...minimalData, partner_job_id: "job-id-10", offer_rome_codes: [] }) + const data = generateJobOfferApiWriteV3({ + ...minimalData, + identifier: { ...minimalData.identifier, partner_job_id: "job-id-10" }, + offer: { + ...minimalData.offer, + rome_codes: [], + }, + }) + await updateJobOffer(_id, identity, data) const job = await getDbCollection("jobs_partners").findOne({ _id }) expect(job?.offer_rome_codes).toEqual(["E1206"]) @@ -1948,7 +2026,16 @@ describe("updateJobOffer", () => { features: [{ geometry: clichyFixture.centre }], }) - await updateJobOffer(_id, identity, { ...minimalData, partner_job_id: "job-id-11", workplace_address_label: "1T impasse Passoir Clichy" }) + const data = generateJobOfferApiWriteV3({ + ...minimalData, + workplace: { + ...minimalData.workplace, + location: { + address: "1T impasse Passoir Clichy", + }, + }, + }) + await updateJobOffer(_id, identity, data) const job = await getDbCollection("jobs_partners").findOne({ _id }) expect(job?.workplace_address_label).toEqual("1T impasse Passoir Clichy") diff --git a/server/src/services/jobs/jobOpportunity/jobOpportunity.service.ts b/server/src/services/jobs/jobOpportunity/jobOpportunity.service.ts index 24ceba0ed3..b8be811c2c 100644 --- a/server/src/services/jobs/jobOpportunity/jobOpportunity.service.ts +++ b/server/src/services/jobs/jobOpportunity/jobOpportunity.service.ts @@ -9,14 +9,22 @@ import { IJobsPartnersOfferApi, IJobsPartnersOfferPrivate, IJobsPartnersRecruiterApi, - IJobsPartnersWritableApi, INiveauDiplomeEuropeen, JOBPARTNERS_LABEL, - ZJobsPartnersOfferApi, ZJobsPartnersRecruiterApi, } from "shared/models/jobsPartners.model" import { zOpcoLabel } from "shared/models/opco.model" -import { IJobOpportunityGetQuery, IJobOpportunityGetQueryResolved, IJobsOpportunityResponse } from "shared/routes/jobOpportunity.routes" +import { + jobsRouteApiv3Converters, + zJobOfferApiReadV3, + zJobRecruiterApiReadV3, + type IJobOfferApiReadV3, + type IJobOfferApiWriteV3, + type IJobRecruiterApiReadV3, + type IJobSearchApiV3Query, + type IJobSearchApiV3QueryResolved, + type IJobSearchApiV3Response, +} from "shared/routes/v3/jobs/jobs.routes.v3.model" import { ZodError } from "zod" import { sentryCaptureException } from "@/common/utils/sentryUtils" @@ -199,7 +207,7 @@ export const getJobsQuery = async ( return result } -export const getJobsPartnersFromDB = async ({ romes, geo, target_diploma_level }: IJobOpportunityGetQueryResolved): Promise => { +export const getJobsPartnersFromDB = async ({ romes, geo, target_diploma_level }: IJobSearchApiV3QueryResolved): Promise => { const query: Filter = { offer_multicast: true, } @@ -236,11 +244,13 @@ export const getJobsPartnersFromDB = async ({ romes, geo, target_diploma_level } ]) .toArray() - return jobsPartners.map((j) => ({ - ...j, - contract_type: j.contract_type ?? [TRAINING_CONTRACT_TYPE.APPRENTISSAGE, TRAINING_CONTRACT_TYPE.PROFESSIONNALISATION], - apply_url: j.apply_url ?? `${config.publicUrl}/recherche-apprentissage?type=partner&itemId=${j._id}`, - })) + return jobsPartners.map((j) => + jobsRouteApiv3Converters.convertToJobOfferApiReadV3({ + ...j, + contract_type: j.contract_type ?? [TRAINING_CONTRACT_TYPE.APPRENTISSAGE, TRAINING_CONTRACT_TYPE.PROFESSIONNALISATION], + apply_url: j.apply_url ?? `${config.publicUrl}/recherche-apprentissage?type=partner&itemId=${j._id}`, + }) + ) } const convertToGeopoint = ({ longitude, latitude }: { longitude: number; latitude: number }): IGeoPoint => ({ type: "Point", coordinates: [longitude, latitude] }) @@ -254,25 +264,32 @@ function convertOpco(recruteurLba: Pick): IJob return null } -export const convertLbaCompanyToJobPartnerRecruiterApi = (recruteursLba: ILbaCompany[]): IJobsPartnersRecruiterApi[] => { +export const convertLbaCompanyToJobRecruiterApi = (recruteursLba: ILbaCompany[]): IJobRecruiterApiReadV3[] => { return recruteursLba.map( - (recruteurLba): IJobsPartnersRecruiterApi => ({ - _id: recruteurLba._id, - workplace_siret: recruteurLba.siret, - workplace_website: recruteurLba.website, - workplace_name: recruteurLba.enseigne ?? recruteurLba.raison_sociale, - workplace_brand: recruteurLba.enseigne, - workplace_legal_name: recruteurLba.raison_sociale, - workplace_description: null, - workplace_size: recruteurLba.company_size, - workplace_address_label: `${recruteurLba.street_number} ${recruteurLba.street_name} ${recruteurLba.zip_code} ${recruteurLba.city}`, - workplace_geopoint: recruteurLba.geopoint!, - workplace_idcc: null, - workplace_opco: convertOpco(recruteurLba), - workplace_naf_code: recruteurLba.naf_code, - workplace_naf_label: recruteurLba.naf_label, - apply_url: `${config.publicUrl}/recherche-apprentissage?type=lba&itemId=${recruteurLba.siret}`, - apply_phone: recruteurLba.phone, + (recruteurLba): IJobRecruiterApiReadV3 => ({ + identifier: { id: recruteurLba._id }, + workplace: { + siret: recruteurLba.siret, + website: recruteurLba.website, + name: recruteurLba.enseigne ?? recruteurLba.raison_sociale, + brand: recruteurLba.enseigne, + legal_name: recruteurLba.raison_sociale, + description: null, + size: recruteurLba.company_size, + location: { + address: `${recruteurLba.street_number} ${recruteurLba.street_name} ${recruteurLba.zip_code} ${recruteurLba.city}`, + geopoint: recruteurLba.geopoint!, + }, + domain: { + idcc: null, + opco: convertOpco(recruteurLba), + naf: recruteurLba.naf_code == null ? null : { code: recruteurLba.naf_code, label: recruteurLba.naf_label }, + }, + }, + apply: { + url: `${config.publicUrl}/recherche-apprentissage?type=lba&itemId=${recruteurLba.siret}`, + phone: recruteurLba.phone, + }, }) ) } @@ -314,7 +331,7 @@ function getDiplomaEuropeanLevel(job: IJob): IJobsPartnersOfferApi["offer_target } } -export const convertLbaRecruiterToJobPartnerOfferApi = (offresEmploiLba: IJobResult[]): IJobsPartnersOfferApi[] => { +export const convertLbaRecruiterToJobOfferApi = (offresEmploiLba: IJobResult[]): IJobOfferApiReadV3[] => { return ( offresEmploiLba // TODO: Temporary fix for missing geopoint & address @@ -327,104 +344,136 @@ export const convertLbaRecruiterToJobPartnerOfferApi = (offresEmploiLba: IJobRes return recruiter.address && recruiter.geopoint && job.rome_label }) .map( - ({ recruiter, job }: IJobResult): IJobsPartnersOfferApi => ({ - _id: job._id.toString(), - partner_label: JOBPARTNERS_LABEL.OFFRES_EMPLOI_LBA, - partner_job_id: null, - contract_start: job.job_start_date, - contract_duration: job.job_duration ?? null, - contract_type: job.job_type, - contract_remote: null, - offer_title: job.rome_label!, - offer_rome_codes: job.rome_code, - offer_description: job.rome_detail.definition, - offer_target_diploma: getDiplomaEuropeanLevel(job), - offer_desired_skills: job.rome_detail.competences.savoir_etre_professionnel?.map((x) => x.libelle) ?? [], - offer_to_be_acquired_skills: job.rome_detail.competences.savoir_faire?.flatMap((x) => x.items.map((y) => `${x.libelle}: ${y.libelle}`)) ?? [], - offer_access_conditions: job.rome_detail.acces_metier.split("\n"), - offer_creation: job.job_creation_date ?? null, - offer_expiration: job.job_expiration_date ?? null, - offer_opening_count: job.job_count ?? 1, - offer_status: translateJobStatus(job.job_status), - - workplace_siret: recruiter.establishment_siret, - workplace_website: null, - workplace_name: recruiter.establishment_enseigne ?? recruiter.establishment_raison_sociale ?? null, - workplace_brand: recruiter.establishment_enseigne ?? null, - workplace_legal_name: recruiter.establishment_raison_sociale ?? null, - workplace_description: null, - workplace_size: recruiter.establishment_size ?? null, - workplace_address_label: recruiter.address!, - workplace_geopoint: recruiter.geopoint!, - workplace_idcc: recruiter.idcc, - workplace_opco: convertOpco(recruiter), - workplace_naf_code: recruiter.naf_code ?? null, - workplace_naf_label: recruiter.naf_label ?? null, - - apply_url: `${config.publicUrl}/recherche-apprentissage?type=matcha&itemId=${job._id}`, - apply_phone: recruiter.phone ?? null, + ({ recruiter, job }: IJobResult): IJobOfferApiReadV3 => ({ + identifier: { id: job._id, partner_label: JOBPARTNERS_LABEL.OFFRES_EMPLOI_LBA, partner_job_id: null }, + contract: { + start: job.job_start_date, + duration: job.job_duration ?? null, + type: job.job_type, + remote: null, + }, + offer: { + title: job.rome_label!, + rome_codes: job.rome_code, + description: job.rome_detail.definition, + target_diploma: getDiplomaEuropeanLevel(job), + desired_skills: job.rome_detail.competences.savoir_etre_professionnel?.map((x) => x.libelle) ?? [], + to_be_acquired_skills: job.rome_detail.competences.savoir_faire?.flatMap((x) => x.items.map((y) => `${x.libelle}: ${y.libelle}`)) ?? [], + access_conditions: job.rome_detail.acces_metier.split("\n"), + publication: { + creation: job.job_creation_date ?? null, + expiration: job.job_expiration_date ?? null, + }, + opening_count: job.job_count ?? 1, + status: translateJobStatus(job.job_status), + }, + + workplace: { + siret: recruiter.establishment_siret, + website: null, + name: recruiter.establishment_enseigne ?? recruiter.establishment_raison_sociale ?? null, + brand: recruiter.establishment_enseigne ?? null, + legal_name: recruiter.establishment_raison_sociale ?? null, + description: null, + size: recruiter.establishment_size ?? null, + location: { + address: recruiter.address!, + geopoint: recruiter.geopoint!, + }, + domain: { + idcc: recruiter.idcc, + opco: convertOpco(recruiter), + naf: + recruiter.naf_code == null + ? null + : { + code: recruiter.naf_code ?? null, + label: recruiter.naf_label ?? null, + }, + }, + }, + + apply: { + url: `${config.publicUrl}/recherche-apprentissage?type=matcha&itemId=${job._id}`, + phone: recruiter.phone ?? null, + }, }) ) ) } -export const convertFranceTravailJobToJobPartnerOfferApi = (offresEmploiFranceTravail: FTJob[]): IJobsPartnersOfferApi[] => { +export const convertFranceTravailJobToJobOfferApi = (offresEmploiFranceTravail: FTJob[]): IJobOfferApiReadV3[] => { return offresEmploiFranceTravail .filter((j) => { // TODO: Temporary fix for missing geopoint return j.lieuTravail.latitude != null && j.lieuTravail.longitude != null }) - .map((offreFT): IJobsPartnersOfferApi => { + .map((offreFT): IJobOfferApiReadV3 => { const contractDuration = parseInt(offreFT.typeContratLibelle, 10) const contractType = parseEnum(TRAINING_CONTRACT_TYPE, offreFT.natureContrat) return { - _id: null, - partner_job_id: offreFT.id, - partner_label: JOBPARTNERS_LABEL.OFFRES_EMPLOI_FRANCE_TRAVAIL, - - contract_start: null, - contract_duration: isNaN(contractDuration) ? null : contractDuration, - contract_type: contractType ? [contractType] : [], - contract_remote: null, - - offer_title: offreFT.intitule, - offer_rome_codes: [offreFT.romeCode], - offer_description: offreFT.description, - offer_target_diploma: null, - offer_desired_skills: [], - offer_to_be_acquired_skills: [], - offer_access_conditions: offreFT.formations ? offreFT.formations?.map((formation) => `${formation.domaineLibelle} - ${formation.niveauLibelle}`) : [], - offer_creation: new Date(offreFT.dateCreation), - offer_expiration: null, - offer_opening_count: offreFT.nombrePostes, - offer_status: JOB_STATUS_ENGLISH.ACTIVE, + identifier: { + id: null, + partner_job_id: offreFT.id, + partner_label: JOBPARTNERS_LABEL.OFFRES_EMPLOI_FRANCE_TRAVAIL, + }, + + contract: { + start: null, + duration: isNaN(contractDuration) ? null : contractDuration, + type: contractType ? [contractType] : [], + remote: null, + }, + + offer: { + title: offreFT.intitule, + rome_codes: [offreFT.romeCode], + description: offreFT.description, + target_diploma: null, + desired_skills: [], + to_be_acquired_skills: [], + access_conditions: offreFT.formations ? offreFT.formations?.map((formation) => `${formation.domaineLibelle} - ${formation.niveauLibelle}`) : [], + publication: { + creation: new Date(offreFT.dateCreation), + expiration: null, + }, + opening_count: offreFT.nombrePostes, + status: JOB_STATUS_ENGLISH.ACTIVE, + }, // Try to find entreprise SIRET from offreFT.entreprise.siret ? - workplace_siret: null, - workplace_brand: null, - workplace_legal_name: null, - workplace_website: null, - workplace_name: offreFT.entreprise.nom, - workplace_description: offreFT.entreprise.description, - workplace_size: null, - workplace_address_label: offreFT.lieuTravail.libelle, - workplace_geopoint: convertToGeopoint({ - longitude: parseFloat(offreFT.lieuTravail.longitude!), - latitude: parseFloat(offreFT.lieuTravail.latitude!), - }), - workplace_idcc: null, - // TODO: try to map opco from FT formation requirement - workplace_opco: null, - workplace_naf_code: offreFT.codeNAF ? offreFT.codeNAF : null, - workplace_naf_label: offreFT.secteurActiviteLibelle ? offreFT.secteurActiviteLibelle : null, - - apply_url: offreFT.origineOffre.partenaires?.[0]?.url ?? offreFT.origineOffre.urlOrigine, - apply_phone: null, + workplace: { + siret: null, + brand: null, + legal_name: null, + website: null, + name: offreFT.entreprise.nom, + description: offreFT.entreprise.description, + size: null, + location: { + address: offreFT.lieuTravail.libelle, + geopoint: convertToGeopoint({ + longitude: parseFloat(offreFT.lieuTravail.longitude!), + latitude: parseFloat(offreFT.lieuTravail.latitude!), + }), + }, + domain: { + idcc: null, + // TODO: try to map opco from FT formation requirement + opco: null, + naf: offreFT.codeNAF ? { code: offreFT.codeNAF, label: offreFT.secteurActiviteLibelle || null } : null, + }, + }, + + apply: { + url: offreFT.origineOffre.partenaires?.[0]?.url ?? offreFT.origineOffre.urlOrigine, + phone: null, + }, } }) } -async function findFranceTravailOpportunities(query: IJobOpportunityGetQueryResolved, context: JobOpportunityRequestContext): Promise { +async function findFranceTravailOpportunities(query: IJobSearchApiV3QueryResolved, context: JobOpportunityRequestContext): Promise { const getFtDiploma = (target_diploma_level: INiveauDiplomeEuropeen | null): keyof typeof NIVEAUX_POUR_OFFRES_PE | null => { switch (target_diploma_level) { case "3": @@ -464,10 +513,10 @@ async function findFranceTravailOpportunities(query: IJobOpportunityGetQueryReso return { resultats: [] } }) - return convertFranceTravailJobToJobPartnerOfferApi(ftJobs.resultats) + return convertFranceTravailJobToJobOfferApi(ftJobs.resultats) } -async function findLbaJobOpportunities(query: IJobOpportunityGetQueryResolved): Promise { +async function findLbaJobOpportunities(query: IJobSearchApiV3QueryResolved): Promise { const payload: Parameters[0] = { geo: query.geo, romes: query.romes ?? null, @@ -481,10 +530,10 @@ async function findLbaJobOpportunities(query: IJobOpportunityGetQueryResolved): const lbaJobs = await getLbaJobsV2(payload) - return convertLbaRecruiterToJobPartnerOfferApi(lbaJobs) + return convertLbaRecruiterToJobOfferApi(lbaJobs) } -async function resolveQuery(query: IJobOpportunityGetQuery): Promise { +async function resolveQuery(query: IJobSearchApiV3Query): Promise { const { romes, rncp, latitude, longitude, radius, ...rest } = query const geo = latitude === null || longitude === null ? null : { latitude, longitude, radius } @@ -516,7 +565,7 @@ async function resolveQuery(query: IJobOpportunityGetQuery): Promise { +export async function findJobsOpportunities(payload: IJobSearchApiV3Query, context: JobOpportunityRequestContext): Promise { const resolvedQuery = await resolveQuery(payload) const [recruterLba, offreEmploiLba, offreEmploiPartenaire, franceTravail] = await Promise.all([ @@ -527,7 +576,7 @@ export async function findJobsOpportunities(payload: IJobOpportunityGetQuery, co ]) const jobs = [...offreEmploiLba, ...franceTravail, ...offreEmploiPartenaire].filter((job) => { - const parsedJob = ZJobsPartnersOfferApi.safeParse(job) + const parsedJob = zJobOfferApiReadV3.safeParse(job) if (!parsedJob.success) { const error = internal("jobOpportunity.service.ts-findJobsOpportunities: invalid job offer", { job, error: parsedJob.error.format() }) logger.error(error) @@ -536,8 +585,8 @@ export async function findJobsOpportunities(payload: IJobOpportunityGetQuery, co } return parsedJob.success }) - const recruiters = convertLbaCompanyToJobPartnerRecruiterApi(recruterLba).filter((recruteur) => { - const parsedRecruiter = ZJobsPartnersRecruiterApi.safeParse(recruteur) + const recruiters = convertLbaCompanyToJobRecruiterApi(recruterLba).filter((recruteur) => { + const parsedRecruiter = zJobRecruiterApiReadV3.safeParse(recruteur) if (!parsedRecruiter.success) { const error = internal("jobOpportunity.service.ts-findJobsOpportunities: invalid recruiter", { recruteur, error: parsedRecruiter.error.format() }) logger.error(error) @@ -612,16 +661,16 @@ async function resolveWorkplaceDataFromSiret(workplace_siret: string, zodError: } } -async function resolveRomeCodes(data: IJobsPartnersWritableApi, siretData: WorkplaceSiretData | null, zodError: ZodError): Promise { - if (data.offer_rome_codes && data.offer_rome_codes.length > 0) { - return data.offer_rome_codes +async function resolveRomeCodes(data: IJobOfferApiWriteV3, siretData: WorkplaceSiretData | null, zodError: ZodError): Promise { + if (data.offer.rome_codes && data.offer.rome_codes.length > 0) { + return data.offer.rome_codes } if (siretData === null) { return null } - const romeoResponse = await getRomeFromRomeo({ intitule: data.offer_title, contexte: siretData.workplace_naf_label ?? undefined }) + const romeoResponse = await getRomeFromRomeo({ intitule: data.offer.title, contexte: siretData.workplace_naf_label ?? undefined }) if (!romeoResponse) { zodError.addIssue({ code: "custom", path: ["offer_rome_codes"], message: "ROME is not provided and we are unable to retrieve ROME code for the given job title" }) return null @@ -632,25 +681,24 @@ async function resolveRomeCodes(data: IJobsPartnersWritableApi, siretData: Workp type InvariantFields = "_id" | "created_at" | "partner_label" -async function upsertJobOffer(data: IJobsPartnersWritableApi, identity: IApiAlternanceTokenData, current: IJobsPartnersOfferPrivate | null): Promise { +async function upsertJobOffer(data: IJobOfferApiWriteV3, identity: IApiAlternanceTokenData, current: IJobsPartnersOfferPrivate | null): Promise { const zodError = new ZodError([]) const [siretData, addressData] = await Promise.all([ - resolveWorkplaceDataFromSiret(data.workplace_siret, zodError), - resolveWorkplaceLocationFromAddress(data.workplace_address_label, zodError), + resolveWorkplaceDataFromSiret(data.workplace.siret, zodError), + resolveWorkplaceLocationFromAddress(data.workplace.location?.address ?? null, zodError), ]) - const romeCode = await resolveRomeCodes(data, siretData, zodError) + const offer_rome_codes = await resolveRomeCodes(data, siretData, zodError) if (!zodError.isEmpty) { throw zodError } - if (!romeCode || !siretData) { + if (!offer_rome_codes || !siretData) { throw internal("unexpected: cannot resolve all required data for the job offer") } - const { offer_creation, offer_expiration, offer_rome_codes, offer_status, offer_target_diploma_european, workplace_address_label, ...rest } = data const now = new Date() const invariantData: Pick = { @@ -663,20 +711,46 @@ async function upsertJobOffer(data: IJobsPartnersWritableApi, identity: IApiAlte ? current.offer_expiration : DateTime.fromJSDate(invariantData.created_at, { zone: "Europe/Paris" }).plus({ months: 2 }).startOf("day").toJSDate() + const offer_target_diploma_european = data.offer.target_diploma?.european ?? null + const writableData: Omit = { - offer_rome_codes: romeCode, - offer_status: offer_status ?? JOB_STATUS_ENGLISH.ACTIVE, - offer_creation: offer_creation ?? invariantData.created_at, - offer_expiration: offer_expiration || defaultOfferExpiration, + partner_job_id: data.identifier.partner_job_id, + + contract_start: data.contract.start, + contract_duration: data.contract.duration, + contract_type: data.contract.type, + contract_remote: data.contract.remote, + + offer_title: data.offer.title, + offer_rome_codes, + offer_description: data.offer.description, offer_target_diploma: - offer_target_diploma_european == null + offer_target_diploma_european === null ? null : { european: offer_target_diploma_european, label: NIVEAU_DIPLOME_LABEL[offer_target_diploma_european], }, + offer_desired_skills: data.offer.desired_skills, + offer_to_be_acquired_skills: data.offer.to_be_acquired_skills, + offer_access_conditions: data.offer.access_conditions, + offer_creation: data.offer.publication.creation ?? invariantData.created_at, + offer_expiration: data.offer.publication.expiration || defaultOfferExpiration, + offer_opening_count: data.offer.opening_count, + offer_origin: data.offer.origin, + offer_status: JOB_STATUS_ENGLISH.ACTIVE, + offer_multicast: data.offer.multicast, + + workplace_siret: data.workplace.siret, + workplace_description: data.workplace.description, + workplace_website: data.workplace.website, + workplace_name: data.workplace.name, + + apply_email: data.apply.email, + apply_url: data.apply.url, + apply_phone: data.apply.phone, + updated_at: now, - ...rest, // Data derived from workplace_address_label take priority over workplace_siret ...siretData, ...addressData, @@ -687,8 +761,8 @@ async function upsertJobOffer(data: IJobsPartnersWritableApi, identity: IApiAlte return invariantData._id } -export async function createJobOffer(identity: IApiAlternanceTokenData, data: IJobsPartnersWritableApi): Promise { - const { partner_job_id } = data +export async function createJobOffer(identity: IApiAlternanceTokenData, data: IJobOfferApiWriteV3): Promise { + const { partner_job_id } = data.identifier const { organisation } = identity const exist = await getDbCollection("jobs_partners").findOne({ partner_label: organisation!, partner_job_id }) if (exist) { @@ -697,7 +771,7 @@ export async function createJobOffer(identity: IApiAlternanceTokenData, data: IJ return upsertJobOffer(data, identity, null) } -export async function updateJobOffer(id: ObjectId, identity: IApiAlternanceTokenData, data: IJobsPartnersWritableApi): Promise { +export async function updateJobOffer(id: ObjectId, identity: IApiAlternanceTokenData, data: IJobOfferApiWriteV3): Promise { const current = await getDbCollection("jobs_partners").findOne({ _id: id }) // TODO: Move to authorisation service diff --git a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap index 35098d0f7a..6b68e21cbb 100644 --- a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap +++ b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap @@ -9685,6 +9685,7 @@ Limite : 20 appel(s) / 1 seconde(s) "type": "object", }, "contract": { + "default": {}, "properties": { "duration": { "description": "Durée du contrat en mois", @@ -9732,6 +9733,7 @@ Limite : 20 appel(s) / 1 seconde(s) "type": "object", }, "identifier": { + "default": {}, "properties": { "partner_job_id": { "description": "Identifiant d'origine l'offre provenant du partenaire", @@ -9784,6 +9786,7 @@ Limite : 20 appel(s) / 1 seconde(s) ], }, "publication": { + "default": {}, "properties": { "creation": { "format": "date-time", @@ -9864,6 +9867,7 @@ Limite : 20 appel(s) / 1 seconde(s) ], }, "location": { + "default": {}, "properties": { "address": { "type": [ @@ -10751,6 +10755,7 @@ Limite : 20 appel(s) / 1 seconde(s) "type": "object", }, "contract": { + "default": {}, "properties": { "duration": { "description": "Durée du contrat en mois", @@ -10798,6 +10803,7 @@ Limite : 20 appel(s) / 1 seconde(s) "type": "object", }, "identifier": { + "default": {}, "properties": { "partner_job_id": { "description": "Identifiant d'origine l'offre provenant du partenaire", @@ -10850,6 +10856,7 @@ Limite : 20 appel(s) / 1 seconde(s) ], }, "publication": { + "default": {}, "properties": { "creation": { "format": "date-time", @@ -10930,6 +10937,7 @@ Limite : 20 appel(s) / 1 seconde(s) ], }, "location": { + "default": {}, "properties": { "address": { "type": [ diff --git a/shared/models/jobPartners.model.test.ts b/shared/models/jobPartners.model.test.ts index dbc9066d15..2e1a8a6232 100644 --- a/shared/models/jobPartners.model.test.ts +++ b/shared/models/jobPartners.model.test.ts @@ -4,7 +4,7 @@ import { describe, expectTypeOf, it } from "vitest" import { OPCOS_LABEL, TRAINING_REMOTE_TYPE } from "../constants/recruteur.js" import { JOB_STATUS_ENGLISH } from "./job.model.js" -import { IJobsPartnersWritableApi, IJobsPartnersOfferApi, IJobsPartnersRecruiterApi, IJobsPartnersWritableApiInput } from "./jobsPartners.model.js" +import { IJobsPartnersOfferApi, IJobsPartnersRecruiterApi } from "./jobsPartners.model.js" type IJobWorkplaceExpected = { workplace_siret: string | null @@ -62,71 +62,6 @@ type IJobOfferExpected = IJobWorkplaceExpected & offer_status: JOB_STATUS_ENGLISH } -type IJobOfferWritableExpected = { - partner_job_id: IJobOfferExpected["partner_job_id"] - - contract_duration: IJobOfferExpected["contract_duration"] - contract_type: IJobOfferExpected["contract_type"] - contract_remote: IJobOfferExpected["contract_remote"] | undefined - contract_start: Date | null - - offer_title: string - offer_rome_codes: string[] | null - offer_description: string - offer_target_diploma_european: "3" | "4" | "5" | "6" | "7" | null - offer_desired_skills: IJobOfferExpected["offer_desired_skills"] - offer_to_be_acquired_skills: IJobOfferExpected["offer_to_be_acquired_skills"] - offer_access_conditions: IJobOfferExpected["offer_access_conditions"] - offer_creation: IJobOfferExpected["offer_creation"] - offer_expiration: IJobOfferExpected["offer_expiration"] - offer_opening_count: IJobOfferExpected["offer_opening_count"] - offer_origin: string | null - offer_multicast: boolean - - apply_url: string | null - apply_phone: string | null - apply_email: string | null - - workplace_siret: string - workplace_name: string | null - workplace_website: string | null - workplace_description: string | null - workplace_address_label: string | null -} - -type IJobOfferWritableInputExpected = { - partner_job_id?: string | null | undefined - - contract_duration?: number | null | undefined - contract_type?: Array<"Apprentissage" | "Professionnalisation"> | undefined - contract_remote?: IJobOfferExpected["contract_remote"] | undefined - contract_start?: string | null | undefined - - offer_title: string - offer_rome_codes?: string[] | null | undefined - offer_description: string - offer_target_diploma_european?: "3" | "4" | "5" | "6" | "7" | null | undefined - offer_desired_skills?: string[] | undefined - offer_to_be_acquired_skills?: string[] | undefined - offer_access_conditions?: string[] | undefined - offer_creation?: string | null | undefined - offer_expiration?: string | null | undefined - offer_opening_count?: number | undefined - offer_origin?: string | null | undefined - offer_multicast?: boolean | undefined - offer_status?: JOB_STATUS_ENGLISH | undefined - - apply_url?: string | null | undefined - apply_phone?: string | null | undefined - apply_email?: string | null | undefined - - workplace_siret: string - workplace_name?: string | null | undefined - workplace_website?: string | null | undefined - workplace_description?: string | null | undefined - workplace_address_label?: string | null | undefined -} - describe("IJobRecruiterExpected", () => { it("should have proper typing", () => { expectTypeOf().toMatchTypeOf() @@ -138,15 +73,3 @@ describe("IJobsPartnersOfferApi", () => { expectTypeOf().toMatchTypeOf() }) }) - -describe("IJobsPartnersWritableApi", () => { - it("should have proper typing", () => { - expectTypeOf().toMatchTypeOf() - }) -}) - -describe("IJobsPartnersWritableApiInput", () => { - it("should have proper typing", () => { - expectTypeOf().toEqualTypeOf() - }) -}) diff --git a/shared/models/jobPartners/jobPartners.model.test.ts b/shared/models/jobPartners/jobPartners.model.test.ts deleted file mode 100644 index 1fc71fda32..0000000000 --- a/shared/models/jobPartners/jobPartners.model.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" - -import { JOB_STATUS_ENGLISH } from "../job.model" -import { IJobsPartnersWritableApiInput, ZJobsPartnersWritableApi } from "../jobsPartners.model" - -describe("ZJobsPartnersWritableApi", () => { - const now = new Date("2024-06-18T14:30:00.000Z") - const oneMinuteAgo = new Date("2024-06-18T14:29:00.000Z") - const inOneMinute = new Date("2024-06-18T14:31:00.000Z") - const oneHourAgo = new Date("2024-06-18T13:30:00.000Z") - const inOneHour = new Date("2024-06-18T15:30:00.000Z") - - const data: IJobsPartnersWritableApiInput = { - offer_title: "Apprentis en développement web", - offer_rome_codes: ["M1602"], - offer_description: "Envie de devenir développeur web ? Rejoignez-nous !", - - apply_email: "mail@mail.com", - - workplace_siret: "39837261500128", - } - - beforeEach(async () => { - // Do not mock nextTick - vi.useFakeTimers({ toFake: ["Date"] }) - vi.setSystemTime(now) - - return () => { - vi.useRealTimers() - } - }) - - describe("contract_start", () => { - it("should be optional", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - }) - - expect(result.success).toBe(true) - expect(result.data?.contract_start).toBe(null) - }) - it("should be required ISO 8601 date string", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - contract_start: "2024-09-01", - }) - - expect(result.success).toBe(false) - expect(result.error?.format()).toEqual({ - _errors: [], - contract_start: { - _errors: ["Expected ISO 8601 date string"], - }, - }) - }) - - it("should allow date in past", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - contract_start: oneHourAgo.toJSON(), - }) - - expect(result.success).toBe(true) - expect(result.data?.contract_start).toEqual(oneHourAgo) - }) - - it("should allow date in future", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - contract_start: inOneHour.toJSON(), - }) - - expect(result.success).toBe(true) - expect(result.data?.contract_start).toEqual(inOneHour) - }) - }) - - describe("offer_status", () => { - it("should be optional", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - }) - - expect(result.success).toBe(true) - expect(result.data?.offer_status).toBe(JOB_STATUS_ENGLISH.ACTIVE) - }) - }) - - describe("offer_creation", () => { - // Fallback is handled in jobOpportinityService - it("should allow null", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_creation: null, - }) - - expect(result.success).toBe(true) - expect(result.data?.offer_creation).toEqual(null) - }) - - it("should be required ISO 8601 date string", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_creation: "2024-09-01", - }) - - expect(result.success).toBe(false) - expect(result.error?.format()).toEqual({ - _errors: [], - offer_creation: { - _errors: ["Expected ISO 8601 date string"], - }, - }) - }) - - it("should allow date in past", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_creation: oneHourAgo.toJSON(), - }) - - expect(result.success).toBe(true) - expect(result.data?.offer_creation).toEqual(oneHourAgo) - }) - - it("should not allow date in future", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_creation: inOneHour.toJSON(), - }) - - expect(result.success).toBe(false) - expect(result.error?.format()).toEqual({ - _errors: [], - offer_creation: { - _errors: ["Creation date cannot be in the future"], - }, - }) - }) - - it("should tolerate time clock sync", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_creation: inOneMinute.toJSON(), - }) - - expect(result.success).toBe(true) - expect(result.data?.offer_creation).toEqual(inOneMinute) - }) - }) - - describe("offer_expiration", () => { - // Fallback is handled in jobOpportinityService - it("should allow null", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_expiration: null, - }) - - expect(result.success).toBe(true) - expect(result.data?.offer_expiration).toEqual(null) - }) - - it("should be required ISO 8601 date string", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_expiration: "2024-09-01", - }) - - expect(result.success).toBe(false) - expect(result.error?.format()).toEqual({ - _errors: [], - offer_expiration: { - _errors: ["Expected ISO 8601 date string"], - }, - }) - }) - - it("should not allow date in future", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_expiration: inOneHour.toJSON(), - }) - - expect(result.success).toBe(true) - expect(result.data?.offer_expiration).toEqual(inOneHour) - }) - - it("should not allow date in past", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_expiration: oneHourAgo.toJSON(), - }) - - expect(result.success).toBe(false) - expect(result.error?.format()).toEqual({ - _errors: [], - offer_expiration: { - _errors: ["Expiration date cannot be in the past"], - }, - }) - }) - - it("should tolerate time clock sync", () => { - const result = ZJobsPartnersWritableApi.safeParse({ - ...data, - offer_expiration: oneMinuteAgo.toJSON(), - }) - - expect(result.success).toBe(true) - expect(result.data?.offer_expiration).toEqual(oneMinuteAgo) - }) - }) -}) diff --git a/shared/models/jobsPartners.model.ts b/shared/models/jobsPartners.model.ts index 167219533f..50dffef5b8 100644 --- a/shared/models/jobsPartners.model.ts +++ b/shared/models/jobsPartners.model.ts @@ -99,83 +99,6 @@ export type IJobsPartnersOfferApi = z.output export type IJobsPartnersRecruiterPrivate = z.output export type IJobsPartnersOfferPrivate = z.output -export type IJobsPartnersOfferPrivateInput = z.input - -const TIME_CLOCK_TOLERANCE = 300_000 - -export const ZJobsPartnersPostApiBodyBase = z.object({ - partner_job_id: ZJobsPartnersOfferPrivate.shape.partner_job_id.default(null), - - contract_start: z - .string({ message: "Expected ISO 8601 date string" }) - .datetime({ offset: true, message: "Expected ISO 8601 date string" }) - .pipe(z.coerce.date()) - .nullable() - .default(null), - contract_duration: ZJobsPartnersOfferPrivate.shape.contract_duration.default(null), - contract_type: ZJobsPartnersOfferPrivate.shape.contract_type.default([TRAINING_CONTRACT_TYPE.APPRENTISSAGE, TRAINING_CONTRACT_TYPE.PROFESSIONNALISATION]), - contract_remote: ZJobsPartnersOfferPrivate.shape.contract_remote.default(null), - - offer_title: ZJobsPartnersOfferPrivate.shape.offer_title, - offer_rome_codes: ZJobsPartnersOfferPrivate.shape.offer_rome_codes.nullable().default(null), - offer_description: ZJobsPartnersOfferPrivate.shape.offer_description.min(30, "Job description should be at least 30 characters"), - offer_target_diploma_european: zDiplomaEuropeanLevel.nullable().default(null), - offer_desired_skills: ZJobsPartnersOfferPrivate.shape.offer_desired_skills.default([]), - offer_to_be_acquired_skills: ZJobsPartnersOfferPrivate.shape.offer_to_be_acquired_skills.default([]), - offer_access_conditions: ZJobsPartnersOfferPrivate.shape.offer_access_conditions.default([]), - offer_creation: z - .string({ message: "Expected ISO 8601 date string" }) - .datetime({ offset: true, message: "Expected ISO 8601 date string" }) - .pipe( - z.coerce.date().refine((value) => value.getTime() < Date.now() + TIME_CLOCK_TOLERANCE, { - message: "Creation date cannot be in the future", - }) - ) - .nullable() - .default(null), - offer_expiration: z - .string({ message: "Expected ISO 8601 date string" }) - .datetime({ offset: true, message: "Expected ISO 8601 date string" }) - .pipe( - z.coerce.date().refine((value) => value === null || value.getTime() > Date.now() - TIME_CLOCK_TOLERANCE, { - message: "Expiration date cannot be in the past", - }) - ) - .nullable() - .default(null), - offer_opening_count: ZJobsPartnersOfferPrivate.shape.offer_opening_count.default(1), - offer_origin: ZJobsPartnersOfferPrivate.shape.offer_origin, - offer_status: ZJobsPartnersOfferPrivate.shape.offer_status.default(JOB_STATUS_ENGLISH.ACTIVE), - offer_multicast: ZJobsPartnersOfferPrivate.shape.offer_multicast, - - workplace_siret: extensions.siret, - workplace_description: ZJobsPartnersOfferPrivate.shape.workplace_description.default(null), - workplace_website: ZJobsPartnersOfferPrivate.shape.workplace_website.default(null), - workplace_name: ZJobsPartnersOfferPrivate.shape.workplace_name.default(null), - workplace_address_label: z.string().nullable().default(null), - - apply_email: ZJobsPartnersOfferPrivate.shape.apply_email, - apply_url: ZJobsPartnersOfferApi.shape.apply_url.nullable().default(null), - apply_phone: extensions.telephone.nullable().describe("Téléphone de contact").default(null), -}) - -export const ZJobsPartnersWritableApi = ZJobsPartnersPostApiBodyBase.superRefine((data, ctx) => { - const keys = ["apply_url", "apply_email", "apply_phone"] as const - if (keys.every((key) => data[key] == null)) { - keys.forEach((key) => { - ctx.addIssue({ - code: "custom", - message: "At least one of apply_url, apply_email, or apply_phone is required", - path: [key], - }) - }) - } - - return data -}) - -export type IJobsPartnersWritableApi = z.output -export type IJobsPartnersWritableApiInput = z.input export default { zod: ZJobsPartnersOfferPrivate, diff --git a/shared/routes/index.ts b/shared/routes/index.ts index 5b4e797607..8cc127a878 100644 --- a/shared/routes/index.ts +++ b/shared/routes/index.ts @@ -115,7 +115,6 @@ const zRoutesPut = { ...zUserRecruteurRoutes.put, ...zFormulaireRoute.put, ...zUpdateLbaCompanyRoutes.put, - ...zJobsRoutesV2.put, ...zJobsRoutesV3.put, } as const diff --git a/shared/routes/jobOpportunity.routes.ts b/shared/routes/jobOpportunity.routes.ts deleted file mode 100644 index 053179a44a..0000000000 --- a/shared/routes/jobOpportunity.routes.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { extensions } from "../helpers/zodHelpers/zodPrimitives" -import { z } from "../helpers/zodWithOpenApi" -import { zDiplomaEuropeanLevel, ZJobsPartnersOfferApi, ZJobsPartnersRecruiterApi } from "../models/jobsPartners.model" - -export const ZJobOpportunityGetQuery = z - .object({ - latitude: extensions.latitude({ coerce: true }).nullable().default(null), - longitude: extensions.longitude({ coerce: true }).nullable().default(null), - radius: z.coerce.number().min(0).max(200).default(30), - target_diploma_level: zDiplomaEuropeanLevel.optional(), - romes: extensions.romeCodeArray().nullable().default(null), - rncp: extensions.rncpCode().nullable().default(null), - }) - .superRefine((data, ctx) => { - if (data.longitude == null && data.latitude != null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["longitude"], - message: "longitude is required when latitude is provided", - }) - } - - if (data.longitude != null && data.latitude == null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["latitude"], - message: "latitude is required when longitude is provided", - }) - } - }) - -export type IJobOpportunityGetQuery = z.output - -export type IJobOpportunityGetQueryResolved = Omit & { - geo: { latitude: number; longitude: number; radius: number } | null -} - -export const ZJobsOpportunityResponse = z.object({ - jobs: z.array(ZJobsPartnersOfferApi), - recruiters: z.array(ZJobsPartnersRecruiterApi), - warnings: z.array(z.object({ message: z.string(), code: z.string() })), -}) - -export const ZJobSearchApiV3Response = z.object({ - jobs: z.array(ZJobsPartnersOfferApi), - recruiters: z.array(ZJobsPartnersRecruiterApi), - warnings: z.array(z.object({ message: z.string(), code: z.string() })), -}) - -export type IJobsOpportunityResponse = z.output diff --git a/shared/routes/jobs.routes.v2.ts b/shared/routes/jobs.routes.v2.ts index 250245ab92..0965d60ff0 100644 --- a/shared/routes/jobs.routes.v2.ts +++ b/shared/routes/jobs.routes.v2.ts @@ -2,7 +2,6 @@ import { LBA_ITEM_TYPE } from "../constants/lbaitem" import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" import { zObjectId } from "../models/common" -import { ZJobsPartnersWritableApi } from "../models/jobsPartners.model" import { ZApiError, ZLbacError, ZLbarError } from "../models/lbacError.model" import { ZLbaItemFtJob, ZLbaItemLbaCompany, ZLbaItemLbaJob } from "../models/lbaItem.model" import { ZRecruiter } from "../models/recruiter.model" @@ -23,7 +22,6 @@ import { zSourcesParams, } from "./_params" import { IRoutesDef, ZResError } from "./common.routes" -import { ZJobOpportunityGetQuery, ZJobsOpportunityResponse } from "./jobOpportunity.routes" export const zJobsRoutesV2 = { get: { @@ -295,34 +293,8 @@ export const zJobsRoutesV2 = { })}`, }, }, - "/jobs/search": { - method: "get", - path: "/jobs/search", - querystring: ZJobOpportunityGetQuery, - response: { - "200": ZJobsOpportunityResponse, - }, - securityScheme: { - auth: "api-apprentissage", - access: null, - resources: {}, - }, - }, }, post: { - "/jobs": { - method: "post", - path: "/jobs", - body: ZJobsPartnersWritableApi, - response: { - "201": z.object({ id: zObjectId }), - }, - securityScheme: { - auth: "api-apprentissage", - access: "api-apprentissage:jobs", - resources: {}, - }, - }, "/jobs/provided/:id": { method: "post", path: "/jobs/provided/:id", @@ -393,24 +365,4 @@ export const zJobsRoutesV2 = { }, }, }, - put: { - "/jobs/:id": { - method: "put", - path: "/jobs/:id", - params: z.object({ - id: zObjectId, - }), - body: ZJobsPartnersWritableApi, - response: { - "204": z.null(), - }, - securityScheme: { - auth: "api-apprentissage", - access: "api-apprentissage:jobs", - resources: { - jobPartner: [{ _id: { type: "params", key: "id" } }], - }, - }, - }, - }, } as const satisfies IRoutesDef diff --git a/shared/routes/jobs/jobs.routes.v2.test.ts b/shared/routes/jobs/jobs.routes.v2.test.ts deleted file mode 100644 index 9e469fd1a2..0000000000 --- a/shared/routes/jobs/jobs.routes.v2.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { IJobSearchQuery } from "api-alternance-sdk" -import type { IJobSearchResponseLba, IJobOfferWritableLba, IJobOfferCreateResponseLba } from "api-alternance-sdk/internal" -import { Jsonify } from "type-fest" -import { describe, expectTypeOf, it } from "vitest" -import { z } from "zod" - -import { TRAINING_REMOTE_TYPE } from "../../constants" -import { IJobsPartnersWritableApiInput } from "../../models/jobsPartners.model" -import { zJobsRoutesV2 } from "../jobs.routes.v2" - -describe("GET /jobs/search", () => { - const { - querystring, - response: { 200: zResponse }, - } = zJobsRoutesV2.get["/jobs/search"] - - it("should questring schema match", () => { - expectTypeOf().toMatchTypeOf>() - }) - - it("should match response schema", () => { - expectTypeOf>>().toMatchTypeOf>() - }) -}) - -describe("POST /jobs", () => { - const { - body, - response: { 201: zResponse }, - } = zJobsRoutesV2.post["/jobs"] - - it("should body schema match", () => { - type ExpectedBody = Omit, "contract_remote"> & { - contract_remote?: `${TRAINING_REMOTE_TYPE}` | IJobsPartnersWritableApiInput["contract_remote"] - } - - expectTypeOf>().toMatchTypeOf() - }) - - it("should match response schema", () => { - expectTypeOf>>().toMatchTypeOf>() - }) -}) - -describe("PUT /jobs/:id", () => { - const { body } = zJobsRoutesV2.put["/jobs/:id"] - - it("should body schema match", () => { - type ExpectedBody = Omit, "contract_remote"> & { - contract_remote?: `${TRAINING_REMOTE_TYPE}` | IJobsPartnersWritableApiInput["contract_remote"] - } - - expectTypeOf>().toMatchTypeOf() - }) -}) diff --git a/shared/routes/v3/jobs/jobs.routes.v3.model.test.ts b/shared/routes/v3/jobs/jobs.routes.v3.model.test.ts index 45629635f2..0d2b7fc5c8 100644 --- a/shared/routes/v3/jobs/jobs.routes.v3.model.test.ts +++ b/shared/routes/v3/jobs/jobs.routes.v3.model.test.ts @@ -1,21 +1,12 @@ import { ObjectId } from "bson" -import type { RequiredDeep } from "type-fest" import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest" import type { z } from "zod" import { OPCOS_LABEL, TRAINING_REMOTE_TYPE } from "../../../constants" import { JOB_STATUS_ENGLISH } from "../../../models" -import type { IJobsPartnersWritableApi } from "../../../models/jobsPartners.model" -import type { IJobsOpportunityResponse } from "../../jobOpportunity.routes" - -import { - jobsRouteApiv3Converters, - type IJobOfferApiWriteV3, - type IJobSearchApiV3, - type zJobOfferApiReadV3, - type zJobOfferApiWriteV3, - type zJobRecruiterApiReadV3, -} from "./jobs.routes.v3.model" +import type { IJobsPartnersOfferApi } from "../../../models/jobsPartners.model" + +import { jobsRouteApiv3Converters, zJobOfferApiWriteV3, type IJobOfferApiReadV3, type zJobOfferApiReadV3, type zJobRecruiterApiReadV3 } from "./jobs.routes.v3.model" type IJobRecruiterExpected = { identifier: { @@ -161,308 +152,415 @@ describe("IJobOfferApiWriteV3", () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() }) -}) -const today = new Date("2024-11-19T00:00:00.000Z") -const startOfNextMonth = new Date("2024-12-01T00:00:00.000Z") -const endOfNextMonth = new Date("2024-12-31T23:59:59.999Z") -const yesterday = new Date("2024-11-18T00:00:00.000Z") + const now = new Date("2024-06-18T14:30:00.000Z") + const oneMinuteAgo = new Date("2024-06-18T14:29:00.000Z") + const inOneMinute = new Date("2024-06-18T14:31:00.000Z") + const oneHourAgo = new Date("2024-06-18T13:30:00.000Z") + const inOneHour = new Date("2024-06-18T15:30:00.000Z") + + const data: IJobOfferApiWriteV3Input = { + offer: { + title: "Apprentis en développement web", + rome_codes: ["M1602"], + description: "Envie de devenir développeur web ? Rejoignez-nous !", + }, + apply: { + email: "mail@mail.com", + }, + + workplace: { + siret: "39837261500128", + }, + } -beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(today) + beforeEach(async () => { + // Do not mock nextTick + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(now) - return () => { - vi.useRealTimers() - } -}) + return () => { + vi.useRealTimers() + } + }) -describe("convertToJobSearchApiV3", () => { - const id1 = new ObjectId() + describe("contract_start", () => { + it("should be optional", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + }) + + expect.soft(result.success).toBe(true) + expect(result.data?.contract.start).toBe(null) + }) + + it("should be required ISO 8601 date string", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + contract: { + ...data.contract, + start: "2024-09-01", + }, + }) + + expect.soft(result.success).toBe(false) + expect(result.error?.format()).toEqual({ + _errors: [], + contract: { + _errors: [], + start: { + _errors: ["Expected ISO 8601 date string"], + }, + }, + }) + }) + + it("should allow date in past", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + contract: { + ...data.contract, + start: oneHourAgo.toJSON(), + }, + }) + + expect(result.success).toBe(true) + expect(result.data?.contract.start).toEqual(oneHourAgo) + }) + + it("should allow date in future", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + contract: { + ...data.contract, + start: inOneHour.toJSON(), + }, + }) - it("should convert job search response from LBA to API format", () => { - const lbaResponse: IJobsOpportunityResponse = { - jobs: [ - { - _id: "1", - apply_phone: "0300000000", - apply_url: "https://postler.com", - contract_duration: 12, - contract_remote: TRAINING_REMOTE_TYPE.onsite, - contract_start: startOfNextMonth, - contract_type: ["Apprentissage"], - offer_access_conditions: ["Ce métier est accessible avec un diplôme de fin d'études secondaires"], - offer_creation: yesterday, - offer_description: "Exécute des travaux administratifs courants", - offer_desired_skills: ["Faire preuve de rigueur et de précision"], - offer_expiration: endOfNextMonth, - offer_opening_count: 1, - offer_rome_codes: ["M1602"], - offer_status: JOB_STATUS_ENGLISH.ACTIVE, - offer_target_diploma: { - european: "4", - label: "BP, Bac, autres formations niveau (Bac)", + expect(result.success).toBe(true) + expect(result.data?.contract.start).toEqual(inOneHour) + }) + }) + + // describe("offer_status", () => { + // it("should be optional", () => { + // const result = zJobOfferApiWriteV3.safeParse({ + // ...data, + // }) + + // expect(result.success).toBe(true) + // expect(result.data?.offer.status).toBe(JOB_STATUS_ENGLISH.ACTIVE) + // }) + // }) + + describe("offer_creation", () => { + // Fallback is handled in jobOpportinityService + it("should allow null", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + creation: null, }, - offer_title: "Opérations administratives", - offer_to_be_acquired_skills: [ - "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", - "Production, Fabrication: Réaliser des travaux de reprographie", - "Organisation: Contrôler la conformité des données ou des documents", - ], - partner_label: "La bonne alternance", - partner_job_id: null, - workplace_address_label: "Paris", - workplace_brand: "Brand", - workplace_description: "Workplace Description", - workplace_geopoint: { - coordinates: [2.347, 48.8589], - type: "Point", + }, + }) + + expect(result.success).toBe(true) + expect(result.data?.offer.publication.creation).toEqual(null) + }) + + it("should be required ISO 8601 date string", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + creation: "2024-09-01", }, - workplace_idcc: 1242, - workplace_legal_name: "ASSEMBLEE NATIONALE", - workplace_naf_code: "84.11Z", - workplace_naf_label: "Autorité constitutionnelle", - workplace_name: "ASSEMBLEE NATIONALE", - workplace_opco: OPCOS_LABEL.AKTO, - workplace_siret: "11000001500013", - workplace_size: null, - workplace_website: null, }, - ], - recruiters: [ - { - _id: id1, - apply_phone: "0100000000", - apply_url: "http://localhost:3000/recherche-apprentissage?type=lba&itemId=11000001500013", - workplace_address_label: "126 RUE DE L'UNIVERSITE 75007 PARIS", - workplace_brand: "ASSEMBLEE NATIONALE - La vraie", - workplace_description: null, - workplace_geopoint: { - coordinates: [2.347, 48.8589], - type: "Point", + }) + + expect(result.success).toBe(false) + expect(result.error?.format()).toEqual({ + _errors: [], + offer: { + _errors: [], + publication: { + _errors: [], + creation: { + _errors: ["Expected ISO 8601 date string"], + }, }, - workplace_idcc: null, - workplace_legal_name: "ASSEMBLEE NATIONALE", - workplace_naf_code: "8411Z", - workplace_naf_label: "Administration publique générale", - workplace_name: "ASSEMBLEE NATIONALE - La vraie", - workplace_opco: null, - workplace_siret: "11000001500013", - workplace_size: null, - workplace_website: null, }, - ], - warnings: [ - { - code: "FRANCE_TRAVAIL_API_ERROR", - message: "Unable to retrieve job offers from France Travail API", + }) + }) + + it("should allow date in past", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + creation: oneHourAgo.toJSON(), + }, }, - ], - } - - const expectedApiResponse: IJobSearchApiV3 = { - jobs: [ - { - identifier: { - id: "1", - partner_label: "La bonne alternance", - partner_job_id: null, + }) + + expect(result.success).toBe(true) + expect(result.data?.offer.publication.creation).toEqual(oneHourAgo) + }) + + it("should not allow date in future", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + creation: inOneHour.toJSON(), }, - workplace: { - siret: "11000001500013", - brand: "Brand", - legal_name: "ASSEMBLEE NATIONALE", - website: null, - name: "ASSEMBLEE NATIONALE", - description: "Workplace Description", - size: null, - location: { - address: "Paris", - geopoint: { - coordinates: [2.347, 48.8589], - type: "Point", - }, - }, - domain: { - idcc: 1242, - opco: OPCOS_LABEL.AKTO, - naf: { - code: "84.11Z", - label: "Autorité constitutionnelle", - }, + }, + }) + + expect(result.success).toBe(false) + expect(result.error?.format()).toEqual({ + _errors: [], + offer: { + _errors: [], + publication: { + _errors: [], + creation: { + _errors: ["Creation date cannot be in the future"], }, }, - apply: { - url: "https://postler.com", - phone: "0300000000", + }, + }) + }) + + it("should tolerate time clock sync", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + creation: inOneMinute.toJSON(), }, - contract: { - start: startOfNextMonth, - duration: 12, - type: ["Apprentissage"], - remote: TRAINING_REMOTE_TYPE.onsite, + }, + }) + + expect(result.success).toBe(true) + expect(result.data?.offer.publication.creation).toEqual(inOneMinute) + }) + }) + + describe("offer_expiration", () => { + // Fallback is handled in jobOpportinityService + it("should allow null", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + expiration: null, }, - offer: { - title: "Opérations administratives", - rome_codes: ["M1602"], - description: "Exécute des travaux administratifs courants", - target_diploma: { - european: "4", - label: "BP, Bac, autres formations niveau (Bac)", - }, - desired_skills: ["Faire preuve de rigueur et de précision"], - to_be_acquired_skills: [ - "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", - "Production, Fabrication: Réaliser des travaux de reprographie", - "Organisation: Contrôler la conformité des données ou des documents", - ], - access_conditions: ["Ce métier est accessible avec un diplôme de fin d'études secondaires"], - publication: { - creation: yesterday, - expiration: endOfNextMonth, + }, + }) + + expect(result.success).toBe(true) + expect(result.data?.offer.publication.expiration).toEqual(null) + }) + + it("should be required ISO 8601 date string", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + expiration: "2024-09-01", + }, + }, + }) + + expect(result.success).toBe(false) + expect(result.error?.format()).toEqual({ + _errors: [], + offer: { + _errors: [], + publication: { + _errors: [], + expiration: { + _errors: ["Expected ISO 8601 date string"], }, - opening_count: 1, - status: JOB_STATUS_ENGLISH.ACTIVE, }, }, - ], - recruiters: [ - { - identifier: { - id: id1, + }) + }) + + it("should not allow date in future", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + expiration: inOneHour.toJSON(), }, - workplace: { - siret: "11000001500013", - brand: "ASSEMBLEE NATIONALE - La vraie", - legal_name: "ASSEMBLEE NATIONALE", - website: null, - name: "ASSEMBLEE NATIONALE - La vraie", - description: null, - size: null, - location: { - address: "126 RUE DE L'UNIVERSITE 75007 PARIS", - geopoint: { - coordinates: [2.347, 48.8589], - type: "Point", - }, - }, - domain: { - idcc: null, - opco: null, - naf: { - code: "8411Z", - label: "Administration publique générale", - }, - }, + }, + }) + + expect(result.success).toBe(true) + expect(result.data?.offer.publication.expiration).toEqual(inOneHour) + }) + + it("should not allow date in past", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + expiration: oneHourAgo.toJSON(), }, - apply: { - url: "http://localhost:3000/recherche-apprentissage?type=lba&itemId=11000001500013", - phone: "0100000000", + }, + }) + + expect(result.success).toBe(false) + expect(result.error?.format()).toEqual({ + _errors: [], + offer: { + _errors: [], + publication: { + _errors: [], + expiration: { + _errors: ["Expiration date cannot be in the past"], + }, }, }, - ], - warnings: [ - { - code: "FRANCE_TRAVAIL_API_ERROR", - message: "Unable to retrieve job offers from France Travail API", + }) + }) + + it("should tolerate time clock sync", () => { + const result = zJobOfferApiWriteV3.safeParse({ + ...data, + offer: { + ...data.offer, + publication: { + ...data.offer.publication, + expiration: oneMinuteAgo.toJSON(), + }, }, - ], - } + }) - const apiResponse = jobsRouteApiv3Converters.convertToJobSearchApiV3(lbaResponse) - expect(apiResponse).toEqual(expectedApiResponse) + expect(result.success).toBe(true) + expect(result.data?.offer.publication.expiration).toEqual(oneMinuteAgo) + }) }) }) -describe("convertToJobsPartnersWritableApi", () => { - it("should convert minimal job offer from API to LBA format", () => { - const apiOffer: IJobOfferApiWriteV3 = { - offer: { - title: "Opérations administratives", - description: "Exécute des travaux administratifs courants", - }, - workplace: { - siret: "11000001500013", - }, - apply: { - phone: "0600000000", - }, - } +const today = new Date("2024-11-19T00:00:00.000Z") +const startOfNextMonth = new Date("2024-12-01T00:00:00.000Z") +const endOfNextMonth = new Date("2024-12-31T23:59:59.999Z") +const yesterday = new Date("2024-11-18T00:00:00.000Z") - const expectedLbaOffer: IJobsPartnersWritableApi = { - partner_job_id: null, +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(today) - offer_title: "Opérations administratives", - offer_rome_codes: null, + return () => { + vi.useRealTimers() + } +}) + +describe("convertToJobOfferApiReadV3", () => { + const id1 = new ObjectId() + + it("should convert job partner response from LBA to API format", () => { + const jobPartner: IJobsPartnersOfferApi = { + _id: id1, + apply_phone: "0300000000", + apply_url: "https://postler.com", + contract_duration: 12, + contract_remote: TRAINING_REMOTE_TYPE.onsite, + contract_start: startOfNextMonth, + contract_type: ["Apprentissage"], + offer_access_conditions: ["Ce métier est accessible avec un diplôme de fin d'études secondaires"], + offer_creation: yesterday, offer_description: "Exécute des travaux administratifs courants", - offer_target_diploma_european: null, - offer_desired_skills: [], - offer_to_be_acquired_skills: [], - offer_access_conditions: [], - offer_creation: null, - offer_expiration: null, + offer_desired_skills: ["Faire preuve de rigueur et de précision"], + offer_expiration: endOfNextMonth, offer_opening_count: 1, + offer_rome_codes: ["M1602"], offer_status: JOB_STATUS_ENGLISH.ACTIVE, - offer_origin: null, - offer_multicast: true, - + offer_target_diploma: { + european: "4", + label: "BP, Bac, autres formations niveau (Bac)", + }, + offer_title: "Opérations administratives", + offer_to_be_acquired_skills: [ + "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", + "Production, Fabrication: Réaliser des travaux de reprographie", + "Organisation: Contrôler la conformité des données ou des documents", + ], + partner_label: "La bonne alternance", + partner_job_id: null, + workplace_address_label: "Paris", + workplace_brand: "Brand", + workplace_description: "Workplace Description", + workplace_geopoint: { + coordinates: [2.347, 48.8589], + type: "Point", + }, + workplace_idcc: 1242, + workplace_legal_name: "ASSEMBLEE NATIONALE", + workplace_naf_code: "84.11Z", + workplace_naf_label: "Autorité constitutionnelle", + workplace_name: "ASSEMBLEE NATIONALE", + workplace_opco: OPCOS_LABEL.AKTO, workplace_siret: "11000001500013", - workplace_address_label: null, - workplace_description: null, + workplace_size: null, workplace_website: null, - workplace_name: null, - - contract_start: null, - contract_duration: null, - contract_type: ["Apprentissage", "Professionnalisation"], - contract_remote: null, - - apply_email: null, - apply_phone: "0600000000", - apply_url: null, } - expect(jobsRouteApiv3Converters.convertToJobsPartnersWritableApi(apiOffer)).toEqual(expectedLbaOffer) - }) - - it("should convert full job offer from API to LBA format", () => { - const apiOffer: RequiredDeep = { + const expected: IJobOfferApiReadV3 = { identifier: { - partner_job_id: "1", - }, - offer: { - title: "Opérations administratives", - description: "Exécute des travaux administratifs courants", - rome_codes: ["M1602"], - desired_skills: ["Faire preuve de rigueur et de précision"], - to_be_acquired_skills: [ - "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", - "Production, Fabrication: Réaliser des travaux de reprographie", - "Organisation: Contrôler la conformité des données ou des documents", - ], - target_diploma: { - european: "4", - }, - access_conditions: ["Ce métier est accessible avec un diplôme de fin d'études secondaires"], - publication: { - creation: yesterday, - expiration: endOfNextMonth, - }, - opening_count: 1, - multicast: true, - origin: "La bonne alternance", + id: id1, + partner_label: "La bonne alternance", + partner_job_id: null, }, workplace: { siret: "11000001500013", + brand: "Brand", + legal_name: "ASSEMBLEE NATIONALE", + website: null, name: "ASSEMBLEE NATIONALE", description: "Workplace Description", - website: "https://assemblee-nationale.fr", - location: { address: "Paris" }, + size: null, + location: { + address: "Paris", + geopoint: { + coordinates: [2.347, 48.8589], + type: "Point", + }, + }, + domain: { + idcc: 1242, + opco: OPCOS_LABEL.AKTO, + naf: { + code: "84.11Z", + label: "Autorité constitutionnelle", + }, + }, }, apply: { url: "https://postler.com", phone: "0300000000", - email: "mail@mail.com", }, contract: { start: startOfNextMonth, @@ -470,226 +568,30 @@ describe("convertToJobsPartnersWritableApi", () => { type: ["Apprentissage"], remote: TRAINING_REMOTE_TYPE.onsite, }, - } - - const expectedLbaOffer: Required = { - partner_job_id: "1", - offer_title: "Opérations administratives", - offer_description: "Exécute des travaux administratifs courants", - offer_rome_codes: ["M1602"], - offer_desired_skills: ["Faire preuve de rigueur et de précision"], - offer_to_be_acquired_skills: [ - "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", - "Production, Fabrication: Réaliser des travaux de reprographie", - "Organisation: Contrôler la conformité des données ou des documents", - ], - offer_target_diploma_european: "4", - offer_access_conditions: ["Ce métier est accessible avec un diplôme de fin d'études secondaires"], - offer_creation: yesterday, - offer_expiration: endOfNextMonth, - offer_opening_count: 1, - offer_multicast: true, - offer_origin: "La bonne alternance", - offer_status: JOB_STATUS_ENGLISH.ACTIVE, - workplace_siret: "11000001500013", - workplace_name: "ASSEMBLEE NATIONALE", - workplace_description: "Workplace Description", - workplace_website: "https://assemblee-nationale.fr", - workplace_address_label: "Paris", - apply_url: "https://postler.com", - apply_phone: "0300000000", - apply_email: "mail@mail.com", - contract_start: startOfNextMonth, - contract_duration: 12, - contract_type: ["Apprentissage"], - contract_remote: TRAINING_REMOTE_TYPE.onsite, - } - - expect(jobsRouteApiv3Converters.convertToJobsPartnersWritableApi(apiOffer)).toEqual(expectedLbaOffer) - }) - - it("should convert full job offer from API to LBA format", () => { - const apiOffer: RequiredDeep = { - identifier: { - partner_job_id: "1", - }, offer: { title: "Opérations administratives", - description: "Exécute des travaux administratifs courants", rome_codes: ["M1602"], + description: "Exécute des travaux administratifs courants", + target_diploma: { + european: "4", + label: "BP, Bac, autres formations niveau (Bac)", + }, desired_skills: ["Faire preuve de rigueur et de précision"], to_be_acquired_skills: [ "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", "Production, Fabrication: Réaliser des travaux de reprographie", "Organisation: Contrôler la conformité des données ou des documents", ], - target_diploma: { - european: "4", - }, access_conditions: ["Ce métier est accessible avec un diplôme de fin d'études secondaires"], publication: { creation: yesterday, expiration: endOfNextMonth, }, opening_count: 1, - multicast: true, - origin: "La bonne alternance", - }, - workplace: { - siret: "11000001500013", - name: "ASSEMBLEE NATIONALE", - description: "Workplace Description", - website: "https://assemblee-nationale.fr", - location: { address: "Paris" }, - }, - apply: { - url: "https://postler.com", - phone: "0300000000", - email: "mail@mail.com", + status: JOB_STATUS_ENGLISH.ACTIVE, }, - contract: { - start: startOfNextMonth, - duration: 12, - type: ["Apprentissage"], - remote: TRAINING_REMOTE_TYPE.onsite, - }, - } - - const expectedLbaOffer: Required = { - partner_job_id: "1", - offer_title: "Opérations administratives", - offer_description: "Exécute des travaux administratifs courants", - offer_rome_codes: ["M1602"], - offer_desired_skills: ["Faire preuve de rigueur et de précision"], - offer_to_be_acquired_skills: [ - "Production, Fabrication: Procéder à l'enregistrement, au tri, à l'affranchissement du courrier", - "Production, Fabrication: Réaliser des travaux de reprographie", - "Organisation: Contrôler la conformité des données ou des documents", - ], - offer_target_diploma_european: "4", - offer_access_conditions: ["Ce métier est accessible avec un diplôme de fin d'études secondaires"], - offer_creation: yesterday, - offer_expiration: endOfNextMonth, - offer_opening_count: 1, - offer_multicast: true, - offer_origin: "La bonne alternance", - offer_status: JOB_STATUS_ENGLISH.ACTIVE, - workplace_siret: "11000001500013", - workplace_name: "ASSEMBLEE NATIONALE", - workplace_description: "Workplace Description", - workplace_website: "https://assemblee-nationale.fr", - workplace_address_label: "Paris", - apply_url: "https://postler.com", - apply_phone: "0300000000", - apply_email: "mail@mail.com", - contract_start: startOfNextMonth, - contract_duration: 12, - contract_type: ["Apprentissage"], - contract_remote: TRAINING_REMOTE_TYPE.onsite, - } - - expect(jobsRouteApiv3Converters.convertToJobsPartnersWritableApi(apiOffer)).toEqual(expectedLbaOffer) - }) - - it("should support null target_diploma", () => { - const apiOffer: IJobOfferApiWriteV3 = { - offer: { - title: "Opérations administratives", - description: "Exécute des travaux administratifs courants", - target_diploma: null, - }, - workplace: { - siret: "11000001500013", - }, - apply: { - phone: "0600000000", - }, - } - - const expectedLbaOffer: IJobsPartnersWritableApi = { - partner_job_id: null, - - offer_title: "Opérations administratives", - offer_rome_codes: null, - offer_description: "Exécute des travaux administratifs courants", - offer_target_diploma_european: null, - offer_desired_skills: [], - offer_to_be_acquired_skills: [], - offer_access_conditions: [], - offer_creation: null, - offer_expiration: null, - offer_opening_count: 1, - offer_status: JOB_STATUS_ENGLISH.ACTIVE, - offer_origin: null, - offer_multicast: true, - - workplace_siret: "11000001500013", - workplace_address_label: null, - workplace_description: null, - workplace_website: null, - workplace_name: null, - - contract_start: null, - contract_duration: null, - contract_type: ["Apprentissage", "Professionnalisation"], - contract_remote: null, - - apply_email: null, - apply_phone: "0600000000", - apply_url: null, - } - - expect(jobsRouteApiv3Converters.convertToJobsPartnersWritableApi(apiOffer)).toEqual(expectedLbaOffer) - }) - - it("should support null workplace_location", () => { - const apiOffer: IJobOfferApiWriteV3 = { - offer: { - title: "Opérations administratives", - description: "Exécute des travaux administratifs courants", - }, - workplace: { - siret: "11000001500013", - location: null, - }, - apply: { - phone: "0600000000", - }, - } - - const expectedLbaOffer: IJobsPartnersWritableApi = { - partner_job_id: null, - - offer_title: "Opérations administratives", - offer_rome_codes: null, - offer_description: "Exécute des travaux administratifs courants", - offer_target_diploma_european: null, - offer_desired_skills: [], - offer_to_be_acquired_skills: [], - offer_access_conditions: [], - offer_creation: null, - offer_expiration: null, - offer_opening_count: 1, - offer_status: JOB_STATUS_ENGLISH.ACTIVE, - offer_origin: null, - offer_multicast: true, - - workplace_siret: "11000001500013", - workplace_address_label: null, - workplace_description: null, - workplace_website: null, - workplace_name: null, - - contract_start: null, - contract_duration: null, - contract_type: ["Apprentissage", "Professionnalisation"], - contract_remote: null, - - apply_email: null, - apply_phone: "0600000000", - apply_url: null, } - expect(jobsRouteApiv3Converters.convertToJobsPartnersWritableApi(apiOffer)).toEqual(expectedLbaOffer) + expect(jobsRouteApiv3Converters.convertToJobOfferApiReadV3(jobPartner)).toEqual(expected) }) }) diff --git a/shared/routes/v3/jobs/jobs.routes.v3.model.ts b/shared/routes/v3/jobs/jobs.routes.v3.model.ts index b1a2b3c2f5..bba285dc85 100644 --- a/shared/routes/v3/jobs/jobs.routes.v3.model.ts +++ b/shared/routes/v3/jobs/jobs.routes.v3.model.ts @@ -1,16 +1,17 @@ import { z } from "zod" +import { TRAINING_CONTRACT_TYPE } from "../../../constants" +import { extensions } from "../../../helpers/zodHelpers/zodPrimitives" import { + zDiplomaEuropeanLevel, ZJobsPartnersOfferApi, - ZJobsPartnersPostApiBodyBase, + ZJobsPartnersOfferPrivate, ZJobsPartnersRecruiterApi, - ZJobsPartnersWritableApi, type IJobsPartnersOfferApi, type IJobsPartnersRecruiterApi, - type IJobsPartnersWritableApi, - type IJobsPartnersWritableApiInput, } from "../../../models/jobsPartners.model" -import type { IJobsOpportunityResponse } from "../../jobOpportunity.routes" + +const TIME_CLOCK_TOLERANCE = 300_000 export const zJobRecruiterApiReadV3 = z.object({ identifier: z.object({ @@ -80,96 +81,142 @@ export const zJobOfferApiReadV3 = z.object({ export type IJobOfferApiReadV3 = z.output -export const zJobSearchApiV3 = z.object({ +export const zJobSearchApiV3Response = z.object({ jobs: zJobOfferApiReadV3.array(), recruiters: zJobRecruiterApiReadV3.array(), warnings: z.array(z.object({ message: z.string(), code: z.string() })), }) -export type IJobSearchApiV3 = z.output - -export const zJobOfferApiWriteV3 = z +export const zJobSearchApiV3Query = z .object({ - identifier: z.object({ - partner_job_id: ZJobsPartnersPostApiBodyBase.shape.partner_job_id, - }), - contract: z.object({ - start: ZJobsPartnersPostApiBodyBase.shape.contract_start, - duration: ZJobsPartnersPostApiBodyBase.shape.contract_duration, - type: ZJobsPartnersPostApiBodyBase.shape.contract_type, - remote: ZJobsPartnersPostApiBodyBase.shape.contract_remote, - }), - offer: z - .object({ - title: ZJobsPartnersPostApiBodyBase.shape.offer_title, - rome_codes: ZJobsPartnersPostApiBodyBase.shape.offer_rome_codes, - description: ZJobsPartnersPostApiBodyBase.shape.offer_description, - target_diploma: z - .object({ - european: ZJobsPartnersPostApiBodyBase.shape.offer_target_diploma_european, - }) - .nullable() - .default(null), - desired_skills: ZJobsPartnersPostApiBodyBase.shape.offer_desired_skills, - to_be_acquired_skills: ZJobsPartnersPostApiBodyBase.shape.offer_to_be_acquired_skills, - access_conditions: ZJobsPartnersPostApiBodyBase.shape.offer_access_conditions, - publication: z - .object({ - creation: ZJobsPartnersPostApiBodyBase.shape.offer_creation, - expiration: ZJobsPartnersPostApiBodyBase.shape.offer_expiration, - }) - .partial(), - opening_count: ZJobsPartnersPostApiBodyBase.shape.offer_opening_count, - origin: ZJobsPartnersPostApiBodyBase.shape.offer_origin, - multicast: ZJobsPartnersPostApiBodyBase.shape.offer_multicast, + latitude: extensions.latitude({ coerce: true }).nullable().default(null), + longitude: extensions.longitude({ coerce: true }).nullable().default(null), + radius: z.coerce.number().min(0).max(200).default(30), + target_diploma_level: zDiplomaEuropeanLevel.optional(), + romes: extensions.romeCodeArray().nullable().default(null), + rncp: extensions.rncpCode().nullable().default(null), + }) + .superRefine((data, ctx) => { + if (data.longitude == null && data.latitude != null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["longitude"], + message: "longitude is required when latitude is provided", }) - .partial() - .required({ - title: true, - description: true, - }), - apply: z + } + + if (data.longitude != null && data.latitude == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["latitude"], + message: "latitude is required when longitude is provided", + }) + } + }) + +export type IJobSearchApiV3Response = z.output +export type IJobSearchApiV3Query = z.output +export type IJobSearchApiV3QueryResolved = Omit & { + geo: { latitude: number; longitude: number; radius: number } | null +} + +export const zJobOfferApiWriteV3 = z.object({ + identifier: z + .object({ + partner_job_id: ZJobsPartnersOfferPrivate.shape.partner_job_id.default(null), + }) + .default({}), + contract: z + .object({ + start: z + .string({ message: "Expected ISO 8601 date string" }) + .datetime({ offset: true, message: "Expected ISO 8601 date string" }) + .pipe(z.coerce.date()) + .nullable() + .default(null), + duration: ZJobsPartnersOfferPrivate.shape.contract_duration.default(null), + type: ZJobsPartnersOfferPrivate.shape.contract_type.default([TRAINING_CONTRACT_TYPE.APPRENTISSAGE, TRAINING_CONTRACT_TYPE.PROFESSIONNALISATION]), + remote: ZJobsPartnersOfferPrivate.shape.contract_remote.default(null), + }) + .default({}), + offer: z.object({ + title: ZJobsPartnersOfferPrivate.shape.offer_title, + rome_codes: ZJobsPartnersOfferPrivate.shape.offer_rome_codes.nullable().default(null), + description: ZJobsPartnersOfferPrivate.shape.offer_description.min(30, "Job description should be at least 30 characters"), + target_diploma: z .object({ - email: ZJobsPartnersPostApiBodyBase.shape.apply_email, - phone: ZJobsPartnersPostApiBodyBase.shape.apply_phone, - url: ZJobsPartnersPostApiBodyBase.shape.apply_url, + european: zDiplomaEuropeanLevel.nullable().default(null), }) - .partial() - .superRefine((data, ctx) => { - const keys = ["url", "email", "phone"] as const - if (keys.every((key) => data[key] == null)) { - keys.forEach((key) => { - ctx.addIssue({ - code: "custom", - message: "At least one of url, email, or phone is required", - path: [key], + .nullable() + .default(null), + desired_skills: ZJobsPartnersOfferPrivate.shape.offer_desired_skills.default([]), + to_be_acquired_skills: ZJobsPartnersOfferPrivate.shape.offer_to_be_acquired_skills.default([]), + access_conditions: ZJobsPartnersOfferPrivate.shape.offer_access_conditions.default([]), + publication: z + .object({ + creation: z + .string({ message: "Expected ISO 8601 date string" }) + .datetime({ offset: true, message: "Expected ISO 8601 date string" }) + .pipe( + z.coerce.date().refine((value) => value.getTime() < Date.now() + TIME_CLOCK_TOLERANCE, { + message: "Creation date cannot be in the future", + }) + ) + .nullable() + .default(null), + expiration: z + .string({ message: "Expected ISO 8601 date string" }) + .datetime({ offset: true, message: "Expected ISO 8601 date string" }) + .pipe( + z.coerce.date().refine((value) => value === null || value.getTime() > Date.now() - TIME_CLOCK_TOLERANCE, { + message: "Expiration date cannot be in the past", }) + ) + .nullable() + .default(null), + }) + .default({}), + opening_count: ZJobsPartnersOfferPrivate.shape.offer_opening_count.default(1), + origin: ZJobsPartnersOfferPrivate.shape.offer_origin, + multicast: ZJobsPartnersOfferPrivate.shape.offer_multicast, + }), + apply: z + .object({ + email: ZJobsPartnersOfferPrivate.shape.apply_email, + phone: extensions.telephone.nullable().describe("Téléphone de contact").default(null), + url: ZJobsPartnersOfferApi.shape.apply_url.nullable().default(null), + }) + .required() + .superRefine((data, ctx) => { + const keys = ["url", "email", "phone"] as const + if (keys.every((key) => data[key] == null)) { + keys.forEach((key) => { + ctx.addIssue({ + code: "custom", + message: "At least one of url, email, or phone is required", + path: [key], }) - } + }) + } - return data - }), - workplace: z + return data + }), + workplace: z.object({ + siret: extensions.siret, + name: ZJobsPartnersOfferPrivate.shape.workplace_name.default(null), + website: ZJobsPartnersOfferPrivate.shape.workplace_website.default(null), + description: ZJobsPartnersOfferPrivate.shape.workplace_description.default(null), + location: z .object({ - siret: ZJobsPartnersPostApiBodyBase.shape.workplace_siret, - name: ZJobsPartnersPostApiBodyBase.shape.workplace_name, - website: ZJobsPartnersPostApiBodyBase.shape.workplace_website, - description: ZJobsPartnersPostApiBodyBase.shape.workplace_description, - location: z - .object({ - address: ZJobsPartnersPostApiBodyBase.shape.workplace_address_label, - }) - .nullish(), + address: z.string().nullable().default(null), }) - .partial() - .required({ siret: true }), - }) - .partial({ - identifier: true, - contract: true, - }) + .nullable() + .default({}), + }), +}) export type IJobOfferApiWriteV3 = z.output +export type IJobOfferApiWriteV3Input = z.input function convertToJobWorkplaceReadV3(input: IJobsPartnersOfferApi | IJobsPartnersRecruiterApi): IJobRecruiterApiReadV3["workplace"] { return { @@ -199,15 +246,6 @@ function convertToJobApplyReadV3(input: IJobsPartnersOfferApi | IJobsPartnersRec } } -function convertToJobRecruiterApiReadV3(input: IJobsPartnersRecruiterApi): IJobRecruiterApiReadV3 { - return { - identifier: { - id: input._id, - }, - workplace: convertToJobWorkplaceReadV3(input), - apply: convertToJobApplyReadV3(input), - } -} function convertToJobOfferApiReadV3(input: IJobsPartnersOfferApi): IJobOfferApiReadV3 { return { identifier: { @@ -244,108 +282,6 @@ function convertToJobOfferApiReadV3(input: IJobsPartnersOfferApi): IJobOfferApiR } } -function convertToJobSearchApiV3(input: IJobsOpportunityResponse): IJobSearchApiV3 { - return { - jobs: input.jobs.map(convertToJobOfferApiReadV3), - recruiters: input.recruiters.map(convertToJobRecruiterApiReadV3), - warnings: input.warnings, - } -} - -function convertToJobsPartnersWritableApi(jobOffer: IJobOfferApiWriteV3): IJobsPartnersWritableApi { - const result: IJobsPartnersWritableApiInput = { - offer_title: jobOffer.offer.title, - offer_description: jobOffer.offer.description, - workplace_siret: jobOffer.workplace.siret, - } - - if (jobOffer.identifier) { - if (jobOffer.identifier.partner_job_id != null) { - result.partner_job_id = jobOffer.identifier.partner_job_id - } - } - - if (jobOffer.workplace.name != null) { - result.workplace_name = jobOffer.workplace.name - } - if (jobOffer.workplace.description != null) { - result.workplace_description = jobOffer.workplace.description - } - if (jobOffer.workplace.website != null) { - result.workplace_website = jobOffer.workplace.website - } - if (jobOffer.workplace.location != null) { - result.workplace_address_label = jobOffer.workplace.location.address - } - - if (jobOffer.apply.url != null) { - result.apply_url = jobOffer.apply.url - } - if (jobOffer.apply.phone != null) { - result.apply_phone = jobOffer.apply.phone - } - if (jobOffer.apply.email != null) { - result.apply_email = jobOffer.apply.email - } - - if (jobOffer.contract) { - if (jobOffer.contract.start != null) { - result.contract_start = jobOffer.contract.start.toJSON() - } - if (jobOffer.contract.duration != null) { - result.contract_duration = jobOffer.contract.duration - } - if (jobOffer.contract.type != null) { - result.contract_type = jobOffer.contract.type - } - if (jobOffer.contract.remote != null) { - result.contract_remote = jobOffer.contract.remote - } - } - - if (jobOffer.offer) { - if (jobOffer.offer.rome_codes != null) { - result.offer_rome_codes = jobOffer.offer.rome_codes - } - if (jobOffer.offer.target_diploma != null) { - result.offer_target_diploma_european = jobOffer.offer.target_diploma != null ? jobOffer.offer.target_diploma.european : null - } - if (jobOffer.offer.desired_skills != null) { - result.offer_desired_skills = jobOffer.offer.desired_skills - } - if (jobOffer.offer.to_be_acquired_skills != null) { - result.offer_to_be_acquired_skills = jobOffer.offer.to_be_acquired_skills - } - if (jobOffer.offer.access_conditions != null) { - result.offer_access_conditions = jobOffer.offer.access_conditions - } - if (jobOffer.offer.publication != null) { - if (jobOffer.offer.publication.creation != null) { - result.offer_creation = jobOffer.offer.publication.creation.toJSON() - } - if (jobOffer.offer.publication.expiration != null) { - result.offer_expiration = jobOffer.offer.publication.expiration.toJSON() - } - } - if (jobOffer.offer.opening_count != null) { - result.offer_opening_count = jobOffer.offer.opening_count - } - if (jobOffer.offer.multicast != null) { - result.offer_multicast = jobOffer.offer.multicast - } - if (jobOffer.offer.origin != null) { - result.offer_origin = jobOffer.offer.origin - } - // TODO: offer_status to be implemented - // if (jobOffer.offer.status != null) { - // result.offer_status = jobOffer.offer.status; - // } - } - - return ZJobsPartnersWritableApi.parse(result) -} - export const jobsRouteApiv3Converters = { - convertToJobSearchApiV3, - convertToJobsPartnersWritableApi, + convertToJobOfferApiReadV3, } diff --git a/shared/routes/v3/jobs/jobs.routes.v3.ts b/shared/routes/v3/jobs/jobs.routes.v3.ts index 177ab07bce..eaf514c628 100644 --- a/shared/routes/v3/jobs/jobs.routes.v3.ts +++ b/shared/routes/v3/jobs/jobs.routes.v3.ts @@ -1,18 +1,17 @@ import { z } from "../../../helpers/zodWithOpenApi" import { zObjectId } from "../../../models/common" import { IRoutesDef } from "../../common.routes" -import { ZJobOpportunityGetQuery } from "../../jobOpportunity.routes" -import { zJobSearchApiV3, zJobOfferApiWriteV3 } from "./jobs.routes.v3.model" +import { zJobSearchApiV3Response, zJobOfferApiWriteV3, zJobSearchApiV3Query } from "./jobs.routes.v3.model" export const zJobsRoutesV3 = { get: { "/v3/jobs/search": { method: "get", path: "/v3/jobs/search", - querystring: ZJobOpportunityGetQuery, + querystring: zJobSearchApiV3Query, response: { - "200": zJobSearchApiV3, + "200": zJobSearchApiV3Response, }, securityScheme: { auth: "api-apprentissage",