diff --git a/src/common/filters/adapters/tanstack/typeGuards.ts b/src/common/filters/adapters/tanstack/typeGuards.ts new file mode 100644 index 00000000..f616875f --- /dev/null +++ b/src/common/filters/adapters/tanstack/typeGuards.ts @@ -0,0 +1,12 @@ +import type { ColumnFilter } from "@tanstack/react-table"; + +/** + * Returns true if the value is a valid TanStack ColumnFilter shape. + * @param value - Value to check. + * @returns true if the value has the expected shape. + */ +export function isColumnFilter(value: unknown): value is ColumnFilter { + if (typeof value !== "object" || value === null) return false; + const filter = value as Record; + return typeof filter.id === "string" && "value" in filter; +} diff --git a/src/common/filters/hooks/UseValidateFilterKeys/hook.ts b/src/common/filters/hooks/UseValidateFilterKeys/hook.ts new file mode 100644 index 00000000..6e70f5ff --- /dev/null +++ b/src/common/filters/hooks/UseValidateFilterKeys/hook.ts @@ -0,0 +1,75 @@ +import { useRouter } from "next/router"; +import { useMemo } from "react"; +import { validateFilterParam } from "./utils"; + +/** + * Validates the filter query parameter shape only (no key validation). + * Throws a DataExplorerError during render if the filter param is present + * but contains malformed JSON or an invalid entry shape. + * @param queryParamKey - URL query parameter key to read (e.g. "filter"). + * @param validKeys - Must be undefined to use this overload. + * @param entryValidator - Type guard that validates each parsed entry's shape. + * @returns void; throws DataExplorerError on invalid input. + */ +export function useValidateFilterKeys( + queryParamKey: string, + validKeys: undefined, + entryValidator: (value: unknown) => boolean, +): void; +/** + * Validates the filter query parameter shape and keys against a valid set. + * Throws a DataExplorerError during render if the filter param is present + * but contains malformed JSON, an invalid entry shape, or a key that + * is not in the valid set. + * @param queryParamKey - URL query parameter key to read (e.g. "filter"). + * @param validKeys - Set of valid keys. + * @param entryValidator - Type guard that validates each parsed entry's shape. + * @param keyExtractor - Extracts the key string from a validated entry. + * @returns void; throws DataExplorerError on invalid input. + */ +export function useValidateFilterKeys( + queryParamKey: string, + validKeys: Set, + entryValidator: (value: unknown) => boolean, + keyExtractor: (entry: unknown) => string, +): void; +/** + * Validates the filter query parameter shape and, when validKeys is defined, + * keys against the valid set. Use when validKeys is dynamically determined. + * @param queryParamKey - URL query parameter key to read (e.g. "filter"). + * @param validKeys - Set of valid keys, or undefined to skip key validation. + * @param entryValidator - Type guard that validates each parsed entry's shape. + * @param keyExtractor - Extracts the key string from a validated entry. + * @returns void; throws DataExplorerError on invalid input. + */ +export function useValidateFilterKeys( + queryParamKey: string, + validKeys: Set | undefined, + entryValidator: (value: unknown) => boolean, + keyExtractor: (entry: unknown) => string, +): void; +export function useValidateFilterKeys( + queryParamKey: string, + validKeys: Set | undefined, + entryValidator: (value: unknown) => boolean, + keyExtractor?: (entry: unknown) => string, +): void { + const { query } = useRouter(); + const filterParam = query[queryParamKey]; + + const validationError = useMemo(() => { + if (validKeys && keyExtractor) { + return validateFilterParam( + filterParam, + validKeys, + entryValidator, + keyExtractor, + ); + } + return validateFilterParam(filterParam, undefined, entryValidator); + }, [entryValidator, filterParam, keyExtractor, validKeys]); + + if (validationError) { + throw validationError; + } +} diff --git a/src/common/filters/hooks/UseValidateFilterKeys/utils.ts b/src/common/filters/hooks/UseValidateFilterKeys/utils.ts new file mode 100644 index 00000000..0f7d01aa --- /dev/null +++ b/src/common/filters/hooks/UseValidateFilterKeys/utils.ts @@ -0,0 +1,83 @@ +import { DataExplorerError } from "../../../../types/error"; + +const INVALID_FILTER_PARAM = "Invalid filter parameter in URL"; +const INVALID_FILTER_SHAPE = "Invalid filter entry shape in URL"; +const UNKNOWN_FILTER_KEY = "Unknown filter key in URL"; + +/** + * Validates a filter query parameter value (shape only, no key validation). + * @param filterParam - Raw URL query param value. + * @param validKeys - Must be undefined to use this overload. + * @param entryValidator - Type guard that validates each parsed entry's shape. + * @returns DataExplorerError if invalid, undefined if valid. + */ +export function validateFilterParam( + filterParam: string | string[] | undefined, + validKeys: undefined, + entryValidator: (value: unknown) => boolean, +): DataExplorerError | undefined; +/** + * Validates a filter query parameter value against a set of valid keys. + * @param filterParam - Raw URL query param value. + * @param validKeys - Set of valid keys. + * @param entryValidator - Type guard that validates each parsed entry's shape. + * @param keyExtractor - Extracts the key string from a validated entry. + * @returns DataExplorerError if invalid, undefined if valid. + */ +export function validateFilterParam( + filterParam: string | string[] | undefined, + validKeys: Set, + entryValidator: (value: unknown) => boolean, + keyExtractor: (entry: unknown) => string, +): DataExplorerError | undefined; +export function validateFilterParam( + filterParam: string | string[] | undefined, + validKeys: Set | undefined, + entryValidator: (value: unknown) => boolean, + keyExtractor?: (entry: unknown) => string, +): DataExplorerError | undefined { + if (filterParam === undefined) return undefined; + + if (typeof filterParam !== "string") { + return new DataExplorerError({ message: INVALID_FILTER_PARAM }); + } + + // Try JSON.parse directly first (Next.js router.query already decodes), + // then fall back to decodeURIComponent + JSON.parse for encoded values. + let parsed: unknown; + try { + parsed = JSON.parse(filterParam); + } catch { + try { + parsed = JSON.parse(decodeURIComponent(filterParam)); + } catch { + return new DataExplorerError({ message: INVALID_FILTER_PARAM }); + } + } + + if (!Array.isArray(parsed)) { + return new DataExplorerError({ message: INVALID_FILTER_PARAM }); + } + + // An empty array (e.g. "[]") is valid — it means no filters selected. + if (parsed.length === 0) return undefined; + + // Validate shape: every entry must match the expected shape. + if (!parsed.every(entryValidator)) { + return new DataExplorerError({ message: INVALID_FILTER_SHAPE }); + } + + // Validate keys: every extracted key must be in the valid set. + if (validKeys && keyExtractor) { + const invalidKey = parsed + .map(keyExtractor) + .find((key) => !validKeys.has(key)); + if (invalidKey !== undefined) { + return new DataExplorerError({ + message: `${UNKNOWN_FILTER_KEY}: ${invalidKey}`, + }); + } + } + + return undefined; +} diff --git a/src/common/filters/typeGuards.ts b/src/common/filters/typeGuards.ts index ebd05ff9..5dd6f344 100644 --- a/src/common/filters/typeGuards.ts +++ b/src/common/filters/typeGuards.ts @@ -1,4 +1,4 @@ -import { SelectedFilter } from "../entities"; +import type { SelectedFilter } from "../entities"; /** * Returns true if the value is a valid SelectedFilter. diff --git a/src/components/DataDictionary/dataDictionary.tsx b/src/components/DataDictionary/dataDictionary.tsx index 7c6035d0..fd496263 100644 --- a/src/components/DataDictionary/dataDictionary.tsx +++ b/src/components/DataDictionary/dataDictionary.tsx @@ -1,10 +1,13 @@ import { Fade } from "@mui/material"; import { RowData } from "@tanstack/react-table"; -import { JSX } from "react"; +import { JSX, useMemo } from "react"; import { Attribute } from "../../common/entities"; +import { isColumnFilter } from "../../common/filters/adapters/tanstack/typeGuards"; +import { useValidateFilterKeys } from "../../common/filters/hooks/UseValidateFilterKeys/hook"; import { PROPERTY } from "../../hooks/useHtmlStyle/constants"; import { useHtmlStyle } from "../../hooks/useHtmlStyle/hook"; import { useLayoutSpacing } from "../../hooks/UseLayoutSpacing/hook"; +import { DATA_DICTIONARY_URL_PARAMS } from "../../providers/dataDictionaryState/dictionaries/constants"; import { Description } from "./components/Description/description"; import { Entities } from "./components/Entities/entities"; import { ColumnFilterTags } from "./components/Filters/components/ColumnFilterTags/columnFilterTags"; @@ -21,6 +24,7 @@ import { View } from "./dataDictionary.styles"; import { useDataDictionaryConfig } from "./hooks/UseDataDictionaryConfig/hook"; import { useMeasureFilters } from "./hooks/UseMeasureFilters/hook"; import { DataDictionaryProps } from "./types"; +import { extractColumnId, getValidColumnIds } from "./utils"; export const DataDictionary = ({ className, @@ -47,6 +51,16 @@ export const DataDictionary = ({ // Table instance. const table = useTable(dictionary, classes, tableOptions); + // Validate filter URL param keys against the table's column IDs. + const validColumnIds = useMemo(() => getValidColumnIds(table), [table]); + + useValidateFilterKeys( + DATA_DICTIONARY_URL_PARAMS.COLUMN_FILTERS, + validColumnIds, + isColumnFilter, + extractColumnId, + ); + // Dictionary outline. const outline = buildClassesOutline(table); diff --git a/src/components/DataDictionary/utils.ts b/src/components/DataDictionary/utils.ts new file mode 100644 index 00000000..cd27456b --- /dev/null +++ b/src/components/DataDictionary/utils.ts @@ -0,0 +1,21 @@ +import type { ColumnFilter, RowData, Table } from "@tanstack/react-table"; + +/** + * Extracts the column ID from a validated ColumnFilter entry. + * @param entry - Validated entry. + * @returns column ID. + */ +export function extractColumnId(entry: unknown): string { + return (entry as ColumnFilter).id; +} + +/** + * Returns the set of valid column IDs from a TanStack table instance. + * @param table - TanStack table instance. + * @returns set of valid column IDs. + */ +export function getValidColumnIds( + table: Table, +): Set { + return new Set(table.getAllColumns().map((column) => column.id)); +} diff --git a/src/providers/exploreState/initializer/utils.ts b/src/providers/exploreState/initializer/utils.ts index 2149589a..f1fdfa0a 100644 --- a/src/providers/exploreState/initializer/utils.ts +++ b/src/providers/exploreState/initializer/utils.ts @@ -120,7 +120,7 @@ function columnFiltersToSelectedFilters( * @param entityConfig - Entity config. * @returns entity related category group config. */ -function getEntityCategoryGroupConfig( +export function getEntityCategoryGroupConfig( siteConfig: SiteConfig, entityConfig: EntityConfig, ): CategoryGroupConfig | undefined { diff --git a/src/views/DataDictionaryView/dataDictionaryView.tsx b/src/views/DataDictionaryView/dataDictionaryView.tsx index 3a31eb75..7a87f22d 100644 --- a/src/views/DataDictionaryView/dataDictionaryView.tsx +++ b/src/views/DataDictionaryView/dataDictionaryView.tsx @@ -1,10 +1,13 @@ import { JSX } from "react"; +import { isColumnFilter } from "../../common/filters/adapters/tanstack/typeGuards"; +import { useValidateFilterKeys } from "../../common/filters/hooks/UseValidateFilterKeys/hook"; import { DataDictionary } from "../../components/DataDictionary/dataDictionary"; import { useStateSyncManager } from "../../hooks/stateSyncManager/hook"; import { DataDictionaryContext } from "../../providers/dataDictionary/context"; import { clearMeta } from "../../providers/dataDictionaryState/actions/clearMeta/dispatch"; import { stateToUrl } from "../../providers/dataDictionaryState/actions/stateToUrl/dispatch"; import { urlToState } from "../../providers/dataDictionaryState/actions/urlToState/dispatch"; +import { DATA_DICTIONARY_URL_PARAMS } from "../../providers/dataDictionaryState/dictionaries/constants"; import { useDataDictionaryState } from "../../providers/dataDictionaryState/hooks/UseDataDictionaryState/hook"; import { DataDictionaryViewProps } from "./types"; import { buildStateSyncManagerContext } from "./utils"; @@ -16,6 +19,14 @@ export const DataDictionaryView = ({ const { dataDictionaryDispatch, dataDictionaryState } = useDataDictionaryState(); + // Shape-only validation (no key check) — must run before useStateSyncManager + // to prevent malformed entries from being dispatched into state. + useValidateFilterKeys( + DATA_DICTIONARY_URL_PARAMS.COLUMN_FILTERS, + undefined, + isColumnFilter, + ); + useStateSyncManager({ actions: { clearMeta, stateToUrl, urlToState }, dispatch: dataDictionaryDispatch, diff --git a/src/views/ExploreView/hooks/UseValidateFilterParam/hook.ts b/src/views/ExploreView/hooks/UseValidateFilterParam/hook.ts index 4ce4b4dc..03cb25f7 100644 --- a/src/views/ExploreView/hooks/UseValidateFilterParam/hook.ts +++ b/src/views/ExploreView/hooks/UseValidateFilterParam/hook.ts @@ -1,59 +1,29 @@ -import { useRouter } from "next/router"; import { useMemo } from "react"; -import { parseFilterParam } from "../../../../common/filters/typeGuards"; +import { useValidateFilterKeys } from "../../../../common/filters/hooks/UseValidateFilterKeys/hook"; +import { isSelectedFilter } from "../../../../common/filters/typeGuards"; +import { useConfig } from "../../../../hooks/useConfig"; import { EXPLORE_URL_PARAMS } from "../../../../providers/exploreState/constants"; -import { DataExplorerError } from "../../../../types/error"; - -const INVALID_FILTER_PARAM_ERROR = "Invalid filter parameter in URL"; +import { extractCategoryKey, getValidCategoryKeys } from "./utils"; /** - * Validates the filter query parameter from the URL. + * Validates the filter query parameter from the URL for the ExploreView. * Throws a DataExplorerError during render if the filter param is present - * but contains malformed JSON or an invalid filter shape, allowing the + * but contains malformed JSON, an invalid filter shape, or a categoryKey + * that does not exist in the configured categories, allowing the * ErrorBoundary to catch and display the error page. - * @returns Nothing; this hook performs validation and throws on invalid input. + * @returns void; throws DataExplorerError on invalid input. */ export function useValidateFilterParam(): void { - const { query } = useRouter(); - const filterParam = query[EXPLORE_URL_PARAMS.FILTER]; - - const validationError = useMemo((): DataExplorerError | undefined => { - if (filterParam === undefined) return undefined; - - if (typeof filterParam !== "string") { - return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); - } - - let decoded: string; - - try { - decoded = decodeURIComponent(filterParam); - } catch { - return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); - } - - // Parse and validate the filter param. - // An empty array (e.g. "[]") is valid — it means no filters selected. - // A non-empty array with no valid entries is invalid. - let parsed: unknown; - try { - parsed = JSON.parse(decoded); - } catch { - return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); - } - if (!Array.isArray(parsed)) { - return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); - } - if (parsed.length === 0) return undefined; - const filters = parseFilterParam(decoded); - if (filters.length === 0) { - return new DataExplorerError({ message: INVALID_FILTER_PARAM_ERROR }); - } - - return undefined; - }, [filterParam]); - - if (validationError) { - throw validationError; - } + const { config, entityConfig } = useConfig(); + const validKeys = useMemo( + () => getValidCategoryKeys(config, entityConfig), + [config, entityConfig], + ); + + useValidateFilterKeys( + EXPLORE_URL_PARAMS.FILTER, + validKeys, + isSelectedFilter, + extractCategoryKey, + ); } diff --git a/src/views/ExploreView/hooks/UseValidateFilterParam/utils.ts b/src/views/ExploreView/hooks/UseValidateFilterParam/utils.ts new file mode 100644 index 00000000..8a8cd545 --- /dev/null +++ b/src/views/ExploreView/hooks/UseValidateFilterParam/utils.ts @@ -0,0 +1,38 @@ +import type { SelectedFilter } from "../../../../common/entities"; +import type { EntityConfig, SiteConfig } from "../../../../config/entities"; +import { getEntityCategoryGroupConfig } from "../../../../providers/exploreState/initializer/utils"; + +/** + * Extracts the categoryKey from a validated SelectedFilter entry. + * @param entry - Validated entry. + * @returns category key. + */ +export function extractCategoryKey(entry: unknown): string { + return (entry as SelectedFilter).categoryKey; +} + +/** + * Returns the set of valid category keys for the given entity, derived from + * the entity's category group config. Returns undefined if no category group + * config is available (skipping key validation). + * @param config - Site config. + * @param entityConfig - Entity config. + * @returns set of valid category keys, or undefined. + */ +export function getValidCategoryKeys( + config: SiteConfig, + entityConfig: EntityConfig, +): Set | undefined { + const categoryGroupConfig = getEntityCategoryGroupConfig( + config, + entityConfig, + ); + if (!categoryGroupConfig) return undefined; + const keys = new Set(); + for (const group of categoryGroupConfig.categoryGroups) { + for (const categoryConfig of group.categoryConfigs) { + keys.add(categoryConfig.key); + } + } + return keys; +} diff --git a/tests/validateFilterKeys.test.ts b/tests/validateFilterKeys.test.ts new file mode 100644 index 00000000..e675ecf5 --- /dev/null +++ b/tests/validateFilterKeys.test.ts @@ -0,0 +1,244 @@ +import type { RowData, Table } from "@tanstack/react-table"; +import { isColumnFilter } from "../src/common/filters/adapters/tanstack/typeGuards"; +import { validateFilterParam } from "../src/common/filters/hooks/UseValidateFilterKeys/utils"; +import { getValidColumnIds } from "../src/components/DataDictionary/utils"; +import type { + CategoryGroupConfig, + EntityConfig, + SiteConfig, +} from "../src/config/entities"; +import { DataExplorerError } from "../src/types/error"; +import { getValidCategoryKeys } from "../src/views/ExploreView/hooks/UseValidateFilterParam/utils"; + +// Simple validator/extractor for testing validateFilterParam. +const isEntry = (v: unknown): boolean => + typeof v === "object" && v !== null && "key" in v; +const extractKey = (v: unknown): string => (v as { key: string }).key; + +describe("validateFilterParam", () => { + it("returns undefined for undefined param", () => { + expect( + validateFilterParam(undefined, new Set(["a"]), isEntry, extractKey), + ).toBeUndefined(); + }); + + it("returns error for non-string param (string array)", () => { + const result = validateFilterParam( + ["a", "b"], + undefined, + isEntry, + extractKey, + ); + expect(result).toBeInstanceOf(DataExplorerError); + expect(result?.message).toBe("Invalid filter parameter in URL"); + }); + + it("returns error for malformed URI encoding", () => { + const result = validateFilterParam( + "%E0%A4%A", + undefined, + isEntry, + extractKey, + ); + expect(result).toBeInstanceOf(DataExplorerError); + expect(result?.message).toBe("Invalid filter parameter in URL"); + }); + + it("returns error for invalid JSON", () => { + const result = validateFilterParam( + '{"truncated', + undefined, + isEntry, + extractKey, + ); + expect(result).toBeInstanceOf(DataExplorerError); + expect(result?.message).toBe("Invalid filter parameter in URL"); + }); + + it("returns error for non-array JSON", () => { + const result = validateFilterParam( + '{"key":"a"}', + undefined, + isEntry, + extractKey, + ); + expect(result).toBeInstanceOf(DataExplorerError); + expect(result?.message).toBe("Invalid filter parameter in URL"); + }); + + it("returns undefined for empty array", () => { + expect( + validateFilterParam("[]", new Set(["a"]), isEntry, extractKey), + ).toBeUndefined(); + }); + + it("returns error when entries fail shape validation", () => { + const param = JSON.stringify(["not an object"]); + const result = validateFilterParam(param, undefined, isEntry, extractKey); + expect(result).toBeInstanceOf(DataExplorerError); + expect(result?.message).toBe("Invalid filter entry shape in URL"); + }); + + it("returns undefined when entries pass shape validation and no valid keys", () => { + const param = JSON.stringify([{ key: "a" }]); + expect( + validateFilterParam(param, undefined, isEntry, extractKey), + ).toBeUndefined(); + }); + + it("returns undefined when all keys are valid", () => { + const param = JSON.stringify([{ key: "a" }, { key: "b" }]); + expect( + validateFilterParam(param, new Set(["a", "b", "c"]), isEntry, extractKey), + ).toBeUndefined(); + }); + + it("returns error with offending key when a key is not in the valid set", () => { + const param = JSON.stringify([{ key: "a" }, { key: "bogus" }]); + const result = validateFilterParam( + param, + new Set(["a", "b"]), + isEntry, + extractKey, + ); + expect(result).toBeInstanceOf(DataExplorerError); + expect(result?.message).toBe("Unknown filter key in URL: bogus"); + }); + + it("skips key validation when validKeys is undefined", () => { + const param = JSON.stringify([{ key: "anything" }]); + expect( + validateFilterParam(param, undefined, isEntry, extractKey), + ).toBeUndefined(); + }); + + it("handles URI-encoded param", () => { + const raw = JSON.stringify([{ key: "a" }]); + const encoded = encodeURIComponent(raw); + expect( + validateFilterParam(encoded, new Set(["a"]), isEntry, extractKey), + ).toBeUndefined(); + }); +}); + +describe("isColumnFilter", () => { + it("returns true for a valid ColumnFilter", () => { + expect(isColumnFilter({ id: "name", value: "test" })).toBe(true); + }); + + it("returns true for a ColumnFilter with array value", () => { + expect(isColumnFilter({ id: "status", value: ["active", "pending"] })).toBe( + true, + ); + }); + + it("returns true for a ColumnFilter with null value", () => { + expect(isColumnFilter({ id: "name", value: null })).toBe(true); + }); + + it("returns false when id is missing", () => { + expect(isColumnFilter({ value: "test" })).toBe(false); + }); + + it("returns false when value is missing", () => { + expect(isColumnFilter({ id: "name" })).toBe(false); + }); + + it("returns false when id is not a string", () => { + expect(isColumnFilter({ id: 123, value: "test" })).toBe(false); + }); + + it("returns false for null", () => { + expect(isColumnFilter(null)).toBe(false); + }); + + it("returns false for a string", () => { + expect(isColumnFilter("not a filter")).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isColumnFilter(undefined)).toBe(false); + }); +}); + +describe("getValidCategoryKeys", () => { + const buildConfig = ( + entityCategoryGroupConfig?: CategoryGroupConfig, + siteCategoryGroupConfig?: CategoryGroupConfig, + ): { config: SiteConfig; entityConfig: EntityConfig } => { + const entityConfig = { + categoryGroupConfig: entityCategoryGroupConfig, + } as EntityConfig; + const config = { + categoryGroupConfig: siteCategoryGroupConfig, + } as SiteConfig; + return { config, entityConfig }; + }; + + it("returns keys from entity category group config", () => { + const { config, entityConfig } = buildConfig({ + categoryGroups: [ + { + categoryConfigs: [ + { key: "species", label: "Species" }, + { key: "organ", label: "Organ" }, + ], + }, + { + categoryConfigs: [{ key: "library", label: "Library" }], + }, + ], + key: "test", + }); + const result = getValidCategoryKeys(config, entityConfig); + expect(result).toEqual(new Set(["species", "organ", "library"])); + }); + + it("falls back to site category group config when entity has none", () => { + const { config, entityConfig } = buildConfig(undefined, { + categoryGroups: [ + { + categoryConfigs: [{ key: "project", label: "Project" }], + }, + ], + key: "site", + }); + const result = getValidCategoryKeys(config, entityConfig); + expect(result).toEqual(new Set(["project"])); + }); + + it("returns undefined when no category group config exists", () => { + const { config, entityConfig } = buildConfig(undefined, undefined); + const result = getValidCategoryKeys(config, entityConfig); + expect(result).toBeUndefined(); + }); + + it("returns empty set when category groups have no configs", () => { + const { config, entityConfig } = buildConfig({ + categoryGroups: [{ categoryConfigs: [] }], + key: "empty", + }); + const result = getValidCategoryKeys(config, entityConfig); + expect(result).toEqual(new Set()); + }); +}); + +describe("getValidColumnIds", () => { + const buildTable = (columnIds: string[]): Table => { + return { + getAllColumns: () => columnIds.map((id) => ({ id })), + } as unknown as Table; + }; + + it("returns column IDs from table instance", () => { + const table = buildTable(["name", "type", "description"]); + expect(getValidColumnIds(table)).toEqual( + new Set(["name", "type", "description"]), + ); + }); + + it("returns empty set when table has no columns", () => { + const table = buildTable([]); + expect(getValidColumnIds(table)).toEqual(new Set()); + }); +});