Skip to content

Commit b70a31c

Browse files
committed
Merge remote-tracking branch 'origin/alpha' into feat/issue-3177-maildev-setup
2 parents b304983 + c42bc81 commit b70a31c

29 files changed

Lines changed: 1606 additions & 5 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Metadata } from "next";
2+
import { notFound } from "next/navigation";
3+
4+
import { PublicReferentDetail } from "~/modules/referents";
5+
import { api } from "~/trpc/server";
6+
7+
export const metadata: Metadata = {
8+
title: "Référent Égalité Professionnelle",
9+
description:
10+
"Coordonnées du référent Égalité Professionnelle : contact principal et suppléant.",
11+
};
12+
13+
type Props = {
14+
params: Promise<{ id: string }>;
15+
};
16+
17+
export default async function Page({ params }: Props) {
18+
const { id } = await params;
19+
20+
const referent = await api.publicReferents.getById({ id }).catch(() => null);
21+
22+
if (!referent) {
23+
notFound();
24+
}
25+
26+
return <PublicReferentDetail referent={referent} />;
27+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Metadata } from "next";
2+
3+
import { PublicReferentsPage } from "~/modules/referents";
4+
import { HydrateClient } from "~/trpc/server";
5+
6+
export const metadata: Metadata = {
7+
title: "Référents Égalité Professionnelle",
8+
description:
9+
"Trouvez le référent Égalité Professionnelle de votre région ou de votre département. Recherche par région, département ou nom.",
10+
};
11+
12+
export default function Page() {
13+
return (
14+
<HydrateClient>
15+
<PublicReferentsPage />
16+
</HydrateClient>
17+
);
18+
}

packages/app/src/e2e/helpers/db.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,59 @@ export async function deleteCampaignDeadlines(year: number) {
365365
}
366366
}
367367

368+
type ReferentSeed = {
369+
id: string;
370+
region: string;
371+
county: string | null;
372+
name: string;
373+
type: "email" | "url";
374+
value: string;
375+
principal: boolean;
376+
substituteName: string | null;
377+
substituteEmail: string | null;
378+
};
379+
380+
/** Insert or update a list of referents (used by public referents E2E tests). */
381+
export async function seedReferents(rows: ReferentSeed[]) {
382+
const sql = createConnection();
383+
try {
384+
for (const r of rows) {
385+
await sql`
386+
INSERT INTO app_referent (
387+
id, region, county, name, type, value, principal,
388+
substitute_name, substitute_email, created_at, updated_at
389+
) VALUES (
390+
${r.id}, ${r.region}, ${r.county}, ${r.name}, ${r.type},
391+
${r.value}, ${r.principal},
392+
${r.substituteName}, ${r.substituteEmail}, NOW(), NOW()
393+
)
394+
ON CONFLICT (id) DO UPDATE SET
395+
region = EXCLUDED.region,
396+
county = EXCLUDED.county,
397+
name = EXCLUDED.name,
398+
type = EXCLUDED.type,
399+
value = EXCLUDED.value,
400+
principal = EXCLUDED.principal,
401+
substitute_name = EXCLUDED.substitute_name,
402+
substitute_email = EXCLUDED.substitute_email
403+
`;
404+
}
405+
} finally {
406+
await sql.end();
407+
}
408+
}
409+
410+
/** Remove referents by id (used by public referents E2E tests). */
411+
export async function deleteReferents(ids: string[]) {
412+
if (ids.length === 0) return;
413+
const sql = createConnection();
414+
try {
415+
await sql`DELETE FROM app_referent WHERE id = ANY(${ids})`;
416+
} finally {
417+
await sql.end();
418+
}
419+
}
420+
368421
/** Clear or set the phone number for the test user (identified via user_company link to TEST_SIREN). */
369422
export async function setUserPhone(phone: string | null) {
370423
const sql = createConnection();
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
import { deleteReferents, seedReferents } from "./helpers/db";
4+
5+
// Fixed UUIDs so detail-page URLs (`/referents/[id]`) pass the
6+
// `z.string().uuid()` check in `publicReferents.getById`.
7+
const TEST_REFERENTS = [
8+
{
9+
id: "11111111-1111-4111-8111-111111111111",
10+
region: "11",
11+
county: "75",
12+
name: "E2E Référent Paris",
13+
type: "email" as const,
14+
value: "e2e-paris@dreets.test",
15+
principal: true,
16+
substituteName: "Suppléant Paris",
17+
substituteEmail: "e2e-paris-sub@dreets.test",
18+
},
19+
{
20+
id: "22222222-2222-4222-8222-222222222222",
21+
region: "11",
22+
county: "92",
23+
name: "E2E Référent Hauts-de-Seine",
24+
type: "url" as const,
25+
value: "https://dreets.test/contact-92",
26+
principal: false,
27+
substituteName: null,
28+
substituteEmail: null,
29+
},
30+
{
31+
id: "33333333-3333-4333-8333-333333333333",
32+
region: "53",
33+
county: "35",
34+
name: "E2E Référent Rennes",
35+
type: "email" as const,
36+
value: "e2e-rennes@dreets.test",
37+
principal: true,
38+
substituteName: null,
39+
substituteEmail: null,
40+
},
41+
];
42+
43+
test.beforeAll(async () => {
44+
await seedReferents(TEST_REFERENTS);
45+
});
46+
47+
test.afterAll(async () => {
48+
await deleteReferents(TEST_REFERENTS.map((r) => r.id));
49+
});
50+
51+
test.describe("public referents search", () => {
52+
test("anonymous user can access /referents without authentication", async ({
53+
browser,
54+
}) => {
55+
const anonCtx = await browser.newContext({ storageState: undefined });
56+
try {
57+
const page = await anonCtx.newPage();
58+
await page.goto("/referents");
59+
await expect(
60+
page.getByRole("heading", {
61+
name: /référents égalité professionnelle/i,
62+
level: 1,
63+
}),
64+
).toBeVisible();
65+
expect(page.url()).toContain("/referents");
66+
} finally {
67+
await anonCtx.close();
68+
}
69+
});
70+
71+
test("search by region filters the results", async ({ browser }) => {
72+
const anonCtx = await browser.newContext({ storageState: undefined });
73+
try {
74+
const page = await anonCtx.newPage();
75+
await page.goto("/referents");
76+
await page.getByLabel("Région").selectOption("11");
77+
await page.getByRole("button", { name: /^rechercher$/i }).click();
78+
79+
await expect(page.getByText("E2E Référent Paris")).toBeVisible();
80+
await expect(page.getByText("E2E Référent Hauts-de-Seine")).toBeVisible();
81+
await expect(page.getByText("E2E Référent Rennes")).not.toBeVisible();
82+
} finally {
83+
await anonCtx.close();
84+
}
85+
});
86+
87+
test("search by name filters the results", async ({ browser }) => {
88+
const anonCtx = await browser.newContext({ storageState: undefined });
89+
try {
90+
const page = await anonCtx.newPage();
91+
await page.goto("/referents");
92+
await page.getByLabel("Nom du référent").fill("Rennes");
93+
await page.getByRole("button", { name: /^rechercher$/i }).click();
94+
95+
await expect(page.getByText("E2E Référent Rennes")).toBeVisible();
96+
await expect(page.getByText("E2E Référent Paris")).not.toBeVisible();
97+
} finally {
98+
await anonCtx.close();
99+
}
100+
});
101+
102+
test("search with no matches shows the empty state", async ({ browser }) => {
103+
const anonCtx = await browser.newContext({ storageState: undefined });
104+
try {
105+
const page = await anonCtx.newPage();
106+
await page.goto("/referents");
107+
await page
108+
.getByLabel("Nom du référent")
109+
.fill("ZZZZ-nonexistent-name-XXX");
110+
await page.getByRole("button", { name: /^rechercher$/i }).click();
111+
112+
await expect(page.getByText(/aucun référent/i)).toBeVisible();
113+
} finally {
114+
await anonCtx.close();
115+
}
116+
});
117+
118+
test("list page does not show contact details", async ({ browser }) => {
119+
const anonCtx = await browser.newContext({ storageState: undefined });
120+
try {
121+
const page = await anonCtx.newPage();
122+
await page.goto("/referents");
123+
await expect(page.getByText("E2E Référent Paris")).toBeVisible();
124+
await expect(page.getByText("e2e-paris@dreets.test")).not.toBeVisible();
125+
await expect(
126+
page.getByText("e2e-paris-sub@dreets.test"),
127+
).not.toBeVisible();
128+
} finally {
129+
await anonCtx.close();
130+
}
131+
});
132+
133+
test("click-through to detail page reveals contact info", async ({
134+
browser,
135+
}) => {
136+
const anonCtx = await browser.newContext({ storageState: undefined });
137+
try {
138+
const page = await anonCtx.newPage();
139+
await page.goto("/referents");
140+
141+
const row = page.locator("li", { hasText: "E2E Référent Paris" });
142+
await row.getByRole("link", { name: /voir le contact/i }).click();
143+
144+
await expect(
145+
page.getByRole("heading", {
146+
level: 1,
147+
name: /E2E Référent Paris/,
148+
}),
149+
).toBeVisible();
150+
await expect(page.getByText("e2e-paris@dreets.test")).toBeVisible();
151+
await expect(page.getByText("Suppléant Paris")).toBeVisible();
152+
await expect(page.getByText("e2e-paris-sub@dreets.test")).toBeVisible();
153+
} finally {
154+
await anonCtx.close();
155+
}
156+
});
157+
158+
test("URL-type referent is rendered as an external link", async ({
159+
browser,
160+
}) => {
161+
const anonCtx = await browser.newContext({ storageState: undefined });
162+
try {
163+
const page = await anonCtx.newPage();
164+
await page.goto("/referents/22222222-2222-4222-8222-222222222222");
165+
166+
const externalLink = page.getByRole("link", {
167+
name: /dreets\.test\/contact-92/i,
168+
});
169+
await expect(externalLink).toBeVisible();
170+
await expect(externalLink).toHaveAttribute("target", "_blank");
171+
} finally {
172+
await anonCtx.close();
173+
}
174+
});
175+
176+
test("detail page returns 404 for unknown id", async ({ browser }) => {
177+
const anonCtx = await browser.newContext({ storageState: undefined });
178+
try {
179+
const page = await anonCtx.newPage();
180+
const response = await page.goto(
181+
"/referents/00000000-0000-4000-8000-000000000000",
182+
);
183+
expect(response?.status()).toBe(404);
184+
} finally {
185+
await anonCtx.close();
186+
}
187+
});
188+
});
189+
190+
test("link from /aide/nous-contacter points to /referents", async ({
191+
browser,
192+
}) => {
193+
const anonCtx = await browser.newContext({ storageState: undefined });
194+
try {
195+
const page = await anonCtx.newPage();
196+
await page.goto("/aide/nous-contacter");
197+
const searchLink = page.getByRole("link", {
198+
name: /rechercher un référent par région ou département/i,
199+
});
200+
await expect(searchLink).toBeVisible();
201+
await expect(searchLink).toHaveAttribute("href", "/referents");
202+
} finally {
203+
await anonCtx.close();
204+
}
205+
});

packages/app/src/modules/aide/ContactPage.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Link from "next/link";
2+
13
import { Breadcrumb } from "~/modules/layout";
24

35
import { AideIllustration } from "./AideIllustration";
@@ -37,6 +39,11 @@ export function ContactPage() {
3739

3840
<div className="fr-mb-4w">
3941
<h2 className="fr-h6">Contactez votre référent régional :</h2>
42+
<p className="fr-mb-2w">
43+
<Link className="fr-link" href="/referents">
44+
Rechercher un référent par région ou département
45+
</Link>
46+
</p>
4047
<a
4148
className="fr-link fr-link--download"
4249
download

packages/app/src/modules/aide/__tests__/ContactPage.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ describe("ContactPage", () => {
7575
);
7676
});
7777

78+
it("renders a link to the public referents search page", () => {
79+
render(<ContactPage />);
80+
const searchLink = screen.getByRole("link", {
81+
name: /rechercher un référent par région ou département/i,
82+
});
83+
expect(searchLink).toHaveAttribute("href", "/referents");
84+
});
85+
7886
it("displays the contact email address", () => {
7987
render(<ContactPage />);
8088
expect(screen.getByText("index@travail.gouv.fr")).toBeInTheDocument();

packages/app/src/modules/audit/__tests__/actionKeys.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,19 @@ describe("retention constants", () => {
3333
it("read_sensitive falls into the short retention bucket", () => {
3434
expect(SHORT_RETENTION_CATEGORIES).toContain("read_sensitive");
3535
});
36+
37+
it("public_search falls into the short retention bucket", () => {
38+
expect(SHORT_RETENTION_CATEGORIES).toContain("public_search");
39+
});
40+
});
41+
42+
describe("public_search category", () => {
43+
it("maps public referent actions to public_search", () => {
44+
expect(AUDIT_ACTION_CATEGORIES[AUDIT_ACTIONS.PUBLIC_REFERENT_SEARCH]).toBe(
45+
"public_search",
46+
);
47+
expect(AUDIT_ACTION_CATEGORIES[AUDIT_ACTIONS.PUBLIC_REFERENT_VIEW]).toBe(
48+
"public_search",
49+
);
50+
});
3651
});

packages/app/src/modules/audit/shared/actionKeys.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ export const AUDIT_ACTIONS = {
7070
MAIL_RECEIPT_SEND: "mail.receipt_send",
7171
MAIL_RECEIPT_RESEND: "mail.receipt_resend",
7272

73+
// ── Public searches ────────────────────────────────────
74+
PUBLIC_REFERENT_SEARCH: "public_referents.search",
75+
PUBLIC_REFERENT_VIEW: "public_referents.view",
76+
7377
// ── System / cron-triggered ────────────────────────────
7478
SYSTEM_AUDIT_CLEANUP: "system.audit_cleanup",
7579
} as const;
@@ -129,5 +133,8 @@ export const AUDIT_ACTION_CATEGORIES: Record<AuditActionKey, AuditCategory> = {
129133
[AUDIT_ACTIONS.MAIL_RECEIPT_SEND]: "mutation",
130134
[AUDIT_ACTIONS.MAIL_RECEIPT_RESEND]: "mutation",
131135

136+
[AUDIT_ACTIONS.PUBLIC_REFERENT_SEARCH]: "public_search",
137+
[AUDIT_ACTIONS.PUBLIC_REFERENT_VIEW]: "public_search",
138+
132139
[AUDIT_ACTIONS.SYSTEM_AUDIT_CLEANUP]: "system",
133140
};

packages/app/src/modules/audit/shared/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ export const AUDIT_RETENTION_DAYS_LONG = 365;
1717
*/
1818
export const SHORT_RETENTION_CATEGORIES: ReadonlyArray<AuditCategory> = [
1919
"read_sensitive",
20+
"public_search",
2021
];

packages/app/src/modules/audit/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type AuditCategory =
77
| "auth"
88
| "mutation"
99
| "read_sensitive"
10+
| "public_search"
1011
| "export"
1112
| "system";
1213

0 commit comments

Comments
 (0)