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
183 changes: 183 additions & 0 deletions app/modules/member-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> => {
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);
}
});
});
75 changes: 57 additions & 18 deletions app/modules/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import { z } from 'zod';

import { CHALLENGE_KEYS } from './challenge-schema';

/**
* Common validation schemas that can be reused
*/
Expand Down Expand Up @@ -172,24 +174,61 @@ export type MemberTrack = z.infer<typeof MemberTrackSchema>;
/**
* Team member frontmatter schema — one file per person in `team/<slug>.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/<slug>.md`
Expand Down
16 changes: 16 additions & 0 deletions app/test/fixtures/content/team/aude-cadiot.md
Original file line number Diff line number Diff line change
@@ -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.
---
16 changes: 16 additions & 0 deletions app/test/fixtures/content/team/benjamin-boileux.md
Original file line number Diff line number Diff line change
@@ -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.
---
15 changes: 15 additions & 0 deletions app/test/fixtures/content/team/clara-lecarrie.md
Original file line number Diff line number Diff line change
@@ -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.
---
16 changes: 16 additions & 0 deletions app/test/fixtures/content/team/clementine-du-pradel.md
Original file line number Diff line number Diff line change
@@ -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
---
16 changes: 16 additions & 0 deletions app/test/fixtures/content/team/corentin-guerin.md
Original file line number Diff line number Diff line change
@@ -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.
---
15 changes: 15 additions & 0 deletions app/test/fixtures/content/team/damien-robert.md
Original file line number Diff line number Diff line change
@@ -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.
---
15 changes: 15 additions & 0 deletions app/test/fixtures/content/team/domitille-raimbault.md
Original file line number Diff line number Diff line change
@@ -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.
---
Loading
Loading