From 03b07f90d2cd2fbdf1681877ac457750f1623ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Thu, 22 Oct 2026 16:18:26 +0200 Subject: [PATCH 1/2] test(team): copy the posts team files as fixtures Verbatim copies of every team/*.md file of ocobo-revops/posts (main b34a449). --- app/test/fixtures/content/team/aude-cadiot.md | 16 ++++++++++++++++ .../fixtures/content/team/benjamin-boileux.md | 16 ++++++++++++++++ app/test/fixtures/content/team/clara-lecarrie.md | 15 +++++++++++++++ .../content/team/clementine-du-pradel.md | 16 ++++++++++++++++ .../fixtures/content/team/corentin-guerin.md | 16 ++++++++++++++++ app/test/fixtures/content/team/damien-robert.md | 15 +++++++++++++++ .../fixtures/content/team/domitille-raimbault.md | 15 +++++++++++++++ .../fixtures/content/team/dorian-cutullic.md | 14 ++++++++++++++ .../fixtures/content/team/edmond-de-maistre.md | 16 ++++++++++++++++ app/test/fixtures/content/team/ethel-gosset.md | 15 +++++++++++++++ app/test/fixtures/content/team/hugo-moulon.md | 16 ++++++++++++++++ app/test/fixtures/content/team/jeanne-hebben.md | 16 ++++++++++++++++ app/test/fixtures/content/team/louis-strauss.md | 15 +++++++++++++++ .../fixtures/content/team/mariette-baudras.md | 15 +++++++++++++++ .../fixtures/content/team/morgane-philbert.md | 16 ++++++++++++++++ .../fixtures/content/team/pauline-belaubre.md | 16 ++++++++++++++++ .../fixtures/content/team/thomas-sionville.md | 16 ++++++++++++++++ app/test/fixtures/content/team/victor-lajri.md | 16 ++++++++++++++++ app/test/fixtures/content/team/yoann-boileux.md | 15 +++++++++++++++ 19 files changed, 295 insertions(+) create mode 100644 app/test/fixtures/content/team/aude-cadiot.md create mode 100644 app/test/fixtures/content/team/benjamin-boileux.md create mode 100644 app/test/fixtures/content/team/clara-lecarrie.md create mode 100644 app/test/fixtures/content/team/clementine-du-pradel.md create mode 100644 app/test/fixtures/content/team/corentin-guerin.md create mode 100644 app/test/fixtures/content/team/damien-robert.md create mode 100644 app/test/fixtures/content/team/domitille-raimbault.md create mode 100644 app/test/fixtures/content/team/dorian-cutullic.md create mode 100644 app/test/fixtures/content/team/edmond-de-maistre.md create mode 100644 app/test/fixtures/content/team/ethel-gosset.md create mode 100644 app/test/fixtures/content/team/hugo-moulon.md create mode 100644 app/test/fixtures/content/team/jeanne-hebben.md create mode 100644 app/test/fixtures/content/team/louis-strauss.md create mode 100644 app/test/fixtures/content/team/mariette-baudras.md create mode 100644 app/test/fixtures/content/team/morgane-philbert.md create mode 100644 app/test/fixtures/content/team/pauline-belaubre.md create mode 100644 app/test/fixtures/content/team/thomas-sionville.md create mode 100644 app/test/fixtures/content/team/victor-lajri.md create mode 100644 app/test/fixtures/content/team/yoann-boileux.md diff --git a/app/test/fixtures/content/team/aude-cadiot.md b/app/test/fixtures/content/team/aude-cadiot.md new file mode 100644 index 00000000..53a88bbc --- /dev/null +++ b/app/test/fixtures/content/team/aude-cadiot.md @@ -0,0 +1,16 @@ +--- +name: Aude Cadiot +role: + fr: Associée + en: Partner +track: architect +linkedin: https://www.linkedin.com/in/aude-cadiot-9597096b/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/aude-cadiot.jpg +displayOrder: 2 +active: true +featuredOnAboutUs: true +color: coral +bio: + fr: Référence française du Revenue Operations (TheFork, Spendesk). Experte du customer journey, des organisations GTM, du scaling d'équipes RevOps. + en: France's Revenue Operations reference (TheFork, Spendesk). Expert in customer journey, GTM organisations, and scaling RevOps teams. +--- diff --git a/app/test/fixtures/content/team/benjamin-boileux.md b/app/test/fixtures/content/team/benjamin-boileux.md new file mode 100644 index 00000000..1c955057 --- /dev/null +++ b/app/test/fixtures/content/team/benjamin-boileux.md @@ -0,0 +1,16 @@ +--- +name: Benjamin Boileux +role: + fr: Associé + en: Partner +track: architect +linkedin: https://www.linkedin.com/in/benjaminboileux/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/benjamin-boileux.jpg +displayOrder: 1 +active: true +featuredOnAboutUs: true +color: yellow +bio: + fr: 12+ ans en Ops, CRM, Product et Revenue Operations (TheFork, Tripadvisor, Yousign). Spécialiste des systèmes et du pilotage opérationnel. + en: 12+ years in Ops, CRM, Product and Revenue Operations (TheFork, Tripadvisor, Yousign). Systems and operational management specialist. +--- diff --git a/app/test/fixtures/content/team/clara-lecarrie.md b/app/test/fixtures/content/team/clara-lecarrie.md new file mode 100644 index 00000000..3cacacb0 --- /dev/null +++ b/app/test/fixtures/content/team/clara-lecarrie.md @@ -0,0 +1,15 @@ +--- +name: Clara Lecarrié +role: + fr: Manager + en: Manager +track: architect +linkedin: https://www.linkedin.com/in/clara-lecarrie-070595106/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/clara-lecarrie.jpg +displayOrder: 4 +active: true +featuredOnAboutUs: false +bio: + fr: Spécialiste de l'expérience utilisateur et de l'optimisation des parcours de vente complexes. + en: Specialist in user experience and optimising complex sales journeys. +--- diff --git a/app/test/fixtures/content/team/clementine-du-pradel.md b/app/test/fixtures/content/team/clementine-du-pradel.md new file mode 100644 index 00000000..71b291ba --- /dev/null +++ b/app/test/fixtures/content/team/clementine-du-pradel.md @@ -0,0 +1,16 @@ +--- +slug: clementine-du-pradel +name: Clémentine du Pradel +track: architect +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/clementine-du-pradel.png +role: + fr: Manager + en: Manager +bio: + fr: Experte en pilotage de projets complexes et optimisation des processus, pour aligner les parties prenantes et transformer les opérations en leviers de croissance. + en: Expert in managing complex projects and optimising processes, to align stakeholders and transform operations into growth drivers. +linkedin: "https://www.linkedin.com/in/clémentine-du-pradel-237ab948/" +active: true +featuredOnAboutUs: false +displayOrder: 12 +--- diff --git a/app/test/fixtures/content/team/corentin-guerin.md b/app/test/fixtures/content/team/corentin-guerin.md new file mode 100644 index 00000000..58af2399 --- /dev/null +++ b/app/test/fixtures/content/team/corentin-guerin.md @@ -0,0 +1,16 @@ +--- +name: Corentin Guérin +role: + fr: Associé + en: Partner +track: architect +linkedin: https://www.linkedin.com/in/corentinguerin/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/corentin-guerin.jpeg +displayOrder: 3 +active: true +featuredOnAboutUs: true +color: sky +bio: + fr: Finance, Deloitte, Business Ops (TheFork). Spécialiste du forecasting, de la performance, des rémunérations variables et du pricing. + en: Finance, Deloitte, Business Ops (TheFork). Forecasting, performance, variable compensation and pricing specialist. +--- diff --git a/app/test/fixtures/content/team/damien-robert.md b/app/test/fixtures/content/team/damien-robert.md new file mode 100644 index 00000000..de5e820d --- /dev/null +++ b/app/test/fixtures/content/team/damien-robert.md @@ -0,0 +1,15 @@ +--- +name: Damien Robert +role: + fr: RevOps Manager + en: RevOps Manager +track: builder +linkedin: https://www.linkedin.com/in/damien-robert-b3438310a/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/damien-robert.jpg +displayOrder: 9 +active: true +featuredOnAboutUs: false +bio: + fr: Spécialiste de la performance commerciale et du pilotage par la donnée fiable. + en: Specialist in commercial performance and data-driven management. +--- diff --git a/app/test/fixtures/content/team/domitille-raimbault.md b/app/test/fixtures/content/team/domitille-raimbault.md new file mode 100644 index 00000000..135806d3 --- /dev/null +++ b/app/test/fixtures/content/team/domitille-raimbault.md @@ -0,0 +1,15 @@ +--- +name: Domitille Raimbault +role: + fr: Senior RevOps Manager + en: Senior RevOps Manager +track: builder +linkedin: https://www.linkedin.com/in/domitille-raimbault-488366107/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/domitille-raimbault.jpeg +displayOrder: 6 +active: true +featuredOnAboutUs: false +bio: + fr: Maîtrise les architectures CRM les plus complexes pour transformer la donnée en levier de croissance. + en: Masters the most complex CRM architectures to turn data into a growth lever. +--- diff --git a/app/test/fixtures/content/team/dorian-cutullic.md b/app/test/fixtures/content/team/dorian-cutullic.md new file mode 100644 index 00000000..ba0d122e --- /dev/null +++ b/app/test/fixtures/content/team/dorian-cutullic.md @@ -0,0 +1,14 @@ +--- +name: Dorian Cutullic +role: + fr: RevOps Manager + en: RevOps Manager +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/dorian-cutullic.jpg +displayOrder: 8 +active: true +featuredOnAboutUs: false +bio: + fr: Accompagne les scale-ups dans la structuration de leur stack et l'adoption des rituels RevOps. + en: Supports scale-ups in structuring their stack and adopting RevOps rituals. +--- diff --git a/app/test/fixtures/content/team/edmond-de-maistre.md b/app/test/fixtures/content/team/edmond-de-maistre.md new file mode 100644 index 00000000..79aa70d3 --- /dev/null +++ b/app/test/fixtures/content/team/edmond-de-maistre.md @@ -0,0 +1,16 @@ +--- +slug: edmond-de-maistre +name: Edmond de Maistre +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/edmond-de-maistre.png +role: + fr: Revenue Operations Associate + en: Revenue Operations Associate +bio: + fr: Spécialiste de la performance commerciale et du pilotage par la donnée fiable. + en: Specialist in commercial performance and reliable, data-driven decision-making. +linkedin: "https://www.linkedin.com/in/edmond-de-maistre/" +active: true +featuredOnAboutUs: false +displayOrder: 13 +--- diff --git a/app/test/fixtures/content/team/ethel-gosset.md b/app/test/fixtures/content/team/ethel-gosset.md new file mode 100644 index 00000000..3922aa56 --- /dev/null +++ b/app/test/fixtures/content/team/ethel-gosset.md @@ -0,0 +1,15 @@ +--- +name: Ethel Gosset +role: + fr: Senior RevOps Manager + en: Senior RevOps Manager +track: builder +linkedin: https://www.linkedin.com/in/ethel-gosset-a4b295136/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/ethel-gosset.jpeg +displayOrder: 7 +active: false +featuredOnAboutUs: false +bio: + fr: Experte en automatisation et fluidification des processus opérationnels Sales & Marketing. + en: Expert in automating and streamlining Sales & Marketing operational processes. +--- diff --git a/app/test/fixtures/content/team/hugo-moulon.md b/app/test/fixtures/content/team/hugo-moulon.md new file mode 100644 index 00000000..c97f3a53 --- /dev/null +++ b/app/test/fixtures/content/team/hugo-moulon.md @@ -0,0 +1,16 @@ +--- +slug: hugo-moulon +name: Hugo Moulon +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/hugo-moulon.jpeg +role: + fr: RevOps Manager + en: RevOps Manager +bio: + fr: Expert dans la structuration et l'optimisation des processus Sales et CSM, de la compréhension métier à l'adaptation du CRM jusqu'au pilotage par la donnée, pour accélérer la croissance et éliminer les frictions opérationnelles. + en: Expert in structuring and optimising Sales and CSM processes — from grasping the business needs to tailoring the CRM and steering by data — to accelerate growth and remove operational friction. +linkedin: "https://www.linkedin.com/in/hugo-moulon-44b4a613b/" +active: true +featuredOnAboutUs: false +displayOrder: 14 +--- diff --git a/app/test/fixtures/content/team/jeanne-hebben.md b/app/test/fixtures/content/team/jeanne-hebben.md new file mode 100644 index 00000000..181f697e --- /dev/null +++ b/app/test/fixtures/content/team/jeanne-hebben.md @@ -0,0 +1,16 @@ +--- +slug: jeanne-hebben +name: Jeanne Hebben +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/jeanne-hebben.png +role: + fr: Senior Revenue Operations Manager + en: Senior Revenue Operations Manager +bio: + fr: Experte dans l'optimisation et l'automatisation des process Sales pour aligner stratégie commerciale et performance opérationnelle. + en: Expert in optimising and automating Sales processes to align commercial strategy with operational performance. +linkedin: "https://www.linkedin.com/in/jeanne-hebben/" +active: true +featuredOnAboutUs: false +displayOrder: 15 +--- diff --git a/app/test/fixtures/content/team/louis-strauss.md b/app/test/fixtures/content/team/louis-strauss.md new file mode 100644 index 00000000..a7d39e89 --- /dev/null +++ b/app/test/fixtures/content/team/louis-strauss.md @@ -0,0 +1,15 @@ +--- +name: Louis Strauss +role: + fr: RevOps Engineer + en: RevOps Engineer +track: expert-engineer +linkedin: https://www.linkedin.com/in/louis-strauss1/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/louis-strauss.jpg +displayOrder: 5 +active: true +featuredOnAboutUs: false +bio: + fr: Le cerveau technique derrière nos intégrations les plus poussées et nos outils propriétaires. + en: The technical brain behind our most advanced integrations and proprietary tools. +--- diff --git a/app/test/fixtures/content/team/mariette-baudras.md b/app/test/fixtures/content/team/mariette-baudras.md new file mode 100644 index 00000000..775592d4 --- /dev/null +++ b/app/test/fixtures/content/team/mariette-baudras.md @@ -0,0 +1,15 @@ +--- +name: Mariette Baudras +role: + fr: RevOps Manager + en: RevOps Manager +track: builder +linkedin: https://www.linkedin.com/in/mariette-baudras-bove/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/mariette-baudras.jpg +displayOrder: 10 +active: true +featuredOnAboutUs: false +bio: + fr: Experte en Customer Success Operations et optimisation de la rétention client. + en: Expert in Customer Success Operations and client retention optimisation. +--- diff --git a/app/test/fixtures/content/team/morgane-philbert.md b/app/test/fixtures/content/team/morgane-philbert.md new file mode 100644 index 00000000..c6c2ebae --- /dev/null +++ b/app/test/fixtures/content/team/morgane-philbert.md @@ -0,0 +1,16 @@ +--- +slug: morgane-philbert +name: Morgane Philbert +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/morgane-philbert.jpg +role: + fr: Brand & Communication Manager + en: Brand & Communication Manager +bio: + fr: "Experte en marque et contenu : ligne éditoriale, podcast, événementiel et partenariats. Pilote aussi l'acquisition et le marketing operations d'Ocobo." + en: "Brand and content expert: editorial strategy, podcast, events and partnerships. Also drives Ocobo's acquisition and marketing operations." +linkedin: "https://www.linkedin.com/in/morganephilbert/" +active: true +featuredOnAboutUs: false +displayOrder: 17 +--- diff --git a/app/test/fixtures/content/team/pauline-belaubre.md b/app/test/fixtures/content/team/pauline-belaubre.md new file mode 100644 index 00000000..4117b177 --- /dev/null +++ b/app/test/fixtures/content/team/pauline-belaubre.md @@ -0,0 +1,16 @@ +--- +slug: pauline-belaubre +name: Pauline Belaubre +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/pauline-belaubre.jpg +role: + fr: RevOps Manager + en: RevOps Manager +bio: + fr: "Spécialiste de la structuration des process et du CRM, et de leur adoption par les équipes, pour gagner en efficacité et en fiabilité au quotidien." + en: "Specialist in structuring processes and the CRM, and in driving their adoption by teams, to gain efficiency and reliability day to day." +linkedin: "https://www.linkedin.com/in/pauline-belaubre/" +active: true +featuredOnAboutUs: false +displayOrder: 18 +--- diff --git a/app/test/fixtures/content/team/thomas-sionville.md b/app/test/fixtures/content/team/thomas-sionville.md new file mode 100644 index 00000000..47c0f3a3 --- /dev/null +++ b/app/test/fixtures/content/team/thomas-sionville.md @@ -0,0 +1,16 @@ +--- +slug: thomas-sionville +name: Thomas Sionville +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/thomas-sionville.png +role: + fr: RevOps Manager + en: RevOps Manager +bio: + fr: Spécialiste de l'automatisation des processus et de la structuration des données Sales et CSM. + en: Specialist in process automation and in structuring Sales and CSM data. +linkedin: "https://www.linkedin.com/in/thomas-revops/" +active: true +featuredOnAboutUs: false +displayOrder: 16 +--- diff --git a/app/test/fixtures/content/team/victor-lajri.md b/app/test/fixtures/content/team/victor-lajri.md new file mode 100644 index 00000000..7498de91 --- /dev/null +++ b/app/test/fixtures/content/team/victor-lajri.md @@ -0,0 +1,16 @@ +--- +slug: victor-lajri +name: Victor Lajri +track: builder +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/victor-lajri.jpg +role: + fr: RevOps Manager + en: RevOps Manager +bio: + fr: Expert en optimisation CRM et fiabilisation des données Sales, pour libérer du temps de vente aux équipes commerciales. + en: Expert in CRM optimisation and Sales data reliability, freeing up selling time for sales teams. +linkedin: "https://www.linkedin.com/in/victor-lajri-a8a993115/" +active: true +featuredOnAboutUs: false +displayOrder: 19 +--- diff --git a/app/test/fixtures/content/team/yoann-boileux.md b/app/test/fixtures/content/team/yoann-boileux.md new file mode 100644 index 00000000..a9f085a5 --- /dev/null +++ b/app/test/fixtures/content/team/yoann-boileux.md @@ -0,0 +1,15 @@ +--- +name: Yoann Boileux +role: + fr: RevOps Manager + en: RevOps Manager +track: builder +linkedin: https://www.linkedin.com/in/yoann-boileux/ +avatar: https://ipjmp3k0z0p479cb.public.blob.vercel-storage.com/content/team/yoann-boileux.jpeg +displayOrder: 11 +active: true +featuredOnAboutUs: false +bio: + fr: Focus sur l'enablement des équipes et la création de playbooks d'exécution robustes. + en: Focused on team enablement and creating robust execution playbooks. +--- From d8305b09e01ca3a71cc6ebd65f5a38c1a6d1119c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Thu, 22 Oct 2026 16:18:26 +0200 Subject: [PATCH 2/2] feat(team): expert fields on the Team member schema Mirror ocobo-revops/posts#95: optional challenge, contribution (fr and en), tools (ordered Tool slugs) and previousCompanies. contribution and tools are required when challenge is set. Same caps, regexes and messages as the posts schema. No quote field: bio stays the about-us text. Tests validate every posts team file and cover each expert field rule. --- app/modules/member-schema.test.ts | 183 ++++++++++++++++++++++++++++++ app/modules/schemas.ts | 75 +++++++++--- 2 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 app/modules/member-schema.test.ts diff --git a/app/modules/member-schema.test.ts b/app/modules/member-schema.test.ts new file mode 100644 index 00000000..9ed4570a --- /dev/null +++ b/app/modules/member-schema.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment node + +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import Markdoc from '@markdoc/markdoc'; +import yaml from 'js-yaml'; +import { describe, expect, it } from 'vitest'; + +import { CHALLENGE_KEYS } from './challenge-schema'; +import { MemberFrontmatterSchema } from './schemas'; + +// Base fields are also covered in `schemas.test.ts`; this file covers the +// posts files and the expert fields of ocobo-revops/posts#95. +// +// Copies of every `team/*.md` file of ocobo-revops/posts (main b34a449). None +// sets an expert field yet. Refresh them when the posts Team member schema +// changes or when expert fields arrive in `team/*.md`. +const FIXTURES_DIR = join(__dirname, '../test/fixtures/content/team'); + +// Same frontmatter parsing as the content pipeline (`content/processor.ts`). +const loadFixture = (file: string): Record => { + const markdown = readFileSync(join(FIXTURES_DIR, file), 'utf8'); + const { frontmatter } = Markdoc.parse(markdown).attributes; + return yaml.load(frontmatter, { schema: yaml.CORE_SCHEMA }) as Record< + string, + any + >; +}; + +const messagesOf = (data: unknown) => { + const result = MemberFrontmatterSchema.safeParse(data); + return result.success ? [] : result.error.issues.map((i) => i.message); +}; + +const accepts = (data: unknown) => + MemberFrontmatterSchema.safeParse(data).success; + +describe('Team member fixtures', () => { + const files = readdirSync(FIXTURES_DIR).filter((f) => f.endsWith('.md')); + + it('has the posts team files', () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files)('validates %s', (file) => { + expect(messagesOf(loadFixture(file))).toEqual([]); + }); +}); + +describe('Team member expert fields', () => { + const base = loadFixture('benjamin-boileux.md'); + const expert = { + ...base, + challenge: 'sc', + contribution: { fr: 'Contribution', en: 'Contribution EN' }, + tools: ['hubspot', 'qobra'], + previousCompanies: ['Qonto'], + }; + + it('accepts a member with all four expert fields', () => { + const result = MemberFrontmatterSchema.safeParse(expert); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatchObject({ + challenge: 'sc', + contribution: expert.contribution, + tools: ['hubspot', 'qobra'], + previousCompanies: ['Qonto'], + }); + } + }); + + it.each(CHALLENGE_KEYS)('accepts the challenge key %s', (challenge) => { + expect(accepts({ ...expert, challenge })).toBe(true); + }); + + it('rejects an unknown challenge key', () => { + expect(accepts({ ...expert, challenge: 'xx' })).toBe(false); + }); + + it.each(['contribution', 'tools'] as const)( + 'rejects challenge without %s', + (field) => { + const { [field]: _omitted, ...rest } = expert; + const result = MemberFrontmatterSchema.safeParse(rest); + expect(result.success).toBe(false); + expect( + result.error?.issues.map(({ path, message }) => ({ path, message })), + ).toEqual([ + { + path: [field], + message: `${field} is required when challenge is set`, + }, + ]); + }, + ); + + it('accepts challenge without previousCompanies', () => { + const { previousCompanies: _omitted, ...rest } = expert; + expect(accepts(rest)).toBe(true); + }); + + it('accepts contribution, tools and previousCompanies without challenge', () => { + const { challenge: _omitted, ...rest } = expert; + expect(accepts(rest)).toBe(true); + }); + + it('caps contribution at 100 characters per language', () => { + const ok = 'x'.repeat(100); + const tooLong = 'x'.repeat(101); + expect(accepts({ ...expert, contribution: { fr: ok, en: ok } })).toBe(true); + expect(accepts({ ...expert, contribution: { fr: tooLong, en: ok } })).toBe( + false, + ); + expect(accepts({ ...expert, contribution: { fr: ok, en: tooLong } })).toBe( + false, + ); + }); + + it('applies the contribution cap to the raw text', () => { + const contribution = { fr: `${'x'.repeat(100)} `, en: 'ok' }; + expect(accepts({ ...expert, contribution })).toBe(false); + }); + + it('rejects a contribution missing a language', () => { + expect(accepts({ ...expert, contribution: { fr: 'Contribution' } })).toBe( + false, + ); + }); + + it('accepts 1 to 8 tools and rejects 0 or 9', () => { + const slugs = (n: number) => Array.from({ length: n }, (_, i) => `t-${i}`); + expect(accepts({ ...expert, tools: slugs(1) })).toBe(true); + expect(accepts({ ...expert, tools: slugs(8) })).toBe(true); + expect(accepts({ ...expert, tools: [] })).toBe(false); + expect(accepts({ ...expert, tools: slugs(9) })).toBe(false); + }); + + it('keeps the tools order', () => { + const tools = ['qobra', 'hubspot', 'notion']; + const result = MemberFrontmatterSchema.safeParse({ ...expert, tools }); + expect(result.success && result.data.tools).toEqual(tools); + }); + + it('rejects duplicate tools', () => { + expect(messagesOf({ ...expert, tools: ['hubspot', 'hubspot'] })).toContain( + 'tools must not contain duplicates', + ); + }); + + it('rejects a blank contribution or previous company', () => { + const contribution = { fr: ' ', en: 'Contribution' }; + expect(accepts({ ...expert, contribution })).toBe(false); + expect(accepts({ ...expert, previousCompanies: [' '] })).toBe(false); + }); + + it('accepts 1 to 3 previous companies and rejects 0 or 4', () => { + const four = ['A', 'B', 'C', 'D']; + expect(accepts({ ...expert, previousCompanies: ['A', 'B', 'C'] })).toBe( + true, + ); + expect(accepts({ ...expert, previousCompanies: [] })).toBe(false); + expect(accepts({ ...expert, previousCompanies: four })).toBe(false); + }); + + it('caps a previous company name at 30 characters', () => { + const name = (n: number) => 'x'.repeat(n); + expect(accepts({ ...expert, previousCompanies: [name(30)] })).toBe(true); + expect(accepts({ ...expert, previousCompanies: [name(31)] })).toBe(false); + }); + + it('drops a quote key: bio stays the only about-us text', () => { + const result = MemberFrontmatterSchema.safeParse({ + ...expert, + quote: 'Ignored', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('quote'); + expect(result.data.bio).toEqual(base.bio); + } + }); +}); diff --git a/app/modules/schemas.ts b/app/modules/schemas.ts index 7ddf1a1d..061e048e 100644 --- a/app/modules/schemas.ts +++ b/app/modules/schemas.ts @@ -8,6 +8,8 @@ import { z } from 'zod'; +import { CHALLENGE_KEYS } from './challenge-schema'; + /** * Common validation schemas that can be reused */ @@ -172,24 +174,61 @@ export type MemberTrack = z.infer; /** * Team member frontmatter schema — one file per person in `team/.md` */ -export const MemberFrontmatterSchema = z.object({ - name: CommonSchemas.nonEmptyString, - role: z.object({ - fr: CommonSchemas.nonEmptyString, - en: CommonSchemas.nonEmptyString, - }), - track: MemberTrackSchema, - linkedin: z.string().url().optional(), - avatar: z.string().url(), - displayOrder: z.number().int(), - active: z.boolean().default(true), - bio: z.object({ - fr: CommonSchemas.nonEmptyString, - en: CommonSchemas.nonEmptyString, - }), - featuredOnAboutUs: z.boolean().default(false), - color: MemberColorSchema.optional(), -}); +export const MemberFrontmatterSchema = z + .object({ + name: CommonSchemas.nonEmptyString, + role: z.object({ + fr: CommonSchemas.nonEmptyString, + en: CommonSchemas.nonEmptyString, + }), + track: MemberTrackSchema, + linkedin: z.string().url().optional(), + avatar: z.string().url(), + displayOrder: z.number().int(), + active: z.boolean().default(true), + bio: z.object({ + fr: CommonSchemas.nonEmptyString, + en: CommonSchemas.nonEmptyString, + }), + featuredOnAboutUs: z.boolean().default(false), + color: MemberColorSchema.optional(), + // Expert fields: all optional, absent for people who are not challenge + // experts. Mirrors the posts repo (`scripts/schemas/team-member.schema.js`, + // ocobo-revops/posts#95). Caps apply to the raw text, as published, so no + // trim; `\S` rejects blank values. + challenge: z.enum(CHALLENGE_KEYS).optional(), + contribution: z + .object({ + fr: z.string().regex(/\S/).max(100), + en: z.string().regex(/\S/).max(100), + }) + .optional(), + tools: z + .array(z.string().min(1)) + .min(1) + .max(8) + .refine((slugs) => new Set(slugs).size === slugs.length, { + message: 'tools must not contain duplicates', + }) + .optional(), + previousCompanies: z + .array(z.string().regex(/\S/).max(30)) + .min(1) + .max(3) + .optional(), + }) + .superRefine((member, ctx) => { + if (member.challenge === undefined) return; + for (const field of ['contribution', 'tools'] as const) { + if (member[field] === undefined) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required when challenge is set`, + }); + } + } + }); /** * Tool frontmatter schema — one file per tool in `tools/.md`