From b129709cbfba2511479a96606f8e8fb79ea33cc8 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Mon, 28 Sep 2026 14:15:24 +0100 Subject: [PATCH 1/4] test(auth): add colocated route tests for email verification verify (#1408) --- .../verify/__tests__/route.test.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/app/api/auth/email-verification/verify/__tests__/route.test.ts diff --git a/src/app/api/auth/email-verification/verify/__tests__/route.test.ts b/src/app/api/auth/email-verification/verify/__tests__/route.test.ts new file mode 100644 index 00000000..162dfc38 --- /dev/null +++ b/src/app/api/auth/email-verification/verify/__tests__/route.test.ts @@ -0,0 +1,231 @@ +/** + * Colocated tests for the email verification route handler + * (src/app/api/auth/email-verification/verify/route.ts). + * + * The handler is the only place that decides how a verification token is read + * and how each outcome is reported, so these tests pin both the success + * contract (verified / already verified / expired) and the validation-failure + * contract (missing or malformed token, malformed body, unexpected error). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GET, POST } from '../route'; + +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +vi.mock('@/lib/auth/email-verification', () => ({ + verifyEmailToken: vi.fn(), +})); + +import { verifyEmailToken } from '@/lib/auth/email-verification'; + +const VERIFY_URL = 'http://localhost/api/auth/email-verification/verify'; + +/** The handlers are declared for `NextRequest`; these tests drive them with `Request`. */ +type Handler = (request: Request) => Promise; + +function invoke(handler: unknown, request: Request): Promise { + return (handler as Handler)(request); +} + +/** + * The mocked result unions carry more members than these cases assert on, so the + * mocks are driven through a narrow structural type instead of a cast per value. + */ +type Stub = { + mockResolvedValue(value: unknown): void; + mockRejectedValue(reason: unknown): void; +}; + +function stub(module: unknown): Stub { + return module as Stub; +} + +function postRequest(body: unknown): Request { + return new Request(VERIFY_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('GET /api/auth/email-verification/verify', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('verifies the token supplied as a query parameter', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'verified' }); + + const response = await invoke(GET, new Request(`${VERIFY_URL}?token=query-token`)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.message).toBe('Email verified'); + expect(body.verification).toEqual({ status: 'verified' }); + expect(verifyEmailToken).toHaveBeenCalledWith('query-token'); + }); + + it('falls back to the x-verification-token header when the query has no token', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'verified' }); + + const request = new Request(VERIFY_URL, { + headers: { 'x-verification-token': 'header-token' }, + }); + const response = await invoke(GET, request); + + expect(response.status).toBe(200); + expect(verifyEmailToken).toHaveBeenCalledWith('header-token'); + }); + + it('prefers the query parameter over the header', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'verified' }); + + const request = new Request(`${VERIFY_URL}?token=query-token`, { + headers: { 'x-verification-token': 'header-token' }, + }); + await invoke(GET, request); + + expect(verifyEmailToken).toHaveBeenCalledWith('query-token'); + }); + + it('reports an already verified email without changing state', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'already_verified' }); + + const response = await invoke(GET, new Request(`${VERIFY_URL}?token=used-token`)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.message).toBe('Email already verified'); + expect(body.verification).toEqual({ status: 'already_verified' }); + }); + + it('returns 410 for an expired or unknown token', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'expired' }); + + const response = await invoke(GET, new Request(`${VERIFY_URL}?token=stale-token`)); + const body = await response.json(); + + expect(response.status).toBe(410); + expect(body.message).toBe('Verification token expired'); + expect(body.verification).toEqual({ status: 'expired' }); + }); + + it('returns 400 when no token is supplied at all', async () => { + const response = await invoke(GET, new Request(VERIFY_URL)); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Verification token is required'); + expect(verifyEmailToken).not.toHaveBeenCalled(); + }); + + it('returns 500 when verification throws', async () => { + stub(verifyEmailToken).mockRejectedValue(new Error('hash comparison failed')); + + const response = await invoke(GET, new Request(`${VERIFY_URL}?token=query-token`)); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.message).toBe('Internal server error'); + }); +}); + +describe('POST /api/auth/email-verification/verify', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('verifies the token supplied in the request body', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'verified' }); + + const response = await invoke(POST, postRequest({ token: 'body-token' })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.verification).toEqual({ status: 'verified' }); + expect(verifyEmailToken).toHaveBeenCalledWith('body-token'); + }); + + it('accepts the token alongside the email it belongs to', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'already_verified' }); + + const response = await invoke( + POST, + postRequest({ token: 'body-token', email: 'student@teachlink.com' }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.verification).toEqual({ status: 'already_verified' }); + }); + + it('falls back to the query parameter when the body omits the token', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'verified' }); + + const request = new Request(`${VERIFY_URL}?token=query-token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'student@teachlink.com' }), + }); + await invoke(POST, request); + + expect(verifyEmailToken).toHaveBeenCalledWith('query-token'); + }); + + it('returns 400 for a body that fails validation', async () => { + const response = await invoke(POST, postRequest({ email: 'not-an-email' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Validation failed'); + expect(body.errors).toEqual([{ field: 'email', message: 'Invalid email address' }]); + expect(verifyEmailToken).not.toHaveBeenCalled(); + }); + + it('returns 400 for an empty token string', async () => { + const response = await invoke(POST, postRequest({ token: '' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Validation failed'); + expect(verifyEmailToken).not.toHaveBeenCalled(); + }); + + it('returns 400 when neither the body nor the request carries a token', async () => { + const response = await invoke(POST, postRequest({})); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.message).toBe('Verification token is required'); + expect(verifyEmailToken).not.toHaveBeenCalled(); + }); + + it('returns 410 for an expired or unknown token', async () => { + stub(verifyEmailToken).mockResolvedValue({ status: 'expired' }); + + const response = await invoke(POST, postRequest({ token: 'stale-token' })); + const body = await response.json(); + + expect(response.status).toBe(410); + expect(body.verification).toEqual({ status: 'expired' }); + }); + + it('returns 500 when verification throws', async () => { + stub(verifyEmailToken).mockRejectedValue(new Error('hash comparison failed')); + + const response = await invoke(POST, postRequest({ token: 'body-token' })); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.message).toBe('Internal server error'); + }); +}); From 0488907e986f38e16f9a29c6535998f2dbb740b4 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Mon, 28 Sep 2026 14:15:29 +0100 Subject: [PATCH 2/4] docs(governance): define the moderator role (#1480) --- Governance/roles/MODERATOR.md | 97 ++++++++++++++++++++++++ Governance/roles/MODERATOR.test.ts | 114 +++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 Governance/roles/MODERATOR.md create mode 100644 Governance/roles/MODERATOR.test.ts diff --git a/Governance/roles/MODERATOR.md b/Governance/roles/MODERATOR.md new file mode 100644 index 00000000..587bff62 --- /dev/null +++ b/Governance/roles/MODERATOR.md @@ -0,0 +1,97 @@ +# Moderator Role + +## Purpose + +This document defines the moderator role in TeachLink Web: who may hold it, what +duties they perform, their moderation powers and limits, how they are appointed +and removed, and how the role is tracked in public. It fills a known governance +gap by making moderation expectations explicit and self-contained in the +`Governance/` folder. + +## Scope + +The moderator role applies to public spaces that the project operates, including +repository discussions, issue and pull request comments, reviews, project boards, +and other project-controlled communication channels as identified in +`Governance/policies/MODERATION.md`. It does not replace platform-level +moderation rules or override applicable laws. It complements +`Governance/policies/MODERATION.md`, the conflict handling in +`Governance/processes/CONFLICT_RESOLUTION.md`, and the guidance in +`Governance/domains/COMMENT_MODERATION.md`. + +## Duties + +Moderators are expected to: + +- **Uphold the Code of Conduct.** Enforce `Governance/CODE_OF_CONDUCT.md` + consistently and impartially in the spaces covered by this role. +- **Intervene early.** Respond to reports and visible violations in a way that + de-escalates and protects the wellbeing of participants. +- **Document actions.** Record moderation actions in the public thread or issue + associated with the report when safe to do so, without disclosing + confidential details that would put reporters at risk. +- **Collaborate on escalations.** Escalate serious or unresolved matters using + `Governance/processes/ESCALATION_PATH.md` and + `Governance/processes/CONFLICT_RESOLUTION.md`. +- **Maintain clarity.** Explain the rationale for moderation actions in terms + of the Code of Conduct and applicable policies. + +## Moderation Powers and Limits + +Moderators have limited authority to maintain a safe, constructive environment: + +- **Powers.** Warn participants, apply temporary restrictions appropriate to the + space, lock or close threads to further escalation, hide or remove comments + that clearly violate the Code of Conduct, and decline to engage with abusive + behaviour. These actions must be proportionate to the incident. +- **Limits.** Moderators do not unilaterally change governance documents, remove + other roles without following removal rules, or disclose private information + obtained in a report. Bans or permanent exclusion require escalation per + `Governance/processes/ESCALATION_PATH.md` unless the policy explicitly grants + the specific action. +- **Impartiality.** A moderator must recuse themselves from moderating a + situation where they have a conflict of interest under + `Governance/policies/CONFLICT_OF_INTEREST.md`. + +## Appointment and Removal + +Moderators are selected and held accountable through the project's nomination +process: + +- **Appointment.** Nominations follow the + `Governance/processes/NOMINATION.md` process. The nomination must state + evidence of calm judgement, familiarity with the Code of Conduct, and prior + constructive participation. Self-nominations are allowed and treated like any + other nomination. +- **Eligibility.** A nominee should be a contributor or maintainer as defined in + `Governance/roles/CONTRIBUTOR.md` and `Governance/roles/MAINTAINER.md`. +- **Removal.** If a moderator fails to meet duties, violates this role, or + breaches the Code of Conduct, removal proceeds through the same + `Governance/processes/NOMINATION.md` mechanism (motion to revoke the role) + with evidence documented in the public nomination issue. + +## Ownership + +Maintainers own this role definition and any updates to it. Changes to this +document are proposed in a pull request that touches only the `Governance/` +folder. + +## Success + +This role succeeds when moderation is predictable, proportionate, and auditable; +when reports are handled consistently with the Code of Conduct and published +policies; and when participants understand the boundaries of moderator authority. + +## Regression Tests + +Regression coverage for this role lives in +`Governance/roles/MODERATOR.test.ts`. It verifies the document structure, +required sections, line length, absence of placeholders, references to key +policies and processes, and the explicit appointment/removal path via +`Governance/processes/NOMINATION.md`. + +## Revision History + +| Version | Date | Change | Author | +| ------- | ---------- | ---------------- | --------------------- | +| 1.0 | 2026-09-28 | Initial version. | TeachLink maintainers | diff --git a/Governance/roles/MODERATOR.test.ts b/Governance/roles/MODERATOR.test.ts new file mode 100644 index 00000000..0bb287ea --- /dev/null +++ b/Governance/roles/MODERATOR.test.ts @@ -0,0 +1,114 @@ +/** + * Regression tests for the Moderator Role + * (Governance/roles/MODERATOR.md). + * + * The moderator role is governance documentation. These tests pin the + * canonical structure, required content (duties, powers/limits, + * appointment/removal), and cross-references to existing policies/processes + * so edits cannot silently remove critical guarantees. The tests keep + * the document consistent with the Governance folder's style and rules. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const DOC_PATH = path.resolve(__dirname, 'MODERATOR.md'); +const doc = readFileSync(DOC_PATH, 'utf8'); + +/** Strip Markdown syntax so keyword assertions match prose, not formatting. */ +function plainProse(markdown: string): string { + return markdown + .replace(/`([^`]*)`/g, '$1') // inline code keeps its text + .replace(/\*\*([^*]*)\*\*/g, '$1') // bold keeps its text + .replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2') // links keep text and target + .replace(/\s+/g, ' ') // line wrapping must not affect prose matching + .toLowerCase(); +} + +const prose = plainProse(doc); + +/** Every "## Heading" in the document, in order. */ +const sections = [...doc.matchAll(/^## (.+)$/gm)].map((match) => match[1]); + +/** Extract the body of a single "## Section" (text up to the next heading). */ +function sectionBody(title: string): string { + const start = doc.indexOf(`## ${title}\n`); + expect(start, `section "${title}" is missing`).toBeGreaterThanOrEqual(0); + const next = doc.indexOf('\n## ', start + 1); + const body = next === -1 ? doc.slice(start) : doc.slice(start, next); + return plainProse(body); +} + +describe('MODERATOR role document structure', () => { + it('is titled "Moderator Role"', () => { + expect(doc.startsWith('# Moderator Role\n')).toBe(true); + }); + + it('keeps the canonical governance document sections', () => { + expect(sections).toEqual([ + 'Purpose', + 'Scope', + 'Duties', + 'Moderation Powers and Limits', + 'Appointment and Removal', + 'Ownership', + 'Success', + 'Regression Tests', + 'Revision History', + ]); + }); + + it('has no unresolved template placeholders', () => { + expect(doc).not.toMatch(/TBD|TODO|FIXME|<[a-z-]+>|XXX/); + }); + + it('stays within the house documentation line width (max 82 columns)', () => { + const longest = Math.max(...doc.split('\n').map((line) => line.length)); + expect(longest).toBeLessThanOrEqual(82); + }); +}); + +describe('moderator role content requirements', () => { + it('states duties clearly', () => { + const body = sectionBody('Duties'); + expect(body).toContain('uphold the code of conduct'); + expect(body).toContain('intervene early'); + expect(body).toContain('document actions'); + expect(body).toContain('collaborate on escalations'); + }); + + it('defines powers and limits', () => { + const body = sectionBody('Moderation Powers and Limits'); + expect(body).toContain('powers'); + expect(body).toContain('limits'); + expect(body).toContain('recuse themselves'); + expect(body).toContain('conflict of interest'); + }); + + it('defines appointment and removal via nomination process', () => { + const body = sectionBody('Appointment and Removal'); + expect(body).toContain('appointment'); + expect(body).toContain('removal'); + expect(body).toContain('governance/processes/nomination.md'); + expect(body).toContain('motion to revoke the role'); + expect(body).toContain('self-nominations'); + }); + + it('references moderation policy and escalation paths', () => { + expect(prose).toContain('governance/policies/moderation.md'); + expect(prose).toContain('governance/processes/escalation_path.md'); + expect(prose).toContain('governance/processes/conflict_resolution.md'); + expect(prose).toContain('governance/domains/comment_moderation.md'); + expect(prose).toContain('governance/code_of_conduct.md'); + }); + + it('limits changes to governance folder only', () => { + const body = sectionBody('Ownership'); + expect(body).toContain('governance/'); + }); + + it('pins regression coverage to the companion test file', () => { + const body = sectionBody('Regression Tests'); + expect(body).toContain('governance/roles/moderator.test.ts'); + }); +}); From e403c3ccb25fbac67bdb7fcb94bae66f1dff1172 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Mon, 28 Sep 2026 14:15:34 +0100 Subject: [PATCH 3/4] docs(governance): define the emeritus role (#1481) --- Governance/roles/EMERITUS.md | 75 ++++++++++++++++++++++++ Governance/roles/EMERITUS.test.ts | 94 +++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 Governance/roles/EMERITUS.md create mode 100644 Governance/roles/EMERITUS.test.ts diff --git a/Governance/roles/EMERITUS.md b/Governance/roles/EMERITUS.md new file mode 100644 index 00000000..69daca43 --- /dev/null +++ b/Governance/roles/EMERITUS.md @@ -0,0 +1,75 @@ +# Emeritus Role + +## Purpose + +This document defines emeritus status in TeachLink Web: what it means to be +emeritus, which privileges are retained and which are removed, and how the +status is granted. It closes a governance gap by making this status explicit and +self-contained in the `Governance/` folder. + +## Scope + +Emeritus status applies to people who previously held a defined role in +`Governance/roles/` (for example contributor, issue triager, moderator, +maintainer, or treasurer) and have stepped down from active duty while wishing +to remain associated with the project in a limited capacity. It does not create +a new voting role and does not override other policies. + +## Emeritus Status + +Being emeritus means a person is no longer actively responsible for the duties +of their former role, but the project recognizes their past service. The status +is honorary and opt-in: it is granted at the request of the former role holder +or by mutual agreement when stepping down. + +## Retained and Removed Privileges + +- **Retained.** Emeritus members may be listed in project recognition + materials per `Governance/RECOGNITION.md`, receive appropriate attribution, + and continue to be consulted informally if they consent. They may also + participate in discussions as community members. +- **Removed.** Emeritus members do not hold the duties, access rights, or + decision authority of the former role unless explicitly reappointed. They are + not counted toward quorum for decisions reserved to maintainers or other role + holders unless a specific policy states otherwise. Any system access tied to + the role is revoked or converted to community-level access as appropriate. + +## How the Status Is Granted + +- **Request.** A person eligible for emeritus status (or their designee in + consultation with maintainers) requests it when stepping down from an active + role. The request may be part of a nomination/removal outcome or a separate + request recorded in a public issue. +- **Process.** The maintainers record the transition in a public issue, + confirm the retained/removed privileges with the individual, and update role + records to reflect emeritus status. When stepping down from a role governed + by `Governance/processes/NOMINATION.md`, the nomination outcome may document + emeritus status. +- **Documentation.** The decision and rationale are recorded in the public + thread with the individual's consent. Recognition follows + `Governance/RECOGNITION.md`. + +## Ownership + +Maintainers own this role definition and updates to it. Changes to this +document are proposed in a pull request that touches only the `Governance/` +folder. + +## Success + +This status succeeds when transitions are clear and respectful, when service is +recognized without implying ongoing responsibility, and when former role holders +understand exactly what remains and what does not. + +## Regression Tests + +Regression coverage for this role lives in +`Governance/roles/EMERITUS.test.ts`. It verifies the document structure, +required sections, line length, absence of placeholders, references to +recognition and nomination processes, and the explicit grant process. + +## Revision History + +| Version | Date | Change | Author | +| ------- | ---------- | ---------------- | --------------------- | +| 1.0 | 2026-09-28 | Initial version. | TeachLink maintainers | diff --git a/Governance/roles/EMERITUS.test.ts b/Governance/roles/EMERITUS.test.ts new file mode 100644 index 00000000..f4af3b20 --- /dev/null +++ b/Governance/roles/EMERITUS.test.ts @@ -0,0 +1,94 @@ +/** + * Regression tests for the Emeritus Role + * (Governance/roles/EMERITUS.md). + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const DOC_PATH = path.resolve(__dirname, 'EMERITUS.md'); +const doc = readFileSync(DOC_PATH, 'utf8'); + +function plainProse(markdown: string): string { + return markdown + .replace(/`([^`]*)`/g, '$1') + .replace(/\*\*([^*]*)\*\*/g, '$1') + .replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2') + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +const prose = plainProse(doc); +const sections = [...doc.matchAll(/^## (.+)$/gm)].map((match) => match[1]); + +function sectionBody(title: string): string { + const start = doc.indexOf(`## ${title}\n`); + expect(start, `section "${title}" is missing`).toBeGreaterThanOrEqual(0); + const next = doc.indexOf('\n## ', start + 1); + const body = next === -1 ? doc.slice(start) : doc.slice(start, next); + return plainProse(body); +} + +describe('EMERITUS role document structure', () => { + it('is titled "Emeritus Role"', () => { + expect(doc.startsWith('# Emeritus Role\n')).toBe(true); + }); + + it('keeps the canonical governance document sections', () => { + expect(sections).toEqual([ + 'Purpose', + 'Scope', + 'Emeritus Status', + 'Retained and Removed Privileges', + 'How the Status Is Granted', + 'Ownership', + 'Success', + 'Regression Tests', + 'Revision History', + ]); + }); + + it('has no unresolved template placeholders', () => { + expect(doc).not.toMatch(/TBD|TODO|FIXME|<[a-z-]+>|XXX/); + }); + + it('stays within the house documentation line width (max 82 columns)', () => { + const longest = Math.max(...doc.split('\n').map((line) => line.length)); + expect(longest).toBeLessThanOrEqual(82); + }); +}); + +describe('emeritus role content requirements', () => { + it('defines emeritus status as honour-of-active-duty', () => { + const body = sectionBody('Emeritus Status'); + expect(body).toContain('no longer actively responsible'); + expect(body).toContain('honorary and opt-in'); + }); + + it('defines retained and removed privileges', () => { + const body = sectionBody('Retained and Removed Privileges'); + expect(body).toContain('retained'); + expect(body).toContain('removed'); + expect(body).toContain('attribution'); + expect(body).toContain('decision authority'); + expect(body).toContain('not counted toward quorum'); + }); + + it('defines how status is granted', () => { + const body = sectionBody('How the Status Is Granted'); + expect(body).toContain('request'); + expect(body).toContain('process'); + expect(body).toContain('documentation'); + expect(body).toContain('public issue'); + }); + + it('references recognition and nomination processes', () => { + expect(prose).toContain('governance/recognition.md'); + expect(prose).toContain('governance/processes/nomination.md'); + }); + + it('pins regression coverage to companion test', () => { + const body = sectionBody('Regression Tests'); + expect(body).toContain('governance/roles/emeritus.test.ts'); + }); +}); From 29828a07a1a0665fa46370e77041a83587baf467 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Mon, 28 Sep 2026 14:15:40 +0100 Subject: [PATCH 4/4] docs(governance): add a contributor onboarding process (#1482) --- Governance/processes/ONBOARDING.md | 85 +++++++++++++++++++++ Governance/processes/ONBOARDING.test.ts | 99 +++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 Governance/processes/ONBOARDING.md create mode 100644 Governance/processes/ONBOARDING.test.ts diff --git a/Governance/processes/ONBOARDING.md b/Governance/processes/ONBOARDING.md new file mode 100644 index 00000000..e58f7f84 --- /dev/null +++ b/Governance/processes/ONBOARDING.md @@ -0,0 +1,85 @@ +# Contributor Onboarding Process + +## Purpose + +This process defines how new contributors are onboarded in TeachLink Web: the +steps they follow, the resources they receive, and who owns onboarding. It makes +the path from interest to productive contribution explicit and keeps it +self-contained in the `Governance/` folder. + +## Scope + +This process applies to people becoming contributors as defined in +`Governance/roles/CONTRIBUTOR.md`. It complements `CONTRIBUTING.md`, +`Governance/policies/FIRST_TIME_CONTRIBUTOR.md`, and +`Governance/policies/GOOD_FIRST_ISSUE.md`. It focuses on the human onboarding +experience (orientation, resources, and clarity of expectations). + +## Onboarding Steps + +New contributors follow these steps: + +1. **Orientation.** Review `CONTRIBUTING.md` and the Code of Conduct + (`Governance/CODE_OF_CONDUCT.md`) to understand expectations and norms. +2. **Find a suitable issue.** Look for issues labelled as good first issues + per `Governance/policies/GOOD_FIRST_ISSUE.md` or discuss a proposed change in + an issue before starting work. +3. **Get assigned.** An issue must be assigned before opening a pull request, as + described in `CONTRIBUTING.md`. +4. **Set up locally.** Follow project setup instructions in `README.md` and any + environment notes referenced by the issue. +5. **Implement and test.** Make a small, focused change on a feature branch, + following project standards and quality gates (`type-check`, `lint`, `build`, + `test`, `security-audit`). +6. **Submit for review.** Open a pull request that references and closes the + assigned issue, responding to review feedback until approved and merged. +7. **Follow-up and recognition.** After merge, contributors are acknowledged + according to project practices and may explore the next steps on the + contributor ladder. + +## Resources a New Contributor Receives + +New contributors receive the following resources to be successful: + +- **Clear entry points.** Guidance via `Governance/policies/GOOD_FIRST_ISSUE.md` + and `Governance/policies/FIRST_TIME_CONTRIBUTOR.md`. +- **Project documentation.** Access to `CONTRIBUTING.md`, `README.md`, and + relevant `Governance/` documents for context. +- **Feedback loops.** Timely review feedback on pull requests and responses to + questions in issue discussions, consistent with + `Governance/policies/REVIEW_SLA.md` and `Governance/processes/TRIAGE.md`. +- **Mentorship guidance.** Direction to maintainers or experienced contributors + when questions arise, without assuming private hand-holding. + +## Who Owns Onboarding + +- **Maintainers.** Own the onboarding process and ensure it remains accurate + and accessible. +- **Triage/experienced contributors.** Help label and recommend good first + issues, provide clarifying feedback, and support newcomers in public forums. +- **New contributors.** Own their learning by reading the provided resources, + asking questions in public, and following the agreed steps. + +## Ownership + +Maintainers own this process and updates to it. Changes to this document are +proposed in a pull request that touches only the `Governance/` folder. + +## Success + +This process succeeds when new contributors can move from interest to their +first merged pull request with minimal friction, when expectations are clear, +and when onboarding is consistent across contributors. + +## Regression Tests + +Regression coverage for this process lives in +`Governance/processes/ONBOARDING.test.ts`. It verifies the document structure, +required sections, line length, absence of placeholders, references to key +policies and resources, and the explicit ownership model. + +## Revision History + +| Version | Date | Change | Author | +| ------- | ---------- | ---------------- | --------------------- | +| 1.0 | 2026-09-28 | Initial version. | TeachLink maintainers | diff --git a/Governance/processes/ONBOARDING.test.ts b/Governance/processes/ONBOARDING.test.ts new file mode 100644 index 00000000..0639836c --- /dev/null +++ b/Governance/processes/ONBOARDING.test.ts @@ -0,0 +1,99 @@ +/** + * Regression tests for the Contributor Onboarding Process + * (Governance/processes/ONBOARDING.md). + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const DOC_PATH = path.resolve(__dirname, 'ONBOARDING.md'); +const doc = readFileSync(DOC_PATH, 'utf8'); + +function plainProse(markdown: string): string { + return markdown + .replace(/`([^`]*)`/g, '$1') + .replace(/\*\*([^*]*)\*\*/g, '$1') + .replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2') + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +const prose = plainProse(doc); +const sections = [...doc.matchAll(/^## (.+)$/gm)].map((match) => match[1]); + +function sectionBody(title: string): string { + const start = doc.indexOf(`## ${title}\n`); + expect(start, `section "${title}" is missing`).toBeGreaterThanOrEqual(0); + const next = doc.indexOf('\n## ', start + 1); + const body = next === -1 ? doc.slice(start) : doc.slice(start, next); + return plainProse(body); +} + +describe('ONBOARDING process document structure', () => { + it('is titled "Contributor Onboarding Process"', () => { + expect(doc.startsWith('# Contributor Onboarding Process\n')).toBe(true); + }); + + it('keeps the canonical governance document sections', () => { + expect(sections).toEqual([ + 'Purpose', + 'Scope', + 'Onboarding Steps', + 'Resources a New Contributor Receives', + 'Who Owns Onboarding', + 'Ownership', + 'Success', + 'Regression Tests', + 'Revision History', + ]); + }); + + it('has no unresolved template placeholders', () => { + expect(doc).not.toMatch(/TBD|TODO|FIXME|<[a-z-]+>|XXX/); + }); + + it('stays within the house documentation line width (max 82 columns)', () => { + const longest = Math.max(...doc.split('\n').map((line) => line.length)); + expect(longest).toBeLessThanOrEqual(82); + }); +}); + +describe('onboarding process content requirements', () => { + it('lists onboarding steps clearly', () => { + const body = sectionBody('Onboarding Steps'); + expect(body).toContain('orientation'); + expect(body).toContain('find a suitable issue'); + expect(body).toContain('get assigned'); + expect(body).toContain('set up locally'); + expect(body).toContain('implement and test'); + expect(body).toContain('submit for review'); + }); + + it('specifies resources received', () => { + const body = sectionBody('Resources a New Contributor Receives'); + expect(body).toContain('clear entry points'); + expect(body).toContain('project documentation'); + expect(body).toContain('feedback loops'); + expect(body).toContain('mentorship guidance'); + }); + + it('defines ownership model', () => { + const body = sectionBody('Who Owns Onboarding'); + expect(body).toContain('maintainers'); + expect(body).toContain('new contributors'); + }); + + it('references key policies and resources', () => { + expect(prose).toContain('governance/roles/contributor.md'); + expect(prose).toContain('contributing.md'); + expect(prose).toContain('governance/policies/first_time_contributor.md'); + expect(prose).toContain('governance/policies/good_first_issue.md'); + expect(prose).toContain('governance/policies/review_sla.md'); + expect(prose).toContain('governance/processes/triage.md'); + }); + + it('pins regression coverage to companion test', () => { + const body = sectionBody('Regression Tests'); + expect(body).toContain('governance/processes/onboarding.test.ts'); + }); +});