mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-12 22:14:54 +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,121 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
api: {
|
||||
getSession: vi.fn(),
|
||||
verifyApiKey: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const verifyOAuthTokenMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: authMock,
|
||||
verifyOAuthToken: verifyOAuthTokenMock,
|
||||
}));
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({ user: { __table: "user" } }));
|
||||
vi.mock("drizzle-orm", () => ({ eq: () => "EQ" }));
|
||||
|
||||
const { resolveUserFromRequestHeaders } = await import("./context");
|
||||
|
||||
const setupDbResolves = (userResult: unknown) => {
|
||||
dbMock.select.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(userResult ? [userResult] : []),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
authMock.api.getSession.mockReset();
|
||||
authMock.api.verifyApiKey.mockReset();
|
||||
verifyOAuthTokenMock.mockReset();
|
||||
dbMock.select.mockReset();
|
||||
};
|
||||
|
||||
describe("resolveUserFromRequestHeaders", () => {
|
||||
it("returns the user resolved from a valid x-api-key", async () => {
|
||||
reset();
|
||||
authMock.api.verifyApiKey.mockResolvedValueOnce({ valid: true, key: { referenceId: "user-1" } });
|
||||
setupDbResolves({ id: "user-1", name: "Alice" });
|
||||
|
||||
const headers = new Headers({ "x-api-key": "abc123" });
|
||||
const user = await resolveUserFromRequestHeaders(headers);
|
||||
|
||||
expect(authMock.api.verifyApiKey).toHaveBeenCalledWith({ body: { key: "abc123" } });
|
||||
expect(user).toMatchObject({ id: "user-1", name: "Alice" });
|
||||
});
|
||||
|
||||
it("falls back to session when api key is invalid", async () => {
|
||||
reset();
|
||||
authMock.api.verifyApiKey.mockResolvedValueOnce({ valid: false });
|
||||
authMock.api.getSession.mockResolvedValueOnce({ user: { id: "session-user" } });
|
||||
|
||||
const headers = new Headers({ "x-api-key": "bad" });
|
||||
const user = await resolveUserFromRequestHeaders(headers);
|
||||
|
||||
expect(user).toMatchObject({ id: "session-user" });
|
||||
});
|
||||
|
||||
it("uses Bearer token when present and no api key", async () => {
|
||||
reset();
|
||||
verifyOAuthTokenMock.mockResolvedValueOnce({ sub: "user-bearer" });
|
||||
setupDbResolves({ id: "user-bearer", name: "Bob" });
|
||||
|
||||
const headers = new Headers({ authorization: "Bearer xxx.yyy.zzz" });
|
||||
const user = await resolveUserFromRequestHeaders(headers);
|
||||
|
||||
expect(verifyOAuthTokenMock).toHaveBeenCalledWith("xxx.yyy.zzz");
|
||||
expect(user).toMatchObject({ id: "user-bearer", name: "Bob" });
|
||||
});
|
||||
|
||||
it("falls back to session when Bearer verification fails", async () => {
|
||||
reset();
|
||||
verifyOAuthTokenMock.mockResolvedValueOnce(null);
|
||||
authMock.api.getSession.mockResolvedValueOnce({ user: { id: "session-user" } });
|
||||
|
||||
const headers = new Headers({ authorization: "Bearer bad" });
|
||||
const user = await resolveUserFromRequestHeaders(headers);
|
||||
|
||||
expect(user).toMatchObject({ id: "session-user" });
|
||||
});
|
||||
|
||||
it("returns null when no auth method succeeds", async () => {
|
||||
reset();
|
||||
authMock.api.getSession.mockResolvedValueOnce(null);
|
||||
|
||||
const user = await resolveUserFromRequestHeaders(new Headers());
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw when verifyOAuthToken throws (logs and continues)", async () => {
|
||||
reset();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
verifyOAuthTokenMock.mockRejectedValueOnce(new Error("bad token"));
|
||||
authMock.api.getSession.mockResolvedValueOnce({ user: { id: "fallback" } });
|
||||
|
||||
const headers = new Headers({ authorization: "Bearer xxx" });
|
||||
const user = await resolveUserFromRequestHeaders(headers);
|
||||
|
||||
expect(user).toMatchObject({ id: "fallback" });
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null if Bearer header does not start with 'Bearer '", async () => {
|
||||
reset();
|
||||
authMock.api.getSession.mockResolvedValueOnce(null);
|
||||
|
||||
const headers = new Headers({ authorization: "Basic abc" });
|
||||
const user = await resolveUserFromRequestHeaders(headers);
|
||||
|
||||
expect(user).toBeNull();
|
||||
expect(verifyOAuthTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({ APP_URL: "https://example.com" }));
|
||||
const cookies = vi.hoisted(() => ({
|
||||
get: vi.fn<(name: string) => string | undefined>(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getCookie: (name: string) => cookies.get(name),
|
||||
setCookie: (name: string, value: string, options: unknown) => cookies.set(name, value, options),
|
||||
}));
|
||||
|
||||
const { hasResumeAccess, grantResumeAccess } = await import("./resume-access");
|
||||
|
||||
const signToken = (resumeId: string, passwordHash: string) =>
|
||||
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
|
||||
|
||||
describe("hasResumeAccess", () => {
|
||||
it("returns false when no passwordHash is supplied", () => {
|
||||
expect(hasResumeAccess("resume-1", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no cookie is present", () => {
|
||||
cookies.get.mockReturnValueOnce(undefined);
|
||||
expect(hasResumeAccess("resume-1", "hash")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a cookie value that matches the expected signed token", () => {
|
||||
const token = signToken("resume-1", "hash");
|
||||
cookies.get.mockReturnValueOnce(token);
|
||||
|
||||
expect(hasResumeAccess("resume-1", "hash")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a cookie value that does not match the expected signed token", () => {
|
||||
cookies.get.mockReturnValueOnce("not-the-right-token");
|
||||
|
||||
expect(hasResumeAccess("resume-1", "hash")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the cookie has a different length than the expected token", () => {
|
||||
cookies.get.mockReturnValueOnce("short");
|
||||
|
||||
expect(hasResumeAccess("resume-1", "hash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grantResumeAccess", () => {
|
||||
it("writes a signed cookie scoped to the resume id with httpOnly + sameSite=lax + 10-minute TTL", () => {
|
||||
cookies.set.mockReset();
|
||||
|
||||
grantResumeAccess("resume-42", "hash");
|
||||
|
||||
expect(cookies.set).toHaveBeenCalledTimes(1);
|
||||
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the cookie write exists before destructuring it.
|
||||
const [name, value, options] = cookies.set.mock.calls[0]!;
|
||||
expect(name).toBe("resume_access_resume-42");
|
||||
expect(value).toBe(signToken("resume-42", "hash"));
|
||||
expect(options).toMatchObject({
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("only marks the cookie secure when APP_URL is https", () => {
|
||||
envMock.APP_URL = "http://localhost:3000";
|
||||
cookies.set.mockReset();
|
||||
grantResumeAccess("r", "h");
|
||||
expect(cookies.set.mock.calls[0]?.[2]).toMatchObject({ secure: false });
|
||||
|
||||
envMock.APP_URL = "https://example.com";
|
||||
cookies.set.mockReset();
|
||||
grantResumeAccess("r", "h");
|
||||
expect(cookies.set.mock.calls[0]?.[2]).toMatchObject({ secure: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
GOOGLE_CLIENT_ID: undefined as string | undefined,
|
||||
GOOGLE_CLIENT_SECRET: undefined as string | undefined,
|
||||
GITHUB_CLIENT_ID: undefined as string | undefined,
|
||||
GITHUB_CLIENT_SECRET: undefined as string | undefined,
|
||||
LINKEDIN_CLIENT_ID: undefined as string | undefined,
|
||||
LINKEDIN_CLIENT_SECRET: undefined as string | undefined,
|
||||
OAUTH_CLIENT_ID: undefined as string | undefined,
|
||||
OAUTH_CLIENT_SECRET: undefined as string | undefined,
|
||||
OAUTH_PROVIDER_NAME: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
// auth.ts also imports db client and storage; stub them with no-op surfaces.
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: { delete: vi.fn() } }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({ user: {} }));
|
||||
vi.mock("./storage", () => ({ getStorageService: () => ({ delete: vi.fn() }) }));
|
||||
|
||||
const { authService } = await import("./auth");
|
||||
|
||||
const resetEnv = () => {
|
||||
envMock.GOOGLE_CLIENT_ID = undefined;
|
||||
envMock.GOOGLE_CLIENT_SECRET = undefined;
|
||||
envMock.GITHUB_CLIENT_ID = undefined;
|
||||
envMock.GITHUB_CLIENT_SECRET = undefined;
|
||||
envMock.LINKEDIN_CLIENT_ID = undefined;
|
||||
envMock.LINKEDIN_CLIENT_SECRET = undefined;
|
||||
envMock.OAUTH_CLIENT_ID = undefined;
|
||||
envMock.OAUTH_CLIENT_SECRET = undefined;
|
||||
envMock.OAUTH_PROVIDER_NAME = undefined;
|
||||
};
|
||||
|
||||
describe("authService.providers.list", () => {
|
||||
it("always includes credential and passkey providers", () => {
|
||||
resetEnv();
|
||||
const providers = authService.providers.list();
|
||||
expect(providers.credential).toBe("Password");
|
||||
expect(providers.passkey).toBe("Passkey");
|
||||
});
|
||||
|
||||
it("omits social providers when credentials are not configured", () => {
|
||||
resetEnv();
|
||||
const providers = authService.providers.list();
|
||||
expect(providers.google).toBeUndefined();
|
||||
expect(providers.github).toBeUndefined();
|
||||
expect(providers.linkedin).toBeUndefined();
|
||||
expect(providers.custom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes Google when both client id and secret are present", () => {
|
||||
resetEnv();
|
||||
envMock.GOOGLE_CLIENT_ID = "id";
|
||||
envMock.GOOGLE_CLIENT_SECRET = "secret";
|
||||
const providers = authService.providers.list();
|
||||
expect(providers.google).toBe("Google");
|
||||
});
|
||||
|
||||
it("does NOT include Google when only one of id/secret is set", () => {
|
||||
resetEnv();
|
||||
envMock.GOOGLE_CLIENT_ID = "id";
|
||||
const providers = authService.providers.list();
|
||||
expect(providers.google).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes GitHub when both client id and secret are present", () => {
|
||||
resetEnv();
|
||||
envMock.GITHUB_CLIENT_ID = "id";
|
||||
envMock.GITHUB_CLIENT_SECRET = "secret";
|
||||
expect(authService.providers.list().github).toBe("GitHub");
|
||||
});
|
||||
|
||||
it("includes LinkedIn when both client id and secret are present", () => {
|
||||
resetEnv();
|
||||
envMock.LINKEDIN_CLIENT_ID = "id";
|
||||
envMock.LINKEDIN_CLIENT_SECRET = "secret";
|
||||
expect(authService.providers.list().linkedin).toBe("LinkedIn");
|
||||
});
|
||||
|
||||
it("labels the custom OAuth provider with OAUTH_PROVIDER_NAME when set", () => {
|
||||
resetEnv();
|
||||
envMock.OAUTH_CLIENT_ID = "id";
|
||||
envMock.OAUTH_CLIENT_SECRET = "secret";
|
||||
envMock.OAUTH_PROVIDER_NAME = "Acme SSO";
|
||||
expect(authService.providers.list().custom).toBe("Acme SSO");
|
||||
});
|
||||
|
||||
it("falls back to 'Custom OAuth' when OAUTH_PROVIDER_NAME is not set", () => {
|
||||
resetEnv();
|
||||
envMock.OAUTH_CLIENT_ID = "id";
|
||||
envMock.OAUTH_CLIENT_SECRET = "secret";
|
||||
expect(authService.providers.list().custom).toBe("Custom OAuth");
|
||||
});
|
||||
|
||||
it("can register multiple social providers at once", () => {
|
||||
resetEnv();
|
||||
envMock.GOOGLE_CLIENT_ID = "g";
|
||||
envMock.GOOGLE_CLIENT_SECRET = "g";
|
||||
envMock.GITHUB_CLIENT_ID = "h";
|
||||
envMock.GITHUB_CLIENT_SECRET = "h";
|
||||
const providers = authService.providers.list();
|
||||
expect(providers.google).toBe("Google");
|
||||
expect(providers.github).toBe("GitHub");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
FLAG_DISABLE_SIGNUPS: false,
|
||||
FLAG_DISABLE_EMAIL_AUTH: false,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
const { flagsService } = await import("./flags");
|
||||
|
||||
describe("flagsService.getFlags", () => {
|
||||
it("reads disableSignups + disableEmailAuth from env", () => {
|
||||
envMock.FLAG_DISABLE_SIGNUPS = false;
|
||||
envMock.FLAG_DISABLE_EMAIL_AUTH = false;
|
||||
expect(flagsService.getFlags()).toEqual({
|
||||
disableSignups: false,
|
||||
disableEmailAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns disableSignups=true when env flag is set", () => {
|
||||
envMock.FLAG_DISABLE_SIGNUPS = true;
|
||||
envMock.FLAG_DISABLE_EMAIL_AUTH = false;
|
||||
expect(flagsService.getFlags()).toEqual({
|
||||
disableSignups: true,
|
||||
disableEmailAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns disableEmailAuth=true when env flag is set", () => {
|
||||
envMock.FLAG_DISABLE_SIGNUPS = false;
|
||||
envMock.FLAG_DISABLE_EMAIL_AUTH = true;
|
||||
expect(flagsService.getFlags()).toEqual({
|
||||
disableSignups: false,
|
||||
disableEmailAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the latest env values on every call (no stale cache)", () => {
|
||||
envMock.FLAG_DISABLE_SIGNUPS = false;
|
||||
const before = flagsService.getFlags();
|
||||
envMock.FLAG_DISABLE_SIGNUPS = true;
|
||||
const after = flagsService.getFlags();
|
||||
|
||||
expect(before.disableSignups).toBe(false);
|
||||
expect(after.disableSignups).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pool = vi.hoisted(() => ({
|
||||
query: vi.fn().mockResolvedValue(undefined),
|
||||
connect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ getPool: () => pool }));
|
||||
|
||||
const { publishResumeUpdated, subscribeResumeUpdated } = await import("./resume-events");
|
||||
|
||||
const exampleEvent = {
|
||||
type: "resume.updated" as const,
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
mutation: "patch" as const,
|
||||
};
|
||||
|
||||
describe("publishResumeUpdated", () => {
|
||||
it("issues a pg_notify with the channel and serialized event", async () => {
|
||||
pool.query.mockClear();
|
||||
|
||||
await publishResumeUpdated(exampleEvent);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledTimes(1);
|
||||
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the query call exists before destructuring it.
|
||||
const [sql, params] = pool.query.mock.calls[0]!;
|
||||
expect(sql).toBe("SELECT pg_notify($1, $2)");
|
||||
expect(params?.[0]).toBe("resume_updated");
|
||||
expect(JSON.parse(params?.[1] as string)).toEqual(exampleEvent);
|
||||
});
|
||||
});
|
||||
|
||||
const makeFakeClient = () => {
|
||||
type Listener = (notification: { channel?: string; payload?: string }) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const client = {
|
||||
query: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn((event: string, fn: Listener) => {
|
||||
if (event === "notification") listeners.add(fn);
|
||||
}),
|
||||
off: vi.fn((event: string, fn: Listener) => {
|
||||
if (event === "notification") listeners.delete(fn);
|
||||
}),
|
||||
release: vi.fn(),
|
||||
__notify(channel: string, payload: string) {
|
||||
for (const fn of listeners) fn({ channel, payload });
|
||||
},
|
||||
};
|
||||
return client;
|
||||
};
|
||||
|
||||
describe("subscribeResumeUpdated", () => {
|
||||
it("yields events whose resumeId and userId match the subscription", async () => {
|
||||
const client = makeFakeClient();
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = subscribeResumeUpdated({
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Kick the generator so listeners are installed, then push a notification.
|
||||
const firstP = iterator.next();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
client.__notify("resume_updated", JSON.stringify(exampleEvent));
|
||||
|
||||
const first = await firstP;
|
||||
expect(first.done).toBe(false);
|
||||
expect(first.value).toEqual(exampleEvent);
|
||||
|
||||
controller.abort();
|
||||
const last = await iterator.next();
|
||||
expect(last.done).toBe(true);
|
||||
|
||||
expect(client.query).toHaveBeenCalledWith("LISTEN resume_updated");
|
||||
expect(client.query).toHaveBeenCalledWith("UNLISTEN resume_updated");
|
||||
expect(client.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores notifications for other resumes / users", async () => {
|
||||
const client = makeFakeClient();
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = subscribeResumeUpdated({
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const resultP = iterator.next();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, resumeId: "other" }));
|
||||
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, userId: "other" }));
|
||||
client.__notify("resume_updated", JSON.stringify(exampleEvent));
|
||||
|
||||
const result = await resultP;
|
||||
expect(result.value?.resumeId).toBe("r1");
|
||||
|
||||
controller.abort();
|
||||
await iterator.next();
|
||||
});
|
||||
|
||||
it("ignores malformed notifications and notifications on other channels", async () => {
|
||||
const client = makeFakeClient();
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = subscribeResumeUpdated({
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const resultP = iterator.next();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Wrong channel.
|
||||
client.__notify("other_channel", JSON.stringify(exampleEvent));
|
||||
// Malformed JSON.
|
||||
client.__notify("resume_updated", "{not-json");
|
||||
// Missing required fields.
|
||||
client.__notify("resume_updated", JSON.stringify({ type: "wrong" }));
|
||||
// Valid event after the noise.
|
||||
client.__notify("resume_updated", JSON.stringify(exampleEvent));
|
||||
|
||||
const result = await resultP;
|
||||
expect(result.value).toEqual(exampleEvent);
|
||||
|
||||
controller.abort();
|
||||
await iterator.next();
|
||||
});
|
||||
|
||||
it("terminates immediately if signal is already aborted", async () => {
|
||||
const client = makeFakeClient();
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const iterator = subscribeResumeUpdated({
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const result = await iterator.next();
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
// Filled in by beforeEach so caches do not bleed between tests.
|
||||
LOCAL_STORAGE_PATH: "" as string,
|
||||
}));
|
||||
|
||||
const dbResult = vi.hoisted(() => ({ count: 0 }));
|
||||
const dbMock = vi.hoisted(() => {
|
||||
const select = vi.fn();
|
||||
select.mockReturnValue({ from: () => Promise.resolve([dbResult]) });
|
||||
return { select };
|
||||
});
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({ user: { __table: "user" }, resume: { __table: "resume" } }));
|
||||
vi.mock("drizzle-orm", () => ({ count: () => "count(*)" }));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchMock.mockReset();
|
||||
dbMock.select.mockClear();
|
||||
});
|
||||
|
||||
const { statisticsService } = await import("./statistics");
|
||||
|
||||
// Each test gets a unique LOCAL_STORAGE_PATH to avoid cross-test cache hits.
|
||||
beforeEach(() => {
|
||||
envMock.LOCAL_STORAGE_PATH = mkdtempSync(join(tmpdir(), "rr-statistics-test-"));
|
||||
});
|
||||
|
||||
describe("statisticsService.user.getCount", () => {
|
||||
it("returns the DB count when the fetcher succeeds", async () => {
|
||||
dbResult.count = 42;
|
||||
await expect(statisticsService.user.getCount()).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("falls back to the last-known value when the DB throws", async () => {
|
||||
dbMock.select.mockImplementationOnce(() => {
|
||||
throw new Error("db down");
|
||||
});
|
||||
const value = await statisticsService.user.getCount();
|
||||
// Last known is 978_528; we just check it's > 0 (don't hard-code the magic number).
|
||||
expect(value).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statisticsService.resume.getCount", () => {
|
||||
it("returns the DB count for resume", async () => {
|
||||
dbResult.count = 7;
|
||||
await expect(statisticsService.resume.getCount()).resolves.toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statisticsService.github.getStarCount", () => {
|
||||
it("returns the parsed stargazers_count when GitHub responds OK", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ stargazers_count: 12345 }),
|
||||
});
|
||||
|
||||
const stars = await statisticsService.github.getStarCount();
|
||||
expect(stars).toBe(12345);
|
||||
});
|
||||
|
||||
it("falls back to last-known on non-OK responses (retries internally)", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const stars = await statisticsService.github.getStarCount();
|
||||
expect(stars).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to last-known when fetch throws", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const stars = await statisticsService.github.getStarCount();
|
||||
expect(stars).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects non-positive stargazers_count and falls back", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ stargazers_count: 0 }),
|
||||
});
|
||||
|
||||
const stars = await statisticsService.github.getStarCount();
|
||||
expect(stars).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects non-numeric stargazers_count and falls back", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ stargazers_count: "not a number" }),
|
||||
});
|
||||
|
||||
const stars = await statisticsService.github.getStarCount();
|
||||
expect(stars).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
APP_URL: "https://example.com",
|
||||
LOCAL_STORAGE_PATH: "",
|
||||
S3_ACCESS_KEY_ID: undefined as string | undefined,
|
||||
S3_SECRET_ACCESS_KEY: undefined as string | undefined,
|
||||
S3_REGION: "us-east-1",
|
||||
S3_ENDPOINT: undefined as string | undefined,
|
||||
S3_BUCKET: undefined as string | undefined,
|
||||
S3_FORCE_PATH_STYLE: false,
|
||||
FLAG_DISABLE_IMAGE_PROCESSING: false,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
// sharp is exercised by processImageForUpload; keep it out of the import graph entirely
|
||||
// because resolving it loads native bindings we can't rely on in CI.
|
||||
vi.mock("sharp", () => {
|
||||
const chain = {
|
||||
resize: () => chain,
|
||||
jpeg: () => chain,
|
||||
rotate: () => chain,
|
||||
toBuffer: async () => Buffer.from("processed"),
|
||||
metadata: async () => ({ width: 100, height: 100 }),
|
||||
};
|
||||
return { default: () => chain };
|
||||
});
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
S3Client: vi.fn(),
|
||||
PutObjectCommand: vi.fn(),
|
||||
GetObjectCommand: vi.fn(),
|
||||
DeleteObjectCommand: vi.fn(),
|
||||
ListObjectsV2Command: vi.fn(),
|
||||
}));
|
||||
|
||||
const { inferContentType, isImageFile, processImageForUpload } = await import("./storage");
|
||||
|
||||
const makeFile = (bytes: Uint8Array, type = "image/png") =>
|
||||
({
|
||||
arrayBuffer: async () => bytes.buffer,
|
||||
type,
|
||||
}) as unknown as File;
|
||||
|
||||
describe("inferContentType", () => {
|
||||
it("maps common image extensions to their MIME types", () => {
|
||||
expect(inferContentType("photo.jpg")).toBe("image/jpeg");
|
||||
expect(inferContentType("photo.jpeg")).toBe("image/jpeg");
|
||||
expect(inferContentType("photo.png")).toBe("image/png");
|
||||
expect(inferContentType("animated.gif")).toBe("image/gif");
|
||||
expect(inferContentType("logo.svg")).toBe("image/svg+xml");
|
||||
expect(inferContentType("photo.webp")).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("maps .pdf to application/pdf", () => {
|
||||
expect(inferContentType("doc.pdf")).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("is case-insensitive on the extension", () => {
|
||||
expect(inferContentType("PHOTO.JPG")).toBe("image/jpeg");
|
||||
expect(inferContentType("Document.PDF")).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("falls back to application/octet-stream for unknown extensions", () => {
|
||||
expect(inferContentType("data.xyz")).toBe("application/octet-stream");
|
||||
expect(inferContentType("README")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("uses just the file extension regardless of path depth", () => {
|
||||
expect(inferContentType("/nested/dir/file.png")).toBe("image/png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processImageForUpload", () => {
|
||||
it("returns the file untouched when image processing is disabled", async () => {
|
||||
envMock.FLAG_DISABLE_IMAGE_PROCESSING = true;
|
||||
const file = makeFile(new Uint8Array([1, 2, 3, 4]), "image/png");
|
||||
|
||||
const result = await processImageForUpload(file);
|
||||
|
||||
expect(result.contentType).toBe("image/png");
|
||||
expect(Array.from(result.data)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("re-encodes to JPEG via sharp when processing is enabled", async () => {
|
||||
envMock.FLAG_DISABLE_IMAGE_PROCESSING = false;
|
||||
const file = makeFile(new Uint8Array([5, 6, 7, 8]), "image/png");
|
||||
|
||||
const result = await processImageForUpload(file);
|
||||
|
||||
expect(result.contentType).toBe("image/jpeg");
|
||||
// Sharp mock returns "processed" — ensure we got something not equal to the input.
|
||||
expect(result.data.length).toBeGreaterThan(0);
|
||||
expect(Array.from(result.data)).not.toEqual([5, 6, 7, 8]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isImageFile", () => {
|
||||
it("returns true for supported image mime types", () => {
|
||||
for (const type of ["image/gif", "image/png", "image/jpeg", "image/webp"]) {
|
||||
expect(isImageFile(type), type).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for image/svg+xml (not in the upload allowlist)", () => {
|
||||
expect(isImageFile("image/svg+xml")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for application/pdf and other non-image types", () => {
|
||||
expect(isImageFile("application/pdf")).toBe(false);
|
||||
expect(isImageFile("text/plain")).toBe(false);
|
||||
expect(isImageFile("")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user