diff --git a/scripts/__tests__/schemas.test.js b/scripts/__tests__/schemas.test.js index eeb965f..47a62c1 100644 --- a/scripts/__tests__/schemas.test.js +++ b/scripts/__tests__/schemas.test.js @@ -75,6 +75,104 @@ describe('team-member schema', () => { }); }); +describe('team-member expert fields', () => { + const expert = { + ...validMember, + challenge: 'sc', + contribution: { fr: 'Contribution', en: 'Contribution EN' }, + tools: ['hubspot', 'qobra'], + previousCompanies: ['Qonto'], + }; + const fails = (data) => validateTeamMember(data).success === false; + const messages = (data) => validateTeamMember(data).error.issues.map((i) => i.message); + + it('accepts a member without any expert field', () => { + expect(validateTeamMember(validMember).success).toBe(true); + }); + + it('accepts a member with all four expert fields', () => { + expect(validateTeamMember(expert).success).toBe(true); + }); + + it('accepts every challenge key', () => { + for (const challenge of ['rp', 'se', 'sc', 'cx', 'sy', 'ai']) { + expect(validateTeamMember({ ...expert, challenge }).success).toBe(true); + } + }); + + it('rejects an unknown challenge key', () => { + expect(fails({ ...expert, challenge: 'xx' })).toBe(true); + }); + + it('rejects challenge without contribution', () => { + const { contribution, ...rest } = expert; + expect(fails(rest)).toBe(true); + expect(messages(rest)).toContain('contribution is required when challenge is set'); + }); + + it('rejects challenge without tools', () => { + const { tools, ...rest } = expert; + expect(fails(rest)).toBe(true); + expect(messages(rest)).toContain('tools is required when challenge is set'); + }); + + it('accepts challenge without previousCompanies', () => { + const { previousCompanies, ...rest } = expert; + expect(validateTeamMember(rest).success).toBe(true); + }); + + it('accepts contribution, tools and previousCompanies without challenge', () => { + const { challenge, ...rest } = expert; + expect(validateTeamMember(rest).success).toBe(true); + }); + + it('caps contribution at 100 characters per language', () => { + const ok = 'x'.repeat(100); + const tooLong = 'x'.repeat(101); + expect(validateTeamMember({ ...expert, contribution: { fr: ok, en: ok } }).success).toBe(true); + expect(fails({ ...expert, contribution: { fr: tooLong, en: ok } })).toBe(true); + expect(fails({ ...expert, contribution: { fr: ok, en: tooLong } })).toBe(true); + }); + + it('rejects a contribution missing a language', () => { + expect(fails({ ...expert, contribution: { fr: 'Contribution' } })).toBe(true); + }); + + it('accepts 1 to 8 tools and rejects 0 or 9', () => { + const slugs = (n) => Array.from({ length: n }, (_, i) => `tool-${i}`); + expect(validateTeamMember({ ...expert, tools: slugs(1) }).success).toBe(true); + expect(validateTeamMember({ ...expert, tools: slugs(8) }).success).toBe(true); + expect(fails({ ...expert, tools: [] })).toBe(true); + expect(fails({ ...expert, tools: slugs(9) })).toBe(true); + }); + + it('rejects duplicate tools', () => { + const data = { ...expert, tools: ['hubspot', 'hubspot'] }; + expect(fails(data)).toBe(true); + expect(messages(data)).toContain('tools must not contain duplicates'); + }); + + it('rejects a blank contribution or previous company', () => { + expect(fails({ ...expert, contribution: { fr: ' ', en: 'Contribution' } })).toBe(true); + expect(fails({ ...expert, previousCompanies: [' '] })).toBe(true); + }); + + it('applies the contribution cap to the raw text', () => { + expect(fails({ ...expert, contribution: { fr: `${'x'.repeat(100)} `, en: 'ok' } })).toBe(true); + }); + + it('accepts 1 to 3 previous companies and rejects 0 or 4', () => { + expect(validateTeamMember({ ...expert, previousCompanies: ['A', 'B', 'C'] }).success).toBe(true); + expect(fails({ ...expert, previousCompanies: [] })).toBe(true); + expect(fails({ ...expert, previousCompanies: ['A', 'B', 'C', 'D'] })).toBe(true); + }); + + it('caps a previous company name at 30 characters', () => { + expect(validateTeamMember({ ...expert, previousCompanies: ['x'.repeat(30)] }).success).toBe(true); + expect(fails({ ...expert, previousCompanies: ['x'.repeat(31)] })).toBe(true); + }); +}); + // ── blog post ───────────────────────────────────────────────────────────────── const validPost = { diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index 488eb45..a3b511f 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -98,6 +98,56 @@ describe('validateRepository', () => { expect(assetErrors).toEqual([expect.stringContaining('assets/clients/acme-avatar.jpeg')]); expect(assetWarnings).toEqual([]); }); + + it('reports a team member citing a missing tool slug', () => { + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}challenge: sc +contribution: + fr: Contribution + en: Contribution +tools: + - hubspot + - ghost-tool +--- +`); + + const { failures } = validateRepository(repoRoot); + + expect(failures).toEqual([ + { + file: 'team/bob.md', + schemaErrors: [], + crossRefErrors: [ + { path: ['tools'], message: '"ghost-tool" does not match any tool slug' }, + ], + }, + ]); + }); + + it('accepts a team member whose tools are all existing tool slugs', () => { + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}challenge: sc +contribution: + fr: Contribution + en: Contribution +tools: + - hubspot +--- +`); + + expect(validateRepository(repoRoot).failures).toEqual([]); + }); + + it('reports a schema error, not a crash, when tools is not an array', () => { + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}tools: hubspot +--- +`); + + const { failures } = validateRepository(repoRoot); + + expect(failures).toHaveLength(1); + expect(failures[0].file).toBe('team/bob.md'); + expect(failures[0].schemaErrors).toEqual([{ path: ['tools'], message: expect.any(String) }]); + expect(failures[0].crossRefErrors).toEqual([]); + }); }); describe('validateRepository with challenge files', () => { diff --git a/scripts/schemas/team-member.schema.js b/scripts/schemas/team-member.schema.js index 44d9649..9907b9e 100644 --- a/scripts/schemas/team-member.schema.js +++ b/scripts/schemas/team-member.schema.js @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { CHALLENGE_KEYS } from './challenge.schema.js'; const bilingualString = z.object({ fr: z.string().min(1), en: z.string().min(1) }); @@ -17,6 +18,29 @@ export const teamMemberSchema = z.object({ active: z.boolean().optional(), featuredOnAboutUs: z.boolean().optional(), color: z.enum(['yellow', 'coral', 'sky']).optional(), + // Expert fields: all optional, absent for people who are not challenge experts. + challenge: z.enum(CHALLENGE_KEYS).optional(), + // Caps apply to the raw text, as published, so no .trim(); \S rejects blanks. + 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']) { + if (member[field] === undefined) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required when challenge is set`, + }); + } + } }); export const validateTeamMember = (data) => teamMemberSchema.safeParse(data); diff --git a/scripts/validate-repository.js b/scripts/validate-repository.js index 3c70353..beed9e1 100644 --- a/scripts/validate-repository.js +++ b/scripts/validate-repository.js @@ -45,6 +45,15 @@ const validateCrossRefs = (type, data, resolver) => { }); } } + if (type === 'team-member') { + // A non-array `tools` is already a schema error; skip the cross-reference. + const tools = Array.isArray(data.tools) ? data.tools.filter((t) => typeof t === 'string') : []; + tools.forEach((t) => { + if (!resolver.isValidToolSlug(t)) { + errors.push({ path: ['tools'], message: `"${t}" does not match any tool slug` }); + } + }); + } if (type === 'job' && data.hiringContact && !resolver.isValidTeamSlug(data.hiringContact)) { errors.push({ path: ['hiringContact'],