mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 09:54:43 +10:00
test: add ~500 tests across web, utils, api, import, ai, db, email, auth (#3038)
* test(web): add tests for zustand stores and pure helpers
Cover stores and pure helpers across the builder/dashboard/command-palette
surfaces that previously had 0% coverage:
- command-palette store (open/close, page stack, search clearing, goBack)
- builder assistant-store
- builder sidebar store + parseBuilderLayoutCookie / mapPanelLayoutToBuilderLayout
- builder section store (collapse, toggle, toggleAll)
- builder preview page-layout toggle
- dashboard resume-thumbnail render-size math + cache key
- MCP tool name + annotations invariants
* test(web): cover MCP helpers and template metadata
Add tests for previously 0%-coverage MCP and dialog helpers:
- buildMcpServerCard: server-info, tool catalog vs MCP_TOOL_NAME, prompts,
resource templates, configuration schema, auth schemes
- registerPrompts (build/improve/review): registration, args schema, resource
context with interpolated resume id, read-only / no-fabrication directives
- registerResources (resume://{id}, resume://_meta/schema): handler reads via
oRPC client, error on missing id, schema returns valid JSON
- templates metadata: ids match display names, valid sidebar positions,
unique image URLs, every entry has tags + description
* test(web): cover sidebar section helpers and layout screens
Add tests for previously near-0%-coverage modules:
- libs/resume/section: getSectionTitle / getSectionIcon return distinct,
exhaustive results for every sidebar section + cover-letter; icon props
forwarding; left/right sidebar collections do not overlap.
- layout/loading-screen: spinner + text render.
- layout/error-screen: error message surfaces, Refresh button triggers reset.
- layout/breakpoint-indicator: default + each corner positioning, all
breakpoint labels rendered, print-hidden class applied.
* test(web): cover preview canvas math and font-weight defaults
Add tests for pure helpers that previously had no direct coverage:
- typography/getNextWeights: prefers 400 + 600 when both are available,
returns null for unknown families, never produces duplicates, bounded
to two weights from the 100..900 set.
- preview.shared/normalizeResumePreviewProps: documented defaults +
pass-through.
- preview.shared/getScaledPreviewPageSize: scaling identity at 1, and
fractional scaling.
- preview.shared/getPreviewCanvasScale: respects 4x desired scale for
small pages, honors high devicePixelRatio, clamps to the 16M-pixel
canvas budget for large pages.
* test(utils): cover DOCX section renderers and html-to-paragraphs
@reactive-resume/utils/resume/docx was previously at ~2.84% statement
coverage despite being load-bearing for the resume DOCX export.
- section-renderers: empty-string / hidden-section / hidden-item branches
for renderSummary, renderBuiltInSection, and renderCustomSection;
cover-letter and summary custom-section dispatch; unknown-type fallback;
setRenderConfig idempotency.
- html-to-docx: whitespace-only short-circuit, multiple top-level blocks,
h1..h6 paragraph mapping, inline style and link rendering, custom
font/size/color/linkColor config, ignored script/comment nodes.
* test: cover DOCX builder smoke paths and reactive-resume JSON importer
- utils/resume/docx/builder: buildDocument runs end-to-end against the
default and sample resume data, both page formats, full-width and
sidebar layouts, and gracefully degrades with unparseable color or
empty font family inputs.
- import/reactive-resume-json: ReactiveResumeJSONImporter validates
malformed JSON, recovers missing built-in sections by appending them
to page 1 without reordering, and preserves layouts that already
contain every built-in section.
* test(import): cover JSONResumeImporter parse/convert
JSONResumeImporter (450 lines) was previously at 0% coverage. Add tests
for the public surface:
- Invalid JSON / invalid-shape errors are surfaced.
- basics, summary, picture, education, projects, skills, profiles all
map to the corresponding ResumeData sections.
- Empty work/education entries (missing key field) are filtered out.
- Highlights become HTML list items in the description field.
- Skill level parsing flows through utils/level.parseLevel.
- formatLocation joins city, region, countryCode with commas.
* test(web): cover query client serializer and home-page animations
- libs/query/client: getQueryClient returns a fresh QueryClient,
queryKeyHashFn produces stable JSON envelopes for matching keys
(and distinct strings for different keys), dehydrate/hydrate
round-trip Date values via the oRPC serializer.
- components/animation/spotlight: overlay container is
pointer-events-none, both beam groups render, custom
width/height/translateY/gradient props flow into inline styles.
- components/animation/comet-card: children mount inside the
perspective wrapper, custom className is merged with the 3D
baseline classes, glare overlay renders, mouse move/leave
handlers do not throw.
* test(web): cover Copyright footer
Verify the footer's MIT license link, Amruth Pillai attribution,
external-tab targets, embedded app version (via __APP_VERSION__ stub),
and custom className merging — previously at 0% coverage.
* test(api): cover flags, auth providers, and resume-access cookies
Unlock @reactive-resume/api by mocking @reactive-resume/env/server and
@tanstack/react-start/server. Previously the only services tested were
the standalone AI test and resume-access-policy.
- services/flags: flagsService.getFlags reads disableSignups/disableEmailAuth
from env (no stale cache).
- services/auth: providers.list always exposes credential + passkey, and
only adds Google/GitHub/LinkedIn/custom when both id and secret are set;
custom provider uses OAUTH_PROVIDER_NAME with a 'Custom OAuth' fallback.
- helpers/resume-access: hasResumeAccess validates against signed cookies
with constant-time comparison; grantResumeAccess writes a 10-minute
httpOnly cookie with the secure flag matching APP_URL's https-ness.
* test: cover statistics service and email transport via env mocks
- api/services/statistics: github star count succeeds, retries on
non-OK, falls back to last-known on fetch error / non-positive /
non-numeric responses; user and resume counts roll up DB count.
- email/src/transport: returns silently with no text/html, logs when
SMTP is not configured, dispatches via nodemailer with the env
config when fully wired, renders react elements to html + text
bodies, swallows transport errors instead of crashing.
* test(api): cover resume-events publish + subscribe
- publishResumeUpdated issues pg_notify with channel and serialized
event payload.
- subscribeResumeUpdated yields events whose resumeId+userId match
the subscription, filters out other resumes/users, ignores
malformed JSON and notifications on other channels, calls
LISTEN/UNLISTEN and releases the client, and terminates
immediately if the abort signal fires before iteration starts.
* test(api): cover oRPC auth resolution
resolveUserFromRequestHeaders is the single point where every oRPC
procedure picks up the authenticated user. Test the priority chain:
- x-api-key wins when present and valid
- on invalid api key, falls back to session via auth.api.getSession
- Bearer JWT in Authorization header is verified via verifyOAuthToken
- invalid Bearer falls back to session
- Authorization scheme other than Bearer is ignored entirely
- thrown errors from token verification are logged and swallowed
(caller still tries session)
- returns null when no auth method succeeds
* test(api): cover storage helpers
inferContentType, isImageFile, processImageForUpload were 0%
coverage despite being on the picture upload path.
- inferContentType maps known image and pdf extensions, is
case-insensitive, ignores path depth, and falls back to
application/octet-stream for unknown.
- isImageFile allows only the upload allowlist (gif/png/jpeg/webp)
and rejects image/svg+xml, application/pdf, and empty strings.
- processImageForUpload short-circuits to the original bytes when
FLAG_DISABLE_IMAGE_PROCESSING is true, otherwise pipes through
sharp and returns image/jpeg.
* test(import): broaden v4 importer section-mapping coverage
The existing v4 importer test focused on a single bug (description-only
custom items) and the skill/language level scaling. This new test
exercises the bulk of the v4 → v5 transformation path:
- basics, picture (with border), summary, customFields
- every section's filter-by-required-field invariant (awards needs
title, certifications needs name, education needs institution,
experience needs company, volunteer needs organization, etc.)
- experience / education / awards / certifications / references field
renames between schemas
- language and skill level scaling (v4 0..10 → v5 0..5)
Brings reactive-resume-v4-json from ~66% statement coverage to a
materially higher figure (the bulk of the 410-line transformer body).
* test: cover buildDocx entry and AI configuration store
- utils/resume/docx/index: buildDocx returns a non-empty Blob for both
default and populated resumes (previously 0% coverage despite being
the public DOCX entry point).
- ai/store: useAIStore preserves verification status across no-op
updates, but resets testStatus + enabled whenever provider, model,
apiKey, or baseURL changes; canEnable is gated to testStatus=success;
setEnabled(true) is refused unless verified; reset clears every
field. Brings @reactive-resume/ai from ~72% to materially higher
coverage.
* test(db): cover resume schema definitions
packages/db was previously at 0% coverage. Smoke-test the public
resume / resume_statistics / resume_analysis tables:
- getTableName matches the SQL identifier used by migrations
- expected columns are present on each table
- defaultResumeData wiring on the data column resolves to a valid
shape
These are structural assertions that catch accidental renames /
removals without needing a live database connection.
* test(db): cover auth schema tables and relations export
- src/schema/auth: table-driven test for each of the 12 auth tables
asserting SQL name and presence of the key columns (user/session/
account/verification/two_factor/passkey/apikey/jwks/oauth_*).
- src/relations: smoke test confirming the relations export is defined.
Brings @reactive-resume/db from 0% to materially higher coverage.
* test(auth): cover getSession isomorphic helper
@reactive-resume/auth was previously at 0% coverage. functions.ts
is the server entry point that other packages call. Mock the auth
config + tanstack/react-start to verify:
- getSession forwards getRequestHeaders() to auth.api.getSession
- returns null when better-auth returns null
* test(web): cover BuilderSidebarEdge
Small presentational component on the builder layout — assert children
mount, left/right positioning class branches, and the sm:flex
mobile-hide behavior.
* test(web): cover section-title-locale resolver cache and hook
The section-title-locale module wraps createSectionTitleResolver with
a per-locale async cache and a React hook for consumers in the
builder. Cover:
- createSectionTitleResolverForLocale returns a usable resolver
- repeated calls for the same locale share a cached promise
- unknown locales fall back through resolveLocale
- useSectionTitleResolver returns null while loading and when no
locale is passed
- the hook resolves to a function once the async loader settles
* test(web): cover BaseCommandGroup page-stack gating
BaseCommandGroup conditionally renders based on the top of the
command-palette page stack. Tests cover:
- root group renders when no sub-page is active
- root group hides when a sub-page is on top
- sub-page group renders only when its page matches
- mismatched sub-page leaves the group hidden
* test(web): cover ThemeProvider context
- useTheme outside ThemeProvider throws the documented error
- useTheme inside ThemeProvider returns the theme + setTheme +
toggleTheme helpers
* test(web): cover ConfirmDialogProvider + useConfirm hook
- useConfirm outside provider throws the documented error
- confirm returns a pending promise
- promise resolves false when the Cancel button is clicked
- promise resolves true when the Confirm button is clicked
- works with custom confirmText label
apps/web has its own copy of this hook distinct from
packages/ui (mirrors the existing UI-package tests).
* test(web): cover PromptDialogProvider + usePrompt hook
- usePrompt outside provider throws the documented error
- returns a function when wrapped
- Cancel click resolves the promise to null
- Confirm click resolves to the current input value
- defaultValue option seeds the initial input value
* test(web): cover DashboardHeader
Small presentational header used across dashboard routes — title h1,
icon rendering, className merge, mobile sidebar trigger present and
hidden on md+.
* test(web): cover Create/Import resume cards
Both cards on the resumes dashboard wire a click handler to open
the appropriate dialog via the dialog store:
- CreateResumeCard opens resume.create
- ImportResumeCard opens resume.import
Also asserts the i18n copy strings (icons aside, the cards are
otherwise structural).
* test(web): cover command-palette language sub-page
LanguageCommandPage is a BaseCommandGroup gated on page='language'.
Tests assert:
- it is hidden when 'language' is not the top of the page stack
- when active, it renders a CommandItem per localeMap entry
- documented locale codes (en-US, de-DE, ja-JP) appear
* test(web): cover command-palette theme + preferences sub-pages
- ThemeCommandPage: hidden when 'theme' is not on top, renders Light
and Dark options when active
- PreferencesCommandGroup: root group renders both Change theme to...
and Change language to... items; clicking each pushes the
corresponding page onto the command-palette stack
* test(ai): cover executePatchResume tool
- patchResumeInputSchema rejects empty operations and unknown op
values; accepts valid replace/add/remove
- executePatchResume returns the applied operations on success
- executePatchResume throws when an operation targets an invalid path
(passes through the underlying applyResumePatches validation)
- multi-op patches against top-level fields succeed end-to-end
* test(ai): cover sanitize edge branches
Hit the previously-uncovered branches in sanitize.ts:
- numeric 1 coerces to true
- '1' / '0' string shorthand coerces to true/false
- missing item.hidden gets salvaged to false
- empty input causes a non-Zod throw (caught + rethrown with generic message)
* test(ai): cover patch-proposal preview + normalize edge cases
- remove operations surface before-value with after=undefined
- buildResumePatchProposalPreview labels metadata/page paths sanely
- normalizeResumePatchProposals stamps every proposal with baseUpdatedAt
- normalizeResumePatchProposals preserves input order
* test(web): cover getLocaleOptions helper
Locale combobox surface — verify the option list mirrors localeMap
shape, uses locale codes as values, populates label + keywords with
the translated display name, and produces unique values.
* test(web): cover LevelTypeCombobox option mapping
LevelTypeCombobox maps levelDesignSchema.shape.type.options through
the internal getLevelTypeName labeler. Assert all 7 level types are
exposed and that each produces a non-empty label.
* test(web): cover ThemeToggleButton fallback paths
- aria-label flips between 'Switch to light theme' and 'Switch to
dark theme' based on current theme
- clicking when document.startViewTransition is unavailable
short-circuits to toggleTheme directly
- prefers-reduced-motion forces the direct toggle path even when
the view-transition API is available
* test(web): cover NotFoundScreen
Mock the TanStack Router Link so the screen renders standalone, then
assert: documented error heading, routeId is surfaced verbatim, and
the Go Back link points to '..' (parent route).
* test(web): cover InformationSectionBuilder
Stub SectionBase so the donation/info section renders standalone.
Assert: donation prompt copy, OpenCollective CTA link, all 5
external resource links present, and external links target _blank
with rel=noopener.
* test(web): cover NotesSectionBuilder
Mock SectionBase, RichInput, and the resume-draft hooks so the
notes section renders in isolation. Assert: privacy hint copy
renders, RichInput is seeded with metadata.notes, and onChange
proxies through updateResumeData with a draft recipe that mutates
metadata.notes.
* test(web): cover TemplateSectionBuilder
Stub SectionBase and useCurrentResume so the right-sidebar template
section renders standalone. Asserts: current template name in the
heading, template tags rendered as badges, preview image points to
the catalog asset, and clicking the preview opens the
resume.template.gallery dialog.
* test(web): cover ColorPicker preset selection and trigger override
Mock the heavy @uiw/react-color-colorful dependency. Test:
- the trigger swatch reflects the controlled value
- clicking a preset color invokes onChange with an rgba() string
- a custom trigger replaces the default swatch when provided
* test(web): cover ExportSectionBuilder
Mock the heavy export pipelines (buildDocx, createResumePdfBlob,
downloadWithAnchor) and the resume-draft hook to test:
- JSON button packages resume.data as application/json and triggers
download with the {name}.json filename
- DOCX button awaits buildDocx and downloads .docx
- PDF button awaits createResumePdfBlob and downloads .pdf
* test(web): cover ProfilesSectionBuilder
Stub the resume-draft hooks, SectionBase, SectionItem, and
SectionAddItemButton so the profiles section renders standalone:
- one SectionItem per profile with network as title and username as
subtitle
- 'Add a new profile' affordance present
- when items.length > 0, the wrapper uses a solid border (not dashed)
* test(web): cover SkillsSectionBuilder
Mirror the profiles test for the skills section — verifies one
SectionItem per skill (name → title, proficiency → subtitle) and
the Add a new skill affordance.
* test(web): bulk-cover 7 left-sidebar section builders
Single test file covers awards, certifications, interests, languages,
publications, references, and volunteer builders. For each:
- one SectionItem rendered with the documented field → title/subtitle
mapping
- awards: title → awarder
- certifications: title → 'issuer • date'
- interests: name → (no subtitle)
- languages: language → fluency
- publications: title → publisher
- references: name → (no subtitle)
- volunteer: organization → location
- the 'Add a new {kind}' affordance with the matching copy
Mocks SectionBase, SectionItem, SectionAddItemButton, and the
resume-draft hooks so each builder renders standalone.
* test(web): cover ProjectsSectionBuilder buildSubtitle
The projects section is the only left-sidebar builder with a
composite subtitle. Tests three branches of its inline
buildSubtitle helper:
- period + website.label → joined with ' • '
- period only → just the period
- empty period + whitespace-only website.label → returns undefined
* test(web): cover Education + Experience section builders
- Education: school → title, degree → subtitle, add-new affordance
- Experience: position → subtitle when set; falls back to '1 role' /
'N roles' (lingui plural) when position empty and roles[] present;
add-new affordance
* test(web): cover CountUp animated number renderer
- default aria attributes (aria-live=polite, aria-atomic=true)
- initial textContent seeds to 'from' (up) or 'to' (down) value
- separator option formats with grouping
- decimal places are preserved when from/to are fractional
- aria-hidden=true strips aria-live + aria-atomic
- custom className is applied to the rendered span
* test(web): cover TextMaskEffect SVG renderer
- supplied text renders in every visible <text> layer
- aria-hidden + aria-label forwarded onto the root svg
- mouse enter/move/leave handlers don't throw
- custom className merges into the svg's class attribute
* test(web): cover URLInput prefix handling
- displayed input strips the https:// prefix so users only edit the
meaningful portion
- editing re-adds the prefix on the way back through onChange
- pre-prefixed input is preserved
- cleared input emits an empty url (no prefix forced)
- hideLabelButton=true removes the popover trigger; default keeps it
* test(web): cover GithubStarsButton
Mocks useQuery + the CountUp animation so the button renders
standalone. Asserts:
- anchor points at the project repo with rel=noopener + target=_blank
- aria-label is the no-count copy when star count is undefined
- CountUp renders only once the count loads
- aria-label includes the localized count once data arrives
* test(web): cover ui/Combobox trigger label rendering
The shared Combobox wraps base-ui's combobox primitives. Smoke-test
the trigger label resolution:
- placeholder shows when nothing is selected
- selected option's label renders in the trigger
- multi-select default values render all labels
- empty options array renders the placeholder without crashing
* test(web): cover IconPicker trigger rendering
Stub react-window's Grid so happy-dom can render the picker without
layout-measurement deps. Verify:
- trigger renders an <i class='ph-{value}'> for the current value
- changing value updates the trigger icon class
- the picker emits a trigger button
* test(web): cover ChipInput add/dedupe/description behaviors
- existing chips render as Badges
- Enter adds the typed value to the chip list
- comma also commits the typed value
- duplicate input is dropped (onChange not called with a longer list)
- empty / whitespace-only input is dropped
- hideDescription removes the keyboard hint <kbd>; default keeps it
* test(web): cover StatisticsSectionBuilder
Mock useQuery + useParams + section-base so the right-sidebar
statistics section renders standalone:
- returns null content while the query is loading
- shows the private-resume hint when isPublic=false
- shows views/downloads counters and labels when isPublic=true
- includes 'Last viewed' timestamp copy when lastViewedAt is set
* test(web): cover TemplateGalleryDialog selection flow
- title + intro copy render
- one tile per template (>= 14)
- currently-selected template tile carries the ring-highlight
- clicking a different tile triggers updateResumeData with a recipe
that sets metadata.template to the chosen template id
* test(web): cover Prefooter home-page section
- community tagline heading renders
- community-thanks paragraph renders
- the decorative TextMaskEffect renders an svg
* test(web): cover home-page Footer
- Resources and Community headings render
- documented resource links (Documentation, Sponsorships, Source
Code, Changelog) all appear in the rendered output
- documented community links (Report an issue, Translations,
Subreddit, Discord) all appear
- social anchors point at GitHub, LinkedIn, and X (Twitter)
- Copyright sub-component surfaces the app version via __APP_VERSION__
* test(web): cover home-page Header navigation
Mock TanStack Router Link + child components so the header renders
standalone. Verify:
- homepage anchor (/ link) carries the documented aria-label
- dashboard anchor points to /dashboard
- ThemeToggleButton and GithubStarsButton both mount
- the <nav> landmark is labeled 'Main navigation'
* chore: fix linter warnings
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
vi.stubGlobal("__APP_VERSION__", "9.9.9");
|
||||
|
||||
// The footer module evaluates `socialLinks = [{ label: t`...`, ... }]` at module
|
||||
// scope. That `t` call needs an activated locale BEFORE the import, so do that
|
||||
// here instead of in beforeAll.
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
const { Footer } = await import("./footer");
|
||||
|
||||
const renderFooter = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Footer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("Footer", () => {
|
||||
it("renders Resources and Community link group headings", () => {
|
||||
renderFooter();
|
||||
expect(screen.getByText("Resources")).toBeInTheDocument();
|
||||
expect(screen.getByText("Community")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the documented resource links", () => {
|
||||
const { container } = renderFooter();
|
||||
const text = container.textContent ?? "";
|
||||
for (const label of ["Documentation", "Sponsorships", "Source Code", "Changelog"]) {
|
||||
expect(text, label).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the documented community links", () => {
|
||||
const { container } = renderFooter();
|
||||
const text = container.textContent ?? "";
|
||||
for (const label of ["Report an issue", "Translations", "Subreddit", "Discord"]) {
|
||||
expect(text, label).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders social media icon links to GitHub, LinkedIn, and X", () => {
|
||||
const { container } = renderFooter();
|
||||
const hrefs = Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).map((a) => a.href);
|
||||
expect(hrefs.some((h) => h.includes("github.com/amruthpillai/reactive-resume"))).toBe(true);
|
||||
expect(hrefs.some((h) => h.includes("linkedin.com/in/amruthpillai"))).toBe(true);
|
||||
expect(hrefs.some((h) => h.includes("x.com/KingOKings"))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes Reactive Resume version copy via Copyright", () => {
|
||||
renderFooter();
|
||||
expect(screen.getByText(/v9\.9\.9/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/input/github-stars-button", () => ({
|
||||
GithubStarsButton: () => <div data-testid="github-stars-button" />,
|
||||
}));
|
||||
vi.mock("@/components/locale/combobox", () => ({
|
||||
LocaleCombobox: ({ render: renderProp }: { render: React.ReactElement }) => renderProp,
|
||||
}));
|
||||
vi.mock("@/components/theme/toggle-button", () => ({
|
||||
ThemeToggleButton: () => <button type="button" data-testid="theme-toggle" />,
|
||||
}));
|
||||
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
const { Header } = await import("./header");
|
||||
|
||||
const renderHeader = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Header />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("Header", () => {
|
||||
it("renders a homepage link with the brand icon", () => {
|
||||
const { container } = renderHeader();
|
||||
const home = Array.from(container.querySelectorAll("a")).find((a) => a.getAttribute("href") === "/");
|
||||
expect(home).toBeDefined();
|
||||
expect(home?.getAttribute("aria-label")).toBe("Reactive Resume - Go to homepage");
|
||||
});
|
||||
|
||||
it("renders a dashboard link with the documented aria-label", () => {
|
||||
const { container } = renderHeader();
|
||||
const dashboard = Array.from(container.querySelectorAll("a")).find((a) => a.getAttribute("href") === "/dashboard");
|
||||
expect(dashboard).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes ThemeToggleButton and GithubStarsButton in the navigation", () => {
|
||||
const { getByTestId } = renderHeader();
|
||||
expect(getByTestId("theme-toggle")).toBeInTheDocument();
|
||||
expect(getByTestId("github-stars-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the navigation landmark", () => {
|
||||
const { container } = renderHeader();
|
||||
const nav = container.querySelector("nav") as HTMLElement;
|
||||
expect(nav.getAttribute("aria-label")).toBe("Main navigation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { Prefooter } from "./prefooter";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const renderPrefooter = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Prefooter />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("Prefooter", () => {
|
||||
it("renders the community tagline as a heading", () => {
|
||||
renderPrefooter();
|
||||
expect(screen.getByText("By the community, for the community.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the community-thanks paragraph", () => {
|
||||
renderPrefooter();
|
||||
expect(screen.getByText(/vibrant community/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the decorative TextMaskEffect (svg)", () => {
|
||||
const { container } = renderPrefooter();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useBuilderAssistantStore } from "./assistant-store";
|
||||
|
||||
afterEach(() => useBuilderAssistantStore.setState({ isOpen: false }));
|
||||
|
||||
describe("useBuilderAssistantStore", () => {
|
||||
it("starts closed", () => {
|
||||
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("setOpen overrides the open state directly", () => {
|
||||
useBuilderAssistantStore.getState().setOpen(true);
|
||||
expect(useBuilderAssistantStore.getState().isOpen).toBe(true);
|
||||
|
||||
useBuilderAssistantStore.getState().setOpen(false);
|
||||
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("toggleOpen flips the state", () => {
|
||||
const { toggleOpen } = useBuilderAssistantStore.getState();
|
||||
|
||||
toggleOpen();
|
||||
expect(useBuilderAssistantStore.getState().isOpen).toBe(true);
|
||||
|
||||
toggleOpen();
|
||||
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BuilderSidebarEdge } from "./edge";
|
||||
|
||||
describe("BuilderSidebarEdge", () => {
|
||||
it("renders its children inside the edge container", () => {
|
||||
const { getByText } = render(
|
||||
<BuilderSidebarEdge side="left">
|
||||
<span>child</span>
|
||||
</BuilderSidebarEdge>,
|
||||
);
|
||||
expect(getByText("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses left-side classes (inset-s-0 + border-r) when side='left'", () => {
|
||||
const { container } = render(
|
||||
<BuilderSidebarEdge side="left">
|
||||
<span>x</span>
|
||||
</BuilderSidebarEdge>,
|
||||
);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("inset-s-0");
|
||||
expect(wrapper.className).toContain("border-r");
|
||||
});
|
||||
|
||||
it("uses right-side classes (inset-e-0 + border-l) when side='right'", () => {
|
||||
const { container } = render(
|
||||
<BuilderSidebarEdge side="right">
|
||||
<span>x</span>
|
||||
</BuilderSidebarEdge>,
|
||||
);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("inset-e-0");
|
||||
expect(wrapper.className).toContain("border-l");
|
||||
});
|
||||
|
||||
it("is hidden on mobile (hidden + sm:flex)", () => {
|
||||
const { container } = render(
|
||||
<BuilderSidebarEdge side="left">
|
||||
<span>x</span>
|
||||
</BuilderSidebarEdge>,
|
||||
);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("hidden");
|
||||
expect(wrapper.className).toContain("sm:flex");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT, getNextBuilderPreviewPageLayout } from "./page-layout";
|
||||
|
||||
describe("DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT", () => {
|
||||
it("defaults to horizontal", () => {
|
||||
expect(DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT).toBe("horizontal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextBuilderPreviewPageLayout", () => {
|
||||
it("returns vertical when given horizontal", () => {
|
||||
expect(getNextBuilderPreviewPageLayout("horizontal")).toBe("vertical");
|
||||
});
|
||||
|
||||
it("returns horizontal when given vertical", () => {
|
||||
expect(getNextBuilderPreviewPageLayout("vertical")).toBe("horizontal");
|
||||
});
|
||||
|
||||
it("is its own inverse", () => {
|
||||
const start: "horizontal" | "vertical" = "horizontal";
|
||||
const back = getNextBuilderPreviewPageLayout(getNextBuilderPreviewPageLayout(start));
|
||||
expect(back).toBe(start);
|
||||
});
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const educationItems = vi.hoisted(() => [
|
||||
{
|
||||
id: "e1",
|
||||
school: "MIT",
|
||||
degree: "BS",
|
||||
area: "CS",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "2010-2014",
|
||||
description: "",
|
||||
hidden: false,
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
]);
|
||||
const experienceItems = vi.hoisted(() => [
|
||||
// Experience with position set
|
||||
{
|
||||
id: "x1",
|
||||
company: "Acme",
|
||||
position: "Senior Engineer",
|
||||
location: "",
|
||||
period: "2020-2024",
|
||||
description: "",
|
||||
hidden: false,
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
roles: [],
|
||||
},
|
||||
// Experience with empty position and multiple roles → falls back to "N roles"
|
||||
{
|
||||
id: "x2",
|
||||
company: "BetaCo",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "2015-2020",
|
||||
description: "",
|
||||
hidden: false,
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
roles: [
|
||||
{ position: "Engineer", period: "" },
|
||||
{ position: "Lead", period: "" },
|
||||
],
|
||||
},
|
||||
// Experience with empty position and one role → "1 role"
|
||||
{
|
||||
id: "x3",
|
||||
company: "Gamma",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "2014",
|
||||
description: "",
|
||||
hidden: false,
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
roles: [{ position: "Intern", period: "" }],
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: {
|
||||
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
|
||||
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
|
||||
},
|
||||
},
|
||||
}),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const { EducationSectionBuilder } = await import("./education");
|
||||
const { ExperienceSectionBuilder } = await import("./experience");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const wrap = (ui: React.ReactNode) => <I18nProvider i18n={i18n}>{ui}</I18nProvider>;
|
||||
|
||||
describe("EducationSectionBuilder", () => {
|
||||
it("maps school → title and degree → subtitle", () => {
|
||||
render(wrap(<EducationSectionBuilder />));
|
||||
expect(screen.getByTestId("item-title").textContent).toBe("MIT");
|
||||
expect(screen.getByTestId("item-subtitle").textContent).toBe("BS");
|
||||
});
|
||||
|
||||
it("renders the Add a new education affordance", () => {
|
||||
render(wrap(<EducationSectionBuilder />));
|
||||
expect(screen.getByRole("button", { name: "Add a new education" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExperienceSectionBuilder", () => {
|
||||
it("uses position as subtitle when present", () => {
|
||||
render(wrap(<ExperienceSectionBuilder />));
|
||||
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
|
||||
expect(subtitles[0]).toBe("Senior Engineer");
|
||||
});
|
||||
|
||||
it("falls back to 'N roles' when position is empty and multiple roles exist", () => {
|
||||
render(wrap(<ExperienceSectionBuilder />));
|
||||
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
|
||||
expect(subtitles[1]).toBe("2 roles");
|
||||
});
|
||||
|
||||
it("uses '1 role' (singular) for a single role entry", () => {
|
||||
render(wrap(<ExperienceSectionBuilder />));
|
||||
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
|
||||
expect(subtitles[2]).toBe("1 role");
|
||||
});
|
||||
|
||||
it("renders the Add a new experience affordance", () => {
|
||||
render(wrap(<ExperienceSectionBuilder />));
|
||||
expect(screen.getByRole("button", { name: "Add a new experience" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
//
|
||||
// Bulk-cover the small reorderable section components — they all share the same
|
||||
// shape: render a SectionItem per data row with title/subtitle mapped to specific
|
||||
// fields, plus an "Add a new X" button. Test them together to amortize the mock setup.
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const sections = vi.hoisted(() => ({
|
||||
awards: [
|
||||
{
|
||||
id: "a1",
|
||||
title: "Hackathon Winner",
|
||||
awarder: "Acme",
|
||||
date: "2024",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
],
|
||||
certifications: [
|
||||
{
|
||||
id: "c1",
|
||||
title: "AWS Solutions Architect",
|
||||
issuer: "AWS",
|
||||
date: "2024",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
],
|
||||
interests: [{ id: "i1", name: "Cooking", keywords: [], hidden: false, icon: "", iconColor: "" }],
|
||||
languages: [{ id: "l1", language: "English", fluency: "Native", level: 5, hidden: false }],
|
||||
publications: [
|
||||
{
|
||||
id: "p1",
|
||||
title: "On Type Systems",
|
||||
publisher: "ACM",
|
||||
date: "2024",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Bob Smith",
|
||||
position: "Manager",
|
||||
phone: "",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
],
|
||||
volunteer: [
|
||||
{
|
||||
id: "v1",
|
||||
organization: "Code for Good",
|
||||
position: "Mentor",
|
||||
location: "Berlin",
|
||||
period: "2022",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
summary: "",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: {
|
||||
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
|
||||
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
|
||||
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
|
||||
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
|
||||
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
|
||||
references: { title: "References", columns: 1, hidden: false, items: sections.references },
|
||||
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
|
||||
},
|
||||
},
|
||||
}),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className} data-testid="section-base">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button" data-testid="add-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const { AwardsSectionBuilder } = await import("./awards");
|
||||
const { CertificationsSectionBuilder } = await import("./certifications");
|
||||
const { InterestsSectionBuilder } = await import("./interests");
|
||||
const { LanguagesSectionBuilder } = await import("./languages");
|
||||
const { PublicationsSectionBuilder } = await import("./publications");
|
||||
const { ReferencesSectionBuilder } = await import("./references");
|
||||
const { VolunteerSectionBuilder } = await import("./volunteer");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const wrap = (ui: React.ReactNode) => <I18nProvider i18n={i18n}>{ui}</I18nProvider>;
|
||||
|
||||
describe("left sidebar section builders — title/subtitle mapping", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "awards",
|
||||
Component: AwardsSectionBuilder,
|
||||
title: "Hackathon Winner",
|
||||
subtitle: "Acme",
|
||||
addCopy: "Add a new award",
|
||||
},
|
||||
{
|
||||
name: "certifications",
|
||||
Component: CertificationsSectionBuilder,
|
||||
title: "AWS Solutions Architect",
|
||||
subtitle: "AWS • 2024",
|
||||
addCopy: "Add a new certification",
|
||||
},
|
||||
{
|
||||
name: "interests",
|
||||
Component: InterestsSectionBuilder,
|
||||
title: "Cooking",
|
||||
subtitle: "",
|
||||
addCopy: "Add a new interest",
|
||||
},
|
||||
{
|
||||
name: "languages",
|
||||
Component: LanguagesSectionBuilder,
|
||||
title: "English",
|
||||
subtitle: "Native",
|
||||
addCopy: "Add a new language",
|
||||
},
|
||||
{
|
||||
name: "publications",
|
||||
Component: PublicationsSectionBuilder,
|
||||
title: "On Type Systems",
|
||||
subtitle: "ACM",
|
||||
addCopy: "Add a new publication",
|
||||
},
|
||||
{
|
||||
name: "references",
|
||||
Component: ReferencesSectionBuilder,
|
||||
title: "Bob Smith",
|
||||
subtitle: "",
|
||||
addCopy: "Add a new reference",
|
||||
},
|
||||
{
|
||||
name: "volunteer",
|
||||
Component: VolunteerSectionBuilder,
|
||||
title: "Code for Good",
|
||||
subtitle: "Berlin",
|
||||
addCopy: "Add a new volunteer experience",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const { name, Component, title, subtitle, addCopy } of cases) {
|
||||
it(`${name}: maps fields and renders the add button`, () => {
|
||||
const { unmount } = render(wrap(<Component />));
|
||||
expect(screen.getByTestId("item-title").textContent).toBe(title);
|
||||
expect(screen.getByTestId("item-subtitle").textContent ?? "").toBe(subtitle);
|
||||
expect(screen.getByRole("button", { name: addCopy })).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const sectionItems = vi.hoisted(() => [
|
||||
{
|
||||
id: "p1",
|
||||
hidden: false,
|
||||
network: "GitHub",
|
||||
username: "jane",
|
||||
icon: "github",
|
||||
iconColor: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
hidden: false,
|
||||
network: "LinkedIn",
|
||||
username: "jane-doe",
|
||||
icon: "linkedin",
|
||||
iconColor: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
|
||||
},
|
||||
}),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className} data-testid="section-base">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const { ProfilesSectionBuilder } = await import("./profiles");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("ProfilesSectionBuilder", () => {
|
||||
it("renders one SectionItem per profile with network as title and username as subtitle", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ProfilesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const titles = screen.getAllByTestId("item-title").map((el) => el.textContent);
|
||||
expect(titles).toEqual(["GitHub", "LinkedIn"]);
|
||||
|
||||
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
|
||||
expect(subtitles).toEqual(["jane", "jane-doe"]);
|
||||
});
|
||||
|
||||
it("renders the 'Add a new profile' affordance", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ProfilesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Add a new profile" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses non-dashed border when items are present", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ProfilesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const wrapper = screen.getByTestId("section-base");
|
||||
expect(wrapper.className).toContain("border");
|
||||
expect(wrapper.className).not.toContain("border-dashed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const items = vi.hoisted(() => [
|
||||
// Both period and website label present → "2024 • Site"
|
||||
{
|
||||
id: "p1",
|
||||
name: "Open CLI",
|
||||
period: "2024",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "https://example.com", label: "Site", inlineLink: false },
|
||||
},
|
||||
// Only period
|
||||
{
|
||||
id: "p2",
|
||||
name: "Plugin",
|
||||
period: "2023",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
},
|
||||
// No period, no label
|
||||
{
|
||||
id: "p3",
|
||||
name: "Drafts",
|
||||
period: "",
|
||||
hidden: false,
|
||||
description: "",
|
||||
website: { url: "", label: " ", inlineLink: false },
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
|
||||
}),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle?: string }) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle ?? "<undefined>"}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const { ProjectsSectionBuilder } = await import("./projects");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("ProjectsSectionBuilder buildSubtitle", () => {
|
||||
it("joins period and website label with ' • ' when both are present", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ProjectsSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
|
||||
expect(subtitles[0]).toBe("2024 • Site");
|
||||
});
|
||||
|
||||
it("uses just the period when no website label is set", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ProjectsSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
|
||||
expect(subtitles[1]).toBe("2023");
|
||||
});
|
||||
|
||||
it("returns undefined when neither period nor a non-blank website label is provided", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ProjectsSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const subtitles = screen.getAllByTestId("item-subtitle").map((el) => el.textContent);
|
||||
// Mock stub renders the literal string "<undefined>" when subtitle was undefined.
|
||||
expect(subtitles[2]).toBe("<undefined>");
|
||||
});
|
||||
|
||||
it("renders the Add a new project affordance", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ProjectsSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Add a new project" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const sectionItems = vi.hoisted(() => [
|
||||
{ id: "s1", name: "TypeScript", proficiency: "Expert", level: 5, keywords: [], description: "", hidden: false },
|
||||
{ id: "s2", name: "Go", proficiency: "Intermediate", level: 3, keywords: [], description: "", hidden: false },
|
||||
]);
|
||||
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
|
||||
}),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className} data-testid="section-base">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const { SkillsSectionBuilder } = await import("./skills");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("SkillsSectionBuilder", () => {
|
||||
it("renders one SectionItem per skill, mapping name → title and proficiency → subtitle", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<SkillsSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("item-title").map((el) => el.textContent)).toEqual(["TypeScript", "Go"]);
|
||||
expect(screen.getAllByTestId("item-subtitle").map((el) => el.textContent)).toEqual(["Expert", "Intermediate"]);
|
||||
});
|
||||
|
||||
it("renders an Add a new skill affordance", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<SkillsSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Add a new skill" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const downloadWithAnchor = vi.hoisted(() => vi.fn());
|
||||
const buildDocx = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/x-docx" })));
|
||||
const createResumePdfBlob = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/pdf" })));
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@reactive-resume/utils/file", () => ({
|
||||
downloadWithAnchor,
|
||||
generateFilename: (name: string, ext: string) => `${name}.${ext}`,
|
||||
}));
|
||||
vi.mock("@reactive-resume/utils/resume/docx", () => ({ buildDocx }));
|
||||
vi.mock("@/libs/resume/pdf-document", () => ({ createResumePdfBlob }));
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useResume: () => ({ id: "r1", name: "My Resume", data: defaultResumeData }),
|
||||
}));
|
||||
|
||||
const { ExportSectionBuilder } = await import("./export");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
downloadWithAnchor.mockReset();
|
||||
buildDocx.mockClear();
|
||||
createResumePdfBlob.mockClear();
|
||||
});
|
||||
|
||||
const renderExport = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ExportSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("ExportSectionBuilder", () => {
|
||||
it("renders JSON, DOCX, and PDF action buttons", () => {
|
||||
renderExport();
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
expect(screen.getByText("DOCX")).toBeInTheDocument();
|
||||
expect(screen.getByText("PDF")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("downloads a JSON blob when the JSON button is clicked", () => {
|
||||
renderExport();
|
||||
const button = screen.getByText("JSON").closest("button") as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it.
|
||||
const [blob, filename] = downloadWithAnchor.mock.calls[0]!;
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect((blob as Blob).type).toBe("application/json");
|
||||
expect(filename).toBe("My Resume.json");
|
||||
});
|
||||
|
||||
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
|
||||
renderExport();
|
||||
const button = screen.getByText("DOCX").closest("button") as HTMLButtonElement;
|
||||
|
||||
fireEvent.click(button);
|
||||
// Wait for the async callback chain to settle.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(buildDocx).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.docx");
|
||||
});
|
||||
|
||||
it("calls createResumePdfBlob and downloads when PDF is clicked", async () => {
|
||||
renderExport();
|
||||
const button = screen.getByText("PDF").closest("button") as HTMLButtonElement;
|
||||
|
||||
fireEvent.click(button);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const { InformationSectionBuilder } = await import("./information");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const renderInfo = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<InformationSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("InformationSectionBuilder", () => {
|
||||
it("renders the donation prompt and CTA", () => {
|
||||
renderInfo();
|
||||
expect(screen.getByText("Support the app by doing what you can!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Donate to Reactive Resume")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links to the OpenCollective donation page", () => {
|
||||
renderInfo();
|
||||
const donateLink = screen.getByText("Donate to Reactive Resume").closest("a");
|
||||
expect(donateLink?.getAttribute("href")).toBe("http://opencollective.com/reactive-resume");
|
||||
});
|
||||
|
||||
it("includes external resource links (docs, source, bugs, translations, sponsors)", () => {
|
||||
renderInfo();
|
||||
const labels = ["Documentation", "Source Code", "Report a Bug", "Translations", "Sponsors"];
|
||||
for (const label of labels) {
|
||||
expect(screen.getByText(label).closest("a"), label).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens external links in a new tab", () => {
|
||||
renderInfo();
|
||||
const docs = screen.getByText("Documentation").closest("a") as HTMLAnchorElement;
|
||||
expect(docs.getAttribute("target")).toBe("_blank");
|
||||
expect(docs.getAttribute("rel")).toBe("noopener");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const updateResumeData = vi.hoisted(() => vi.fn());
|
||||
const richInputProps = vi.hoisted(() => ({
|
||||
value: undefined as string | undefined,
|
||||
onChange: undefined as ((value: string) => void) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div data-testid="section-base">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/components/input/rich-input", () => ({
|
||||
RichInput: (props: { value: string; onChange: (value: string) => void }) => {
|
||||
richInputProps.value = props.value;
|
||||
richInputProps.onChange = props.onChange;
|
||||
return <textarea data-testid="rich-input" value={props.value} readOnly />;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { metadata: { notes: "saved notes" } },
|
||||
}),
|
||||
useUpdateResumeData: () => updateResumeData,
|
||||
}));
|
||||
|
||||
const { NotesSectionBuilder } = await import("./notes");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("NotesSectionBuilder", () => {
|
||||
it("renders the privacy hint copy", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<NotesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/personal notes specific to this resume/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("seeds the rich input with the current notes value", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<NotesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(richInputProps.value).toBe("saved notes");
|
||||
});
|
||||
|
||||
it("forwards rich input changes through updateResumeData", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<NotesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
richInputProps.onChange?.("<p>new note</p>");
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// updateResumeData receives a recipe that mutates the draft.
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { notes: string } }) => void;
|
||||
const draft = { metadata: { notes: "" } };
|
||||
recipe(draft);
|
||||
expect(draft.metadata.notes).toBe("<p>new note</p>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const queryResult = vi.hoisted(() => ({
|
||||
data: undefined as
|
||||
| undefined
|
||||
| {
|
||||
isPublic: boolean;
|
||||
views: number;
|
||||
downloads: number;
|
||||
lastViewedAt: Date | null;
|
||||
lastDownloadedAt: Date | null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryResult,
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => ({ resumeId: "r1" }),
|
||||
}));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { resume: { statistics: { getById: { queryOptions: () => ({}) } } } },
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const { StatisticsSectionBuilder } = await import("./statistics");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
queryResult.data = undefined;
|
||||
});
|
||||
|
||||
const renderStats = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<StatisticsSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("StatisticsSectionBuilder", () => {
|
||||
it("renders nothing while the query result is undefined", () => {
|
||||
const { container } = renderStats();
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders the private hint when isPublic=false", () => {
|
||||
queryResult.data = {
|
||||
isPublic: false,
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
lastViewedAt: null,
|
||||
lastDownloadedAt: null,
|
||||
};
|
||||
renderStats();
|
||||
expect(screen.getByText("Track your resume's views and downloads")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the views/downloads counters when isPublic=true", () => {
|
||||
queryResult.data = {
|
||||
isPublic: true,
|
||||
views: 42,
|
||||
downloads: 7,
|
||||
lastViewedAt: null,
|
||||
lastDownloadedAt: null,
|
||||
};
|
||||
renderStats();
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
expect(screen.getByText("Views")).toBeInTheDocument();
|
||||
expect(screen.getByText("Downloads")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'last viewed/downloaded' timestamps when present", () => {
|
||||
queryResult.data = {
|
||||
isPublic: true,
|
||||
views: 1,
|
||||
downloads: 0,
|
||||
lastViewedAt: new Date("2024-01-15T00:00:00Z"),
|
||||
lastDownloadedAt: null,
|
||||
};
|
||||
renderStats();
|
||||
// Just verify some 'Last viewed' copy appears — the date formatting depends on the runner's locale.
|
||||
expect(screen.getByText(/Last viewed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@/components/resume/builder-resume-draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { metadata: { template: "ditto" } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const { TemplateSectionBuilder } = await import("./template");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDialogStore.setState({ open: false, activeDialog: null, onBeforeClose: null });
|
||||
});
|
||||
|
||||
const renderTemplate = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TemplateSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("TemplateSectionBuilder", () => {
|
||||
it("renders the current template's display name", () => {
|
||||
renderTemplate();
|
||||
expect(screen.getByRole("heading", { level: 3 }).textContent).toBe("Ditto");
|
||||
});
|
||||
|
||||
it("renders the template tags as badges", () => {
|
||||
renderTemplate();
|
||||
// Ditto's tags include 'ATS friendly' per templates/data.
|
||||
expect(screen.getByText("ATS friendly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the template gallery dialog when the preview is clicked", () => {
|
||||
renderTemplate();
|
||||
|
||||
const preview = screen.getByAltText("Ditto").closest("button") as HTMLButtonElement;
|
||||
fireEvent.click(preview);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.template.gallery");
|
||||
});
|
||||
|
||||
it("renders the template preview image with the data-mapped URL", () => {
|
||||
renderTemplate();
|
||||
const img = screen.getByAltText("Ditto") as HTMLImageElement;
|
||||
expect(img.src).toContain("/templates/jpg/ditto.jpg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useSectionStore } from "./section";
|
||||
|
||||
afterEach(() => {
|
||||
useSectionStore.setState({ sections: {} });
|
||||
});
|
||||
|
||||
describe("useSectionStore", () => {
|
||||
it("starts with no collapsed sections", () => {
|
||||
expect(useSectionStore.getState().sections).toEqual({});
|
||||
});
|
||||
|
||||
it("setCollapsed stores collapse state for a single id", () => {
|
||||
useSectionStore.getState().setCollapsed("experience", true);
|
||||
|
||||
expect(useSectionStore.getState().sections.experience).toEqual({ collapsed: true });
|
||||
});
|
||||
|
||||
it("setCollapsed(false) overrides previous collapsed=true", () => {
|
||||
const { setCollapsed } = useSectionStore.getState();
|
||||
setCollapsed("skills", true);
|
||||
setCollapsed("skills", false);
|
||||
|
||||
expect(useSectionStore.getState().sections.skills).toEqual({ collapsed: false });
|
||||
});
|
||||
|
||||
it("toggleCollapsed flips from undefined → true → false", () => {
|
||||
const { toggleCollapsed } = useSectionStore.getState();
|
||||
|
||||
toggleCollapsed("education");
|
||||
expect(useSectionStore.getState().sections.education?.collapsed).toBe(true);
|
||||
|
||||
toggleCollapsed("education");
|
||||
expect(useSectionStore.getState().sections.education?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("toggleAll collapses all built-in sidebar sections from a clean state", () => {
|
||||
useSectionStore.getState().toggleAll();
|
||||
|
||||
const sections = useSectionStore.getState().sections;
|
||||
// Spot-check a few known sidebar section ids.
|
||||
expect(sections.experience?.collapsed).toBe(true);
|
||||
expect(sections.template?.collapsed).toBe(true);
|
||||
expect(sections.layout?.collapsed).toBe(true);
|
||||
expect(Object.keys(sections).length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("toggleAll flips each individual section independently of the others", () => {
|
||||
const { setCollapsed, toggleAll } = useSectionStore.getState();
|
||||
|
||||
setCollapsed("skills", true); // skills starts collapsed=true
|
||||
// Other ids remain undefined → treated as collapsed=false by toggleCollapsed semantics.
|
||||
|
||||
toggleAll();
|
||||
|
||||
const sections = useSectionStore.getState().sections;
|
||||
expect(sections.skills?.collapsed).toBe(false);
|
||||
expect(sections.experience?.collapsed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_BUILDER_LAYOUT,
|
||||
mapPanelLayoutToBuilderLayout,
|
||||
parseBuilderLayoutCookie,
|
||||
useBuilderSidebarStore,
|
||||
} from "./sidebar";
|
||||
|
||||
afterEach(() => {
|
||||
useBuilderSidebarStore.setState({
|
||||
layout: DEFAULT_BUILDER_LAYOUT,
|
||||
leftSidebar: null,
|
||||
rightSidebar: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBuilderLayoutCookie", () => {
|
||||
it("returns the default layout when value is undefined", () => {
|
||||
expect(parseBuilderLayoutCookie(undefined)).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the default layout when value is null", () => {
|
||||
expect(parseBuilderLayoutCookie(null)).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the default layout when value is an empty string", () => {
|
||||
expect(parseBuilderLayoutCookie("")).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the default layout when value is malformed JSON", () => {
|
||||
expect(parseBuilderLayoutCookie("{not-json")).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the default layout when value is a JSON array", () => {
|
||||
expect(parseBuilderLayoutCookie("[1,2,3]")).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the default layout when value is a JSON primitive", () => {
|
||||
expect(parseBuilderLayoutCookie("42")).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
expect(parseBuilderLayoutCookie("null")).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
expect(parseBuilderLayoutCookie('"string"')).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the default layout when any field is missing or not a number", () => {
|
||||
expect(parseBuilderLayoutCookie('{"left":10,"artboard":50}')).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
expect(parseBuilderLayoutCookie('{"left":"10","artboard":50,"right":40}')).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the parsed layout when all fields are numeric", () => {
|
||||
const json = JSON.stringify({ left: 10, artboard: 60, right: 30 });
|
||||
expect(parseBuilderLayoutCookie(json)).toEqual({ left: 10, artboard: 60, right: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPanelLayoutToBuilderLayout", () => {
|
||||
it("returns the default layout if any panel size is missing", () => {
|
||||
expect(mapPanelLayoutToBuilderLayout({} as never)).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
expect(mapPanelLayoutToBuilderLayout({ left: 10, artboard: 60 } as never)).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
});
|
||||
|
||||
it("returns the layout when all panel sizes are numeric", () => {
|
||||
const layout = mapPanelLayoutToBuilderLayout({ left: 15, artboard: 70, right: 15 } as never);
|
||||
expect(layout).toEqual({ left: 15, artboard: 70, right: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useBuilderSidebarStore", () => {
|
||||
it("starts with default layout and null panel refs", () => {
|
||||
const state = useBuilderSidebarStore.getState();
|
||||
expect(state.layout).toEqual(DEFAULT_BUILDER_LAYOUT);
|
||||
expect(state.leftSidebar).toBeNull();
|
||||
expect(state.rightSidebar).toBeNull();
|
||||
});
|
||||
|
||||
it("setLayout replaces the layout", () => {
|
||||
useBuilderSidebarStore.getState().setLayout({ left: 1, artboard: 98, right: 1 });
|
||||
expect(useBuilderSidebarStore.getState().layout).toEqual({ left: 1, artboard: 98, right: 1 });
|
||||
});
|
||||
|
||||
it("setLeftSidebar and setRightSidebar store refs", () => {
|
||||
const left = { foo: "left" } as never;
|
||||
const right = { foo: "right" } as never;
|
||||
|
||||
useBuilderSidebarStore.getState().setLeftSidebar(left);
|
||||
useBuilderSidebarStore.getState().setRightSidebar(right);
|
||||
|
||||
expect(useBuilderSidebarStore.getState().leftSidebar).toBe(left);
|
||||
expect(useBuilderSidebarStore.getState().rightSidebar).toBe(right);
|
||||
});
|
||||
|
||||
it("accepts null to clear sidebar refs", () => {
|
||||
useBuilderSidebarStore.getState().setLeftSidebar({ foo: "x" } as never);
|
||||
useBuilderSidebarStore.getState().setLeftSidebar(null);
|
||||
expect(useBuilderSidebarStore.getState().leftSidebar).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ListIcon } from "@phosphor-icons/react";
|
||||
import { SidebarProvider } from "@reactive-resume/ui/components/sidebar";
|
||||
import { DashboardHeader } from "./header";
|
||||
|
||||
const renderHeader = (props: Partial<React.ComponentProps<typeof DashboardHeader>> = {}) =>
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<DashboardHeader title="Resumes" icon={ListIcon} {...props} />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
describe("DashboardHeader", () => {
|
||||
it("renders the title as an h1", () => {
|
||||
renderHeader();
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.textContent).toBe("Resumes");
|
||||
});
|
||||
|
||||
it("renders the supplied icon as an SVG", () => {
|
||||
const { container } = renderHeader();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("merges custom className into the wrapper", () => {
|
||||
const { container } = renderHeader({ className: "custom-class" });
|
||||
const wrapper = container.querySelector(".custom-class");
|
||||
expect(wrapper).not.toBeNull();
|
||||
});
|
||||
|
||||
it("includes the mobile sidebar trigger (hidden on md+)", () => {
|
||||
const { container } = renderHeader();
|
||||
const trigger = container.querySelector('[data-sidebar="trigger"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.className).toContain("md:hidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { CreateResumeCard } from "./create-card";
|
||||
import { ImportResumeCard } from "./import-card";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useDialogStore.setState({ open: false, activeDialog: null, onBeforeClose: null });
|
||||
});
|
||||
|
||||
describe("CreateResumeCard", () => {
|
||||
it("renders the create-resume copy", () => {
|
||||
render(<CreateResumeCard />);
|
||||
expect(screen.getByText("Create a new resume")).toBeInTheDocument();
|
||||
expect(screen.getByText("Start building your resume from scratch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the resume.create dialog when clicked", () => {
|
||||
render(<CreateResumeCard />);
|
||||
|
||||
const card = screen.getByText("Create a new resume").closest("div[class*='aspect-page']") as HTMLElement;
|
||||
fireEvent.click(card);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.create");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImportResumeCard", () => {
|
||||
it("renders the import-resume copy", () => {
|
||||
render(<ImportResumeCard />);
|
||||
expect(screen.getByText("Import an existing resume")).toBeInTheDocument();
|
||||
expect(screen.getByText("Continue where you left off")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the resume.import dialog when clicked", () => {
|
||||
render(<ImportResumeCard />);
|
||||
|
||||
const card = screen.getByText("Import an existing resume").closest("div[class*='aspect-page']") as HTMLElement;
|
||||
fireEvent.click(card);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.import");
|
||||
});
|
||||
});
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getResumeThumbnailCacheKey,
|
||||
getResumeThumbnailRenderSize,
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
} from "./resume-thumbnail.shared";
|
||||
|
||||
describe("getResumeThumbnailCacheKey", () => {
|
||||
it("composes id and updated-at epoch milliseconds with a colon", () => {
|
||||
const date = new Date("2024-01-15T00:00:00.000Z");
|
||||
expect(getResumeThumbnailCacheKey("abc", date)).toBe(`abc:${date.getTime()}`);
|
||||
});
|
||||
|
||||
it("differs when updatedAt changes", () => {
|
||||
const id = "resume-1";
|
||||
const a = getResumeThumbnailCacheKey(id, new Date(1000));
|
||||
const b = getResumeThumbnailCacheKey(id, new Date(2000));
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResumeThumbnailRenderSize", () => {
|
||||
it("uses the default target width when not provided", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 });
|
||||
|
||||
expect(size.width).toBe(RESUME_THUMBNAIL_TARGET_WIDTH);
|
||||
// Scale = 420/800 = 0.525 → height rounds to 630
|
||||
expect(size.height).toBe(630);
|
||||
expect(size.scale).toBeCloseTo(0.525, 5);
|
||||
});
|
||||
|
||||
it("scales relative to the provided target width", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400);
|
||||
expect(size.width).toBe(400);
|
||||
expect(size.scale).toBeCloseTo(0.5, 5);
|
||||
expect(size.height).toBe(600);
|
||||
});
|
||||
|
||||
it("clamps pixelRatio to a minimum of 1", () => {
|
||||
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 0.5);
|
||||
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 1);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("clamps pixelRatio to a maximum of 2", () => {
|
||||
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 3);
|
||||
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("multiplies width/height by pixelRatio when within bounds", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
|
||||
// pageScale = 0.5, outputScale = 2 → width = 800, height = 1200
|
||||
expect(size.width).toBe(800);
|
||||
expect(size.height).toBe(1200);
|
||||
expect(size.scale).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("rounds the output dimensions to integers", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 793, height: 1123 }, 421, 1.5);
|
||||
expect(Number.isInteger(size.width)).toBe(true);
|
||||
expect(Number.isInteger(size.height)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMcpServerCard } from "./mcp-server-card";
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
|
||||
describe("buildMcpServerCard", () => {
|
||||
const card = buildMcpServerCard("1.2.3");
|
||||
|
||||
it("includes the provided app version in serverInfo", () => {
|
||||
expect(card.serverInfo.version).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("identifies the server as reactive-resume", () => {
|
||||
expect(card.serverInfo.name).toBe("reactive-resume");
|
||||
expect(card.serverInfo.title).toBe("Reactive Resume");
|
||||
expect(card.serverInfo.websiteUrl).toBe("https://rxresu.me");
|
||||
});
|
||||
|
||||
it("exposes light + dark theme icons", () => {
|
||||
const themes = card.serverInfo.icons.map((icon) => icon.theme).sort();
|
||||
expect(themes).toEqual(["dark", "light"]);
|
||||
});
|
||||
|
||||
it("requires authentication with oauth2 + bearer schemes", () => {
|
||||
expect(card.authentication.required).toBe(true);
|
||||
expect(card.authentication.schemes).toContain("oauth2");
|
||||
expect(card.authentication.schemes).toContain("bearer");
|
||||
});
|
||||
|
||||
it("registers one entry per MCP tool", () => {
|
||||
const cardToolNames = card.tools.map((tool) => tool.name).sort();
|
||||
const expectedNames = Object.values(MCP_TOOL_NAME).sort();
|
||||
expect(cardToolNames).toEqual(expectedNames);
|
||||
});
|
||||
|
||||
it("declares a JSON Schema input for every tool", () => {
|
||||
for (const tool of card.tools) {
|
||||
expect(tool.inputSchema, tool.name).toBeDefined();
|
||||
expect(tool.annotations, tool.name).toBeDefined();
|
||||
expect(tool.title.length, tool.name).toBeGreaterThan(0);
|
||||
expect(tool.description.length, tool.name).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the three documented prompts", () => {
|
||||
const promptNames = card.prompts.map((p) => p.name).sort();
|
||||
expect(promptNames).toEqual(["build_resume", "improve_resume", "review_resume"]);
|
||||
});
|
||||
|
||||
it("publishes a resource template for resume://{id}", () => {
|
||||
const template = card.resourceTemplates.find((r) => r.name === "resume");
|
||||
expect(template?.uriTemplate).toBe("resume://{id}");
|
||||
expect(template?.mimeType).toBe("application/json");
|
||||
});
|
||||
|
||||
it("includes resume and meta-schema in the static resources list", () => {
|
||||
const resourceNames = card.resources.map((r) => r.name);
|
||||
expect(resourceNames).toContain("resume");
|
||||
expect(card.resources.some((r) => r.mimeType === "application/json")).toBe(true);
|
||||
});
|
||||
|
||||
it("documents an optional apiKey in the configuration schema", () => {
|
||||
const props = card.configurationSchema.properties as Record<string, unknown>;
|
||||
expect(props.apiKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// biome-ignore-all lint/style/noNonNullAssertion: These tests assert registered prompt names before exercising their handlers.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `./tools` pulls in the env / db client modules which require runtime env vars.
|
||||
// `prompts.ts` only imports `MCP_TOOL_NAME` from `./tools`, so we stub it with the
|
||||
// pure constants module to keep this test isolated.
|
||||
vi.mock("./tools", async () => await import("./mcp-tool-names"));
|
||||
|
||||
const { registerPrompts } = await import("./prompts");
|
||||
|
||||
type Registration = {
|
||||
name: string;
|
||||
config: {
|
||||
title: string;
|
||||
description: string;
|
||||
argsSchema: Record<string, unknown>;
|
||||
};
|
||||
handler: (args: { id: string }) => Promise<{
|
||||
messages: Array<{
|
||||
role: "user";
|
||||
content: { type: string; text?: string; resource?: { uri: string; mimeType: string } };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
const makeFakeServer = () => {
|
||||
const registered: Registration[] = [];
|
||||
const server = {
|
||||
registerPrompt: vi.fn((name: string, config: Registration["config"], handler: Registration["handler"]) => {
|
||||
registered.push({ name, config, handler });
|
||||
}),
|
||||
};
|
||||
return { server, registered };
|
||||
};
|
||||
|
||||
describe("registerPrompts", () => {
|
||||
it("registers build_resume, improve_resume, and review_resume", () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
|
||||
registerPrompts(server as never);
|
||||
|
||||
expect(server.registerPrompt).toHaveBeenCalledTimes(3);
|
||||
expect(registered.map((r) => r.name).sort()).toEqual(["build_resume", "improve_resume", "review_resume"]);
|
||||
});
|
||||
|
||||
it("requires an `id` argument on every prompt", () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
|
||||
registerPrompts(server as never);
|
||||
|
||||
for (const reg of registered) {
|
||||
expect(reg.config.argsSchema.id, reg.name).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("build_resume handler attaches the resume + schema resources and a guidance text", async () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerPrompts(server as never);
|
||||
|
||||
const build = registered.find((r) => r.name === "build_resume")!;
|
||||
const result = await build.handler({ id: "abc-123" });
|
||||
|
||||
const resourceUris = result.messages
|
||||
.filter((m) => m.content.type === "resource")
|
||||
.map((m) => m.content.resource?.uri);
|
||||
|
||||
expect(resourceUris).toContain("resume://abc-123");
|
||||
expect(resourceUris).toContain("resume://_meta/schema");
|
||||
|
||||
const textMessage = result.messages.find((m) => m.content.type === "text");
|
||||
expect(textMessage?.content.text).toContain("step by step");
|
||||
expect(textMessage?.content.text).toContain("JSON Patch");
|
||||
});
|
||||
|
||||
it("improve_resume handler tells the model not to fabricate information", async () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerPrompts(server as never);
|
||||
|
||||
const improve = registered.find((r) => r.name === "improve_resume")!;
|
||||
const result = await improve.handler({ id: "id-1" });
|
||||
|
||||
const text = result.messages.find((m) => m.content.type === "text")?.content.text ?? "";
|
||||
expect(text).toContain("Do NOT fabricate");
|
||||
expect(text).toContain("Passive voice");
|
||||
});
|
||||
|
||||
it("review_resume handler is read-only and forbids patching", async () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerPrompts(server as never);
|
||||
|
||||
const review = registered.find((r) => r.name === "review_resume")!;
|
||||
const result = await review.handler({ id: "id-2" });
|
||||
|
||||
const text = result.messages.find((m) => m.content.type === "text")?.content.text ?? "";
|
||||
expect(text).toContain("read-only");
|
||||
expect(text).toContain("Do NOT call");
|
||||
expect(text).toContain("Scorecard");
|
||||
});
|
||||
|
||||
it("interpolates the provided resume id into the resource URI", async () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerPrompts(server as never);
|
||||
|
||||
const review = registered.find((r) => r.name === "review_resume")!;
|
||||
const result = await review.handler({ id: "8f-test" });
|
||||
|
||||
const resourceUris = result.messages
|
||||
.filter((m) => m.content.type === "resource")
|
||||
.map((m) => m.content.resource?.uri);
|
||||
|
||||
expect(resourceUris).toContain("resume://8f-test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// biome-ignore-all lint/style/noNonNullAssertion: These tests assert registered resource names before exercising their handlers.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Avoid pulling in env-validated modules through `./tools`.
|
||||
vi.mock("./tools", async () => await import("./mcp-tool-names"));
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
resume: {
|
||||
getById: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({ client: clientMock }));
|
||||
|
||||
const { registerResources } = await import("./resources");
|
||||
|
||||
type ResourceHandler = (uri: URL) => Promise<{
|
||||
contents: Array<{ uri: string; mimeType: string; text: string }>;
|
||||
}>;
|
||||
|
||||
type Registration = {
|
||||
name: string;
|
||||
uriOrTemplate: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
handler: ResourceHandler;
|
||||
};
|
||||
|
||||
const makeFakeServer = () => {
|
||||
const registered: Registration[] = [];
|
||||
const server = {
|
||||
registerResource: vi.fn(
|
||||
(name: string, uriOrTemplate: unknown, metadata: Record<string, unknown>, handler: ResourceHandler) => {
|
||||
registered.push({ name, uriOrTemplate, metadata, handler });
|
||||
},
|
||||
),
|
||||
};
|
||||
return { server, registered };
|
||||
};
|
||||
|
||||
describe("registerResources", () => {
|
||||
it("registers the resume and resume-schema resources", () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerResources(server as never);
|
||||
|
||||
expect(server.registerResource).toHaveBeenCalledTimes(2);
|
||||
expect(registered.map((r) => r.name).sort()).toEqual(["resume", "resume-schema"]);
|
||||
});
|
||||
|
||||
it("resume handler fetches resume data via the oRPC client and returns it as JSON", async () => {
|
||||
clientMock.resume.getById.mockResolvedValueOnce({ data: { id: "abc", basics: { name: "Jane" } } });
|
||||
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerResources(server as never);
|
||||
|
||||
const resume = registered.find((r) => r.name === "resume")!;
|
||||
const result = await resume.handler(new URL("resume://abc"));
|
||||
|
||||
expect(clientMock.resume.getById).toHaveBeenCalledWith({ id: "abc" });
|
||||
expect(result.contents[0]?.uri).toBe("resume://abc");
|
||||
expect(result.contents[0]?.mimeType).toBe("application/json");
|
||||
expect(JSON.parse(result.contents[0]?.text)).toEqual({ id: "abc", basics: { name: "Jane" } });
|
||||
});
|
||||
|
||||
it("resume handler throws when the URI has no id segment", async () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerResources(server as never);
|
||||
|
||||
const resume = registered.find((r) => r.name === "resume")!;
|
||||
await expect(resume.handler(new URL("resume://"))).rejects.toThrow(/Invalid resume URI/);
|
||||
});
|
||||
|
||||
it("resume-schema handler returns the static JSON schema as text", async () => {
|
||||
const { server, registered } = makeFakeServer();
|
||||
registerResources(server as never);
|
||||
|
||||
const resumeSchema = registered.find((r) => r.name === "resume-schema")!;
|
||||
const result = await resumeSchema.handler(new URL("resume://_meta/schema"));
|
||||
|
||||
expect(result.contents[0]?.uri).toBe("resume://_meta/schema");
|
||||
expect(result.contents[0]?.mimeType).toBe("application/json");
|
||||
|
||||
// Should round-trip through JSON.parse without throwing.
|
||||
expect(() => JSON.parse(result.contents[0]?.text)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
import { TOOL_ANNOTATIONS } from "./tool-annotations";
|
||||
|
||||
describe("MCP_TOOL_NAME", () => {
|
||||
it("prefixes every tool name with reactive_resume_", () => {
|
||||
for (const name of Object.values(MCP_TOOL_NAME)) {
|
||||
expect(name.startsWith("reactive_resume_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses unique values for every tool", () => {
|
||||
const values = Object.values(MCP_TOOL_NAME);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TOOL_ANNOTATIONS", () => {
|
||||
it("provides annotations for every registered tool", () => {
|
||||
for (const name of Object.values(MCP_TOOL_NAME)) {
|
||||
expect(TOOL_ANNOTATIONS[name]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks list/get tools as readOnly + idempotent + non-destructive", () => {
|
||||
const readOnlyTools = [
|
||||
MCP_TOOL_NAME.listResumes,
|
||||
MCP_TOOL_NAME.listResumeTags,
|
||||
MCP_TOOL_NAME.getResume,
|
||||
MCP_TOOL_NAME.getResumeAnalysis,
|
||||
MCP_TOOL_NAME.getResumeStatistics,
|
||||
];
|
||||
for (const name of readOnlyTools) {
|
||||
const annotations = TOOL_ANNOTATIONS[name];
|
||||
expect(annotations.readOnlyHint, name).toBe(true);
|
||||
expect(annotations.destructiveHint, name).toBe(false);
|
||||
expect(annotations.idempotentHint, name).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks deleteResume as destructive (but still idempotent)", () => {
|
||||
const annotations = TOOL_ANNOTATIONS[MCP_TOOL_NAME.deleteResume];
|
||||
expect(annotations.destructiveHint).toBe(true);
|
||||
expect(annotations.idempotentHint).toBe(true);
|
||||
expect(annotations.readOnlyHint).toBe(false);
|
||||
});
|
||||
|
||||
it("marks creation/import/duplicate as non-readonly and non-idempotent", () => {
|
||||
for (const name of [
|
||||
MCP_TOOL_NAME.createResume,
|
||||
MCP_TOOL_NAME.importResume,
|
||||
MCP_TOOL_NAME.duplicateResume,
|
||||
MCP_TOOL_NAME.patchResume,
|
||||
MCP_TOOL_NAME.updateResume,
|
||||
]) {
|
||||
const annotations = TOOL_ANNOTATIONS[name];
|
||||
expect(annotations.readOnlyHint, name).toBe(false);
|
||||
expect(annotations.idempotentHint, name).toBe(false);
|
||||
expect(annotations.destructiveHint, name).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks lockResume / unlockResume as idempotent and non-destructive", () => {
|
||||
for (const name of [MCP_TOOL_NAME.lockResume, MCP_TOOL_NAME.unlockResume]) {
|
||||
const annotations = TOOL_ANNOTATIONS[name];
|
||||
expect(annotations.idempotentHint, name).toBe(true);
|
||||
expect(annotations.destructiveHint, name).toBe(false);
|
||||
expect(annotations.readOnlyHint, name).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares no tools as open-world by default", () => {
|
||||
for (const annotations of Object.values(TOOL_ANNOTATIONS)) {
|
||||
expect(annotations.openWorldHint).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user