Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/api/src/tenders/tenders.contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ import { z } from "zod";
// Bornes calquees sur api_v2/routers/tenders.py::list_tenders -- valider ici
// evite d'expedier une requete que l'API VigieProcure refusera de toute
// facon, et donne une erreur Zod lisible cote CRM plutot qu'un 422 distant.
//
// `status` : mapping metier valide cote fiche compte (onglet "Marches
// publics") -- "En cours" = active, "Notifie" = awarded, "Prevu" =
// previsionnel, "Tous" = parametre omis. `published`/`cancelled`/`expired`
// existent cote api_v2 mais n'ont pas d'usage CRM identifie ; ne pas les
// exposer ici tant qu'un besoin ne les justifie pas.
export const tendersListOuvertsInput = z.object({
cpv: z.string().trim().min(2).max(400).optional(),
department: z.string().trim().min(1).max(3).optional(),
siren: z.string().trim().min(9).max(14).optional(),
status: z.enum(["active", "awarded", "previsionnel"]).optional(),
q: z.string().trim().min(2).max(200).optional(),
page: z.number().int().min(1).max(500).default(1),
limit: z.number().int().min(1).max(100).default(20),
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/tenders/tenders.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export class TendersService {
return listOpenTenders({
cpv: input.cpv,
department: input.department,
siren: input.siren,
status: input.status,
q: input.q,
page: input.page,
limit: input.limit,
Expand Down
23 changes: 17 additions & 6 deletions apps/api/src/tenders/vigieprocure-tenders.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export type TendersPage = {
export type TendersQuery = {
cpv?: string;
department?: string;
siren?: string;
status?: "active" | "awarded" | "previsionnel";
q?: string;
page?: number;
limit?: number;
Expand All @@ -100,11 +102,16 @@ export type TendersResult =
| { outcome: "failed"; reason: string };

/**
* Liste les marches ouverts via l'API VigieProcure
* (`GET /api/v1/tenders?status=active`). `status` est TOUJOURS "active" --
* ce n'est pas un parametre d'entree cote CRM, c'est le seul statut que
* cette page a vocation a montrer (marches publies et non encore
* echus, cf. api_v2/routers/tenders.py::_STATUTS).
* Liste les marches via l'API VigieProcure (`GET /api/v1/tenders`).
*
* `status` est un parametre d'entree optionnel cote CRM (mapping metier
* "En cours"=active / "Notifie"=awarded / "Prevu"=previsionnel, defini dans
* `tenders.contracts.ts`). Un appel SANS `status` transmet la requete telle
* quelle a api_v2, qui repond alors sans filtre de statut ("Tous") --
* c'est le comportement voulu par l'onglet "Marches publics" de la fiche
* compte, PAS un defaut a "active" impose ici (cf. api_v2/routers/tenders.py
* ::_STATUTS ; `status=active` doit desormais etre demande explicitement
* par l'appelant s'il veut "En cours").
*
* Pas de champ "lien vers l'avis d'origine" ici : `external_url` n'existe
* QUE sur `GET /tenders/{id}` (extrait de `raw_data->>'url_avis'`), pas sur
Expand All @@ -117,12 +124,16 @@ export async function listOpenTenders(
if (!api) return { outcome: "not-configured" };

const target = new URL(api.url);
target.searchParams.set("status", "active");
// Omission volontaire des parametres vides : `q`/`cpv` exigent
// min_length>=2 et `department` min_length>=1 cote api_v2 -- envoyer une
// chaine vide donnerait un 422 sur CHAQUE chargement de page non filtre.
if (query.cpv) target.searchParams.set("cpv", query.cpv);
if (query.department) target.searchParams.set("department", query.department);
// api_v2 tronque au SIREN (9 premiers caracteres) quand un SIRET (14) est
// fourni -- cf. api_v2/routers/tenders.py::list_tenders. On envoie la
// valeur telle quelle, c'est la responsabilite d'api_v2, pas la notre.
if (query.siren) target.searchParams.set("siren", query.siren);
if (query.status) target.searchParams.set("status", query.status);
if (query.q) target.searchParams.set("q", query.q);
if (query.cursor) {
target.searchParams.set("cursor", query.cursor);
Expand Down
141 changes: 141 additions & 0 deletions apps/api/test/vigieprocure-tenders-client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import type { z } from "zod";
import {
listOpenTenders,
vigieProcureTendersApi,
} from "../src/tenders/vigieprocure-tenders.client";

const realFetch = globalThis.fetch;

let previousUrl: string | undefined;
let previousJwt: string | undefined;

beforeEach(() => {
previousUrl = process.env.VIGIEPROCURE_API_URL;
previousJwt = process.env.VIGIEPROCURE_API_JWT;
process.env.VIGIEPROCURE_API_URL = "https://api.vigieproc.fr";
process.env.VIGIEPROCURE_API_JWT = "test-jwt";
});

afterEach(() => {
globalThis.fetch = realFetch;
if (previousUrl === undefined) delete process.env.VIGIEPROCURE_API_URL;
else process.env.VIGIEPROCURE_API_URL = previousUrl;
if (previousJwt === undefined) delete process.env.VIGIEPROCURE_API_JWT;
else process.env.VIGIEPROCURE_API_JWT = previousJwt;
});

type FetchStub = { calledWith: () => URL };

/** Stub fetch and capture the request URL it was called with. */
function stub(status: number, body: z.core.util.JSONType): FetchStub {
let captured: URL | null = null;
globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => {
captured = input instanceof Request ? new URL(input.url) : new URL(input);
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
return {
calledWith: () => {
if (!captured) throw new Error("fetch was not called");
return captured;
},
};
}

const emptyPage = {
items: [],
count: 0,
total: 0,
total_est_plafonne: false,
page: 1,
page_size: 20,
next_cursor: null,
};

describe("vigieProcureTendersApi", () => {
it("returns null when the JWT is unset -- not an unauthenticated call", () => {
delete process.env.VIGIEPROCURE_API_JWT;
expect(vigieProcureTendersApi()).toBeNull();
});

it("returns null when the URL is unset", () => {
delete process.env.VIGIEPROCURE_API_URL;
expect(vigieProcureTendersApi()).toBeNull();
});
});

describe("listOpenTenders", () => {
it("degrades to not-configured when the env is unset -- never crashes", async () => {
delete process.env.VIGIEPROCURE_API_URL;
delete process.env.VIGIEPROCURE_API_JWT;
const result = await listOpenTenders({});
expect(result).toEqual({ outcome: "not-configured" });
});

it("sends no status param when none is given -- 'Tous', not a hidden default to active", async () => {
const fetchSpy = stub(200, emptyPage);
await listOpenTenders({});
expect(fetchSpy.calledWith().searchParams.has("status")).toBe(false);
});

it("forwards an explicit status=active ('En cours')", async () => {
const fetchSpy = stub(200, emptyPage);
await listOpenTenders({ status: "active" });
expect(fetchSpy.calledWith().searchParams.get("status")).toBe("active");
});

it("forwards status=awarded ('Notifie')", async () => {
const fetchSpy = stub(200, emptyPage);
await listOpenTenders({ status: "awarded" });
expect(fetchSpy.calledWith().searchParams.get("status")).toBe("awarded");
});

it("forwards status=previsionnel ('Prevu')", async () => {
const fetchSpy = stub(200, emptyPage);
await listOpenTenders({ status: "previsionnel" });
expect(fetchSpy.calledWith().searchParams.get("status")).toBe(
"previsionnel",
);
});

it("forwards siren untouched -- truncation to 9 digits is api_v2's job", async () => {
const fetchSpy = stub(200, emptyPage);
await listOpenTenders({ siren: "42498265000012" });
expect(fetchSpy.calledWith().searchParams.get("siren")).toBe(
"42498265000012",
);
});

it("omits siren when not given", async () => {
const fetchSpy = stub(200, emptyPage);
await listOpenTenders({});
expect(fetchSpy.calledWith().searchParams.has("siren")).toBe(false);
});

it("combines siren and status together (account-tab use case)", async () => {
const fetchSpy = stub(200, emptyPage);
await listOpenTenders({ siren: "424982650", status: "awarded" });
const params = fetchSpy.calledWith().searchParams;
expect(params.get("siren")).toBe("424982650");
expect(params.get("status")).toBe("awarded");
});

it("maps 401 to unauthorized", async () => {
stub(401, { detail: "Not authenticated" });
const result = await listOpenTenders({});
expect(result.outcome).toBe("unauthorized");
});

it("maps a 200 body through to outcome ok", async () => {
stub(200, emptyPage);
const result = await listOpenTenders({});
expect(result.outcome).toBe("ok");
if (result.outcome === "ok") {
expect(result.page.items).toEqual([]);
expect(result.page.total).toBe(0);
}
});
});
Loading
Loading