2026-08-03 18:37:42 +08:00
package migrations_test
import (
"context"
"database/sql"
2026-08-04 17:01:35 +08:00
"os"
2026-08-03 18:37:42 +08:00
"path/filepath"
"runtime"
2026-08-04 17:01:35 +08:00
"strconv"
"strings"
2026-08-03 18:37:42 +08:00
"testing"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
"github.com/pressly/goose/v3"
)
2026-08-04 17:01:35 +08:00
const migrationTime = "2026-08-04T00:00:00Z"
2026-08-03 18:37:42 +08:00
func TestUpDownAndIdempotence ( t * testing . T ) {
database := openTestDatabase ( t )
directory := migrationDirectory ( t )
context := context . Background ()
if err := migrations . Up ( context , database , directory ); err != nil {
t . Fatalf ( "apply migrations: %v" , err )
}
2026-08-04 20:57:33 +08:00
assertVersion ( t , database , 4 )
2026-08-03 18:37:42 +08:00
assertTableExists ( t , database , "tasks" , true )
2026-08-04 17:01:35 +08:00
assertTableExists ( t , database , "spec_trials" , false )
2026-08-03 18:37:42 +08:00
assertTableExists ( t , database , "order_authorizations" , true )
2026-08-04 17:01:35 +08:00
assertTableExists ( t , database , "purchase_attempts" , true )
2026-08-03 18:37:42 +08:00
assertTableExists ( t , database , "order_submissions" , true )
2026-08-04 19:42:35 +08:00
assertTableExists ( t , database , "evidence_assets" , true )
2026-08-04 20:57:33 +08:00
assertTableExists ( t , database , "device_credentials" , true )
2026-08-04 17:01:35 +08:00
assertTableExists ( t , database , "single_pass_upgrade_guard" , false )
2026-08-03 18:37:42 +08:00
if err := migrations . Up ( context , database , directory ); err != nil {
t . Fatalf ( "reapply migrations: %v" , err )
}
2026-08-04 20:57:33 +08:00
assertVersion ( t , database , 4 )
if err := migrations . Down ( context , database , directory ); err != nil {
t . Fatalf ( "roll back device credential migration: %v" , err )
}
2026-08-04 19:42:35 +08:00
assertVersion ( t , database , 3 )
2026-08-04 20:57:33 +08:00
assertTableExists ( t , database , "device_credentials" , false )
assertTableExists ( t , database , "evidence_assets" , true )
2026-08-04 19:42:35 +08:00
if err := migrations . Down ( context , database , directory ); err != nil {
t . Fatalf ( "roll back evidence migration: %v" , err )
}
2026-08-04 17:01:35 +08:00
assertVersion ( t , database , 2 )
2026-08-04 19:42:35 +08:00
assertTableExists ( t , database , "evidence_assets" , false )
2026-08-03 18:37:42 +08:00
if err := migrations . Down ( context , database , directory ); err != nil {
2026-08-04 17:01:35 +08:00
t . Fatalf ( "roll back v2 migration: %v" , err )
2026-08-03 18:37:42 +08:00
}
assertVersion ( t , database , 1 )
2026-08-04 17:01:35 +08:00
assertTableExists ( t , database , "spec_trials" , true )
assertTableExists ( t , database , "purchase_attempts" , false )
assertTableExists ( t , database , "single_pass_downgrade_guard" , false )
if err := migrations . Up ( context , database , directory ); err != nil {
t . Fatalf ( "reapply v2 after rollback: %v" , err )
}
2026-08-04 20:57:33 +08:00
assertVersion ( t , database , 4 )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
func TestUpgradePreservesManualDraftLosslessly ( t * testing . T ) {
database := openTestDatabase ( t )
migrateToV1 ( t , database )
if _ , err := database . Exec ( `
INSERT INTO tasks (
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50',
'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
` ); err != nil {
t . Fatalf ( "insert v1 draft: %v" , err )
}
if err := migrations . Up ( context . Background (), database , migrationDirectory ( t )); err != nil {
t . Fatalf ( "upgrade v1 draft: %v" , err )
}
2026-08-04 20:57:33 +08:00
assertVersion ( t , database , 4 )
2026-08-04 17:01:35 +08:00
var got struct {
id , source , sourceRef , title , goodsID , color , size , maxPrice , assetID , status , created , updated string
quantity , version int
}
if err := database . QueryRow ( `SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'` ). Scan (
& got . id , & got . source , & got . sourceRef , & got . title , & got . goodsID , & got . color , & got . size , & got . quantity , & got . maxPrice , & got . assetID , & got . status , & got . version , & got . created , & got . updated ,
); err != nil {
t . Fatalf ( "read upgraded draft: %v" , err )
}
if got != ( struct {
id , source , sourceRef , title , goodsID , color , size , maxPrice , assetID , status , created , updated string
quantity , version int
}{ "draft-one" , "MANUAL" , "source-ref" , "title" , "goods" , "white" , "XL" , "80.50" , "asset-id" , "DRAFT" , "2026-08-03T00:00:00Z" , "2026-08-03T01:00:00Z" , 2 , 7 }) {
t . Fatalf ( "upgraded draft changed: %#v" , got )
}
}
func TestUpgradeRejectsLegacyExecutionDataAtomically ( t * testing . T ) {
tests := [] struct {
name string
setup func ( * testing . T , * sql . DB )
}{
{ "non-draft task" , func ( t * testing . T , database * sql . DB ) {
insertV1Task ( t , database , "pending" , "MANUAL" , "PENDING" , "1.00" )
}},
{ "non-manual task" , func ( t * testing . T , database * sql . DB ) { insertV1Task ( t , database , "excel" , "EXCEL" , "DRAFT" , "1.00" ) }},
{ "invalid v2 money" , func ( t * testing . T , database * sql . DB ) { insertV1Task ( t , database , "zero" , "MANUAL" , "DRAFT" , "0.00" ) }},
{ "third decimal place" , func ( t * testing . T , database * sql . DB ) {
insertV1Task ( t , database , "third-decimal" , "MANUAL" , "DRAFT" , "1.234" )
}},
{ "spec trial" , func ( t * testing . T , database * sql . DB ) {
insertV1Task ( t , database , "task" , "MANUAL" , "DRAFT" , "1.00" )
insertV1SpecTrial ( t , database , "trial" , "task" )
}},
{ "authorization" , func ( t * testing . T , database * sql . DB ) {
insertV1Task ( t , database , "task" , "MANUAL" , "DRAFT" , "1.00" )
insertV1SpecTrial ( t , database , "trial" , "task" )
insertV1Authorization ( t , database , "auth" , "task" , "trial" )
}},
{ "submission" , func ( t * testing . T , database * sql . DB ) {
insertV1Task ( t , database , "task" , "MANUAL" , "DRAFT" , "1.00" )
insertV1SpecTrial ( t , database , "trial" , "task" )
insertV1Authorization ( t , database , "auth" , "task" , "trial" )
if _ , err := database . Exec ( `INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )` , migrationTime ); err != nil {
t . Fatalf ( "insert v1 submission: %v" , err )
}
}},
}
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
database := openTestDatabase ( t )
migrateToV1 ( t , database )
test . setup ( t , database )
before := v1RowCount ( t , database )
if err := migrations . Up ( context . Background (), database , migrationDirectory ( t )); err == nil {
t . Fatal ( "unsafe legacy data upgraded successfully" )
}
assertVersion ( t , database , 1 )
assertTableExists ( t , database , "spec_trials" , true )
assertTableExists ( t , database , "purchase_attempts" , false )
assertTableExists ( t , database , "single_pass_upgrade_guard" , false )
if after := v1RowCount ( t , database ); after != before {
t . Fatalf ( "v1 data changed after rejection: before=%d after=%d" , before , after )
}
})
}
}
func TestV2SchemaConstraintsAndRelationships ( t * testing . T ) {
2026-08-03 18:37:42 +08:00
database := openTestDatabase ( t )
if err := migrations . Up ( context . Background (), database , migrationDirectory ( t )); err != nil {
t . Fatalf ( "apply migrations: %v" , err )
}
2026-08-04 17:01:35 +08:00
for _ , column := range [] struct { table , name string }{
2026-08-03 18:37:42 +08:00
{ "tasks" , "max_total_price" },
{ "order_authorizations" , "total_price_cap" },
2026-08-04 17:01:35 +08:00
{ "purchase_attempts" , "gate1_unit_price" },
{ "purchase_attempts" , "gate2_unit_price" },
{ "purchase_attempts" , "confirm_amount" },
{ "order_submissions" , "gate1_unit_price" },
{ "order_submissions" , "gate2_unit_price" },
{ "order_submissions" , "confirm_amount" },
2026-08-03 18:37:42 +08:00
} {
assertColumnType ( t , database , column . table , column . name , "TEXT" )
}
2026-08-04 17:01:35 +08:00
for _ , legacy := range [] string { "spec_trials" , "authorized_unit_price" , "spec_trial_id" , "command_id" , "dry_run_id" } {
var count int
if err := database . QueryRow ( `SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'` , legacy ). Scan ( & count ); err != nil {
t . Fatalf ( "search schema for %s: %v" , legacy , err )
}
if count != 0 {
t . Fatalf ( "legacy identifier %q remains in v2 schema" , legacy )
}
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
insertV2Task ( t , database , "task-one" , "MANUAL" , "DRAFT" )
insertV2Task ( t , database , "task-two" , "MANUAL" , "DRAFT" )
for index , value := range [] string { "" , "0" , "0.00" , "-1.00" , "1e2" , "1." , "1.234" , " 1.00" , "one" } {
if _ , err := database . Exec ( `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)` , "bad-price-" + strconv . Itoa ( index ), value , migrationTime , migrationTime ); err == nil {
t . Fatalf ( "invalid total price %q succeeded" , value )
}
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
if _ , err := database . Exec ( `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)` , migrationTime , migrationTime ); err == nil {
t . Fatal ( "unknown task status succeeded" )
}
insertV2Authorization ( t , database , "auth-one" , "task-one" , 1 , "start-one" )
insertV2Authorization ( t , database , "auth-two" , "task-two" , 1 , "start-two" )
if _ , err := database . Exec ( `INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)` , migrationTime , migrationTime ); err == nil {
t . Fatal ( "third decimal authorization cap succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)` , migrationTime , migrationTime ); err == nil {
t . Fatal ( "unknown authorization status succeeded" )
}
insertV2Authorization ( t , database , "auth-one-b" , "task-one" , 2 , "start-one-b" )
if _ , err := database . Exec ( `INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)` , migrationTime , migrationTime ); err == nil {
t . Fatal ( "duplicate task version authorization succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)` , migrationTime ); err == nil {
t . Fatal ( "attempt using another task authorization succeeded" )
}
insertV2Attempt ( t , database , "attempt-one" , "task-one" , "auth-one" , 1 )
if _ , err := database . Exec ( `INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)` , migrationTime ); err == nil {
t . Fatal ( "third decimal gate price succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)` , migrationTime ); err == nil {
t . Fatal ( "unknown attempt status succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)` , migrationTime ); err == nil {
t . Fatal ( "unknown failure code succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)` , migrationTime ); err == nil {
t . Fatal ( "submission using another task authorization succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)` , migrationTime ); err == nil {
t . Fatal ( "submission combining another same-task authorization and attempt succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)` , migrationTime ); err == nil {
t . Fatal ( "unknown submission status succeeded" )
}
if _ , err := database . Exec ( `INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)` , migrationTime ); err == nil {
t . Fatal ( "third decimal submission price succeeded" )
}
insertV2Submission ( t , database , "submission-one" , "task-one" , "auth-one" , "attempt-one" )
if _ , err := database . Exec ( `INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)` , migrationTime ); err == nil {
t . Fatal ( "second submission for fenced authorization succeeded" )
}
}
2026-08-03 18:37:42 +08:00
2026-08-04 19:42:35 +08:00
func TestEvidenceSchemaConstraintsAndDowngradeGuard ( t * testing . T ) {
database := openTestDatabase ( t )
if err := migrations . Up ( context . Background (), database , migrationDirectory ( t )); err != nil {
t . Fatalf ( "apply migrations: %v" , err )
}
insertV2Task ( t , database , "task-one" , "MANUAL" , "DRAFT" )
insertV2Authorization ( t , database , "auth-one" , "task-one" , 1 , "start-one" )
insertV2Attempt ( t , database , "attempt-one" , "task-one" , "auth-one" , 1 )
insertV2Task ( t , database , "task-two" , "MANUAL" , "DRAFT" )
insertV2Authorization ( t , database , "auth-two" , "task-two" , 1 , "start-two" )
insertV2Attempt ( t , database , "attempt-two" , "task-two" , "auth-two" , 1 )
hash := strings . Repeat ( "a" , 64 )
insert := `INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
validArgs := [] any { "asset-one" , "upload-one" , "task-one" , "attempt-one" , "SKU_PANEL_GATE_1" , "INTERNAL_RAW" , hash , 100 , "image/png" , 100 , 100 , "aa/" + hash + ".png" , "device-one" , migrationTime , migrationTime }
if _ , err := database . Exec ( insert , validArgs ... ); err != nil {
t . Fatalf ( "insert valid evidence: %v" , err )
}
for name , mutate := range map [ string ] func ([] any ){
"attempt from another task" : func ( values [] any ) { values [ 0 ], values [ 1 ], values [ 3 ] = "bad-task" , "upload-bad-task" , "attempt-two" },
"unapproved kind" : func ( values [] any ) { values [ 0 ], values [ 1 ], values [ 4 ] = "bad-kind" , "upload-bad-kind" , "ORDER_CONFIRM" },
"wrong privacy" : func ( values [] any ) { values [ 0 ], values [ 1 ], values [ 5 ] = "bad-privacy" , "upload-bad-privacy" , "PUBLIC" },
"uppercase hash" : func ( values [] any ) {
values [ 0 ], values [ 1 ], values [ 6 ], values [ 11 ] = "bad-hash" , "upload-bad-hash" , strings . Repeat ( "A" , 64 ), "AA/" + strings . Repeat ( "A" , 64 ) + ".png"
},
"too many pixels" : func ( values [] any ) {
values [ 0 ], values [ 1 ], values [ 9 ], values [ 10 ] = "bad-pixels" , "upload-bad-pixels" , 8192 , 8192
},
"client path" : func ( values [] any ) { values [ 0 ], values [ 1 ], values [ 11 ] = "bad-path" , "upload-bad-path" , `..\secret.png` },
} {
t . Run ( name , func ( t * testing . T ) {
values := append ([] any ( nil ), validArgs ... )
mutate ( values )
if _ , err := database . Exec ( insert , values ... ); err == nil {
t . Fatal ( "invalid evidence row succeeded" )
}
})
}
2026-08-04 20:57:33 +08:00
if err := migrations . Down ( context . Background (), database , migrationDirectory ( t )); err != nil {
t . Fatalf ( "roll back empty device credential migration: %v" , err )
}
2026-08-04 19:42:35 +08:00
if err := migrations . Down ( context . Background (), database , migrationDirectory ( t )); err == nil {
t . Fatal ( "evidence-bearing schema downgraded successfully" )
}
assertVersion ( t , database , 3 )
assertTableExists ( t , database , "evidence_assets" , true )
var count int
if err := database . QueryRow ( "SELECT COUNT(*) FROM evidence_assets" ). Scan ( & count ); err != nil || count != 1 {
t . Fatalf ( "evidence after rejected downgrade = %d, err=%v" , count , err )
}
}
2026-08-04 20:57:33 +08:00
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard ( t * testing . T ) {
database := openTestDatabase ( t )
if err := migrations . Up ( context . Background (), database , migrationDirectory ( t )); err != nil {
t . Fatalf ( "apply migrations: %v" , err )
}
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
hash := make ([] byte , 32 )
for index := range hash {
hash [ index ] = byte ( index + 1 )
}
insert := `INSERT INTO device_credentials (device_id, display_name, token_sha256, status, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)`
valid := [] any { deviceID , "采购工具一号" , hash , "ACTIVE" , migrationTime , nil }
if _ , err := database . Exec ( insert , valid ... ); err != nil {
t . Fatalf ( "insert valid credential: %v" , err )
}
for name , mutate := range map [ string ] func ([] any ){
"uppercase uuid" : func ( values [] any ) { values [ 0 ], values [ 2 ] = strings . ToUpper ( deviceID ), append ([] byte ( nil ), hash ... ) },
"wrong uuid version" : func ( values [] any ) {
values [ 0 ], values [ 2 ] = "23c9f507-7473-3fa6-8d71-8786c34c6301" , append ([] byte ( nil ), hash ... )
},
"blank display name" : func ( values [] any ) {
values [ 0 ], values [ 1 ], values [ 2 ] = "33c9f507-7473-4fa6-8d71-8786c34c6301" , "" , append ([] byte ( nil ), hash ... )
},
"padded display name" : func ( values [] any ) {
values [ 0 ], values [ 1 ], values [ 2 ] = "43c9f507-7473-4fa6-8d71-8786c34c6301" , " padded" , append ([] byte ( nil ), hash ... )
},
"text hash" : func ( values [] any ) {
values [ 0 ], values [ 2 ] = "53c9f507-7473-4fa6-8d71-8786c34c6301" , strings . Repeat ( "a" , 32 )
},
"short blob hash" : func ( values [] any ) { values [ 0 ], values [ 2 ] = "63c9f507-7473-4fa6-8d71-8786c34c6301" , make ([] byte , 31 ) },
"unknown status" : func ( values [] any ) {
values [ 0 ], values [ 2 ], values [ 3 ] = "73c9f507-7473-4fa6-8d71-8786c34c6301" , append ([] byte ( nil ), hash ... ), "UNKNOWN"
},
"active with revoke time" : func ( values [] any ) {
values [ 0 ], values [ 2 ], values [ 5 ] = "83c9f507-7473-4fa6-8d71-8786c34c6301" , append ([] byte ( nil ), hash ... ), migrationTime
},
"revoked without time" : func ( values [] any ) {
values [ 0 ], values [ 2 ], values [ 3 ] = "93c9f507-7473-4fa6-8d71-8786c34c6301" , append ([] byte ( nil ), hash ... ), "REVOKED"
},
"revoke before creation" : func ( values [] any ) {
values [ 0 ], values [ 2 ], values [ 3 ], values [ 4 ], values [ 5 ] = "b3c9f507-7473-4fa6-8d71-8786c34c6301" , append ([] byte ( nil ), hash ... ), "REVOKED" , "2026-08-04T01:00:00Z" , "2026-08-04T00:00:00Z"
},
"non UTC created time" : func ( values [] any ) {
values [ 0 ], values [ 2 ], values [ 4 ] = "a3c9f507-7473-4fa6-8d71-8786c34c6301" , append ([] byte ( nil ), hash ... ), "2026-08-04T08:00:00+08:00"
},
} {
t . Run ( name , func ( t * testing . T ) {
values := append ([] any ( nil ), valid ... )
mutate ( values )
if bytesValue , ok := values [ 2 ].([] byte ); ok && len ( bytesValue ) == 32 {
bytesValue [ 0 ] ++
}
if _ , err := database . Exec ( insert , values ... ); err == nil {
t . Fatal ( "invalid device credential row succeeded" )
}
})
}
if err := migrations . Down ( context . Background (), database , migrationDirectory ( t )); err == nil {
t . Fatal ( "credential-bearing schema downgraded successfully" )
}
assertVersion ( t , database , 4 )
assertTableExists ( t , database , "device_credentials" , true )
var count int
if err := database . QueryRow ( `SELECT COUNT(*) FROM device_credentials` ). Scan ( & count ); err != nil || count != 1 {
t . Fatalf ( "credentials after rejected downgrade = %d, err=%v" , count , err )
}
}
2026-08-04 17:01:35 +08:00
func TestDowngradeRejectsV2BusinessDataAtomically ( t * testing . T ) {
tests := [] struct {
name string
setup func ( * testing . T , * sql . DB )
}{
{ "authorization" , func ( t * testing . T , database * sql . DB ) {
insertV2Task ( t , database , "task" , "MANUAL" , "DRAFT" )
insertV2Authorization ( t , database , "auth" , "task" , 1 , "start" )
}},
{ "attempt" , func ( t * testing . T , database * sql . DB ) {
insertV2Task ( t , database , "task" , "MANUAL" , "DRAFT" )
insertV2Authorization ( t , database , "auth" , "task" , 1 , "start" )
insertV2Attempt ( t , database , "attempt" , "task" , "auth" , 1 )
}},
{ "submission" , func ( t * testing . T , database * sql . DB ) {
insertV2Task ( t , database , "task" , "MANUAL" , "DRAFT" )
insertV2Authorization ( t , database , "auth" , "task" , 1 , "start" )
insertV2Attempt ( t , database , "attempt" , "task" , "auth" , 1 )
insertV2Submission ( t , database , "submission" , "task" , "auth" , "attempt" )
}},
{ "non-draft task" , func ( t * testing . T , database * sql . DB ) { insertV2Task ( t , database , "pending" , "MANUAL" , "PENDING" ) }},
{ "non-manual task" , func ( t * testing . T , database * sql . DB ) { insertV2Task ( t , database , "excel" , "EXCEL" , "DRAFT" ) }},
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
database := openTestDatabase ( t )
2026-08-04 19:42:35 +08:00
migrateToV2 ( t , database )
2026-08-04 17:01:35 +08:00
test . setup ( t , database )
before := v2RowCount ( t , database )
if err := migrations . Down ( context . Background (), database , migrationDirectory ( t )); err == nil {
t . Fatal ( "unsafe v2 data downgraded successfully" )
}
assertVersion ( t , database , 2 )
assertTableExists ( t , database , "purchase_attempts" , true )
assertTableExists ( t , database , "spec_trials" , false )
assertTableExists ( t , database , "single_pass_downgrade_guard" , false )
if after := v2RowCount ( t , database ); after != before {
t . Fatalf ( "v2 data changed after rejected downgrade: before=%d after=%d" , before , after )
}
})
}
}
2026-08-03 18:37:42 +08:00
2026-08-04 17:01:35 +08:00
func migrateToV1 ( t * testing . T , database * sql . DB ) {
t . Helper ()
if err := migrations . Run ( context . Background (), database , migrationDirectory ( t ), "up-by-one" ); err != nil {
t . Fatalf ( "apply v1: %v" , err )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
assertVersion ( t , database , 1 )
}
2026-08-03 18:37:42 +08:00
2026-08-04 19:42:35 +08:00
func migrateToV2 ( t * testing . T , database * sql . DB ) {
t . Helper ()
if err := migrations . Run ( context . Background (), database , migrationDirectory ( t ), "up-by-one" ); err != nil {
t . Fatalf ( "apply v1: %v" , err )
}
if err := migrations . Run ( context . Background (), database , migrationDirectory ( t ), "up-by-one" ); err != nil {
t . Fatalf ( "apply v2: %v" , err )
}
assertVersion ( t , database , 2 )
}
2026-08-04 17:01:35 +08:00
func insertV1Task ( t * testing . T , database * sql . DB , id , source , status , price string ) {
t . Helper ()
if _ , err := database . Exec ( `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)` , id , source , price , status , migrationTime , migrationTime ); err != nil {
t . Fatalf ( "insert v1 task: %v" , err )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
}
2026-08-03 18:37:42 +08:00
2026-08-04 17:01:35 +08:00
func insertV1SpecTrial ( t * testing . T , database * sql . DB , id , taskID string ) {
t . Helper ()
if _ , err := database . Exec ( `INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)` , id , taskID , migrationTime ); err != nil {
t . Fatalf ( "insert v1 spec trial: %v" , err )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
}
2026-08-03 18:37:42 +08:00
2026-08-04 17:01:35 +08:00
func insertV1Authorization ( t * testing . T , database * sql . DB , id , taskID , trialID string ) {
t . Helper ()
if _ , err := database . Exec ( `INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)` , id , taskID , trialID , migrationTime , migrationTime ); err != nil {
t . Fatalf ( "insert v1 authorization: %v" , err )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
}
2026-08-03 18:37:42 +08:00
2026-08-04 17:01:35 +08:00
func insertV2Task ( t * testing . T , database * sql . DB , id , source , status string ) {
t . Helper ()
if _ , err := database . Exec ( `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)` , id , source , status , migrationTime , migrationTime ); err != nil {
t . Fatalf ( "insert v2 task: %v" , err )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
}
2026-08-03 18:37:42 +08:00
2026-08-04 17:01:35 +08:00
func insertV2Authorization ( t * testing . T , database * sql . DB , id , taskID string , version int , startKey string ) {
t . Helper ()
if _ , err := database . Exec ( `INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)` , id , taskID , version , startKey , migrationTime , migrationTime ); err != nil {
t . Fatalf ( "insert v2 authorization: %v" , err )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
}
2026-08-03 18:37:42 +08:00
2026-08-04 17:01:35 +08:00
func insertV2Attempt ( t * testing . T , database * sql . DB , id , taskID , authorizationID string , generation int ) {
t . Helper ()
if _ , err := database . Exec ( `INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)` , id , taskID , authorizationID , generation , migrationTime ); err != nil {
t . Fatalf ( "insert v2 attempt: %v" , err )
2026-08-03 18:37:42 +08:00
}
2026-08-04 17:01:35 +08:00
}
2026-08-03 18:37:42 +08:00
2026-08-04 17:01:35 +08:00
func insertV2Submission ( t * testing . T , database * sql . DB , id , taskID , authorizationID , attemptID string ) {
t . Helper ()
if _ , err := database . Exec ( `INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)` , id , taskID , authorizationID , attemptID , migrationTime ); err != nil {
t . Fatalf ( "insert v2 submission: %v" , err )
2026-08-03 18:37:42 +08:00
}
}
2026-08-04 17:01:35 +08:00
func v1RowCount ( t * testing . T , database * sql . DB ) int {
t . Helper ()
var count int
if err := database . QueryRow ( `SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)` ). Scan ( & count ); err != nil {
t . Fatalf ( "count v1 rows: %v" , err )
}
return count
}
func v2RowCount ( t * testing . T , database * sql . DB ) int {
t . Helper ()
var count int
if err := database . QueryRow ( `SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)` ). Scan ( & count ); err != nil {
t . Fatalf ( "count v2 rows: %v" , err )
}
return count
}
2026-08-03 18:37:42 +08:00
func openTestDatabase ( t * testing . T ) * sql . DB {
t . Helper ()
database , err := sqlite . Open ( filepath . Join ( t . TempDir (), "migrations.db" ))
if err != nil {
t . Fatalf ( "open test database: %v" , err )
}
2026-08-04 17:01:35 +08:00
t . Cleanup ( func () { _ = database . Close () })
2026-08-03 18:37:42 +08:00
return database
}
func migrationDirectory ( t * testing . T ) string {
t . Helper ()
_ , file , _ , ok := runtime . Caller ( 0 )
if ! ok {
t . Fatal ( "locate migration test source" )
}
return filepath . Join ( filepath . Dir ( file ), ".." , ".." , "migrations" )
}
func assertVersion ( t * testing . T , database * sql . DB , want int64 ) {
t . Helper ()
got , err := goose . GetDBVersion ( database )
if err != nil {
t . Fatalf ( "read migration version: %v" , err )
}
if got != want {
t . Fatalf ( "migration version = %d, want %d" , got , want )
}
}
func assertTableExists ( t * testing . T , database * sql . DB , table string , want bool ) {
t . Helper ()
var count int
if err := database . QueryRow ( `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?` , table ). Scan ( & count ); err != nil {
t . Fatalf ( "look up table %s: %v" , table , err )
}
if got := count == 1 ; got != want {
t . Fatalf ( "table %s exists = %t, want %t" , table , got , want )
}
}
func assertColumnType ( t * testing . T , database * sql . DB , table , column , want string ) {
t . Helper ()
var got string
if err := database . QueryRow ( `SELECT type FROM pragma_table_info(?) WHERE name = ?` , table , column ). Scan ( & got ); err != nil {
t . Fatalf ( "read %s.%s type: %v" , table , column , err )
}
if got != want {
t . Fatalf ( "%s.%s type = %s, want %s" , table , column , got , want )
}
}
2026-08-04 19:42:35 +08:00
func TestMigrationsDoNotDisableForeignKeys ( t * testing . T ) {
2026-08-04 20:57:33 +08:00
for _ , name := range [] string { "00002_single_pass_model.sql" , "00003_evidence_assets.sql" , "00004_device_credentials.sql" } {
2026-08-04 19:42:35 +08:00
contents , err := os . ReadFile ( filepath . Join ( migrationDirectory ( t ), name ))
if err != nil {
t . Fatalf ( "read %s: %v" , name , err )
}
if strings . Contains ( strings . ToUpper ( string ( contents )), "PRAGMA FOREIGN_KEYS = OFF" ) {
t . Fatalf ( "%s disables foreign keys" , name )
}
2026-08-03 18:37:42 +08:00
}
}