diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3439a16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# macOS metadata +.DS_Store +**/.DS_Store + +# Python generated files +__pycache__/ +*.py[cod] +*$py.class + +# Python virtual environments +.venv/ +venv/ + +# Test/cache folders +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# RDS/Pulumi local generated files +rds-terraform/**/__pycache__/ +rds-terraform/**/*.py[cod] +rds-terraform/**/.pytest_cache/ +rds-terraform/**/.venv/ +rds-terraform/**/venv/ + +# Search local generated files +ddl/Search/**/.DS_Store diff --git a/ddl/Search/01_enable_fuzzy_search.sql b/ddl/Search/01_enable_fuzzy_search.sql new file mode 100644 index 0000000..0af7d06 --- /dev/null +++ b/ddl/Search/01_enable_fuzzy_search.sql @@ -0,0 +1,6 @@ +-- Enable trigram support for fuzzy search. +-- Idempotent: Can run multiple times +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Verify extension install. +-- SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'; diff --git a/ddl/Search/02_add_request_search.sql b/ddl/Search/02_add_request_search.sql new file mode 100644 index 0000000..549436c --- /dev/null +++ b/ddl/Search/02_add_request_search.sql @@ -0,0 +1,150 @@ +-- Request search. +-- Prerequisite: run 01_enable_fuzzy_search.sql. +-- Set search_path to the target schema before running. + +-- Request-local full-text vector. +ALTER TABLE request +ADD COLUMN IF NOT EXISTS search_vector tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce(req_subj, '')), 'A') || + setweight(to_tsvector('english', coalesce(req_desc, '')), 'B') || + setweight(to_tsvector('english', coalesce(req_loc, '')), 'C') +) STORED; + +-- Request indexes. +CREATE INDEX IF NOT EXISTS idx_request_search_vector +ON request USING GIN (search_vector); + +CREATE INDEX IF NOT EXISTS idx_request_subj_trgm +ON request USING GIN (req_subj gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_request_desc_trgm +ON request USING GIN (req_desc gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_request_loc_trgm +ON request USING GIN (req_loc gin_trgm_ops); + +-- Category indexes used by request search. +CREATE INDEX IF NOT EXISTS idx_help_categories_name_fts +ON help_categories +USING GIN (to_tsvector('english', coalesce(cat_name, ''))); + +CREATE INDEX IF NOT EXISTS idx_help_categories_name_trgm +ON help_categories USING GIN (cat_name gin_trgm_ops); + +-- Request search entry point. +CREATE OR REPLACE FUNCTION search_requests( + query_text TEXT, + limit_results INT DEFAULT 20, + requester_user_id VARCHAR(255) DEFAULT NULL, + requester_access_level SMALLINT DEFAULT NULL, + allowed_request_owner_ids VARCHAR(255)[] DEFAULT NULL +) +RETURNS TABLE ( + req_id VARCHAR(255), + req_cat_id VARCHAR(50), + cat_name VARCHAR(100), + req_subj VARCHAR(125), + req_desc VARCHAR(255), + req_loc VARCHAR(125), + req_status_id INT, + req_priority_id INT, + submission_date TIMESTAMP, + relevance_score REAL +) +LANGUAGE plpgsql +SET search_path FROM CURRENT +AS $$ +BEGIN + IF query_text IS NULL OR btrim(query_text) = '' THEN + RETURN; + END IF; + + RETURN QUERY + WITH query_input AS ( + SELECT plainto_tsquery('english', query_text) AS ts_query + ), + ranked AS ( + SELECT + r.req_id, + r.req_cat_id, + hc.cat_name, + r.req_subj, + r.req_desc, + r.req_loc, + r.req_status_id, + r.req_priority_id, + r.submission_date, + ts_rank_cd( + setweight( + to_tsvector('english', replace(coalesce(hc.cat_name, ''), '_', ' ')), + 'A' + ) || + setweight(to_tsvector('english', coalesce(r.req_subj, '')), 'A') || + setweight(to_tsvector('english', coalesce(r.req_desc, '')), 'B') || + setweight(to_tsvector('english', coalesce(r.req_loc, '')), 'C'), + qi.ts_query + ) AS fts_score, + GREATEST( + similarity(replace(coalesce(hc.cat_name, ''), '_', ' '), query_text), + word_similarity(query_text, replace(coalesce(hc.cat_name, ''), '_', ' ')), + similarity(coalesce(r.req_subj, ''), query_text), + word_similarity(query_text, coalesce(r.req_subj, '')), + similarity(coalesce(r.req_desc, ''), query_text), + word_similarity(query_text, coalesce(r.req_desc, '')), + similarity(coalesce(r.req_loc, ''), query_text) + ) AS fuzzy_score + FROM request r + JOIN help_categories hc + ON hc.cat_id = r.req_cat_id + CROSS JOIN query_input qi + WHERE + ( + coalesce(requester_access_level, 0) >= 4 + OR ( + requester_user_id IS NOT NULL + AND r.req_user_id = requester_user_id + ) + OR ( + allowed_request_owner_ids IS NOT NULL + AND r.req_user_id = ANY(allowed_request_owner_ids) + ) + ) + AND + ( + ( + setweight( + to_tsvector('english', replace(coalesce(hc.cat_name, ''), '_', ' ')), + 'A' + ) || + r.search_vector + ) @@ qi.ts_query + OR to_tsvector('english', replace(coalesce(hc.cat_name, ''), '_', ' ')) @@ qi.ts_query + OR coalesce(r.req_subj, '') % query_text + OR coalesce(r.req_desc, '') % query_text + OR coalesce(r.req_loc, '') % query_text + OR replace(coalesce(hc.cat_name, ''), '_', ' ') % query_text + OR word_similarity(query_text, replace(coalesce(hc.cat_name, ''), '_', ' ')) >= 0.45 + OR word_similarity(query_text, coalesce(r.req_subj, '')) >= 0.45 + OR word_similarity(query_text, coalesce(r.req_desc, '')) >= 0.45 + ) + ) + SELECT + ranked.req_id, + ranked.req_cat_id, + ranked.cat_name, + ranked.req_subj, + ranked.req_desc, + ranked.req_loc, + ranked.req_status_id, + ranked.req_priority_id, + ranked.submission_date, + (ranked.fts_score * 0.70 + ranked.fuzzy_score * 0.30)::REAL AS relevance_score + FROM ranked + ORDER BY relevance_score DESC, submission_date DESC NULLS LAST + LIMIT GREATEST(limit_results, 1); +END; +$$; + +-- Example usage: +-- SELECT * FROM search_requests('emergency medical'); diff --git a/ddl/Search/03_add_user_and_volunteer_search.sql b/ddl/Search/03_add_user_and_volunteer_search.sql new file mode 100644 index 0000000..63cd896 --- /dev/null +++ b/ddl/Search/03_add_user_and_volunteer_search.sql @@ -0,0 +1,127 @@ +-- User search. +-- Prerequisite: run 01_enable_fuzzy_search.sql. +-- Set search_path to the target schema before running. + +-- User-local full-text vector. +ALTER TABLE users +ADD COLUMN IF NOT EXISTS search_vector tsvector +GENERATED ALWAYS AS ( + setweight( + to_tsvector( + 'english', + coalesce(full_name, '') || ' ' || + coalesce(first_name, '') || ' ' || + coalesce(last_name, '') + ), + 'A' + ) || + setweight(to_tsvector('simple', coalesce(primary_email_address, '')), 'B') +) STORED; + +-- User indexes. +CREATE INDEX IF NOT EXISTS idx_users_search_vector +ON users USING GIN (search_vector); + +CREATE INDEX IF NOT EXISTS idx_users_full_name_trgm +ON users USING GIN (full_name gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_users_email_trgm +ON users USING GIN (primary_email_address gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_users_email_exact +ON users (primary_email_address); + +-- User search entry point. +CREATE OR REPLACE FUNCTION search_users( + query_text TEXT, + limit_results INT DEFAULT 20, + requester_user_id VARCHAR(255) DEFAULT NULL, + requester_access_level SMALLINT DEFAULT NULL, + allowed_user_ids VARCHAR(255)[] DEFAULT NULL +) +RETURNS TABLE ( + user_id VARCHAR(255), + full_name VARCHAR(255), + primary_email_address VARCHAR(255), + user_category_id INT, + user_status_id INT, + relevance_score REAL +) +LANGUAGE plpgsql +SET search_path FROM CURRENT +AS $$ +BEGIN + IF query_text IS NULL OR btrim(query_text) = '' THEN + RETURN; + END IF; + + RETURN QUERY + WITH query_input AS ( + SELECT + btrim(query_text) AS raw_query, + plainto_tsquery('english', query_text) AS name_ts_query, + plainto_tsquery('simple', query_text) AS email_ts_query + ), + ranked AS ( + SELECT + u.user_id, + u.full_name, + u.primary_email_address, + u.user_category_id, + u.user_status_id, + GREATEST( + ts_rank_cd(u.search_vector, qi.name_ts_query), + ts_rank_cd(u.search_vector, qi.email_ts_query) + ) AS fts_score, + GREATEST( + similarity(coalesce(u.full_name, ''), qi.raw_query), + word_similarity(qi.raw_query, coalesce(u.full_name, '')), + similarity(coalesce(u.primary_email_address, ''), qi.raw_query) + ) AS fuzzy_score, + CASE + WHEN lower(coalesce(u.primary_email_address, '')) = lower(qi.raw_query) THEN 1.0 + ELSE 0.0 + END AS exact_email_score + FROM users u + CROSS JOIN query_input qi + WHERE + ( + coalesce(requester_access_level, 0) >= 4 + OR ( + requester_user_id IS NOT NULL + AND u.user_id = requester_user_id + ) + OR ( + allowed_user_ids IS NOT NULL + AND u.user_id = ANY(allowed_user_ids) + ) + ) + AND + ( + u.search_vector @@ qi.name_ts_query + OR u.search_vector @@ qi.email_ts_query + OR coalesce(u.full_name, '') % qi.raw_query + OR word_similarity(qi.raw_query, coalesce(u.full_name, '')) >= 0.45 + OR similarity(coalesce(u.primary_email_address, ''), qi.raw_query) >= 0.60 + OR lower(coalesce(u.primary_email_address, '')) = lower(qi.raw_query) + ) + ) + SELECT + ranked.user_id, + ranked.full_name, + ranked.primary_email_address, + ranked.user_category_id, + ranked.user_status_id, + ( + ranked.exact_email_score * 1.00 + + ranked.fts_score * 0.60 + + ranked.fuzzy_score * 0.40 + )::REAL AS relevance_score + FROM ranked + ORDER BY relevance_score DESC, full_name ASC NULLS LAST, user_id ASC + LIMIT GREATEST(limit_results, 1); +END; +$$; + +-- Example usage: +-- SELECT * FROM search_users('arup'); diff --git a/ddl/Search/04_add_category_and_advanced_search.sql b/ddl/Search/04_add_category_and_advanced_search.sql new file mode 100644 index 0000000..ebe9001 --- /dev/null +++ b/ddl/Search/04_add_category_and_advanced_search.sql @@ -0,0 +1,86 @@ +-- Organization search. +-- Prerequisite: run 01_enable_fuzzy_search.sql. +-- Set search_path to the target schema before running. + +-- Organization indexes. +CREATE INDEX IF NOT EXISTS idx_organizations_name_trgm +ON organizations USING GIN (org_name gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_organizations_city_trgm +ON organizations USING GIN (city_name gin_trgm_ops); + +-- Organization search entry point. +CREATE OR REPLACE FUNCTION search_organizations( + query_text TEXT, + limit_results INT DEFAULT 20, + requester_access_level SMALLINT DEFAULT NULL, + allowed_org_ids VARCHAR(255)[] DEFAULT NULL +) +RETURNS TABLE ( + org_id VARCHAR(255), + org_name VARCHAR(125), + city_name VARCHAR(100), + state_code VARCHAR(6), + relevance_score REAL +) +LANGUAGE plpgsql +SET search_path FROM CURRENT +AS $$ +BEGIN + IF query_text IS NULL OR btrim(query_text) = '' THEN + RETURN; + END IF; + + RETURN QUERY + WITH query_input AS ( + SELECT btrim(query_text) AS raw_query + ), + ranked AS ( + SELECT + o.org_id, + o.org_name, + o.city_name, + o.state_code, + GREATEST( + similarity(coalesce(o.org_name, ''), qi.raw_query), + word_similarity(qi.raw_query, coalesce(o.org_name, '')) + ) AS org_score, + GREATEST( + similarity(coalesce(o.city_name, ''), qi.raw_query), + word_similarity(qi.raw_query, coalesce(o.city_name, '')) + ) AS city_score + FROM organizations o + CROSS JOIN query_input qi + WHERE + ( + coalesce(requester_access_level, 0) >= 4 + OR ( + allowed_org_ids IS NOT NULL + AND o.org_id = ANY(allowed_org_ids) + ) + ) + AND + ( + coalesce(o.org_name, '') % qi.raw_query + OR coalesce(o.city_name, '') % qi.raw_query + OR word_similarity(qi.raw_query, coalesce(o.org_name, '')) >= 0.45 + OR word_similarity(qi.raw_query, coalesce(o.city_name, '')) >= 0.45 + ) + ) + SELECT + ranked.org_id, + ranked.org_name, + ranked.city_name, + ranked.state_code, + ( + ranked.org_score * 0.70 + + ranked.city_score * 0.30 + )::REAL AS relevance_score + FROM ranked + ORDER BY relevance_score DESC, org_name ASC, org_id ASC + LIMIT GREATEST(limit_results, 1); +END; +$$; + +-- Example usage: +-- SELECT * FROM search_organizations('Saayam Foundation'); diff --git a/tests/Search/QA_VALIDATION.md b/tests/Search/QA_VALIDATION.md new file mode 100644 index 0000000..f8db8c1 --- /dev/null +++ b/tests/Search/QA_VALIDATION.md @@ -0,0 +1,301 @@ +# Search QA Validation + +## Purpose + +This document explains how to validate the database-side search work locally and later on QA/AWS RDS. + +The local tests prove the SQL compiles and behaves correctly against a Virginia-style clone. +QA validation proves the same search logic works against the real deployed schema, permissions, data shape, and RDS settings. + +## Files Under Test + +Search migration files: + +- `ddl/Search/01_enable_fuzzy_search.sql` +- `ddl/Search/02_add_request_search.sql` +- `ddl/Search/03_add_user_and_volunteer_search.sql` +- `ddl/Search/04_add_category_and_advanced_search.sql` + +Local validation files: + +- `tests/Search/runners/run_virginia_search_migrations.sql` +- `tests/Search/runners/run_ireland_search_migrations.sql` +- `tests/Search/runners/run_virginia_search_validation.sql` +- `tests/Search/runners/run_ireland_clone_validation.sql` +- `tests/Search/runners/run_ireland_search_validation.sql` +- `tests/Search/virginia_validation/01_search_smoke_test.sql` +- `tests/Search/virginia_validation/02_search_index_check.sql` +- `tests/Search/virginia_validation/03_search_function_check.sql` +- `tests/Search/virginia_validation/04_instance_clone_check.sql` +- `tests/Search/ireland_validation/05_ireland_instance_clone_check.sql` +- `tests/Search/test_clones/virginia_search_instance_clone.sql` +- `tests/Search/test_clones/ireland_search_instance_clone.sql` +- `tests/Search/ireland_validation/` + +## Local Validation Runbook + +Use this before QA to confirm the current branch works locally. + +```bash +createdb saayam_search_clone_test + +psql -d saayam_search_clone_test \ + -f tests/Search/test_clones/virginia_search_instance_clone.sql + +psql -d saayam_search_clone_test \ + -f tests/Search/runners/run_virginia_search_migrations.sql + +psql -d saayam_search_clone_test \ + -v ON_ERROR_STOP=1 \ + -f tests/Search/runners/run_virginia_search_validation.sql +``` + +Expected local result: + +- all scripts complete without errors +- runner prints `Virginia search validation completed` + +## Ireland Local Clone Validation + +Ireland uses schema `proposed_saayam`. +The production search scripts are schema-agnostic and use the active `search_path`. + +```bash +createdb saayam_ireland_search_clone_test + +psql -d saayam_ireland_search_clone_test \ + -f tests/Search/test_clones/ireland_search_instance_clone.sql + +psql -d saayam_ireland_search_clone_test \ + -v ON_ERROR_STOP=1 \ + -f tests/Search/runners/run_ireland_clone_validation.sql +``` + +Expected Ireland local result: + +- clone loads without errors +- runner prints `Ireland clone validation completed` +- required Ireland search tables and columns exist under `proposed_saayam` + +## Ireland Full Local Search Validation + +Use this to validate the same search behavior against the Ireland schema name: + +```bash +createdb saayam_ireland_full_search_test + +psql -d saayam_ireland_full_search_test \ + -f tests/Search/test_clones/ireland_search_instance_clone.sql + +psql -d saayam_ireland_full_search_test \ + -f tests/Search/runners/run_ireland_search_migrations.sql + +psql -d saayam_ireland_full_search_test \ + -v ON_ERROR_STOP=1 \ + -f tests/Search/runners/run_ireland_search_validation.sql +``` + +Expected Ireland full-search result: + +- scripts compile against `proposed_saayam` +- smoke test passes +- index check passes +- function check passes +- migrated clone check passes +- runner prints `Ireland full search validation completed` + +## QA/RDS Validation Runbook + +Use this only after QA database access is available. + +Connect to QA: + +```bash +psql -h \ + -U \ + -d +``` + +Run search scripts with the correct target schema. + +For Virginia QA: + +```sql +\i tests/Search/runners/run_virginia_search_migrations.sql +``` + +For Ireland QA: + +```sql +\i tests/Search/runners/run_ireland_search_migrations.sql +``` + +Then run validation checks. + +Recommended first QA checks: + +```sql +\i tests/Search/virginia_validation/02_search_index_check.sql +\i tests/Search/virginia_validation/04_instance_clone_check.sql +``` + +Run behavior checks only after confirming temporary QA inserts are acceptable: + +```sql +\i tests/Search/virginia_validation/01_search_smoke_test.sql +\i tests/Search/virginia_validation/03_search_function_check.sql +``` + +## QA Safety Notes + +The behavior tests use `BEGIN` and `ROLLBACK`, so their sample data should not persist. + +Still confirm before running on QA: + +- the QA role can create extensions, columns, indexes, and functions +- temporary test inserts are allowed in QA +- QA constraints match the local clone assumptions +- no trigger or audit process has side effects from rolled-back inserts +- the active schema is correct for the target region + +## PR Evidence To Capture + +Capture the following for PR review: + +- output showing `run_virginia_search_migrations.sql` completed locally +- output showing `run_virginia_search_validation.sql` passed locally for Virginia +- output showing `run_ireland_search_migrations.sql` completed locally +- output showing `run_ireland_search_validation.sql` passed locally for Ireland +- output showing QA index check passed +- output showing QA function/behavior checks passed, if run +- `EXPLAIN` or `EXPLAIN ANALYZE` for representative search queries +- confirmation that unauthorized or missing-scope calls return zero rows + +Local PR evidence already captured: + +- Virginia schema-agnostic validation passed: `saayam_virginia_agnostic_test_1778949545` +- Ireland schema-agnostic validation passed: `saayam_ireland_agnostic_test_1778949579` +- Both validations used the shared production scripts under `ddl/Search/` +- Region selection is handled through `search_path` in the migration runners + +Useful QA evidence queries: + +Virginia examples are shown below. For Ireland, replace `virginia_dev_saayam_rdbms` with `proposed_saayam`. + +```sql +SELECT extname +FROM pg_extension +WHERE extname = 'pg_trgm'; + +SELECT schemaname, tablename, indexname, indexdef +FROM pg_indexes +WHERE schemaname = 'virginia_dev_saayam_rdbms' + AND indexname IN ( + 'idx_request_search_vector', + 'idx_request_subj_trgm', + 'idx_request_desc_trgm', + 'idx_request_loc_trgm', + 'idx_help_categories_name_fts', + 'idx_help_categories_name_trgm', + 'idx_users_search_vector', + 'idx_users_full_name_trgm', + 'idx_users_email_trgm', + 'idx_users_email_exact', + 'idx_organizations_name_trgm', + 'idx_organizations_city_trgm' + ) +ORDER BY tablename, indexname; +``` + +Representative search checks: + +```sql +SELECT * +FROM virginia_dev_saayam_rdbms.search_requests( + 'medical help', + 10, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] +); + +SELECT * +FROM virginia_dev_saayam_rdbms.search_users( + 'test@example.com', + 10, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] +); + +SELECT * +FROM virginia_dev_saayam_rdbms.search_organizations( + 'food bank', + 10, + 4::SMALLINT, + NULL::VARCHAR(255)[] +); +``` + +Missing-scope checks: + +```sql +SELECT count(*) +FROM virginia_dev_saayam_rdbms.search_requests( + 'medical help', + 10, + NULL::VARCHAR(255), + 1::SMALLINT, + NULL::VARCHAR(255)[] +); + +SELECT count(*) +FROM virginia_dev_saayam_rdbms.search_users( + 'test@example.com', + 10, + NULL::VARCHAR(255), + 1::SMALLINT, + NULL::VARCHAR(255)[] +); + +SELECT count(*) +FROM virginia_dev_saayam_rdbms.search_organizations( + 'food bank', + 10, + 1::SMALLINT, + NULL::VARCHAR(255)[] +); +``` + +Expected missing-scope result: + +- count is `0` + +## DevSecOps Items Still Pending + +These are not solved by local SQL validation: + +- final backend-to-DB authorization context contract +- whether to keep function parameters or move to `SET LOCAL app.*` session variables +- whether to add Row-Level Security policies +- AWS IAM database authentication +- `rds.force_ssl = 1` +- pgAudit or RDS audit logging +- CloudWatch monitoring for suspicious search access + +## Current Boundary + +Current state: + +- local code-level search MVP is implemented +- local clone validation passes +- local behavior/index/function tests pass +- Ireland search-shape clone validation passes +- Ireland full local search validation passes +- schema targeting is complete through `search_path` migration runners + +Not yet complete: + +- QA/RDS validation +- performance review with real data +- final RDS security hardening diff --git a/tests/Search/README.md b/tests/Search/README.md new file mode 100644 index 0000000..a37301c --- /dev/null +++ b/tests/Search/README.md @@ -0,0 +1,116 @@ +# Search Test Workspace + +This folder is for search-specific local testing only. + +Do not edit the original schema files under `ddl/Tables/` for search testing. If a local clone of a deployed schema is needed, place the copied or generated schema assets under `test_clones/`. + +## Intended Flow + +1. Create a local PostgreSQL database. +2. Load a Virginia or Ireland schema clone from `test_clones/`. +3. Run the search scripts from `../` in order. +4. Run local search validation. + +## Search Scripts Under Test + +- `../01_enable_fuzzy_search.sql` +- `../02_add_request_search.sql` +- `../03_add_user_and_volunteer_search.sql` +- `../04_add_category_and_advanced_search.sql` + +## Run All Local Checks + +Run this after loading the local clone and applying search migrations: + +```bash +psql -d saayam_search_clone_test -f tests/Search/runners/run_virginia_search_validation.sql +``` + +This runs the smoke test, index check, function check, and instance clone check in order. + +## Virginia Full Search Check + +```bash +createdb saayam_search_clone_test +psql -d saayam_search_clone_test -f tests/Search/test_clones/virginia_search_instance_clone.sql +psql -d saayam_search_clone_test -f tests/Search/runners/run_virginia_search_migrations.sql +psql -d saayam_search_clone_test -v ON_ERROR_STOP=1 -f tests/Search/runners/run_virginia_search_validation.sql +``` + +## Ireland Clone Check + +The production search scripts are schema-agnostic and rely on `search_path`. +Ireland uses `proposed_saayam`, so the Ireland runner sets that schema before applying the shared scripts. + +```bash +createdb saayam_ireland_search_clone_test +psql -d saayam_ireland_search_clone_test -f tests/Search/test_clones/ireland_search_instance_clone.sql +psql -d saayam_ireland_search_clone_test -f tests/Search/runners/run_ireland_clone_validation.sql +``` + +This confirms the Ireland-style clone has the tables and columns needed by the search plan. + +## Ireland Full Search Check + +The same production scripts under `ddl/Search/` are used for Ireland. +The Ireland migration runner sets `search_path` to `proposed_saayam`. + +```bash +createdb saayam_ireland_full_search_test +psql -d saayam_ireland_full_search_test -f tests/Search/test_clones/ireland_search_instance_clone.sql +psql -d saayam_ireland_full_search_test -f tests/Search/runners/run_ireland_search_migrations.sql +psql -d saayam_ireland_full_search_test -v ON_ERROR_STOP=1 -f tests/Search/runners/run_ireland_search_validation.sql +``` + +This runs the same behavior, index, function, and migrated-clone checks against the Ireland schema name, `proposed_saayam`. + +## Smoke Test + +Run this after loading the local clone and search scripts: + +```bash +psql -d saayam_search_clone_test -f tests/Search/virginia_validation/01_search_smoke_test.sql +``` + +This inserts temporary sample data, checks search behavior, and rolls the data back. + +## Index Check + +Run this after loading the local clone and search scripts: + +```bash +psql -d saayam_search_clone_test -f tests/Search/virginia_validation/02_search_index_check.sql +``` + +This fails if any expected search index is missing or uses the wrong strategy. +It checks GIN full-text indexes, GIN trigram indexes, and the exact email btree index. + +## Function Check + +Run this after loading the local clone and search scripts: + +```bash +psql -d saayam_search_clone_test -f tests/Search/virginia_validation/03_search_function_check.sql +``` + +This checks the main search functions directly: + +- `search_requests(...)` +- `search_users(...)` +- `search_organizations(...)` + +It verifies keyword matching, exact email matching, organization name matching, relevance scores, and empty-query behavior. + +## Instance Clone Check + +Run this after loading the local clone and search scripts: + +```bash +psql -d saayam_search_clone_test -f tests/Search/virginia_validation/04_instance_clone_check.sql +``` + +This verifies the local Virginia clone has the search-required tables, search columns, and search functions. + +## QA Validation + +See `QA_VALIDATION.md` for the QA/RDS validation runbook, safety notes, and PR evidence checklist. diff --git a/tests/Search/ireland_validation/01_search_smoke_test.sql b/tests/Search/ireland_validation/01_search_smoke_test.sql new file mode 100644 index 0000000..2d48aaa --- /dev/null +++ b/tests/Search/ireland_validation/01_search_smoke_test.sql @@ -0,0 +1,273 @@ +-- Search MVP smoke test. +-- Run after instance clone and ddl/Search/01..04. + +BEGIN; + +SET search_path TO proposed_saayam, public; + +INSERT INTO country (country_id, country_name, phone_code, country_code) +VALUES (1, 'United States', '+1', 'US') +ON CONFLICT (country_id) DO NOTHING; + +INSERT INTO state (state_id, country_id, state_name, state_code) +VALUES ('CA', 1, 'California', 'CA') +ON CONFLICT (state_id) DO NOTHING; + +INSERT INTO user_status (user_status_id, user_status, user_status_desc) +VALUES (1, 'active', 'Active user') +ON CONFLICT (user_status_id) DO NOTHING; + +INSERT INTO user_category ( + user_category_id, + user_category, + user_category_desc +) +VALUES + (1, 'requester', 'Requester'), + (4, 'admin', 'Admin') +ON CONFLICT (user_category_id) DO NOTHING; + +INSERT INTO users ( + user_id, + state_id, + country_id, + user_status_id, + user_category_id, + full_name, + first_name, + last_name, + primary_email_address, + city_name +) +VALUES + ('user-admin', 'CA', 1, 1, 4, 'Admin Search', 'Admin', 'Search', 'admin@example.test', 'San Jose'), + ('user-alice', 'CA', 1, 1, 1, 'Alice Emergency', 'Alice', 'Emergency', 'alice@example.test', 'San Jose'), + ('user-bob', 'CA', 1, 1, 1, 'Bob Foodhelp', 'Bob', 'Foodhelp', 'bob@example.test', 'Oakland') +ON CONFLICT (user_id) DO NOTHING; + +INSERT INTO help_categories (cat_id, cat_name, cat_desc) +VALUES + ('medical', 'Medical Help', 'Medical and emergency support'), + ('food', 'Food Support', 'Groceries and food help') +ON CONFLICT (cat_id) DO NOTHING; + +INSERT INTO request_status (req_status_id, req_status, req_status_desc) +VALUES (1, 'open', 'Open request') +ON CONFLICT (req_status_id) DO NOTHING; + +INSERT INTO request_priority (req_priority_id, req_priority, req_priority_desc) +VALUES (1, 'high', 'High priority') +ON CONFLICT (req_priority_id) DO NOTHING; + +INSERT INTO request_type (req_type_id, req_type, req_type_desc) +VALUES (1, 'standard', 'Standard request') +ON CONFLICT (req_type_id) DO NOTHING; + +INSERT INTO request_for (req_for_id, req_for, req_for_desc) +VALUES (1, 'self', 'For self') +ON CONFLICT (req_for_id) DO NOTHING; + +INSERT INTO request_isleadvol (req_islead_id, req_islead, req_islead_desc) +VALUES (1, 'no', 'Not lead volunteer') +ON CONFLICT (req_islead_id) DO NOTHING; + +INSERT INTO request ( + req_id, + req_user_id, + req_for_id, + req_islead_id, + req_cat_id, + req_type_id, + req_priority_id, + req_status_id, + req_loc, + iscalamity, + req_subj, + req_desc, + submission_date +) +VALUES + ( + 'req-medical-1', + 'user-alice', + 1, + 1, + 'medical', + 1, + 1, + 1, + 'San Jose', + false, + 'Emergency medical help', + 'Need urgent medical support and transport', + now() + ), + ( + 'req-food-1', + 'user-bob', + 1, + 1, + 'food', + 1, + 1, + 1, + 'Oakland', + false, + 'Food pantry support', + 'Need groceries and food assistance', + now() + ) +ON CONFLICT (req_id) DO NOTHING; + +INSERT INTO organizations ( + org_id, + org_name, + city_name, + state_code, + org_type, + source, + cat_id +) +VALUES + ('org-saayam-1', 'Saayam Foundation', 'San Jose', 'CA', 'non_profit', 'self_registered', 'medical'), + ('org-food-1', 'Community Food Bank', 'Oakland', 'CA', 'non_profit', 'self_registered', 'food') +ON CONFLICT (org_id) DO NOTHING; + +DO $$ +DECLARE + result_count INT; +BEGIN + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_requests( + 'emergancy medical', + 20, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE req_id = 'req-medical-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'request fuzzy search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_requests( + 'food', + 20, + 'user-alice'::VARCHAR(255), + 1::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE req_id = 'req-food-1'; + + IF result_count <> 0 THEN + RAISE EXCEPTION 'request self-scope filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_requests( + 'food', + 20, + NULL::VARCHAR(255), + 1::SMALLINT, + ARRAY['user-bob']::VARCHAR(255)[] + ) + WHERE req_id = 'req-food-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'request allowed-owner filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_users( + 'Alic', + 20, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE user_id = 'user-alice'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'user fuzzy search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_users( + 'bob@example.test', + 20, + 'user-alice'::VARCHAR(255), + 1::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE user_id = 'user-bob'; + + IF result_count <> 0 THEN + RAISE EXCEPTION 'user self-scope filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_users( + 'bob@example.test', + 20, + NULL::VARCHAR(255), + 1::SMALLINT, + ARRAY['user-bob']::VARCHAR(255)[] + ) + WHERE user_id = 'user-bob'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'user allowed-list filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_organizations( + 'Saaym Foundation', + 20, + 4::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE org_id = 'org-saayam-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'organization fuzzy search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_organizations( + 'Food Bank', + 20, + 1::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE org_id = 'org-food-1'; + + IF result_count <> 0 THEN + RAISE EXCEPTION 'organization scope filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_organizations( + 'Food Bank', + 20, + 1::SMALLINT, + ARRAY['org-food-1']::VARCHAR(255)[] + ) + WHERE org_id = 'org-food-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'organization allowed-list filter failed'; + END IF; +END $$; + +ROLLBACK; diff --git a/tests/Search/ireland_validation/02_search_index_check.sql b/tests/Search/ireland_validation/02_search_index_check.sql new file mode 100644 index 0000000..e6fdcf4 --- /dev/null +++ b/tests/Search/ireland_validation/02_search_index_check.sql @@ -0,0 +1,63 @@ +-- Search index check. +-- Run after instance clone and ddl/Search/01..04. + +DO $$ +DECLARE + missing_indexes TEXT[]; + invalid_indexes TEXT[]; +BEGIN + SELECT array_agg(expected.index_name ORDER BY expected.index_name) + INTO missing_indexes + FROM ( + VALUES + ('idx_request_search_vector'), + ('idx_request_subj_trgm'), + ('idx_request_desc_trgm'), + ('idx_request_loc_trgm'), + ('idx_help_categories_name_fts'), + ('idx_help_categories_name_trgm'), + ('idx_users_search_vector'), + ('idx_users_full_name_trgm'), + ('idx_users_email_trgm'), + ('idx_users_email_exact'), + ('idx_organizations_name_trgm'), + ('idx_organizations_city_trgm') + ) AS expected(index_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM pg_indexes idx + WHERE idx.schemaname = 'proposed_saayam' + AND idx.indexname = expected.index_name + ); + + IF missing_indexes IS NOT NULL THEN + RAISE EXCEPTION 'missing search indexes: %', array_to_string(missing_indexes, ', '); + END IF; + + SELECT array_agg(expected.index_name ORDER BY expected.index_name) + INTO invalid_indexes + FROM ( + VALUES + ('idx_request_search_vector', 'USING gin', 'search_vector'), + ('idx_request_subj_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_request_desc_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_request_loc_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_help_categories_name_fts', 'USING gin', 'to_tsvector'), + ('idx_help_categories_name_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_users_search_vector', 'USING gin', 'search_vector'), + ('idx_users_full_name_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_users_email_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_users_email_exact', 'USING btree', 'primary_email_address'), + ('idx_organizations_name_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_organizations_city_trgm', 'USING gin', 'gin_trgm_ops') + ) AS expected(index_name, required_method, required_expression) + JOIN pg_indexes idx + ON idx.schemaname = 'proposed_saayam' + AND idx.indexname = expected.index_name + WHERE idx.indexdef NOT ILIKE '%' || expected.required_method || '%' + OR idx.indexdef NOT ILIKE '%' || expected.required_expression || '%'; + + IF invalid_indexes IS NOT NULL THEN + RAISE EXCEPTION 'invalid search index definitions: %', array_to_string(invalid_indexes, ', '); + END IF; +END $$; diff --git a/tests/Search/ireland_validation/03_search_function_check.sql b/tests/Search/ireland_validation/03_search_function_check.sql new file mode 100644 index 0000000..a90deab --- /dev/null +++ b/tests/Search/ireland_validation/03_search_function_check.sql @@ -0,0 +1,225 @@ +-- Search function check. +-- Run after instance clone and ddl/Search/01..04. + +BEGIN; + +SET search_path TO proposed_saayam, public; + +INSERT INTO country (country_id, country_name, phone_code, country_code) +VALUES (1, 'United States', '+1', 'US') +ON CONFLICT (country_id) DO NOTHING; + +INSERT INTO state (state_id, country_id, state_name, state_code) +VALUES ('CA', 1, 'California', 'CA') +ON CONFLICT (state_id) DO NOTHING; + +INSERT INTO user_status (user_status_id, user_status, user_status_desc) +VALUES (1, 'active', 'Active user') +ON CONFLICT (user_status_id) DO NOTHING; + +INSERT INTO user_category ( + user_category_id, + user_category, + user_category_desc +) +VALUES + (1, 'requester', 'Requester'), + (4, 'admin', 'Admin') +ON CONFLICT (user_category_id) DO NOTHING; + +INSERT INTO users ( + user_id, + state_id, + country_id, + user_status_id, + user_category_id, + full_name, + first_name, + last_name, + primary_email_address, + city_name +) +VALUES + ('fn-admin', 'CA', 1, 1, 4, 'Function Admin', 'Function', 'Admin', 'fn-admin@example.test', 'San Jose'), + ('fn-requester', 'CA', 1, 1, 1, 'Maya Medical', 'Maya', 'Medical', 'maya.medical@example.test', 'San Jose'), + ('fn-other', 'CA', 1, 1, 1, 'Noah Grocery', 'Noah', 'Grocery', 'noah.grocery@example.test', 'Oakland') +ON CONFLICT (user_id) DO NOTHING; + +INSERT INTO help_categories (cat_id, cat_name, cat_desc) +VALUES + ('fn-medical', 'Medical Care', 'Medical help category'), + ('fn-food', 'Food Delivery', 'Food help category') +ON CONFLICT (cat_id) DO NOTHING; + +INSERT INTO request_status (req_status_id, req_status, req_status_desc) +VALUES (1, 'open', 'Open request') +ON CONFLICT (req_status_id) DO NOTHING; + +INSERT INTO request_priority (req_priority_id, req_priority, req_priority_desc) +VALUES (1, 'high', 'High priority') +ON CONFLICT (req_priority_id) DO NOTHING; + +INSERT INTO request_type (req_type_id, req_type, req_type_desc) +VALUES (1, 'standard', 'Standard request') +ON CONFLICT (req_type_id) DO NOTHING; + +INSERT INTO request_for (req_for_id, req_for, req_for_desc) +VALUES (1, 'self', 'For self') +ON CONFLICT (req_for_id) DO NOTHING; + +INSERT INTO request_isleadvol (req_islead_id, req_islead, req_islead_desc) +VALUES (1, 'no', 'Not lead volunteer') +ON CONFLICT (req_islead_id) DO NOTHING; + +INSERT INTO request ( + req_id, + req_user_id, + req_for_id, + req_islead_id, + req_cat_id, + req_type_id, + req_priority_id, + req_status_id, + req_loc, + iscalamity, + req_subj, + req_desc, + submission_date +) +VALUES + ( + 'fn-req-medical', + 'fn-requester', + 1, + 1, + 'fn-medical', + 1, + 1, + 1, + 'San Jose', + false, + 'Need medical transportation', + 'Need a ride to medical appointment', + now() + ), + ( + 'fn-req-food', + 'fn-other', + 1, + 1, + 'fn-food', + 1, + 1, + 1, + 'Oakland', + false, + 'Need grocery delivery', + 'Need food delivery this week', + now() + ) +ON CONFLICT (req_id) DO NOTHING; + +INSERT INTO organizations ( + org_id, + org_name, + city_name, + state_code, + org_type, + source, + cat_id +) +VALUES + ('fn-org-medical', 'Medical Transport Network', 'San Jose', 'CA', 'non_profit', 'self_registered', 'fn-medical'), + ('fn-org-food', 'Grocery Relief Group', 'Oakland', 'CA', 'non_profit', 'self_registered', 'fn-food') +ON CONFLICT (org_id) DO NOTHING; + +DO $$ +DECLARE + result_count INT; + top_id TEXT; + score REAL; +BEGIN + SELECT count(*), max(req_id), max(relevance_score) + INTO result_count, top_id, score + FROM proposed_saayam.search_requests( + 'medical transportation', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count = 0 OR top_id <> 'fn-req-medical' OR score <= 0 THEN + RAISE EXCEPTION 'search_requests keyword ranking failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_requests( + '', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count <> 0 THEN + RAISE EXCEPTION 'search_requests empty query failed'; + END IF; + + SELECT count(*), max(user_id), max(relevance_score) + INTO result_count, top_id, score + FROM proposed_saayam.search_users( + 'maya.medical@example.test', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count = 0 OR top_id <> 'fn-requester' OR score <= 0 THEN + RAISE EXCEPTION 'search_users exact email failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_users( + '', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count <> 0 THEN + RAISE EXCEPTION 'search_users empty query failed'; + END IF; + + SELECT count(*), max(org_id), max(relevance_score) + INTO result_count, top_id, score + FROM proposed_saayam.search_organizations( + 'Medical Transport', + 5, + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count = 0 OR top_id <> 'fn-org-medical' OR score <= 0 THEN + RAISE EXCEPTION 'search_organizations name search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM proposed_saayam.search_organizations( + '', + 5, + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count <> 0 THEN + RAISE EXCEPTION 'search_organizations empty query failed'; + END IF; +END $$; + +ROLLBACK; diff --git a/tests/Search/ireland_validation/04_instance_clone_check.sql b/tests/Search/ireland_validation/04_instance_clone_check.sql new file mode 100644 index 0000000..5ceb5d1 --- /dev/null +++ b/tests/Search/ireland_validation/04_instance_clone_check.sql @@ -0,0 +1,86 @@ +-- Local instance clone check. +-- Run after instance clone and ddl/Search/01..04. + +DO $$ +DECLARE + missing_tables TEXT[]; + missing_columns TEXT[]; + missing_functions TEXT[]; +BEGIN + SELECT array_agg(expected.table_name ORDER BY expected.table_name) + INTO missing_tables + FROM ( + VALUES + ('country'), + ('state'), + ('user_status'), + ('user_category'), + ('users'), + ('help_categories'), + ('request_status'), + ('request_priority'), + ('request_type'), + ('request_for'), + ('request_isleadvol'), + ('request'), + ('organizations'), + ('user_org_map') + ) AS expected(table_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.tables tbl + WHERE tbl.table_schema = 'proposed_saayam' + AND tbl.table_name = expected.table_name + ); + + IF missing_tables IS NOT NULL THEN + RAISE EXCEPTION 'missing clone tables: %', array_to_string(missing_tables, ', '); + END IF; + + SELECT array_agg(expected.table_name || '.' || expected.column_name ORDER BY expected.table_name, expected.column_name) + INTO missing_columns + FROM ( + VALUES + ('request', 'search_vector'), + ('users', 'search_vector'), + ('request', 'req_subj'), + ('request', 'req_desc'), + ('request', 'req_loc'), + ('help_categories', 'cat_name'), + ('users', 'full_name'), + ('users', 'primary_email_address'), + ('organizations', 'org_name'), + ('organizations', 'city_name') + ) AS expected(table_name, column_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns col + WHERE col.table_schema = 'proposed_saayam' + AND col.table_name = expected.table_name + AND col.column_name = expected.column_name + ); + + IF missing_columns IS NOT NULL THEN + RAISE EXCEPTION 'missing clone/search columns: %', array_to_string(missing_columns, ', '); + END IF; + + SELECT array_agg(expected.function_name ORDER BY expected.function_name) + INTO missing_functions + FROM ( + VALUES + ('search_requests'), + ('search_users'), + ('search_organizations') + ) AS expected(function_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.routines routine + WHERE routine.specific_schema = 'proposed_saayam' + AND routine.routine_name = expected.function_name + AND routine.routine_type = 'FUNCTION' + ); + + IF missing_functions IS NOT NULL THEN + RAISE EXCEPTION 'missing search functions: %', array_to_string(missing_functions, ', '); + END IF; +END $$; diff --git a/tests/Search/ireland_validation/05_ireland_instance_clone_check.sql b/tests/Search/ireland_validation/05_ireland_instance_clone_check.sql new file mode 100644 index 0000000..99165a2 --- /dev/null +++ b/tests/Search/ireland_validation/05_ireland_instance_clone_check.sql @@ -0,0 +1,67 @@ +-- Ireland local instance clone check. +-- Run after tests/Search/test_clones/ireland_search_instance_clone.sql. + +DO $$ +DECLARE + missing_tables TEXT[]; + missing_columns TEXT[]; +BEGIN + SELECT array_agg(expected.table_name ORDER BY expected.table_name) + INTO missing_tables + FROM ( + VALUES + ('country'), + ('state'), + ('user_status'), + ('user_category'), + ('users'), + ('help_categories'), + ('request_status'), + ('request_priority'), + ('request_type'), + ('request_for'), + ('request_isleadvol'), + ('request'), + ('organizations'), + ('user_org_map') + ) AS expected(table_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.tables tbl + WHERE tbl.table_schema = 'proposed_saayam' + AND tbl.table_name = expected.table_name + ); + + IF missing_tables IS NOT NULL THEN + RAISE EXCEPTION 'missing Ireland clone tables: %', array_to_string(missing_tables, ', '); + END IF; + + SELECT array_agg(expected.table_name || '.' || expected.column_name ORDER BY expected.table_name, expected.column_name) + INTO missing_columns + FROM ( + VALUES + ('request', 'req_subj'), + ('request', 'req_desc'), + ('request', 'req_loc'), + ('request', 'req_cat_id'), + ('request', 'req_user_id'), + ('help_categories', 'cat_name'), + ('users', 'full_name'), + ('users', 'first_name'), + ('users', 'last_name'), + ('users', 'primary_email_address'), + ('organizations', 'org_name'), + ('organizations', 'city_name') + ) AS expected(table_name, column_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns col + WHERE col.table_schema = 'proposed_saayam' + AND col.table_name = expected.table_name + AND col.column_name = expected.column_name + ); + + IF missing_columns IS NOT NULL THEN + RAISE EXCEPTION 'missing Ireland search columns: %', array_to_string(missing_columns, ', '); + END IF; +END $$; diff --git a/tests/Search/runners/run_ireland_clone_validation.sql b/tests/Search/runners/run_ireland_clone_validation.sql new file mode 100644 index 0000000..9f144a1 --- /dev/null +++ b/tests/Search/runners/run_ireland_clone_validation.sql @@ -0,0 +1,7 @@ +-- Ireland local clone validation runner. +-- Run after tests/Search/test_clones/ireland_search_instance_clone.sql. + +\echo 'Running Ireland instance clone check' +\ir ../ireland_validation/05_ireland_instance_clone_check.sql + +\echo 'Ireland clone validation completed' diff --git a/tests/Search/runners/run_ireland_search_migrations.sql b/tests/Search/runners/run_ireland_search_migrations.sql new file mode 100644 index 0000000..ee891bc --- /dev/null +++ b/tests/Search/runners/run_ireland_search_migrations.sql @@ -0,0 +1,8 @@ +-- Run production search migrations against the Ireland schema. + +SET search_path TO proposed_saayam, public; + +\ir ../../../ddl/Search/01_enable_fuzzy_search.sql +\ir ../../../ddl/Search/02_add_request_search.sql +\ir ../../../ddl/Search/03_add_user_and_volunteer_search.sql +\ir ../../../ddl/Search/04_add_category_and_advanced_search.sql diff --git a/tests/Search/runners/run_ireland_search_validation.sql b/tests/Search/runners/run_ireland_search_validation.sql new file mode 100644 index 0000000..650e535 --- /dev/null +++ b/tests/Search/runners/run_ireland_search_validation.sql @@ -0,0 +1,16 @@ +-- Ireland full local search validation runner. +-- Run after ireland_search_instance_clone.sql and run_ireland_search_migrations.sql. + +\echo 'Running Ireland search smoke test' +\ir ../ireland_validation/01_search_smoke_test.sql + +\echo 'Running Ireland search index check' +\ir ../ireland_validation/02_search_index_check.sql + +\echo 'Running Ireland search function check' +\ir ../ireland_validation/03_search_function_check.sql + +\echo 'Running Ireland migrated clone check' +\ir ../ireland_validation/04_instance_clone_check.sql + +\echo 'Ireland full search validation completed' diff --git a/tests/Search/runners/run_virginia_search_migrations.sql b/tests/Search/runners/run_virginia_search_migrations.sql new file mode 100644 index 0000000..6db9a4f --- /dev/null +++ b/tests/Search/runners/run_virginia_search_migrations.sql @@ -0,0 +1,8 @@ +-- Run production search migrations against the Virginia schema. + +SET search_path TO virginia_dev_saayam_rdbms, public; + +\ir ../../../ddl/Search/01_enable_fuzzy_search.sql +\ir ../../../ddl/Search/02_add_request_search.sql +\ir ../../../ddl/Search/03_add_user_and_volunteer_search.sql +\ir ../../../ddl/Search/04_add_category_and_advanced_search.sql diff --git a/tests/Search/runners/run_virginia_search_validation.sql b/tests/Search/runners/run_virginia_search_validation.sql new file mode 100644 index 0000000..a6c559c --- /dev/null +++ b/tests/Search/runners/run_virginia_search_validation.sql @@ -0,0 +1,16 @@ +-- Virginia search validation runner. +-- Run after Virginia test clone and search migrations. + +\echo 'Running search smoke test' +\ir ../virginia_validation/01_search_smoke_test.sql + +\echo 'Running search index check' +\ir ../virginia_validation/02_search_index_check.sql + +\echo 'Running search function check' +\ir ../virginia_validation/03_search_function_check.sql + +\echo 'Running instance clone check' +\ir ../virginia_validation/04_instance_clone_check.sql + +\echo 'Virginia search validation completed' diff --git a/tests/Search/test_clones/README.md b/tests/Search/test_clones/README.md new file mode 100644 index 0000000..77b44d6 --- /dev/null +++ b/tests/Search/test_clones/README.md @@ -0,0 +1,65 @@ +# Instance Clone Schemas + +Use this folder for local schema clones that mirror deployed AWS RDS instances. + +Examples: + +- Virginia schema clone +- Ireland schema clone + +These files are test fixtures for search validation. They should be copied or generated from existing deployed/schema sources and kept separate from the original DDL files. + +## Why This Exists + +The search scripts are schema-level migrations. Testing them against a local clone of the Virginia or Ireland RDS schema helps catch: + +- schema-name issues +- missing table or column assumptions +- index creation problems +- function compilation errors +- query behavior differences between minimal test tables and real schema structure + +## Files + +- `virginia_search_instance_clone.sql`: search-scoped Virginia clone for validating the current MVP search scripts. +- `ireland_search_instance_clone.sql`: search-scoped Ireland clone for validating Ireland schema shape without requiring local PostGIS. +- `README.md`: this guide. + +The Virginia clone intentionally includes only the search-relevant tables and dependencies. It is not a replacement for the production DDL. +The Ireland search clone follows the Ireland schema name, `proposed_saayam`, and includes the search-relevant tables/columns only. + +## Suggested Local Test Order + +```bash +createdb saayam_search_clone_test +psql -d saayam_search_clone_test -f tests/Search/test_clones/virginia_search_instance_clone.sql +psql -d saayam_search_clone_test -f tests/Search/runners/run_virginia_search_migrations.sql +psql -d saayam_search_clone_test -v ON_ERROR_STOP=1 -f tests/Search/runners/run_virginia_search_validation.sql +``` + +Add smoke-test SQL files under `tests/Search/` as the search validation suite grows. + +## Ireland Search Clone Check + +Use this when local PostGIS is not available or when the goal is only to confirm Ireland search shape: + +```bash +createdb saayam_ireland_search_clone_test +psql -d saayam_ireland_search_clone_test -f tests/Search/test_clones/ireland_search_instance_clone.sql +psql -d saayam_ireland_search_clone_test -f tests/Search/runners/run_ireland_clone_validation.sql +``` + +This confirms the Ireland-style `proposed_saayam` clone has the tables and columns needed by the current search plan. + +## Ireland Full Search Validation + +Use this to validate the same search behavior against the Ireland schema name: + +```bash +createdb saayam_ireland_full_search_test +psql -d saayam_ireland_full_search_test -f tests/Search/test_clones/ireland_search_instance_clone.sql +psql -d saayam_ireland_full_search_test -f tests/Search/runners/run_ireland_search_migrations.sql +psql -d saayam_ireland_full_search_test -v ON_ERROR_STOP=1 -f tests/Search/runners/run_ireland_search_validation.sql +``` + +The same production search scripts are used for Ireland through `run_ireland_search_migrations.sql`. diff --git a/tests/Search/test_clones/ireland_search_instance_clone.sql b/tests/Search/test_clones/ireland_search_instance_clone.sql new file mode 100644 index 0000000..dc5d4c3 --- /dev/null +++ b/tests/Search/test_clones/ireland_search_instance_clone.sql @@ -0,0 +1,183 @@ +-- Search-scoped Ireland instance clone. +-- Use this fixture to validate Ireland search shape locally without editing ddl/Tables. + +DROP SCHEMA IF EXISTS proposed_saayam CASCADE; +CREATE SCHEMA proposed_saayam; +SET search_path TO proposed_saayam, public; + +CREATE TABLE proposed_saayam.country ( + country_id SERIAL PRIMARY KEY, + country_name VARCHAR(100) NOT NULL, + phone_code VARCHAR(5) NOT NULL, + country_code VARCHAR(6) NOT NULL, + last_update_date TIMESTAMP, + UNIQUE (country_id) +); + +CREATE TABLE proposed_saayam.state ( + state_id VARCHAR(50) PRIMARY KEY, + country_id INT NOT NULL, + state_name VARCHAR(100) NOT NULL, + state_code VARCHAR(6), + last_update_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (country_id) REFERENCES proposed_saayam.country (country_id) +); + +CREATE TABLE proposed_saayam.user_status ( + user_status_id SERIAL PRIMARY KEY, + user_status VARCHAR(255) NOT NULL, + user_status_desc VARCHAR(255), + last_update_date TIMESTAMP, + UNIQUE (user_status_id) +); + +CREATE TABLE proposed_saayam.user_category ( + user_category_id SERIAL PRIMARY KEY, + user_category VARCHAR(255) NOT NULL, + user_category_desc VARCHAR(255), + last_update_date TIMESTAMP, + UNIQUE (user_category_id) +); + +CREATE TABLE proposed_saayam.users ( + user_id VARCHAR(255) PRIMARY KEY, + state_id VARCHAR(50) NULL, + country_id INT NULL, + user_status_id INT NULL, + user_category_id INT NULL, + full_name VARCHAR(255) NULL, + first_name VARCHAR(255) NULL, + middle_name VARCHAR(255) NULL, + last_name VARCHAR(255) NULL, + primary_email_address VARCHAR(255) NULL, + primary_phone_number VARCHAR(255) NULL, + addr_ln1 VARCHAR(255) NULL, + addr_ln2 VARCHAR(255) NULL, + addr_ln3 VARCHAR(255) NULL, + city_name VARCHAR(255) NULL, + zip_code VARCHAR(255) NULL, + last_location point, + last_update_date TIMESTAMP, + time_zone VARCHAR(255) NULL, + profile_picture_path VARCHAR(255) NULL, + gender VARCHAR(255) NULL, + language_1 VARCHAR(255) NULL, + language_2 VARCHAR(255) NULL, + language_3 VARCHAR(255) NULL, + promotion_wizard_stage INT NULL, + promotion_wizard_last_update_date TIMESTAMP, + FOREIGN KEY (country_id) REFERENCES proposed_saayam.country (country_id), + FOREIGN KEY (state_id) REFERENCES proposed_saayam.state (state_id), + FOREIGN KEY (user_status_id) REFERENCES proposed_saayam.user_status (user_status_id), + FOREIGN KEY (user_category_id) REFERENCES proposed_saayam.user_category (user_category_id) +); + +CREATE TABLE proposed_saayam.help_categories ( + cat_id VARCHAR(50) PRIMARY KEY, + cat_name VARCHAR(100) NOT NULL, + cat_desc VARCHAR(150) NOT NULL +); + +CREATE TABLE proposed_saayam.request_status ( + req_status_id SERIAL PRIMARY KEY, + req_status VARCHAR(25) NOT NULL, + req_status_desc VARCHAR(125), + last_updated_date TIMESTAMP +); + +CREATE TABLE proposed_saayam.request_priority ( + req_priority_id SERIAL PRIMARY KEY, + req_priority VARCHAR(25) NOT NULL, + req_priority_desc VARCHAR(125), + last_updated_date TIMESTAMP, + UNIQUE (req_priority_id) +); + +CREATE TABLE proposed_saayam.request_type ( + req_type_id SERIAL PRIMARY KEY, + req_type VARCHAR(25), + req_type_desc VARCHAR(125), + last_updated_date TIMESTAMP +); + +CREATE TABLE proposed_saayam.request_for ( + req_for_id SERIAL PRIMARY KEY, + req_for VARCHAR(25) NOT NULL, + req_for_desc VARCHAR(125), + last_updated_date TIMESTAMP, + UNIQUE (req_for_id) +); + +CREATE TABLE proposed_saayam.request_isleadvol ( + req_islead_id SERIAL PRIMARY KEY, + req_islead VARCHAR(15) NOT NULL, + req_islead_desc VARCHAR(100), + last_updated_date TIMESTAMP +); + +CREATE TABLE proposed_saayam.request ( + req_id VARCHAR(255) PRIMARY KEY, + req_user_id VARCHAR(255) NOT NULL, + req_for_id INT NOT NULL, + req_islead_id INT NOT NULL, + req_cat_id VARCHAR(50) NOT NULL, + req_type_id INT NOT NULL, + req_priority_id INT NOT NULL, + req_status_id INT NOT NULL, + req_loc VARCHAR(125), + iscalamity BOOLEAN, + req_subj VARCHAR(125) NOT NULL, + req_desc VARCHAR(255) NOT NULL, + req_doc_link TEXT, + audio_req_desc VARCHAR(255), + submission_date TIMESTAMP, + serviced_date TIMESTAMP, + last_update_date TIMESTAMP, + UNIQUE (req_id), + FOREIGN KEY (req_user_id) REFERENCES proposed_saayam.users (user_id), + FOREIGN KEY (req_status_id) REFERENCES proposed_saayam.request_status (req_status_id), + FOREIGN KEY (req_priority_id) REFERENCES proposed_saayam.request_priority (req_priority_id), + FOREIGN KEY (req_type_id) REFERENCES proposed_saayam.request_type (req_type_id), + FOREIGN KEY (req_cat_id) REFERENCES proposed_saayam.help_categories (cat_id), + FOREIGN KEY (req_for_id) REFERENCES proposed_saayam.request_for (req_for_id), + FOREIGN KEY (req_islead_id) REFERENCES proposed_saayam.request_isleadvol (req_islead_id) +); + +CREATE TYPE org_type_enum AS ENUM ('non_profit', 'for_profit', 'Hybrid', 'Government'); +CREATE TYPE source_enum AS ENUM ('irs', 'self_registered'); + +CREATE TABLE proposed_saayam.organizations ( + org_id VARCHAR(255) PRIMARY KEY, + org_name VARCHAR(125) NOT NULL, + govt_id_num VARCHAR(20) UNIQUE, + street VARCHAR(255), + city_name VARCHAR(100), + state_code VARCHAR(6), + zip_code VARCHAR(10), + mission TEXT, + web_url VARCHAR(255) CHECK (web_url IS NULL OR web_url LIKE 'http%'), + phone VARCHAR(20), + email VARCHAR(255) CHECK (email IS NULL OR email LIKE '%@%'), + org_type org_type_enum, + source source_enum, + cat_id VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (cat_id) REFERENCES proposed_saayam.help_categories(cat_id) ON DELETE SET NULL +); + +CREATE INDEX idx_org_city_state ON proposed_saayam.organizations(city_name, state_code); +CREATE INDEX idx_org_state ON proposed_saayam.organizations(state_code); +CREATE INDEX idx_org_cat_id ON proposed_saayam.organizations(cat_id); +CREATE INDEX idx_org_name ON proposed_saayam.organizations(org_name); + +CREATE TABLE proposed_saayam.user_org_map ( + user_id VARCHAR(255) NOT NULL, + org_id VARCHAR(255) NOT NULL, + user_role VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_org_map_pk PRIMARY KEY (user_id, org_id), + FOREIGN KEY (user_id) REFERENCES proposed_saayam.users(user_id) ON DELETE CASCADE, + FOREIGN KEY (org_id) REFERENCES proposed_saayam.organizations(org_id) ON DELETE CASCADE +); diff --git a/tests/Search/test_clones/virginia_search_instance_clone.sql b/tests/Search/test_clones/virginia_search_instance_clone.sql new file mode 100644 index 0000000..1a7245c --- /dev/null +++ b/tests/Search/test_clones/virginia_search_instance_clone.sql @@ -0,0 +1,188 @@ +-- Search-scoped Virginia instance clone. +-- Use this fixture to validate ddl/Search locally without editing ddl/Tables. + +DROP SCHEMA IF EXISTS virginia_dev_saayam_rdbms CASCADE; +CREATE SCHEMA virginia_dev_saayam_rdbms; +SET search_path TO virginia_dev_saayam_rdbms, public; + +CREATE TABLE virginia_dev_saayam_rdbms.country ( + country_id SERIAL PRIMARY KEY, + country_name VARCHAR(100) NOT NULL, + phone_code VARCHAR(5) NOT NULL, + country_code VARCHAR(6) NOT NULL, + last_update_date TIMESTAMP, + is_eu_member BOOLEAN DEFAULT FALSE, + UNIQUE (country_id) +); + +CREATE TABLE virginia_dev_saayam_rdbms.state ( + state_id VARCHAR(50) PRIMARY KEY, + country_id INT NOT NULL, + state_name VARCHAR(100) NOT NULL, + state_code VARCHAR(6), + last_update_date TIMESTAMP, + FOREIGN KEY (country_id) REFERENCES virginia_dev_saayam_rdbms.country (country_id) +); + +CREATE TABLE virginia_dev_saayam_rdbms.user_status ( + user_status_id SERIAL PRIMARY KEY, + user_status VARCHAR(255) NOT NULL, + user_status_desc VARCHAR(255), + last_update_date TIMESTAMP, + UNIQUE (user_status_id) +); + +CREATE TABLE virginia_dev_saayam_rdbms.user_category ( + user_category_id SERIAL PRIMARY KEY, + user_category VARCHAR(255) NOT NULL, + user_category_desc VARCHAR(255), + user_access_level SMALLINT, + category_code VARCHAR(50) UNIQUE, + is_deprecated BOOLEAN DEFAULT FALSE, + permissions JSONB, + last_updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE virginia_dev_saayam_rdbms.users ( + user_id VARCHAR(255) PRIMARY KEY, + state_id VARCHAR(30) NULL, + country_id INT NULL, + user_status_id INT NULL, + user_category_id INT NULL, + full_name VARCHAR(255) NULL, + first_name VARCHAR(255) NULL, + middle_name VARCHAR(255) NULL, + last_name VARCHAR(255) NULL, + primary_email_address VARCHAR(255) NULL, + primary_phone_number VARCHAR(255) NULL, + addr_ln1 VARCHAR(255) NULL, + addr_ln2 VARCHAR(255) NULL, + addr_ln3 VARCHAR(255) NULL, + city_name VARCHAR(255) NULL, + zip_code VARCHAR(255) NULL, + last_location point, + last_update_date TIMESTAMP, + time_zone VARCHAR(255) NULL, + profile_picture_path VARCHAR(255) NULL, + gender VARCHAR(255) NULL, + language_1 VARCHAR(255) NULL, + language_2 VARCHAR(255) NULL, + language_3 VARCHAR(255) NULL, + promotion_wizard_stage INT NULL, + promotion_wizard_last_update_date TIMESTAMP, + FOREIGN KEY (country_id) REFERENCES virginia_dev_saayam_rdbms.country (country_id) ON DELETE SET NULL, + FOREIGN KEY (state_id) REFERENCES virginia_dev_saayam_rdbms.state (state_id) ON DELETE SET NULL, + FOREIGN KEY (user_status_id) REFERENCES virginia_dev_saayam_rdbms.user_status (user_status_id), + FOREIGN KEY (user_category_id) REFERENCES virginia_dev_saayam_rdbms.user_category (user_category_id) ON DELETE SET NULL +); + +CREATE TABLE virginia_dev_saayam_rdbms.help_categories ( + cat_id VARCHAR(50) PRIMARY KEY, + cat_name VARCHAR(100) NOT NULL, + cat_desc VARCHAR(150) NOT NULL +); + +CREATE TABLE virginia_dev_saayam_rdbms.request_status ( + req_status_id SERIAL PRIMARY KEY, + req_status VARCHAR(25) NOT NULL, + req_status_desc VARCHAR(125), + last_updated_date TIMESTAMP +); + +CREATE TABLE virginia_dev_saayam_rdbms.request_priority ( + req_priority_id SERIAL PRIMARY KEY, + req_priority VARCHAR(25) NOT NULL, + req_priority_desc VARCHAR(125), + last_updated_date TIMESTAMP, + UNIQUE (req_priority_id) +); + +CREATE TABLE virginia_dev_saayam_rdbms.request_type ( + req_type_id SERIAL PRIMARY KEY, + req_type VARCHAR(25), + req_type_desc VARCHAR(125), + last_updated_date TIMESTAMP +); + +CREATE TABLE virginia_dev_saayam_rdbms.request_for ( + req_for_id SERIAL PRIMARY KEY, + req_for VARCHAR(25) NOT NULL, + req_for_desc VARCHAR(125), + last_updated_date TIMESTAMP, + UNIQUE (req_for_id) +); + +CREATE TABLE virginia_dev_saayam_rdbms.request_isleadvol ( + req_islead_id SERIAL PRIMARY KEY, + req_islead VARCHAR(25) NOT NULL, + req_islead_desc VARCHAR(125), + last_updated_date TIMESTAMP, + UNIQUE (req_islead_id) +); + +CREATE TABLE virginia_dev_saayam_rdbms.request ( + req_id VARCHAR(255) PRIMARY KEY, + req_user_id VARCHAR(255) NOT NULL, + req_for_id INT NOT NULL, + req_islead_id INT NOT NULL, + req_cat_id VARCHAR(50) NOT NULL, + req_type_id INT NOT NULL, + req_priority_id INT NOT NULL, + req_status_id INT NOT NULL, + req_loc VARCHAR(125), + iscalamity BOOLEAN, + req_subj VARCHAR(125) NOT NULL, + req_desc VARCHAR(255) NOT NULL, + req_doc_link TEXT, + audio_req_desc VARCHAR(255), + submission_date TIMESTAMP, + serviced_date TIMESTAMP, + last_update_date TIMESTAMP, + UNIQUE (req_id), + FOREIGN KEY (req_user_id) REFERENCES virginia_dev_saayam_rdbms.users (user_id), + FOREIGN KEY (req_status_id) REFERENCES virginia_dev_saayam_rdbms.request_status (req_status_id), + FOREIGN KEY (req_priority_id) REFERENCES virginia_dev_saayam_rdbms.request_priority (req_priority_id), + FOREIGN KEY (req_type_id) REFERENCES virginia_dev_saayam_rdbms.request_type (req_type_id), + FOREIGN KEY (req_cat_id) REFERENCES virginia_dev_saayam_rdbms.help_categories (cat_id), + FOREIGN KEY (req_for_id) REFERENCES virginia_dev_saayam_rdbms.request_for (req_for_id), + FOREIGN KEY (req_islead_id) REFERENCES virginia_dev_saayam_rdbms.request_isleadvol (req_islead_id) +); + +CREATE TYPE org_type_enum AS ENUM ('non_profit', 'for_profit'); +CREATE TYPE source_enum AS ENUM ('irs', 'self_registered'); + +CREATE TABLE virginia_dev_saayam_rdbms.organizations ( + org_id VARCHAR(255) PRIMARY KEY, + org_name VARCHAR(125) NOT NULL, + govt_id_num VARCHAR(20) UNIQUE, + street VARCHAR(255), + city_name VARCHAR(100), + state_code VARCHAR(6), + zip_code VARCHAR(10), + mission TEXT, + web_url VARCHAR(255) CHECK (web_url IS NULL OR web_url LIKE 'http%'), + phone VARCHAR(20), + email VARCHAR(255) CHECK (email IS NULL OR email LIKE '%@%'), + org_type org_type_enum, + source source_enum, + cat_id VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (cat_id) REFERENCES virginia_dev_saayam_rdbms.help_categories(cat_id) ON DELETE SET NULL +); + +CREATE INDEX idx_org_city_state ON virginia_dev_saayam_rdbms.organizations(city_name, state_code); +CREATE INDEX idx_org_state ON virginia_dev_saayam_rdbms.organizations(state_code); +CREATE INDEX idx_org_cat_id ON virginia_dev_saayam_rdbms.organizations(cat_id); +CREATE INDEX idx_org_name ON virginia_dev_saayam_rdbms.organizations(org_name); + +CREATE TABLE virginia_dev_saayam_rdbms.user_org_map ( + user_id VARCHAR(255) NOT NULL, + org_id VARCHAR(255) NOT NULL, + user_role VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_org_map_pk PRIMARY KEY (user_id, org_id), + FOREIGN KEY (user_id) REFERENCES virginia_dev_saayam_rdbms.users(user_id) ON DELETE CASCADE, + FOREIGN KEY (org_id) REFERENCES virginia_dev_saayam_rdbms.organizations(org_id) ON DELETE CASCADE +); diff --git a/tests/Search/virginia_validation/01_search_smoke_test.sql b/tests/Search/virginia_validation/01_search_smoke_test.sql new file mode 100644 index 0000000..a86576f --- /dev/null +++ b/tests/Search/virginia_validation/01_search_smoke_test.sql @@ -0,0 +1,275 @@ +-- Search MVP smoke test. +-- Run after instance clone and ddl/Search/01..04. + +BEGIN; + +SET search_path TO virginia_dev_saayam_rdbms, public; + +INSERT INTO country (country_id, country_name, phone_code, country_code) +VALUES (1, 'United States', '+1', 'US') +ON CONFLICT (country_id) DO NOTHING; + +INSERT INTO state (state_id, country_id, state_name, state_code) +VALUES ('CA', 1, 'California', 'CA') +ON CONFLICT (state_id) DO NOTHING; + +INSERT INTO user_status (user_status_id, user_status, user_status_desc) +VALUES (1, 'active', 'Active user') +ON CONFLICT (user_status_id) DO NOTHING; + +INSERT INTO user_category ( + user_category_id, + user_category, + user_category_desc, + user_access_level, + category_code +) +VALUES + (1, 'requester', 'Requester', 1, 'REQUESTER'), + (4, 'admin', 'Admin', 4, 'ADMIN') +ON CONFLICT (user_category_id) DO NOTHING; + +INSERT INTO users ( + user_id, + state_id, + country_id, + user_status_id, + user_category_id, + full_name, + first_name, + last_name, + primary_email_address, + city_name +) +VALUES + ('user-admin', 'CA', 1, 1, 4, 'Admin Search', 'Admin', 'Search', 'admin@example.test', 'San Jose'), + ('user-alice', 'CA', 1, 1, 1, 'Alice Emergency', 'Alice', 'Emergency', 'alice@example.test', 'San Jose'), + ('user-bob', 'CA', 1, 1, 1, 'Bob Foodhelp', 'Bob', 'Foodhelp', 'bob@example.test', 'Oakland') +ON CONFLICT (user_id) DO NOTHING; + +INSERT INTO help_categories (cat_id, cat_name, cat_desc) +VALUES + ('medical', 'Medical Help', 'Medical and emergency support'), + ('food', 'Food Support', 'Groceries and food help') +ON CONFLICT (cat_id) DO NOTHING; + +INSERT INTO request_status (req_status_id, req_status, req_status_desc) +VALUES (1, 'open', 'Open request') +ON CONFLICT (req_status_id) DO NOTHING; + +INSERT INTO request_priority (req_priority_id, req_priority, req_priority_desc) +VALUES (1, 'high', 'High priority') +ON CONFLICT (req_priority_id) DO NOTHING; + +INSERT INTO request_type (req_type_id, req_type, req_type_desc) +VALUES (1, 'standard', 'Standard request') +ON CONFLICT (req_type_id) DO NOTHING; + +INSERT INTO request_for (req_for_id, req_for, req_for_desc) +VALUES (1, 'self', 'For self') +ON CONFLICT (req_for_id) DO NOTHING; + +INSERT INTO request_isleadvol (req_islead_id, req_islead, req_islead_desc) +VALUES (1, 'no', 'Not lead volunteer') +ON CONFLICT (req_islead_id) DO NOTHING; + +INSERT INTO request ( + req_id, + req_user_id, + req_for_id, + req_islead_id, + req_cat_id, + req_type_id, + req_priority_id, + req_status_id, + req_loc, + iscalamity, + req_subj, + req_desc, + submission_date +) +VALUES + ( + 'req-medical-1', + 'user-alice', + 1, + 1, + 'medical', + 1, + 1, + 1, + 'San Jose', + false, + 'Emergency medical help', + 'Need urgent medical support and transport', + now() + ), + ( + 'req-food-1', + 'user-bob', + 1, + 1, + 'food', + 1, + 1, + 1, + 'Oakland', + false, + 'Food pantry support', + 'Need groceries and food assistance', + now() + ) +ON CONFLICT (req_id) DO NOTHING; + +INSERT INTO organizations ( + org_id, + org_name, + city_name, + state_code, + org_type, + source, + cat_id +) +VALUES + ('org-saayam-1', 'Saayam Foundation', 'San Jose', 'CA', 'non_profit', 'self_registered', 'medical'), + ('org-food-1', 'Community Food Bank', 'Oakland', 'CA', 'non_profit', 'self_registered', 'food') +ON CONFLICT (org_id) DO NOTHING; + +DO $$ +DECLARE + result_count INT; +BEGIN + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_requests( + 'emergancy medical', + 20, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE req_id = 'req-medical-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'request fuzzy search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_requests( + 'food', + 20, + 'user-alice'::VARCHAR(255), + 1::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE req_id = 'req-food-1'; + + IF result_count <> 0 THEN + RAISE EXCEPTION 'request self-scope filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_requests( + 'food', + 20, + NULL::VARCHAR(255), + 1::SMALLINT, + ARRAY['user-bob']::VARCHAR(255)[] + ) + WHERE req_id = 'req-food-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'request allowed-owner filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_users( + 'Alic', + 20, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE user_id = 'user-alice'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'user fuzzy search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_users( + 'bob@example.test', + 20, + 'user-alice'::VARCHAR(255), + 1::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE user_id = 'user-bob'; + + IF result_count <> 0 THEN + RAISE EXCEPTION 'user self-scope filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_users( + 'bob@example.test', + 20, + NULL::VARCHAR(255), + 1::SMALLINT, + ARRAY['user-bob']::VARCHAR(255)[] + ) + WHERE user_id = 'user-bob'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'user allowed-list filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_organizations( + 'Saaym Foundation', + 20, + 4::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE org_id = 'org-saayam-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'organization fuzzy search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_organizations( + 'Food Bank', + 20, + 1::SMALLINT, + NULL::VARCHAR(255)[] + ) + WHERE org_id = 'org-food-1'; + + IF result_count <> 0 THEN + RAISE EXCEPTION 'organization scope filter failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_organizations( + 'Food Bank', + 20, + 1::SMALLINT, + ARRAY['org-food-1']::VARCHAR(255)[] + ) + WHERE org_id = 'org-food-1'; + + IF result_count <> 1 THEN + RAISE EXCEPTION 'organization allowed-list filter failed'; + END IF; +END $$; + +ROLLBACK; diff --git a/tests/Search/virginia_validation/02_search_index_check.sql b/tests/Search/virginia_validation/02_search_index_check.sql new file mode 100644 index 0000000..3509a5c --- /dev/null +++ b/tests/Search/virginia_validation/02_search_index_check.sql @@ -0,0 +1,63 @@ +-- Search index check. +-- Run after instance clone and ddl/Search/01..04. + +DO $$ +DECLARE + missing_indexes TEXT[]; + invalid_indexes TEXT[]; +BEGIN + SELECT array_agg(expected.index_name ORDER BY expected.index_name) + INTO missing_indexes + FROM ( + VALUES + ('idx_request_search_vector'), + ('idx_request_subj_trgm'), + ('idx_request_desc_trgm'), + ('idx_request_loc_trgm'), + ('idx_help_categories_name_fts'), + ('idx_help_categories_name_trgm'), + ('idx_users_search_vector'), + ('idx_users_full_name_trgm'), + ('idx_users_email_trgm'), + ('idx_users_email_exact'), + ('idx_organizations_name_trgm'), + ('idx_organizations_city_trgm') + ) AS expected(index_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM pg_indexes idx + WHERE idx.schemaname = 'virginia_dev_saayam_rdbms' + AND idx.indexname = expected.index_name + ); + + IF missing_indexes IS NOT NULL THEN + RAISE EXCEPTION 'missing search indexes: %', array_to_string(missing_indexes, ', '); + END IF; + + SELECT array_agg(expected.index_name ORDER BY expected.index_name) + INTO invalid_indexes + FROM ( + VALUES + ('idx_request_search_vector', 'USING gin', 'search_vector'), + ('idx_request_subj_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_request_desc_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_request_loc_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_help_categories_name_fts', 'USING gin', 'to_tsvector'), + ('idx_help_categories_name_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_users_search_vector', 'USING gin', 'search_vector'), + ('idx_users_full_name_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_users_email_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_users_email_exact', 'USING btree', 'primary_email_address'), + ('idx_organizations_name_trgm', 'USING gin', 'gin_trgm_ops'), + ('idx_organizations_city_trgm', 'USING gin', 'gin_trgm_ops') + ) AS expected(index_name, required_method, required_expression) + JOIN pg_indexes idx + ON idx.schemaname = 'virginia_dev_saayam_rdbms' + AND idx.indexname = expected.index_name + WHERE idx.indexdef NOT ILIKE '%' || expected.required_method || '%' + OR idx.indexdef NOT ILIKE '%' || expected.required_expression || '%'; + + IF invalid_indexes IS NOT NULL THEN + RAISE EXCEPTION 'invalid search index definitions: %', array_to_string(invalid_indexes, ', '); + END IF; +END $$; diff --git a/tests/Search/virginia_validation/03_search_function_check.sql b/tests/Search/virginia_validation/03_search_function_check.sql new file mode 100644 index 0000000..c557153 --- /dev/null +++ b/tests/Search/virginia_validation/03_search_function_check.sql @@ -0,0 +1,227 @@ +-- Search function check. +-- Run after instance clone and ddl/Search/01..04. + +BEGIN; + +SET search_path TO virginia_dev_saayam_rdbms, public; + +INSERT INTO country (country_id, country_name, phone_code, country_code) +VALUES (1, 'United States', '+1', 'US') +ON CONFLICT (country_id) DO NOTHING; + +INSERT INTO state (state_id, country_id, state_name, state_code) +VALUES ('CA', 1, 'California', 'CA') +ON CONFLICT (state_id) DO NOTHING; + +INSERT INTO user_status (user_status_id, user_status, user_status_desc) +VALUES (1, 'active', 'Active user') +ON CONFLICT (user_status_id) DO NOTHING; + +INSERT INTO user_category ( + user_category_id, + user_category, + user_category_desc, + user_access_level, + category_code +) +VALUES + (1, 'requester', 'Requester', 1, 'REQUESTER'), + (4, 'admin', 'Admin', 4, 'ADMIN') +ON CONFLICT (user_category_id) DO NOTHING; + +INSERT INTO users ( + user_id, + state_id, + country_id, + user_status_id, + user_category_id, + full_name, + first_name, + last_name, + primary_email_address, + city_name +) +VALUES + ('fn-admin', 'CA', 1, 1, 4, 'Function Admin', 'Function', 'Admin', 'fn-admin@example.test', 'San Jose'), + ('fn-requester', 'CA', 1, 1, 1, 'Maya Medical', 'Maya', 'Medical', 'maya.medical@example.test', 'San Jose'), + ('fn-other', 'CA', 1, 1, 1, 'Noah Grocery', 'Noah', 'Grocery', 'noah.grocery@example.test', 'Oakland') +ON CONFLICT (user_id) DO NOTHING; + +INSERT INTO help_categories (cat_id, cat_name, cat_desc) +VALUES + ('fn-medical', 'Medical Care', 'Medical help category'), + ('fn-food', 'Food Delivery', 'Food help category') +ON CONFLICT (cat_id) DO NOTHING; + +INSERT INTO request_status (req_status_id, req_status, req_status_desc) +VALUES (1, 'open', 'Open request') +ON CONFLICT (req_status_id) DO NOTHING; + +INSERT INTO request_priority (req_priority_id, req_priority, req_priority_desc) +VALUES (1, 'high', 'High priority') +ON CONFLICT (req_priority_id) DO NOTHING; + +INSERT INTO request_type (req_type_id, req_type, req_type_desc) +VALUES (1, 'standard', 'Standard request') +ON CONFLICT (req_type_id) DO NOTHING; + +INSERT INTO request_for (req_for_id, req_for, req_for_desc) +VALUES (1, 'self', 'For self') +ON CONFLICT (req_for_id) DO NOTHING; + +INSERT INTO request_isleadvol (req_islead_id, req_islead, req_islead_desc) +VALUES (1, 'no', 'Not lead volunteer') +ON CONFLICT (req_islead_id) DO NOTHING; + +INSERT INTO request ( + req_id, + req_user_id, + req_for_id, + req_islead_id, + req_cat_id, + req_type_id, + req_priority_id, + req_status_id, + req_loc, + iscalamity, + req_subj, + req_desc, + submission_date +) +VALUES + ( + 'fn-req-medical', + 'fn-requester', + 1, + 1, + 'fn-medical', + 1, + 1, + 1, + 'San Jose', + false, + 'Need medical transportation', + 'Need a ride to medical appointment', + now() + ), + ( + 'fn-req-food', + 'fn-other', + 1, + 1, + 'fn-food', + 1, + 1, + 1, + 'Oakland', + false, + 'Need grocery delivery', + 'Need food delivery this week', + now() + ) +ON CONFLICT (req_id) DO NOTHING; + +INSERT INTO organizations ( + org_id, + org_name, + city_name, + state_code, + org_type, + source, + cat_id +) +VALUES + ('fn-org-medical', 'Medical Transport Network', 'San Jose', 'CA', 'non_profit', 'self_registered', 'fn-medical'), + ('fn-org-food', 'Grocery Relief Group', 'Oakland', 'CA', 'non_profit', 'self_registered', 'fn-food') +ON CONFLICT (org_id) DO NOTHING; + +DO $$ +DECLARE + result_count INT; + top_id TEXT; + score REAL; +BEGIN + SELECT count(*), max(req_id), max(relevance_score) + INTO result_count, top_id, score + FROM virginia_dev_saayam_rdbms.search_requests( + 'medical transportation', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count = 0 OR top_id <> 'fn-req-medical' OR score <= 0 THEN + RAISE EXCEPTION 'search_requests keyword ranking failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_requests( + '', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count <> 0 THEN + RAISE EXCEPTION 'search_requests empty query failed'; + END IF; + + SELECT count(*), max(user_id), max(relevance_score) + INTO result_count, top_id, score + FROM virginia_dev_saayam_rdbms.search_users( + 'maya.medical@example.test', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count = 0 OR top_id <> 'fn-requester' OR score <= 0 THEN + RAISE EXCEPTION 'search_users exact email failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_users( + '', + 5, + NULL::VARCHAR(255), + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count <> 0 THEN + RAISE EXCEPTION 'search_users empty query failed'; + END IF; + + SELECT count(*), max(org_id), max(relevance_score) + INTO result_count, top_id, score + FROM virginia_dev_saayam_rdbms.search_organizations( + 'Medical Transport', + 5, + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count = 0 OR top_id <> 'fn-org-medical' OR score <= 0 THEN + RAISE EXCEPTION 'search_organizations name search failed'; + END IF; + + SELECT count(*) + INTO result_count + FROM virginia_dev_saayam_rdbms.search_organizations( + '', + 5, + 4::SMALLINT, + NULL::VARCHAR(255)[] + ); + + IF result_count <> 0 THEN + RAISE EXCEPTION 'search_organizations empty query failed'; + END IF; +END $$; + +ROLLBACK; diff --git a/tests/Search/virginia_validation/04_instance_clone_check.sql b/tests/Search/virginia_validation/04_instance_clone_check.sql new file mode 100644 index 0000000..cd9e73c --- /dev/null +++ b/tests/Search/virginia_validation/04_instance_clone_check.sql @@ -0,0 +1,86 @@ +-- Local instance clone check. +-- Run after instance clone and ddl/Search/01..04. + +DO $$ +DECLARE + missing_tables TEXT[]; + missing_columns TEXT[]; + missing_functions TEXT[]; +BEGIN + SELECT array_agg(expected.table_name ORDER BY expected.table_name) + INTO missing_tables + FROM ( + VALUES + ('country'), + ('state'), + ('user_status'), + ('user_category'), + ('users'), + ('help_categories'), + ('request_status'), + ('request_priority'), + ('request_type'), + ('request_for'), + ('request_isleadvol'), + ('request'), + ('organizations'), + ('user_org_map') + ) AS expected(table_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.tables tbl + WHERE tbl.table_schema = 'virginia_dev_saayam_rdbms' + AND tbl.table_name = expected.table_name + ); + + IF missing_tables IS NOT NULL THEN + RAISE EXCEPTION 'missing clone tables: %', array_to_string(missing_tables, ', '); + END IF; + + SELECT array_agg(expected.table_name || '.' || expected.column_name ORDER BY expected.table_name, expected.column_name) + INTO missing_columns + FROM ( + VALUES + ('request', 'search_vector'), + ('users', 'search_vector'), + ('request', 'req_subj'), + ('request', 'req_desc'), + ('request', 'req_loc'), + ('help_categories', 'cat_name'), + ('users', 'full_name'), + ('users', 'primary_email_address'), + ('organizations', 'org_name'), + ('organizations', 'city_name') + ) AS expected(table_name, column_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns col + WHERE col.table_schema = 'virginia_dev_saayam_rdbms' + AND col.table_name = expected.table_name + AND col.column_name = expected.column_name + ); + + IF missing_columns IS NOT NULL THEN + RAISE EXCEPTION 'missing clone/search columns: %', array_to_string(missing_columns, ', '); + END IF; + + SELECT array_agg(expected.function_name ORDER BY expected.function_name) + INTO missing_functions + FROM ( + VALUES + ('search_requests'), + ('search_users'), + ('search_organizations') + ) AS expected(function_name) + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.routines routine + WHERE routine.specific_schema = 'virginia_dev_saayam_rdbms' + AND routine.routine_name = expected.function_name + AND routine.routine_type = 'FUNCTION' + ); + + IF missing_functions IS NOT NULL THEN + RAISE EXCEPTION 'missing search functions: %', array_to_string(missing_functions, ', '); + END IF; +END $$;