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
98 changes: 98 additions & 0 deletions scripts/__tests__/schemas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
50 changes: 50 additions & 0 deletions scripts/__tests__/validate-repository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
24 changes: 24 additions & 0 deletions scripts/schemas/team-member.schema.js
Original file line number Diff line number Diff line change
@@ -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) });

Expand All @@ -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);
9 changes: 9 additions & 0 deletions scripts/validate-repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading