diff --git a/jest.config.js b/jest.config.js index d9807e9f6..16174855b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -38,12 +38,20 @@ module.exports = { "\\.(css|less)$": "/__mocks__/styleMock.js" }, collectCoverageFrom: [ - "**/*.{js,jsx}", + "**/*.{js,jsx,ts,tsx}", "!**/node_modules/**", "!**/__test__/**", "!**/deploy/**", "!**/coverage/**" ], + coverageThreshold: { + "src/lib/": { + branches: 30, + functions: 45, + lines: 45, + statements: 45 + } + }, setupFilesAfterEnv: ["/__test__/setup-framework.js"], testPathIgnorePatterns: ["/node_modules/", "/__test__/e2e/"] }; diff --git a/src/lib/attributes.spec.ts b/src/lib/attributes.spec.ts new file mode 100644 index 000000000..8871d6554 --- /dev/null +++ b/src/lib/attributes.spec.ts @@ -0,0 +1,111 @@ +import { + camelCase, + nameComponents, + recordToCamelCase, + snakeToTitleCase, + titleCase +} from "./attributes"; + +describe("camelCase", () => { + it("converts a space-separated string", () => { + expect(camelCase("hello world")).toBe("helloWorld"); + }); + + it("converts a multi-word string", () => { + expect(camelCase("the quick brown fox")).toBe("theQuickBrownFox"); + }); + + it("handles a single word", () => { + expect(camelCase("hello")).toBe("hello"); + }); + + it("handles an already camelCase string", () => { + expect(camelCase("helloWorld")).toBe("helloWorld"); + }); +}); + +describe("titleCase", () => { + it("capitalizes the first letter and lowercases the rest", () => { + expect(titleCase("hello")).toBe("Hello"); + }); + + it("converts an all-uppercase word", () => { + expect(titleCase("HELLO")).toBe("Hello"); + }); + + // Note: this is a single-word titleCase, unlike scripts.ts titleCase + // which handles multiple words. + it("only capitalizes the first character of multi-word input", () => { + expect(titleCase("hello world")).toBe("Hello world"); + }); +}); + +describe("snakeToTitleCase", () => { + it("converts snake_case to Title Case with spaces", () => { + expect(snakeToTitleCase("hello_world")).toBe("Hello World"); + }); + + it("converts a single word", () => { + expect(snakeToTitleCase("hello")).toBe("Hello"); + }); + + it("handles multiple underscores", () => { + expect(snakeToTitleCase("one_two_three")).toBe("One Two Three"); + }); +}); + +describe("nameComponents", () => { + it("splits a two-word name into first and last", () => { + const result = nameComponents("Jane Doe"); + expect(result.firstName).toBe("Jane"); + expect(result.lastName).toBe("Doe"); + expect(result.cellNumber).toBeUndefined(); + }); + + it("treats a single word as firstName only", () => { + const result = nameComponents("Jane"); + expect(result.firstName).toBe("Jane"); + expect(result.lastName).toBeUndefined(); + expect(result.cellNumber).toBeUndefined(); + }); + + it("puts everything after the first word into lastName", () => { + const result = nameComponents("Jane van der Berg"); + expect(result.firstName).toBe("Jane"); + expect(result.lastName).toBe("van der Berg"); + }); + + it("extracts a phone number from the name string", () => { + const result = nameComponents("Jane Doe 2025551234"); + expect(result.firstName).toBe("Jane"); + expect(result.lastName).toBe("Doe"); + expect(result.cellNumber).toBe("+12025551234"); + }); + + it("returns undefined fields for an empty string", () => { + const result = nameComponents(""); + expect(result.firstName).toBeUndefined(); + expect(result.lastName).toBeUndefined(); + expect(result.cellNumber).toBeUndefined(); + }); +}); + +describe("recordToCamelCase", () => { + it("converts snake_case keys to camelCase", () => { + const result = recordToCamelCase({ + first_name: "Jane", + last_name: "Doe" + }); + expect(result).toEqual({ firstName: "Jane", lastName: "Doe" }); + }); + + it("preserves already camelCase keys", () => { + const result = recordToCamelCase({ firstName: "Jane" }); + expect(result).toEqual({ firstName: "Jane" }); + }); + + it("does not modify values", () => { + const result = recordToCamelCase({ some_key: "some_value" }); + expect(result).toEqual({ someKey: "some_value" }); + }); +}); diff --git a/src/lib/datetime.spec.ts b/src/lib/datetime.spec.ts new file mode 100644 index 000000000..e1559bb8f --- /dev/null +++ b/src/lib/datetime.spec.ts @@ -0,0 +1,66 @@ +import { DateTime, deprecatedTimezoneMap, parseIanaZone } from "./datetime"; + +describe("parseIanaZone", () => { + it("converts a deprecated US timezone to its IANA equivalent", () => { + expect(parseIanaZone("US/Eastern")).toBe("America/New_York"); + }); + + it("is case-insensitive", () => { + expect(parseIanaZone("us/eastern")).toBe("America/New_York"); + expect(parseIanaZone("US/EASTERN")).toBe("America/New_York"); + }); + + it("passes through an already-IANA timezone unchanged", () => { + expect(parseIanaZone("America/New_York")).toBe("America/New_York"); + }); + + it("passes through an unknown timezone unchanged", () => { + expect(parseIanaZone("Europe/Berlin")).toBe("Europe/Berlin"); + }); + + it("returns empty string for empty string input", () => { + expect(parseIanaZone("")).toBe(""); + }); +}); + +describe("deprecatedTimezoneMap", () => { + it("contains all expected US timezone mappings", () => { + expect(Object.keys(deprecatedTimezoneMap)).toHaveLength(12); + expect(deprecatedTimezoneMap["us/eastern"]).toBe("America/New_York"); + expect(deprecatedTimezoneMap["us/pacific"]).toBe("America/Los_Angeles"); + expect(deprecatedTimezoneMap["us/central"]).toBe("America/Chicago"); + expect(deprecatedTimezoneMap["us/mountain"]).toBe("America/Denver"); + expect(deprecatedTimezoneMap["us/hawaii"]).toBe("Pacific/Honolulu"); + }); +}); + +describe("DateTime", () => { + it("extends luxon DateTime", () => { + const dt = DateTime.fromISO("2020-01-01T12:00:00Z"); + expect(dt.isValid).toBe(true); + expect(dt.year).toBe(2020); + }); + + describe("setZone", () => { + it("resolves a deprecated timezone name without invalidating", () => { + const dt = DateTime.fromISO("2020-01-01T12:00:00Z"); + const rezoned = dt.setZone("US/Eastern"); + expect(rezoned.isValid).toBe(true); + // The offset matches America/New_York (-5 in January) + expect(rezoned.offset).toBe(-300); + }); + + it("works with standard IANA timezone names", () => { + const dt = DateTime.fromISO("2020-01-01T12:00:00Z"); + const rezoned = dt.setZone("America/Chicago"); + expect(rezoned.isValid).toBe(true); + expect(rezoned.zoneName).toBe("America/Chicago"); + }); + + it("works with UTC", () => { + const dt = DateTime.fromISO("2020-01-01T12:00:00-05:00"); + const rezoned = dt.setZone("utc"); + expect(rezoned.hour).toBe(17); + }); + }); +}); diff --git a/src/lib/interaction-step-helpers.spec.ts b/src/lib/interaction-step-helpers.spec.ts index c89439b4b..1619d6f9f 100644 --- a/src/lib/interaction-step-helpers.spec.ts +++ b/src/lib/interaction-step-helpers.spec.ts @@ -1,5 +1,9 @@ import type { InteractionStep } from "../api/interaction-step"; -import { makeTree } from "./interaction-step-helpers"; +import { + getTopMostParent, + interactionStepForId, + makeTree +} from "./interaction-step-helpers"; const baseEmptyStep: Omit = { questionText: "", @@ -22,6 +26,44 @@ const step = ( ...overrides }); +describe("interactionStepForId", () => { + it("returns the step matching the given id", () => { + const steps = [step("1", null), step("2", "1"), step("3", "1")]; + expect(interactionStepForId("2", steps)).toEqual(steps[1]); + }); + + it("returns null when no step matches", () => { + const steps = [step("1", null)]; + expect(interactionStepForId("999", steps)).toBeNull(); + }); + + // Documents known behavior: uses forEach instead of find, so returns + // the *last* match if there are duplicates. + it("returns the last match when duplicate ids exist", () => { + const first = step("1", null, { questionText: "first" }); + const duplicate = step("1", null, { questionText: "duplicate" }); + expect(interactionStepForId("1", [first, duplicate])).toEqual(duplicate); + }); +}); + +describe("getTopMostParent", () => { + it("returns the root step (parentInteractionId === null)", () => { + const root = step("1", null); + const child = step("2", "1"); + expect(getTopMostParent([root, child], false)).toEqual(root); + }); + + it("returns the newest root when multiple roots exist", () => { + const olderRoot = step("1", null); + const newerRoot = step("2", null); + expect(getTopMostParent([olderRoot, newerRoot], false)).toEqual(newerRoot); + }); + + it("returns undefined for an empty array", () => { + expect(getTopMostParent([], false)).toBeUndefined(); + }); +}); + describe("makeTree", () => { it("returns a wrapper with empty children for an empty input", () => { const tree = makeTree([]); diff --git a/src/lib/permissions.spec.ts b/src/lib/permissions.spec.ts new file mode 100644 index 000000000..3c322e420 --- /dev/null +++ b/src/lib/permissions.spec.ts @@ -0,0 +1,98 @@ +import { UserRoleType } from "../api/organization-membership"; +import { + hasRole, + hasRoleAtLeast, + isRoleGreater, + ROLE_HIERARCHY +} from "./permissions"; + +describe("ROLE_HIERARCHY", () => { + it("orders roles from least to most privileged", () => { + expect(ROLE_HIERARCHY).toEqual([ + UserRoleType.SUSPENDED, + UserRoleType.TEXTER, + UserRoleType.SUPERVOLUNTEER, + UserRoleType.ADMIN, + UserRoleType.OWNER, + UserRoleType.SUPERADMIN + ]); + }); +}); + +describe("isRoleGreater", () => { + it("returns true when first role outranks second", () => { + expect(isRoleGreater(UserRoleType.ADMIN, UserRoleType.TEXTER)).toBe(true); + }); + + it("returns false when first role is lower than second", () => { + expect(isRoleGreater(UserRoleType.TEXTER, UserRoleType.ADMIN)).toBe(false); + }); + + it("returns false when roles are equal", () => { + expect(isRoleGreater(UserRoleType.ADMIN, UserRoleType.ADMIN)).toBe(false); + }); + + it("returns true for SUPERADMIN over OWNER", () => { + expect(isRoleGreater(UserRoleType.SUPERADMIN, UserRoleType.OWNER)).toBe( + true + ); + }); +}); + +describe("hasRoleAtLeast", () => { + it("returns true when role meets the requirement", () => { + expect(hasRoleAtLeast(UserRoleType.ADMIN, UserRoleType.ADMIN)).toBe(true); + }); + + it("returns true when role exceeds the requirement", () => { + expect(hasRoleAtLeast(UserRoleType.OWNER, UserRoleType.ADMIN)).toBe(true); + }); + + it("returns false when role is below the requirement", () => { + expect(hasRoleAtLeast(UserRoleType.TEXTER, UserRoleType.ADMIN)).toBe(false); + }); + + it("returns true for SUSPENDED meeting SUSPENDED", () => { + expect(hasRoleAtLeast(UserRoleType.SUSPENDED, UserRoleType.SUSPENDED)).toBe( + true + ); + }); +}); + +describe("hasRole", () => { + it("returns true when user has a role at or above the required level", () => { + expect(hasRole(UserRoleType.ADMIN, [UserRoleType.OWNER])).toBe(true); + }); + + it("returns false when user only has a lower role", () => { + expect(hasRole(UserRoleType.ADMIN, [UserRoleType.TEXTER])).toBe(false); + }); + + it("returns true when one of multiple roles meets the requirement", () => { + expect( + hasRole(UserRoleType.ADMIN, [UserRoleType.TEXTER, UserRoleType.ADMIN]) + ).toBe(true); + }); + + it("picks the highest from a multi-role list", () => { + expect( + hasRole(UserRoleType.SUPERADMIN, [ + UserRoleType.TEXTER, + UserRoleType.SUPERVOLUNTEER, + UserRoleType.ADMIN, + UserRoleType.OWNER, + UserRoleType.SUPERADMIN + ]) + ).toBe(true); + }); + + // Documents known bug: getHighestRole mutates its input array via .sort() + // This test will be updated when the bug is fixed in PR 4. + it("mutates the input array (known bug)", () => { + const roles = [UserRoleType.ADMIN, UserRoleType.TEXTER]; + hasRole(UserRoleType.TEXTER, roles); + // After calling hasRole, the array has been sorted in place + expect(roles[0]).toBe(UserRoleType.TEXTER); + expect(roles[1]).toBe(UserRoleType.ADMIN); + }); +}); diff --git a/src/lib/phone-format.spec.ts b/src/lib/phone-format.spec.ts new file mode 100644 index 000000000..d63608d2f --- /dev/null +++ b/src/lib/phone-format.spec.ts @@ -0,0 +1,57 @@ +import { getFormattedPhoneNumber, phoneNumberRegex } from "./phone-format"; + +describe("getFormattedPhoneNumber", () => { + it("formats a valid 10-digit US number to E164", () => { + expect(getFormattedPhoneNumber("2025551234")).toBe("+12025551234"); + }); + + it("formats a number with dashes", () => { + expect(getFormattedPhoneNumber("202-555-1234")).toBe("+12025551234"); + }); + + it("formats a number with parentheses and spaces", () => { + expect(getFormattedPhoneNumber("(202) 555-1234")).toBe("+12025551234"); + }); + + it("formats a number already in E164 format", () => { + expect(getFormattedPhoneNumber("+12025551234")).toBe("+12025551234"); + }); + + it("formats a number with leading 1 (country code)", () => { + expect(getFormattedPhoneNumber("12025551234")).toBe("+12025551234"); + }); + + it("returns empty string for an invalid number", () => { + expect(getFormattedPhoneNumber("123")).toBe(""); + }); + + it("returns empty string for an empty string", () => { + expect(getFormattedPhoneNumber("")).toBe(""); + }); + + it("returns empty string for alphabetic input", () => { + expect(getFormattedPhoneNumber("not-a-number")).toBe(""); + }); +}); + +describe("phoneNumberRegex", () => { + beforeEach(() => { + phoneNumberRegex.lastIndex = 0; + }); + + it("matches a 10-digit number with dashes", () => { + const match = "Call 202-555-1234 now".match(phoneNumberRegex); + expect(match).not.toBeNull(); + expect(match![0].trim()).toBe("202-555-1234"); + }); + + it("matches a number with country code prefix", () => { + const match = "Call +1 202 555 1234 now".match(phoneNumberRegex); + expect(match).not.toBeNull(); + }); + + it("does not match a short number", () => { + const match = "Call 555-1234 now".match(phoneNumberRegex); + expect(match).toBeNull(); + }); +}); diff --git a/src/lib/tz-helpers.spec.ts b/src/lib/tz-helpers.spec.ts index 338c894e4..386953994 100644 --- a/src/lib/tz-helpers.spec.ts +++ b/src/lib/tz-helpers.spec.ts @@ -1,5 +1,35 @@ import { DateTime } from "./datetime"; -import { getSendBeforeUtc } from "./tz-helpers"; +import { asUtc, getSendBeforeUtc, isValidTimezone } from "./tz-helpers"; + +describe("isValidTimezone", () => { + it("returns true for a valid IANA timezone", () => { + expect(isValidTimezone("America/New_York")).toBe(true); + }); + + it("returns true for UTC", () => { + expect(isValidTimezone("utc")).toBe(true); + }); + + it("returns false for a nonsense string", () => { + expect(isValidTimezone("Not/A/Timezone")).toBe(false); + }); + + it("returns true for deprecated US timezone names (via DateTime wrapper)", () => { + expect(isValidTimezone("US/Eastern")).toBe(true); + }); +}); + +describe("asUtc", () => { + it("converts a JS Date to a DateTime in UTC", () => { + const jsDate = new Date("2020-06-15T12:00:00Z"); + const result = asUtc(jsDate); + expect(result.zoneName).toBe("UTC"); + expect(result.year).toBe(2020); + expect(result.month).toBe(6); + expect(result.day).toBe(15); + expect(result.hour).toBe(12); + }); +}); describe("getSendBeforeUtc", () => { it("returns end-of-day UTC for a midday local time", () => { diff --git a/src/lib/utils.spec.ts b/src/lib/utils.spec.ts index 8e6fb681f..55720d575 100644 --- a/src/lib/utils.spec.ts +++ b/src/lib/utils.spec.ts @@ -16,6 +16,10 @@ describe("stringIsAValidUrl", () => { ).toBe(true); }); + it("accepts an http URL", () => { + expect(stringIsAValidUrl("http://example.com")).toBe(true); + }); + it("rejects a URL without a scheme", () => { expect(stringIsAValidUrl("www.politicsrewired.com")).toBe(false); }); @@ -23,6 +27,10 @@ describe("stringIsAValidUrl", () => { it("rejects a relative path", () => { expect(stringIsAValidUrl("foo/bar")).toBe(false); }); + + it("rejects an empty string", () => { + expect(stringIsAValidUrl("")).toBe(false); + }); }); describe("replaceAll", () => { @@ -37,6 +45,10 @@ describe("replaceAll", () => { "what about ? characters?" ); }); + + it("returns the original string when search is not found", () => { + expect(replaceAll("hello world", "xyz", "abc")).toBe("hello world"); + }); }); describe("asPercent", () => { @@ -47,6 +59,14 @@ describe("asPercent", () => { it("returns 100 for equal numerator and denominator", () => { expect(asPercent(10, 10)).toBe(100); }); + + it("returns a fractional percentage", () => { + expect(asPercent(1, 3)).toBeCloseTo(33.33, 1); + }); + + it("returns 50 for half", () => { + expect(asPercent(5, 10)).toBe(50); + }); }); describe("asPercentWithTotal", () => { @@ -57,4 +77,8 @@ describe("asPercentWithTotal", () => { it("truncates decimal to 4 characters", () => { expect(asPercentWithTotal(9, 11)).toBe("81.8%(9)"); }); + + it("handles 100% correctly", () => { + expect(asPercentWithTotal(10, 10)).toBe("100%(10)"); + }); });