An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.

fix: add schema-level tests for V008 migration (MM-89 Issue #8)

V003 and V006 migrations have dedicated PRAGMA table_info tests verifying column
presence and types. V008 (nullable password_hash on accounts, pending_did column
on pending_accounts) had none.

Added four new tests:
1. v008_accounts_password_hash_is_nullable - Verify password_hash has notnull=0
2. v008_pending_accounts_has_pending_did_column - Verify pending_did column exists
3. v008_accounts_can_insert_null_password_hash - Test nullable behavior
4. v008_pending_accounts_pending_did_nullable_and_updatable - Test NULL and UPDATE

All tests use PRAGMA table_info to examine schema and verify migration correctness.

+141
+141
crates/relay/src/db/mod.rs
··· 866 866 "duplicate id must be rejected by PRIMARY KEY constraint" 867 867 ); 868 868 } 869 + 870 + // ── V008 tests ─────────────────────────────────────────────────────────── 871 + 872 + /// Verify that V008 rebuilds accounts with nullable password_hash. 873 + /// Before V008, password_hash was NOT NULL. After V008, it should be nullable. 874 + #[tokio::test] 875 + async fn v008_accounts_password_hash_is_nullable() { 876 + let pool = in_memory_pool().await; 877 + run_migrations(&pool).await.unwrap(); 878 + 879 + // PRAGMA table_info returns: (cid, name, type, notnull, dflt_value, pk) 880 + let columns: Vec<(i32, String, String, i32, Option<String>, i32)> = 881 + sqlx::query_as("PRAGMA table_info(accounts)") 882 + .fetch_all(&pool) 883 + .await 884 + .expect("PRAGMA table_info must succeed"); 885 + 886 + // Find the password_hash column. 887 + let password_hash_col = columns 888 + .iter() 889 + .find(|(_, name, _, _, _, _)| name == "password_hash") 890 + .expect("password_hash column must exist"); 891 + 892 + let notnull = password_hash_col.3; 893 + assert_eq!( 894 + notnull, 0, 895 + "password_hash must be nullable (notnull=0); got notnull={}", 896 + notnull 897 + ); 898 + } 899 + 900 + /// Verify that V008 adds pending_did column to pending_accounts. 901 + #[tokio::test] 902 + async fn v008_pending_accounts_has_pending_did_column() { 903 + let pool = in_memory_pool().await; 904 + run_migrations(&pool).await.unwrap(); 905 + 906 + // PRAGMA table_info returns: (cid, name, type, notnull, dflt_value, pk) 907 + let columns: Vec<(i32, String, String, i32, Option<String>, i32)> = 908 + sqlx::query_as("PRAGMA table_info(pending_accounts)") 909 + .fetch_all(&pool) 910 + .await 911 + .expect("PRAGMA table_info must succeed"); 912 + 913 + // Find the pending_did column. 914 + let pending_did_col = columns 915 + .iter() 916 + .find(|(_, name, _, _, _, _)| name == "pending_did"); 917 + 918 + assert!( 919 + pending_did_col.is_some(), 920 + "pending_did column must exist in pending_accounts after V008" 921 + ); 922 + } 923 + 924 + /// Verify that accounts with NULL password_hash can be inserted (for mobile-provisioned accounts). 925 + #[tokio::test] 926 + async fn v008_accounts_can_insert_null_password_hash() { 927 + let pool = in_memory_pool().await; 928 + run_migrations(&pool).await.unwrap(); 929 + 930 + sqlx::query( 931 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 932 + VALUES ('did:plc:mobile', 'mobile@example.com', NULL, datetime('now'), datetime('now'))", 933 + ) 934 + .execute(&pool) 935 + .await 936 + .expect("insert account with NULL password_hash must succeed"); 937 + 938 + let (stored_hash,): (Option<String>,) = 939 + sqlx::query_as("SELECT password_hash FROM accounts WHERE did = 'did:plc:mobile'") 940 + .fetch_one(&pool) 941 + .await 942 + .expect("query must succeed"); 943 + 944 + assert!(stored_hash.is_none(), "password_hash must be NULL"); 945 + } 946 + 947 + /// Verify that pending_did can be NULL (initial state) and can be updated to a DID string. 948 + #[tokio::test] 949 + async fn v008_pending_accounts_pending_did_nullable_and_updatable() { 950 + let pool = in_memory_pool().await; 951 + run_migrations(&pool).await.unwrap(); 952 + 953 + let claim_code = "TEST-CODE"; 954 + sqlx::query( 955 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 956 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 957 + ) 958 + .bind(claim_code) 959 + .execute(&pool) 960 + .await 961 + .expect("insert claim_code"); 962 + 963 + let account_id = "acct-v008-test"; 964 + sqlx::query( 965 + "INSERT INTO pending_accounts (id, email, handle, tier, claim_code, created_at) \ 966 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 967 + ) 968 + .bind(account_id) 969 + .bind("test@example.com") 970 + .bind("test.example.com") 971 + .bind(claim_code) 972 + .execute(&pool) 973 + .await 974 + .expect("insert pending_account"); 975 + 976 + // Initially, pending_did should be NULL. 977 + let (initial_pending_did,): (Option<String>,) = 978 + sqlx::query_as("SELECT pending_did FROM pending_accounts WHERE id = ?") 979 + .bind(account_id) 980 + .fetch_one(&pool) 981 + .await 982 + .expect("query must succeed"); 983 + 984 + assert!( 985 + initial_pending_did.is_none(), 986 + "pending_did should be NULL initially" 987 + ); 988 + 989 + // Update it to a DID value. 990 + sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 991 + .bind("did:plc:test123") 992 + .bind(account_id) 993 + .execute(&pool) 994 + .await 995 + .expect("update must succeed"); 996 + 997 + let (updated_pending_did,): (Option<String>,) = 998 + sqlx::query_as("SELECT pending_did FROM pending_accounts WHERE id = ?") 999 + .bind(account_id) 1000 + .fetch_one(&pool) 1001 + .await 1002 + .expect("query must succeed"); 1003 + 1004 + assert_eq!( 1005 + updated_pending_did, 1006 + Some("did:plc:test123".to_string()), 1007 + "pending_did should be updated" 1008 + ); 1009 + } 869 1010 }