package message import ( "context" "database/sql" "os" "path/filepath" "strings" "testing" "time" "git.asio.asia/nixevol/NixMsg/internal/app/port" "git.asio.asia/nixevol/NixMsg/internal/store" ) func TestC03PurgePlansUseIndexes(t *testing.T) { t.Parallel() e := openDeliveryEnv(t, nil) ctx := context.Background() var plan string rows, err := e.db.Read.QueryContext(ctx, `EXPLAIN QUERY PLAN DELETE FROM receipts WHERE receipt_id IN ( SELECT receipt_id FROM receipts WHERE created_at < 0 LIMIT 2000)`) if err != nil { t.Fatal(err) } plan = collectPlan(t, rows) if !strings.Contains(plan, "idx_receipts_created") { t.Fatalf("receipts plan missing idx_receipts_created: %s", plan) } if strings.Contains(plan, "SCAN receipts") && !strings.Contains(plan, "USING INDEX") { t.Fatalf("SCAN receipts without index: %s", plan) } rows, err = e.db.Read.QueryContext(ctx, `EXPLAIN QUERY PLAN DELETE FROM send_keys WHERE rowid IN ( SELECT sk.rowid FROM send_keys sk WHERE sk.created_at < 0 AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.sender_id = sk.sender_id AND m.id = sk.msg_id) LIMIT 2000)`) if err != nil { t.Fatal(err) } plan = collectPlan(t, rows) if strings.Contains(plan, "SCAN sk") && !strings.Contains(plan, "USING INDEX") { t.Fatalf("SCAN sk without index: %s", plan) } rows, err = e.db.Read.QueryContext(ctx, `EXPLAIN QUERY PLAN UPDATE deliveries SET pushed_conn = NULL WHERE endpoint_id = 'x' AND state = 'pending' AND pushed_conn = 'y'`) if err != nil { t.Fatal(err) } plan = collectPlan(t, rows) if !strings.Contains(plan, "idx_deliveries_outbox") { t.Fatalf("disconnect plan: %s", plan) } rows, err = e.db.Read.QueryContext(ctx, `EXPLAIN QUERY PLAN SELECT seq FROM messages WHERE completed_at < 0`) if err != nil { t.Fatal(err) } plan = collectPlan(t, rows) if !strings.Contains(plan, "idx_messages_completed_at") { t.Fatalf("completed_at plan missing idx_messages_completed_at: %s", plan) } } func collectPlan(t *testing.T, rows *sql.Rows) string { t.Helper() defer func() { _ = rows.Close() }() var b strings.Builder cols, _ := rows.Columns() vals := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range vals { ptrs[i] = &vals[i] } for rows.Next() { if err := rows.Scan(ptrs...); err != nil { t.Fatal(err) } for i, v := range vals { if i > 0 { b.WriteByte(' ') } switch x := v.(type) { case []byte: b.Write(x) case string: b.WriteString(x) } } b.WriteByte('\n') } return b.String() } func TestC03ExpireDoesNotPurgeReceipts(t *testing.T) { t.Parallel() e := openDeliveryEnv(t, func(l *Limits) { l.ReceiptRetentionDays = 1 }) insertEndpoint(t, e.db, "alice", "", 1, 0) ctx := context.Background() old := e.nowMs - 2*24*3600*1000 err := e.db.Queue.Do(ctx, func(tx *sql.Tx) error { _, err := tx.Exec(`INSERT INTO receipts(sender_id, msg_id, endpoint_id, state, reason, created_at, acked) VALUES('alice','m1','bob','accepted','',?,0)`, old) return err }) if err != nil { t.Fatal(err) } if err := e.app.ExpireOnce(ctx, e.nowMs); err != nil { t.Fatal(err) } var n int _ = e.db.Read.QueryRow(`SELECT COUNT(*) FROM receipts`).Scan(&n) if n != 1 { t.Fatalf("expire deleted receipts: %d", n) } if err := e.app.PurgeOnce(ctx, e.nowMs); err != nil { t.Fatal(err) } _ = e.db.Read.QueryRow(`SELECT COUNT(*) FROM receipts`).Scan(&n) if n != 0 { t.Fatalf("purge left receipts: %d", n) } } func TestC03RetentionZeroDeletesCompleted(t *testing.T) { t.Parallel() e := openDeliveryEnv(t, func(l *Limits) { l.RecordRetentionDays = 0 }) insertEndpoint(t, e.db, "alice", "", 1, 0) ctx := context.Background() err := e.db.Queue.Do(ctx, func(tx *sql.Tx) error { _, err := tx.Exec(`INSERT INTO messages(id, sender_id, dest_kind, dest_id, meta, content_type, body_enc, send_at, keep, ttl_seconds, receipt, state, reason, created_at) VALUES('c0','alice','endpoint','bob','{}','text/plain','utf8',?,?,0,0,'completed','',?)`, e.nowMs, e.nowMs, e.nowMs) return err }) if err != nil { t.Fatal(err) } if err := e.app.PurgeOnce(ctx, e.nowMs); err != nil { t.Fatal(err) } var n int _ = e.db.Read.QueryRow(`SELECT COUNT(*) FROM messages WHERE state='completed'`).Scan(&n) if n != 0 { t.Fatalf("completed left=%d", n) } } func TestC03ZombieExpireThenDrop(t *testing.T) { t.Parallel() e := openDeliveryEnv(t, nil) insertEndpoint(t, e.db, "alice", "", 1, 0) insertEndpoint(t, e.db, "bob", "", 1, 0) _ = e.db.Queue.Do(context.Background(), func(tx *sql.Tx) error { _, err := tx.Exec(`UPDATE endpoints SET online_since=?, offline_since=NULL WHERE id=?`, e.nowMs, "bob") return err }) e.conns.Set("bob", LiveConn{ConnID: "stale"}) // 未就绪:分发仍算在线,但清理视为无就绪连接 ctx := context.Background() if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, baseSend("zom1", "bob")); err != nil { t.Fatal(err) } seq := e.seqOf("alice", "zom1") var exp sql.NullInt64 _ = e.db.Read.QueryRow(`SELECT expire_at FROM deliveries WHERE seq=?`, seq).Scan(&exp) if exp.Valid { t.Fatalf("online handshake-in-progress should leave expire_at empty, got %v", exp) } _ = e.app.OnDisconnect(ctx, "bob", "stale", false) if err := e.app.ExpireOnce(ctx, e.nowMs); err != nil { t.Fatal(err) } _ = e.db.Read.QueryRow(`SELECT expire_at FROM deliveries WHERE seq=?`, seq).Scan(&exp) if !exp.Valid { t.Fatal("zombie expire_at not filled") } e.setNow(exp.Int64 + 1) if err := e.app.ExpireOnce(ctx, e.nowMs); err != nil { t.Fatal(err) } st, reason := e.deliveryState(seq, "bob") if st != DeliveryDropped || reason != ReasonOffline { t.Fatalf("got %s/%s", st, reason) } } func TestC03WALShrinksAfterPurge(t *testing.T) { t.Parallel() dir := t.TempDir() data := filepath.Join(dir, "data") db, err := store.Open(data, "FULL") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) ctx := context.Background() nowMs := int64(1_700_000_000_000) err = db.Queue.Do(ctx, func(tx *sql.Tx) error { if _, err := tx.Exec(`INSERT INTO endpoints(id,name,login_hash,enabled,created_at) VALUES('alice','a','x',1,?)`, nowMs); err != nil { return err } for i := 0; i < 200; i++ { if _, err := tx.Exec(`INSERT INTO messages(id, sender_id, dest_kind, dest_id, meta, content_type, body_enc, send_at, keep, ttl_seconds, receipt, state, reason, created_at) VALUES(?,?, 'endpoint','bob','{}','text/plain','utf8',?,0,0,0,'completed','',?)`, "m"+itoa(i), "alice", nowMs-10*24*3600*1000, nowMs-10*24*3600*1000); err != nil { return err } } return nil }) if err != nil { t.Fatal(err) } walPath := filepath.Join(data, store.DBFileName+"-wal") before, _ := os.Stat(walPath) app := New(db, Limits{RecordRetentionDays: 1, ReceiptRetentionDays: 1, IdempotencyHours: 1}, nil) app.nowFn = func() time.Time { return time.UnixMilli(nowMs) } if err := app.PurgeOnce(ctx, nowMs); err != nil { t.Fatal(err) } after, err := os.Stat(walPath) if err == nil && before != nil && after.Size() > before.Size() { t.Fatalf("wal grew %d -> %d", before.Size(), after.Size()) } var n int _ = db.Read.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n) if n != 0 { t.Fatalf("messages left=%d", n) } } func TestC03DispatchOfflineStillOnlineInDB(t *testing.T) { t.Parallel() e := openDeliveryEnv(t, nil) insertEndpoint(t, e.db, "alice", "", 1, 0) insertEndpoint(t, e.db, "bob", "", 1, 0) _ = e.db.Queue.Do(context.Background(), func(tx *sql.Tx) error { _, err := tx.Exec(`UPDATE endpoints SET online_since=?, offline_since=NULL WHERE id=?`, e.nowMs-1000, "bob") return err }) ctx := context.Background() if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, baseSend("dbon", "bob")); err != nil { t.Fatal(err) } seq := e.seqOf("alice", "dbon") st, _ := e.deliveryState(seq, "bob") if st != DeliveryPending { t.Fatalf("state=%s", st) } var exp sql.NullInt64 _ = e.db.Read.QueryRow(`SELECT expire_at FROM deliveries WHERE seq=?`, seq).Scan(&exp) if !exp.Valid || exp.Int64 != e.nowMs+60_000 { t.Fatalf("expire_at=%v want now+grace", exp) } } func TestC03ReadPoolAndQueryOnly(t *testing.T) { t.Parallel() dir := t.TempDir() db, err := store.Open(filepath.Join(dir, "data"), "FULL") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) ctx := context.Background() if _, err := db.Read.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('k','v',1)`); err == nil { t.Fatal("read connection accepted write") } }