diff --git a/.claude/skills/open-pr/SKILL.md b/.claude/skills/open-pr/SKILL.md new file mode 100644 index 00000000..d51e6b1a --- /dev/null +++ b/.claude/skills/open-pr/SKILL.md @@ -0,0 +1,71 @@ +--- +name: open-pr +description: Generate a PR title and description following the WCC repo template, infer change types from the diff, and print the GitHub URL to open the PR in the browser. Use when asked to open a PR or create a pull request. +disable-model-invocation: true +allowed-tools: Bash, Read, Glob +--- + +# Open PR Skill + +> **Canonical runbook**: `.ai/skills/open-pr.md` +> This file is the Claude Code adapter. The workflow logic is defined in the canonical skill +> so it can be shared with other agents (Codex, Copilot, Cursor). Any changes to the workflow +> should be made in `.ai/skills/open-pr.md` first. + +## Workflow + +1. Gather context in parallel: + - `git log main..HEAD --oneline` — commits in this PR + - `git diff main...HEAD --stat` — files changed + - `git branch --show-current` — current branch name + - Read `.github/PULL_REQUEST_TEMPLATE.md` + +2. Determine change types by mapping conventional-commit prefixes and the nature of the diff: + + | Conventional prefix | PR Change Type | + | ------------------------ | -------------- | + | `feat` | New Feature | + | `fix` | Bug Fix | + | `refactor` | Code Refactor | + | `docs` / `doc` | Documentation | + | `test` | Test | + | `chore` / `ci` / `build` | Other | + + Only keep the change types present in this PR — remove the rest from the template. + +3. Suggest a PR title using Conventional Commits format: `: ` (50–72 chars). When multiple types apply, use the most significant: `feat` > `fix` > `refactor` > `test` > `docs` > `chore`. + +4. Generate the filled-in PR description. Pre-check only the applicable change type boxes; remove the inapplicable ones entirely. Write all prose sections (Description, Related Issue) as flowing paragraphs — do NOT wrap lines at any character limit. Each sentence or logical thought should continue on the same line so that GitHub renders the text correctly without unwanted line breaks. + +5. **Screenshots section** — decide based on changed files: + - Frontend changes (`admin-wcc-app/**`, `*.tsx`, `*.css`, components, pages): include the section and list the specific screenshots needed (before/after UI, error states, label/title changes, GIF for interactions). Emphasise that screenshots are required for the reviewer to assess visual changes. + - Backend API changes (new/changed controllers or endpoints): include the section asking for Swagger UI screenshots (`/swagger-ui/index.html`). + - Only `test`, `docs`, `chore`, `ci`, or `refactor` with no endpoint changes: **omit the Screenshots section entirely**. + +6. **Pull request checklist** — always include the contributor guide checkbox. Include "I have tested my changes locally" only for `feat`, `fix`, `refactor`, or frontend changes — omit it for pure `docs`, `chore`, or `ci` changes. + +7. Derive the contributor's GitHub username from the `origin` remote: + + ```bash + git remote get-url origin + # https://github.com//wcc-backend.git or git@github.com:/wcc-backend.git + ``` + + Extract `` from the URL (path segment before `/wcc-backend`). + + Print: + - **Suggested PR title** (plain text) + - **PR description** (markdown code block) + - **GitHub compare URL**: `https://github.com/Women-Coding-Community/wcc-backend/compare/main...:` + - Remind the user to add screenshots before submitting if required + +## Rules + +- **Never hard-wrap prose** — description text must not have forced line breaks mid-sentence or mid-paragraph; let GitHub reflow the text naturally + +- Always target `main` on `Women-Coding-Community/wcc-backend` (upstream) +- Never use `gh pr create` — fork permissions require the user to open the PR through the browser +- If the issue number cannot be found in branch name or commits, use `#?` and ask the user +- Never include change type checkboxes for types not present in the diff +- Never include the Screenshots section for pure test/docs/chore/ci changes +- Never include "I have tested my changes locally" for pure docs/chore/ci changes diff --git a/src/__tests__/api/mentee-registration.test.ts b/src/__tests__/api/mentee-registration.test.ts index 8ea3fc73..1a62c3a4 100644 --- a/src/__tests__/api/mentee-registration.test.ts +++ b/src/__tests__/api/mentee-registration.test.ts @@ -1,7 +1,14 @@ import { NextApiRequest, NextApiResponse } from 'next'; +import * as api from '../../lib/api'; import handler from '../../pages/api/mentee-registration'; +jest.mock('../../lib/api', () => ({ + __esModule: true, + ...jest.requireActual('../../lib/api'), + proxyRequest: jest.fn(), +})); + const makeReq = (overrides: Partial = {}): NextApiRequest => ({ method: 'POST', @@ -20,18 +27,7 @@ const makeRes = (): NextApiResponse => { }; describe('mentee-registration API handler', () => { - const originalEnv = process.env; - - beforeEach(() => { - process.env = { - ...originalEnv, - API_BASE_URL: 'http://localhost:8080/api/cms/v1', - API_KEY: 'test-key', - }; - }); - afterEach(() => { - process.env = originalEnv; jest.resetAllMocks(); }); @@ -46,66 +42,35 @@ describe('mentee-registration API handler', () => { expect(res.json).toHaveBeenCalledWith({ error: 'Method GET Not Allowed' }); }); - it('returns 500 when API_BASE_URL is missing', async () => { - delete process.env.API_BASE_URL; - const req = makeReq(); - const res = makeRes(); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - error: 'Server configuration error', - }); - }); - - it('returns 500 when API_KEY is missing', async () => { - delete process.env.API_KEY; - const req = makeReq(); - const res = makeRes(); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - error: 'Server configuration error', - }); - }); - it('proxies POST to the platform endpoint and returns 201 on success', async () => { const responseBody = { id: 42 }; - globalThis.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 201, - json: jest.fn().mockResolvedValue(responseBody), - }); + (api.proxyRequest as jest.Mock).mockResolvedValue(responseBody); const req = makeReq(); const res = makeRes(); await handler(req, res); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://localhost:8080/api/platform/v1/mentees', + expect(api.proxyRequest).toHaveBeenCalledWith( + 'mentees', expect.objectContaining({ method: 'POST', - headers: expect.objectContaining({ - 'X-API-KEY': 'test-key', - 'Content-Type': 'application/json', - }), + data: req.body, }), + true, ); expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith(responseBody); }); it('forwards backend error status and message on failure', async () => { - const errorBody = { message: 'Email already registered' }; - globalThis.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 409, - json: jest.fn().mockResolvedValue(errorBody), - }); + const errorResponse = { + response: { + status: 409, + data: { message: 'Email already registered' }, + }, + }; + (api.proxyRequest as jest.Mock).mockRejectedValue(errorResponse); const req = makeReq(); const res = makeRes(); @@ -113,15 +78,13 @@ describe('mentee-registration API handler', () => { await handler(req, res); expect(res.status).toHaveBeenCalledWith(409); - expect(res.json).toHaveBeenCalledWith(errorBody); + expect(res.json).toHaveBeenCalledWith(errorResponse.response.data); }); - it('falls back to generic error when backend returns no body', async () => { - globalThis.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 500, - json: jest.fn().mockRejectedValue(new Error('no body')), - }); + it('returns 500 on unexpected error', async () => { + (api.proxyRequest as jest.Mock).mockRejectedValue( + new Error('Network failure'), + ); const req = makeReq(); const res = makeRes(); @@ -129,15 +92,13 @@ describe('mentee-registration API handler', () => { await handler(req, res); expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - error: 'Registration failed. Please try again.', - }); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); }); - it('returns 500 on network error', async () => { - globalThis.fetch = jest - .fn() - .mockRejectedValue(new Error('Network failure')); + it('returns 500 when server configuration error occurs', async () => { + (api.proxyRequest as jest.Mock).mockRejectedValue( + new Error('Server configuration error'), + ); const req = makeReq(); const res = makeRes(); @@ -145,6 +106,8 @@ describe('mentee-registration API handler', () => { await handler(req, res); expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); + expect(res.json).toHaveBeenCalledWith({ + error: 'Server configuration error', + }); }); }); diff --git a/src/__tests__/api/mentor-registration.test.ts b/src/__tests__/api/mentor-registration.test.ts new file mode 100644 index 00000000..e672eb8a --- /dev/null +++ b/src/__tests__/api/mentor-registration.test.ts @@ -0,0 +1,171 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +import * as api from '../../lib/api'; +import handler from '../../pages/api/mentor-registration'; + +jest.mock('../../lib/api', () => ({ + __esModule: true, + ...jest.requireActual('../../lib/api'), + proxyRequest: jest.fn(), +})); + +const makeReq = (overrides: Partial = {}): NextApiRequest => + ({ + method: 'POST', + body: { + fullName: 'Jane Doe', + email: 'jane@example.com', + slackDisplayName: 'janedoe', + country: 'United Kingdom', + city: 'London', + position: 'Software Engineer', + companyName: 'Tech Co', + isLongTermMentor: true, + maxMentees: '2', + calendlyLink: 'https://calendly.com/janedoe', + menteeExpectations: 'Eager to learn and proactive.', + openToNonWomen: true, + languages: ['English'], + yearsExperience: 5, + bio: 'A passionate developer with 5 years of experience.', + technicalAreas: [ + { technicalArea: 'Fullstack', proficiencyLevel: 'Advanced' }, + ], + codeLanguages: [{ language: 'C++', proficiencyLevel: 'Advanced' }], + mentorshipFocusAreas: ['Career advice'], + linkedin: 'https://linkedin.com/in/janedoe', + identity: 'Yes', + pronouns: 'she/her', + socialHighlight: 'Yes', + termsAgreed: true, + }, + ...overrides, + }) as NextApiRequest; + +const makeRes = (): NextApiResponse => { + const res = { + status: jest.fn(), + json: jest.fn(), + setHeader: jest.fn(), + } as unknown as NextApiResponse; + (res.status as jest.Mock).mockReturnValue(res); + return res; +}; + +describe('mentor-registration API handler', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('returns 405 for non-POST methods', async () => { + const req = makeReq({ method: 'GET' }); + const res = makeRes(); + + await handler(req, res); + + expect(res.setHeader).toHaveBeenCalledWith('Allow', ['POST']); + expect(res.status).toHaveBeenCalledWith(405); + }); + + it('proxies POST to the platform endpoint and correctly maps data', async () => { + const responseBody = { id: 123 }; + (api.proxyRequest as jest.Mock).mockResolvedValue(responseBody); + + const req = makeReq(); + const res = makeRes(); + + await handler(req, res); + + expect(api.proxyRequest).toHaveBeenCalledWith( + 'mentors', + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + fullName: 'Jane Doe', + pronounCategory: 'FEMININE', + country: { + countryCode: 'GB', + countryName: 'United Kingdom', + }, + skills: expect.objectContaining({ + yearsExperience: 5, + areas: [ + { technicalArea: 'FULLSTACK', proficiencyLevel: 'ADVANCED' }, + ], + languages: [ + { language: 'C_PLUS_PLUS', proficiencyLevel: 'ADVANCED' }, + ], + }), + menteeSection: expect.objectContaining({ + longTerm: expect.objectContaining({ + numMentee: 2, + }), + }), + network: expect.arrayContaining([ + { + type: 'LINKEDIN', + link: 'https://linkedin.com/in/janedoe', + }, + ]), + }), + }), + true, + ); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(responseBody); + }); + + it('handles backend errors', async () => { + const errorResponse = { + response: { + status: 400, + data: { message: 'Invalid data' }, + }, + }; + (api.proxyRequest as jest.Mock).mockRejectedValue(errorResponse); + + const req = makeReq(); + const res = makeRes(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(errorResponse.response.data); + }); + + it('correctly maps adHocAvailability and handles empty values', async () => { + (api.proxyRequest as jest.Mock).mockResolvedValue({ id: 123 }); + + const req = makeReq({ + body: { + ...makeReq().body, + isAdHocMentor: true, + adHocAvailability: { + January: '2', + February: '', + March: undefined, + April: '3', + }, + }, + }); + const res = makeRes(); + + await handler(req, res); + + expect(api.proxyRequest).toHaveBeenCalledWith( + 'mentors', + expect.objectContaining({ + data: expect.objectContaining({ + menteeSection: expect.objectContaining({ + adHoc: [ + { month: 1, hours: 2 }, + { month: 4, hours: 3 }, + ], + }), + }), + }), + true, + ); + }); +}); diff --git a/src/components/MentorBecomeCard.tsx b/src/components/MentorBecomeCard.tsx index fe3789d6..6e3fb2ef 100644 --- a/src/components/MentorBecomeCard.tsx +++ b/src/components/MentorBecomeCard.tsx @@ -59,7 +59,6 @@ export const MentorBecomeCard = ({ ))} + + + + ); + } + return ( { Step {activeStep} of {totalSteps} + {submissionStatus === 'error' && ( + + {errorMessage} + + )} + { + + {Object.keys(formMethods.formState.errors).length > 0 && ( + + + Please fix the following validation errors: + +
    + {Object.entries(formMethods.formState.errors).map( + ([key, error]: [string, any]) => { + const label = + { + fullName: 'Full Name', + email: 'Email', + slackDisplayName: 'Slack Name', + country: 'Country', + city: 'City', + position: 'Position', + companyName: 'Company Name', + calendlyLink: 'Calendly Link', + menteeExpectations: 'Mentee Expectations', + openToNonWomen: 'Open to non-women', + isLongTermMentor: 'Mentorship Format', + maxMentees: 'Max Mentees', + adHocAvailability: 'Ad-hoc Availability', + languages: 'Languages', + yearsExperience: 'Years of Experience', + bio: 'Bio', + technicalAreas: 'Technical Areas', + codeLanguages: 'Programming Languages', + mentorshipFocusAreas: 'Mentorship Focus Areas', + linkedin: 'LinkedIn', + identity: 'Identity', + pronouns: 'Pronouns', + socialHighlight: 'Social Highlight', + termsAgreed: 'Terms Agreement', + }[key] || key; + return ( +
  • + + {label}:{' '} + {error?.message || 'Invalid value'} + +
  • + ); + }, + )} +
+
+ )} +
+ {activeStep === totalSteps ? ( ) : (