diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index 11b8e05a..cdc88052 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -99,6 +99,14 @@ Resources: Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache + - Sid: DynamoDBRateLimitTableAccess + Effect: Allow + Action: + - dynamodb:DescribeTable + - dynamodb:UpdateItem + Resource: + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter + - Sid: DynamoDBAuditLogTableAccess Effect: Allow Action: diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 99922545..4e5ea85c 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -408,6 +408,30 @@ Resources: - AttributeName: userEmail KeyType: HASH + RateLimiterTable: + Type: "AWS::DynamoDB::Table" + DeletionPolicy: "Delete" + UpdateReplacePolicy: "Delete" + Properties: + BillingMode: "PAY_PER_REQUEST" + TableName: infra-core-api-rate-limiter + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: false + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + EventRecordsTable: Type: "AWS::DynamoDB::Table" DeletionPolicy: "Retain" diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 0aa45327..35b81130 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -29,7 +29,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { checkPaidMembershipFromTable } from "./membership.js"; -function validateGroupId(groupId: string): boolean { +export function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed return groupIdPattern.test(groupId); } @@ -368,7 +368,7 @@ export async function listGroupMembers( * @throws {EntraUserError} If fetching the user profile fails. * @returns {Promise} The user's profile information. */ -export async function getUserProfile( +export async function getUserProflile( token: string, email: string, ): Promise { diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts index b6cbaa38..83316c96 100644 --- a/src/api/functions/siglead.ts +++ b/src/api/functions/siglead.ts @@ -1,16 +1,23 @@ import { + AttributeValue, DynamoDBClient, + GetItemCommand, + PutItemCommand, + PutItemCommandInput, QueryCommand, ScanCommand, } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; -import { OrganizationList } from "common/orgs.js"; +import { DatabaseInsertError } from "common/errors/index.js"; +import { OrganizationList, orgIds2Name } from "common/orgs.js"; import { SigDetailRecord, SigMemberCount, SigMemberRecord, + SigMemberUpdateRecord, } from "common/types/siglead.js"; import { transformSigLeadToURI } from "common/utils.js"; +import { KeyObject } from "crypto"; import { string } from "zod"; export async function fetchMemberRecords( @@ -84,13 +91,11 @@ export async function fetchSigCounts( const result = await dynamoClient.send(scan); - const ids2Name: Record = {}; - OrganizationList.forEach((org) => { - const sigid = transformSigLeadToURI(org); - ids2Name[sigid] = org; - }); - const counts: Record = {}; + // Object.entries(orgIds2Name).forEach(([id, _]) => { + // counts[id] = 0; + // }); + (result.Items || []).forEach((item) => { const sigGroupId = item.sigGroupId?.S; if (sigGroupId) { @@ -98,17 +103,70 @@ export async function fetchSigCounts( } }); - const joined: Record = {}; - Object.keys(counts).forEach((sigid) => { - joined[sigid] = [ids2Name[sigid], counts[sigid]]; - }); - - const countsArray: SigMemberCount[] = Object.entries(joined).map( - ([sigid, [signame, count]]) => ({ - sigid, - signame, + const countsArray: SigMemberCount[] = Object.entries(counts).map( + ([id, count]) => ({ + sigid: id, + signame: orgIds2Name[id], count, }), ); + console.log(countsArray); return countsArray; } + +export async function addMemberToSigDynamo( + sigMemberTableName: string, + sigMemberUpdateRequest: SigMemberUpdateRecord, + dynamoClient: DynamoDBClient, +) { + const item: Record = {}; + Object.entries(sigMemberUpdateRequest).forEach(([k, v]) => { + item[k] = { S: v }; + }); + + // put into table + const put = new PutItemCommand({ + Item: item, + ReturnConsumedCapacity: "TOTAL", + TableName: sigMemberTableName, + }); + try { + const response = await dynamoClient.send(put); + console.log(response); + } catch (e) { + console.error("Put to dynamo db went wrong."); + throw e; + } + + // fetch from db and check if fetched item update time = input item update time + const validatePutQuery = new GetItemCommand({ + TableName: sigMemberTableName, + Key: { + sigGroupId: { S: sigMemberUpdateRequest.sigGroupId }, + email: { S: sigMemberUpdateRequest.email }, + }, + ProjectionExpression: "updatedAt", + }); + + try { + const response = await dynamoClient.send(validatePutQuery); + const item = response.Item; + + if (!item || !item.updatedAt?.S) { + throw new Error("Item not found or missing 'updatedAt'"); + } + + if (item.updatedAt.S !== sigMemberUpdateRequest.updatedAt) { + throw new DatabaseInsertError({ + message: "The member exists, but was updated by someone else!", + }); + } + } catch (e) { + console.error("Validate DynamoDB get went wrong.", e); + throw e; + } +} + +export async function addMemberToSigEntra() { + // uuid validation not implemented yet +} diff --git a/src/api/index.ts b/src/api/index.ts index 845f8455..bc1e682a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -288,9 +288,9 @@ async function init(prettyPrint: boolean = false) { api.register(iamRoutes, { prefix: "/iam" }); api.register(ticketsPlugin, { prefix: "/tickets" }); api.register(linkryRoutes, { prefix: "/linkry" }); - api.register(sigleadRoutes, { prefix: "/siglead" }); api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); api.register(stripeRoutes, { prefix: "/stripe" }); + api.register(sigleadRoutes, { prefix: "/siglead" }); api.register(roomRequestRoutes, { prefix: "/roomRequests" }); api.register(logsPlugin, { prefix: "/logs" }); api.register(apiKeyRoute, { prefix: "/apiKey" }); diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index fca95775..1b27a3e8 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -1,17 +1,23 @@ import { FastifyPluginAsync } from "fastify"; import { DatabaseFetchError } from "../../common/errors/index.js"; + import { genericConfig } from "../../common/config.js"; + import { SigDetailRecord, SigleadGetRequest, SigMemberCount, SigMemberRecord, + SigMemberUpdateRecord, } from "common/types/siglead.js"; import { + addMemberToSigDynamo, fetchMemberRecords, fetchSigCounts, fetchSigDetail, } from "api/functions/siglead.js"; +import { intersection } from "api/plugins/auth.js"; +import { request } from "http"; const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { const limitedRoutes: FastifyPluginAsync = async (fastify) => { @@ -94,36 +100,47 @@ const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { ); // fetch sig count - fastify.get( - "/sigcount", - { - onRequest: async (request, reply) => { - /*await fastify.authorize(request, reply, [ - AppRoles.LINKS_MANAGER, - AppRoles.LINKS_ADMIN, - ]);*/ - }, - }, + fastify.get("/sigcount", async (request, reply) => { + // First try-catch: Fetch owner records + let sigMemCounts: SigMemberCount[]; + try { + sigMemCounts = await fetchSigCounts( + genericConfig.SigleadDynamoSigMemberTableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: + "Failed to fetch sig member counts record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigMemCounts); + }); + + // add member + fastify.post<{ Body: SigMemberUpdateRecord }>( + "/addMember", async (request, reply) => { - // First try-catch: Fetch owner records - let sigMemCounts: SigMemberCount[]; try { - sigMemCounts = await fetchSigCounts( + await addMemberToSigDynamo( genericConfig.SigleadDynamoSigMemberTableName, + request.body, fastify.dynamoClient, ); } catch (error) { request.log.error( - `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + `Failed to add member: ${error instanceof Error ? error.toString() : "Unknown error"}`, ); throw new DatabaseFetchError({ - message: - "Failed to fetch sig member counts record from Dynamo table.", + message: "Failed to add sig member record to Dynamo table.", }); } - - // Send the response - reply.code(200).send(sigMemCounts); + reply.code(200); }, ); }; diff --git a/src/common/config.ts b/src/common/config.ts index 43608b9e..70447d88 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -29,8 +29,6 @@ export type GenericConfigType = { EventsDynamoTableName: string; CacheDynamoTableName: string; LinkryDynamoTableName: string; - SigleadDynamoSigDetailTableName: string; - SigleadDynamoSigMemberTableName: string; StripeLinksDynamoTableName: string; ConfigSecretName: string; EntraSecretName: string; @@ -50,6 +48,10 @@ export type GenericConfigType = { EntraReadOnlySecretName: string; AuditLogTable: string; ApiKeyTable: string; + + RateLimiterDynamoTableName: string; + SigleadDynamoSigDetailTableName: string; + SigleadDynamoSigMemberTableName: string; }; type EnvironmentConfigType = { @@ -65,13 +67,13 @@ export const commChairsTestingGroupId = "d714adb7-07bb-4d4d-a40a-b035bc2a35a3"; export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46"; +export const orgsGroupId = "0b3be7c2-748e-46ce-97e7-cf86f9ca7337"; + const genericConfig: GenericConfigType = { EventsDynamoTableName: "infra-core-api-events", StripeLinksDynamoTableName: "infra-core-api-stripe-links", CacheDynamoTableName: "infra-core-api-cache", LinkryDynamoTableName: "infra-core-api-linkry", - SigleadDynamoSigDetailTableName: "infra-core-api-sig-details", - SigleadDynamoSigMemberTableName: "infra-core-api-sig-member-details", ConfigSecretName: "infra-core-api-config", EntraSecretName: "infra-core-api-entra", EntraReadOnlySecretName: "infra-core-api-ro-entra", @@ -90,6 +92,10 @@ const genericConfig: GenericConfigType = { RoomRequestsStatusTableName: "infra-core-api-room-requests-status", AuditLogTable: "infra-core-api-audit-log", ApiKeyTable: "infra-core-api-keys", + + RateLimiterDynamoTableName: "infra-core-api-rate-limiter", + SigleadDynamoSigDetailTableName: "infra-core-api-sig-details", + SigleadDynamoSigMemberTableName: "infra-core-api-sig-member-details", } as const; const environmentConfig: EnvironmentConfigType = { diff --git a/src/common/orgs.ts b/src/common/orgs.ts index ee84d00e..becff6fc 100644 --- a/src/common/orgs.ts +++ b/src/common/orgs.ts @@ -1,3 +1,5 @@ +import { transformSigLeadToURI } from "./utils.js"; + export const SIGList = [ "SIGPwny", "SIGCHI", @@ -28,3 +30,10 @@ export const CommitteeList = [ "Marketing Committee", ] as [string, ...string[]]; export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList] as [string, ...string[]]; + +const orgIds2Name: Record = {}; +OrganizationList.forEach((org) => { + const sigid = transformSigLeadToURI(org); + orgIds2Name[sigid] = org; +}); +export { orgIds2Name }; \ No newline at end of file diff --git a/src/common/roles.ts b/src/common/roles.ts index c397d69e..acbeee73 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -18,5 +18,8 @@ export enum AppRoles { MANAGE_ORG_API_KEYS = "manage:orgApiKey" } export const allAppRoles = Object.values(AppRoles).filter( - (value) => typeof value === "string", + (value) => typeof value === "string", ); + + + \ No newline at end of file diff --git a/src/common/types/siglead.ts b/src/common/types/siglead.ts index 2822cd4e..9da5b696 100644 --- a/src/common/types/siglead.ts +++ b/src/common/types/siglead.ts @@ -1,24 +1,34 @@ export type SigDetailRecord = { - sigid: string; - signame: string; - description: string; - }; - - export type SigMemberRecord = { - sigGroupId: string; - email: string; - designation: string; - memberName: string; - }; - - export type SigleadGetRequest = { - Params: { sigid: string }; - Querystring: undefined; - Body: undefined; - }; - - export type SigMemberCount = { - sigid: string; - signame: string; - count: number; - }; \ No newline at end of file + sigid: string; + signame: string; + description: string; +}; + +export type SigMemberRecord = { + sigGroupId: string; + email: string; + designation: string; + memberName: string; +}; + +export type SigleadGetRequest = { + Params: { sigid: string }; + Querystring: undefined; + Body: undefined; +}; + +export type SigMemberCount = { + sigid: string; + signame: string; + count: number; +}; + +export type SigMemberUpdateRecord = { + sigGroupId: string; + email: string; + id: string; + memberName: string; + designation: string; + createdAt: string; + updatedAt: string; +} \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index 8959eccd..5efddccc 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -29,6 +29,7 @@ const reservedCharsRegex = /[:\/?#\[\]@!$&'()*+,;=]/g; * @returns {string} - The transformed organization name, ready for use as a URL. */ export function transformSigLeadToURI(org: string) { + // console.log(`org\t${org}`) org = org // change not reserved chars to spaces .trim() @@ -56,3 +57,15 @@ export function transformSigLeadToURI(org: string) { return org === "-" ? "" : org; } + +export function getTimeInFormat() { + const date = new Date(); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; +} diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 92d503c2..28927859 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -27,7 +27,10 @@ import { ManageStripeLinksPage } from "./pages/stripe/ViewLinks.page"; import { ManageRoomRequestsPage } from "./pages/roomRequest/RoomRequestLanding.page"; import { EditSigLeadsPage } from "./pages/siglead/EditSigLeads.page"; import { ManageSigLeadsPage } from "./pages/siglead/ManageSigLeads.page"; -import { ViewSigLeadPage } from "./pages/siglead/ViewSigLead.page"; +import { + AddMemberToSigPage, + ViewSigLeadPage, +} from "./pages/siglead/ViewSigLead.page"; import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page"; import { ViewLogsPage } from "./pages/logs/ViewLogs.page"; import { TermsOfService } from "./pages/tos/TermsOfService.page"; @@ -203,6 +206,10 @@ const authenticatedRouter = createBrowserRouter([ path: "/siglead-management/:sigId", element: , }, + { + path: "/siglead-management/:sigId/addMember", + element: , + }, { path: "/roomRequests", element: , diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index d2720ed5..59ca6bfe 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -18,10 +18,10 @@ import { IconPizza, IconTicket, IconLock, - IconUsers, IconDoor, IconHistory, IconKey, + IconUsers, } from "@tabler/icons-react"; import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index c5d5e362..50d45eaf 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useState } from "react"; import { OrganizationList } from "@common/orgs"; import { NavLink, Paper } from "@mantine/core"; import { IconUsersGroup } from "@tabler/icons-react"; -import { useLocation } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { SigMemberCount } from "@common/types/siglead"; const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { + const navigate = useNavigate(); + const color = "light-dark(var(--mantine-color-black), var(--mantine-color-white))"; const size = "18px"; @@ -14,7 +16,7 @@ const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { const count = sigMemCount.count; return ( navigate(`./${id}`)} active={index % 2 === 0} label={name} color="var(--mantine-color-blue-light)" @@ -29,7 +31,7 @@ const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { fontSize: `${size}`, }} > - MemberCount: {count} + {count} } diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index d752e111..afcf6906 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -11,51 +11,26 @@ import { } from "@mantine/core"; import { notifications } from "@mantine/notifications"; -import React, { useEffect, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { z } from "zod"; import { AuthGuard } from "@ui/components/AuthGuard"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; -import { IconUsersGroup } from "@tabler/icons-react"; - -const baseSigSchema = z.object({ - sigid: z.string().min(1), - signame: z.string().min(1), - description: z.string().optional(), -}); - -const baseSigMemberSchema = z.object({ - sigGroupId: z.string().min(1), - email: z.string().email("Invalid email"), - designation: z.enum(["L", "M"]), - id: z.string().optional(), - memberName: z.string(), -}); - -type sigDetails = z.infer; -type sigMemberDetails = z.infer; +import { + SigDetailRecord, + SigMemberRecord, + SigMemberUpdateRecord, +} from "@common/types/siglead.js"; +import { getTimeInFormat } from "@common/utils"; +import { orgIds2Name } from "@common/orgs"; export const ViewSigLeadPage: React.FC = () => { const navigate = useNavigate(); const api = useApi("core"); const { colorScheme } = useMantineColorScheme(); const { sigId } = useParams(); - const [sigMembers, setSigMembers] = useState([ - { - sigGroupId: sigId || "", - email: "alice1@illinois.edu", - designation: "L", - memberName: "Alice", - }, - { - sigGroupId: sigId || "", - email: "bob2@illinois.edu", - designation: "M", - memberName: "Bob", - }, - ]); - const [sigDetails, setSigDetails] = useState({ + const [sigMembers, setSigMembers] = useState([]); + const [sigDetails, setSigDetails] = useState({ sigid: sigId || "", signame: "Default Sig", description: @@ -63,17 +38,21 @@ export const ViewSigLeadPage: React.FC = () => { }); useEffect(() => { - // Fetch sig data and populate form / for now dummy data... + // Fetch sig data and populate form const getSig = async () => { try { - const sigDetailsData = await api.get( - `/api/v1/siglead/sigdetail/${sigId}`, - ); - setSigDetails(sigDetailsData.data); - const sigMembersData = await api.get( + /*const formValues = { + }; + form.setValues(formValues);*/ + const sigMemberRequest = await api.get( `/api/v1/siglead/sigmembers/${sigId}`, ); - setSigMembers(sigMembersData.data); + setSigMembers(sigMemberRequest.data); + + const sigDetailRequest = await api.get( + `/api/v1/siglead/sigdetail/${sigId}`, + ); + setSigDetails(sigDetailRequest.data); } catch (error) { console.error("Error fetching sig data:", error); notifications.show({ @@ -84,7 +63,7 @@ export const ViewSigLeadPage: React.FC = () => { getSig(); }, [sigId]); - const renderSigMember = (members: sigMemberDetails, index: number) => { + const renderSigMember = (member: SigMemberRecord, index: number) => { const shouldShow = true; return ( { : "#ffffff", }} > - {members.memberName} - {members.email} - {members.designation} + {member.memberName} + {member.email} + {member.designation} )} @@ -172,15 +151,16 @@ export const ViewSigLeadPage: React.FC = () => { - {sigDetails.sigid} + {sigDetails.signame} {sigDetails.description || ""} - + + - */} + + + + + ); +}; diff --git a/tests/unit/common/utils.test.ts b/tests/unit/common/utils.test.ts index 15177175..e22d642c 100644 --- a/tests/unit/common/utils.test.ts +++ b/tests/unit/common/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "vitest"; -import { transformCommaSeperatedName } from "../../../src/common/utils.js"; +import { transformCommaSeperatedName, transformSigLeadToURI } from "../../../src/common/utils.js"; describe("Comma-seperated name transformer tests", () => { test("Already-transformed names are returned as-is", () => { @@ -27,3 +27,146 @@ describe("Comma-seperated name transformer tests", () => { expect(output).toEqual(", Test"); }); }); + +describe("transformSigLeadToURI tests", () => { + + // Basic Functionality Tests + test("should convert simple names with spaces to lowercase hyphenated", () => { + const output = transformSigLeadToURI("SIG Network"); + expect(output).toEqual("sig-network"); + }); + + test("should convert simple names to lowercase", () => { + const output = transformSigLeadToURI("Testing"); + expect(output).toEqual("testing"); + }); + + test("should handle names already in the desired format", () => { + const output = transformSigLeadToURI("already-transformed-name"); + expect(output).toEqual("already-transformed-name"); + }); + + // Camel Case Tests + test("should add hyphens between camelCase words", () => { + const output = transformSigLeadToURI("SIGAuth"); + expect(output).toEqual("sig-auth"); + }); + + test("should handle multiple camelCase words", () => { + const output = transformSigLeadToURI("SuperCamelCaseProject"); + expect(output).toEqual("super-camel-case-project"); + }); + + test("should handle mixed camelCase and spaces", () => { + const output = transformSigLeadToURI("SIG ContribEx"); // SIG Contributor Experience + expect(output).toEqual("sig-contrib-ex"); + }); + + test("should handle camelCase starting with lowercase", () => { + const output = transformSigLeadToURI("myCamelCaseName"); + expect(output).toEqual("my-camel-case-name"); + }); + + // Reserved Character Tests (RFC 3986 gen-delims and sub-delims) + test("should convert reserved characters like & to hyphens", () => { + const output = transformSigLeadToURI("SIG Storage & Backup"); + expect(output).toEqual("sig-storage-backup"); // & -> space -> hyphen + }); + + test("should convert reserved characters like / and : to hyphens", () => { + const output = transformSigLeadToURI("Project:Alpha/Beta"); + expect(output).toEqual("project-alpha-beta"); // : -> space, / -> space, space+space -> hyphen + }); + + test("should convert reserved characters like () and + to hyphens", () => { + const output = transformSigLeadToURI("My Project (Test+Alpha)"); + expect(output).toEqual("my-project-test-alpha"); + }); + + test("should convert various reserved characters #[]@?$, to hyphens", () => { + const output = transformSigLeadToURI("Special#Chars[Test]?@Value,$"); + expect(output).toEqual("special-chars-test-value"); + }); + + // Non-Allowed Character Removal Tests + test("should remove characters not unreserved or reserved (e.g., ™, ©)", () => { + const output = transformSigLeadToURI("MyOrg™ With © Symbols"); + expect(output).toEqual("my-org-with-symbols"); + }); + + test("should remove emoji", () => { + const output = transformSigLeadToURI("Project ✨ Fun"); + expect(output).toEqual("project-fun"); + }); + + + // Whitespace and Hyphen Collapsing Tests + test("should handle multiple spaces between words", () => { + const output = transformSigLeadToURI("SIG UI Project"); + expect(output).toEqual("sig-ui-project"); + }); + + test("should handle leading/trailing whitespace", () => { + const output = transformSigLeadToURI(" Leading and Trailing "); + expect(output).toEqual("leading-and-trailing"); + }); + + test("should handle mixed whitespace (tabs, newlines)", () => { + const output = transformSigLeadToURI("Mix\tOf\nWhite Space"); + expect(output).toEqual("mix-of-white-space"); + }); + + test("should collapse multiple hyphens resulting from transformations", () => { + const output = transformSigLeadToURI("Test--Multiple / Spaces"); + expect(output).toEqual("test-multiple-spaces"); + }); + + test("should collapse hyphens from start/end after transformations", () => { + const output = transformSigLeadToURI("&Another Test!"); + expect(output).toEqual("another-test"); + }); + + // Unreserved Character Tests (RFC 3986) + test("should keep unreserved characters: hyphen, period, underscore, tilde", () => { + const output = transformSigLeadToURI("Keep.These-Chars_Okay~123"); + expect(output).toEqual("keep.these-chars_okay~123"); + }); + + test("should handle unreserved chars next to reserved chars", () => { + const output = transformSigLeadToURI("Test._~&Stuff"); + expect(output).toEqual("test._~-stuff"); + }); + + + // Edge Case Tests + test("should return an empty string for an empty input", () => { + const output = transformSigLeadToURI(""); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only spaces", () => { + const output = transformSigLeadToURI(" "); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only reserved/non-allowed chars and spaces", () => { + const output = transformSigLeadToURI(" & / # ™ © "); + expect(output).toEqual(""); + }); + + test("should handle numbers correctly", () => { + const output = transformSigLeadToURI("ProjectApollo11"); + expect(output).toEqual("project-apollo11"); // Number doesn't trigger camel case break after letter + }); + + test("should handle numbers triggering camel case break", () => { + const output = transformSigLeadToURI("Project11Apollo"); + expect(output).toEqual("project-11-apollo"); // Letter after number triggers camel case break + }); + + test("should handle names starting with lowercase", () => { + const output = transformSigLeadToURI("myOrg"); + expect(output).toEqual("my-org"); + }); + +});