Files
MyGoNavi/internal/app/methods_file_sql_managed_test.go
Syngnat 390a249105 feat(data-import): 重构大文件导入与安全任务运行时
- SQL、CSV、JSON 与 XLSX 改为资源受限的流式解析,并支持压缩、编码和方言预检

- 增加遇错停止/继续策略、持久任务、错误行导出、源文件身份与安全取消

- 为数据库驱动补齐上下文事务和写入结果未知语义,避免失败重放与脏会话复用
2026-08-08 20:51:23 +08:00

309 lines
10 KiB
Go

package app
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/importjob"
"GoNavi-Wails/internal/uievents"
)
func installSQLImportTestDatabase(t *testing.T, database db.Database) {
t.Helper()
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return config, nil
}
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
}
func newManagedSQLImportTestApp(t *testing.T) *App {
t.Helper()
app := NewApp()
app.configDir = t.TempDir()
app.ctx = uievents.WithEmitter(context.Background(), noopImportEventEmitter{})
return app
}
func TestImportDatabaseSQLPersistsCompletedManagedJobAndSourceDigest(t *testing.T) {
database := &fakeSQLFileBatchDB{}
installSQLImportTestDatabase(t, database)
path := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(path, []byte("INSERT INTO users(id) VALUES (1);"), 0o600); err != nil {
t.Fatal(err)
}
app := newManagedSQLImportTestApp(t)
result := app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306},
"app", path, "sql-import-complete", false,
)
if !result.Success {
t.Fatalf("SQL import failed: %#v", result)
}
store, err := app.ensureImportJobStore()
if err != nil {
t.Fatal(err)
}
job, err := store.Get("sql-import-complete")
if err != nil {
t.Fatal(err)
}
if job.Kind != importjob.KindSQL || job.Status != importjob.StatusCompleted || job.Succeeded != 1 || job.Failed != 0 {
t.Fatalf("unexpected SQL job: %#v", job)
}
if job.SourceIdentityToken == "" || job.SourceContentSHA256 == "" || job.SourceBytesTotal <= 0 || job.BytesRead != job.SourceBytesTotal || job.ByteProgressKind != "rawSource" {
t.Fatalf("SQL source identity/progress was not persisted: %#v", job)
}
}
func TestImportDatabaseSQLPersistsPartialManagedJob(t *testing.T) {
database := &fakeSQLFileBatchDB{failExecSQL: "broken_proc"}
installSQLImportTestDatabase(t, database)
path := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(path, []byte("CALL broken_proc();\nINSERT INTO users(id) VALUES (1);"), 0o600); err != nil {
t.Fatal(err)
}
app := newManagedSQLImportTestApp(t)
result := app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306},
"app", path, "sql-import-partial", true,
)
if result.Success {
t.Fatalf("partial SQL import must not report full success: %#v", result)
}
store, err := app.ensureImportJobStore()
if err != nil {
t.Fatal(err)
}
job, err := store.Get("sql-import-partial")
if err != nil {
t.Fatal(err)
}
if job.Status != importjob.StatusPartial || job.Succeeded != 1 || job.Failed != 1 || job.OutcomeUnknown {
t.Fatalf("unexpected partial SQL job: %#v", job)
}
}
func TestImportDatabaseSQLPersistsUnknownJobWhenAutomaticCommitFails(t *testing.T) {
database := &fakeSQLFileBatchDB{failExecSQL: "COMMIT"}
installSQLImportTestDatabase(t, database)
path := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(path, []byte("INSERT INTO users(id) VALUES (1);"), 0o600); err != nil {
t.Fatal(err)
}
app := newManagedSQLImportTestApp(t)
result := app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306},
"app", path, "sql-import-commit-unknown", false,
)
if result.Success {
t.Fatalf("failed automatic commit must not report success: %#v", result)
}
payload, _ := result.Data.(map[string]interface{})
if payload["outcomeUnknown"] != true {
t.Fatalf("failed automatic commit must report an unknown outcome: %#v", result)
}
store, err := app.ensureImportJobStore()
if err != nil {
t.Fatal(err)
}
job, err := store.Get("sql-import-commit-unknown")
if err != nil {
t.Fatal(err)
}
if job.Status != importjob.StatusUnknown || !job.OutcomeUnknown {
t.Fatalf("managed job must preserve the unknown commit outcome: %#v", job)
}
}
func TestSQLImportOptionsHashSeparatesErrorPolicies(t *testing.T) {
if buildSQLImportOptionsHash(false, DefaultSQLImportMaxStatementBytes) == buildSQLImportOptionsHash(true, DefaultSQLImportMaxStatementBytes) {
t.Fatal("different SQL error policies must not share an options hash")
}
if buildSQLImportOptionsHash(false, 0) != buildSQLImportOptionsHash(false, DefaultSQLImportMaxStatementBytes) {
t.Fatal("default SQL statement limit should have a canonical options hash")
}
}
func TestImportDatabaseSQLMarksOutcomeUnknownWhenSourceChangesDuringExecution(t *testing.T) {
path := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(path, []byte("INSERT INTO users(id) VALUES (1);\nINSERT INTO users(id) VALUES (2);"), 0o600); err != nil {
t.Fatal(err)
}
mutated := false
database := &fakeSQLFileBatchDB{execError: func(string) error {
if mutated {
return nil
}
mutated = true
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
t.Errorf("open SQL source for mutation: %v", err)
return nil
}
if _, err := file.WriteString("\nSELECT 3;"); err != nil {
t.Errorf("mutate SQL source: %v", err)
}
if err := file.Close(); err != nil {
t.Errorf("close mutated SQL source: %v", err)
}
return nil
}}
installSQLImportTestDatabase(t, database)
app := newManagedSQLImportTestApp(t)
result := app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306},
"app", path, "sql-import-mutated", true,
)
if result.Success {
t.Fatalf("mutated SQL source must not report a certain success: %#v", result)
}
payload, ok := result.Data.(map[string]interface{})
if !ok || payload["sourceChanged"] != true || payload["outcomeUnknown"] != true {
t.Fatalf("unexpected source-change payload: %#v", result.Data)
}
store, err := app.ensureImportJobStore()
if err != nil {
t.Fatal(err)
}
job, err := store.Get("sql-import-mutated")
if err != nil {
t.Fatal(err)
}
if job.Status != importjob.StatusUnknown || !job.OutcomeUnknown {
t.Fatalf("unexpected source-change job: %#v", job)
}
}
func TestImportDatabaseSQLCanCancelFullPreflightBeforeDatabaseOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(path, []byte("INSERT INTO users(id) VALUES (1);"), 0o600); err != nil {
t.Fatal(err)
}
originalHook := sqlFilePreflightReadHook
t.Cleanup(func() { sqlFilePreflightReadHook = originalHook })
entered := make(chan struct{})
var once sync.Once
sqlFilePreflightReadHook = func(ctx context.Context) {
once.Do(func() { close(entered) })
<-ctx.Done()
}
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
opened := false
newDatabaseFunc = func(string) (db.Database, error) {
opened = true
return &fakeSQLFileBatchDB{}, nil
}
app := newManagedSQLImportTestApp(t)
resultCh := make(chan connection.QueryResult, 1)
go func() {
resultCh <- app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306},
"app", path, "sql-import-preflight-cancel", false,
)
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("SQL preflight did not start")
}
if cancelResult := app.CancelSQLFileExecution("sql-import-preflight-cancel"); !cancelResult.Success {
t.Fatalf("cancel failed: %#v", cancelResult)
}
var result connection.QueryResult
select {
case result = <-resultCh:
case <-time.After(5 * time.Second):
t.Fatal("cancelled SQL preflight did not unwind")
}
if result.Success {
t.Fatalf("cancelled preflight reported success: %#v", result)
}
payload, _ := result.Data.(map[string]interface{})
if payload["cancelled"] != true || opened {
t.Fatalf("result=%#v opened=%v, want cancellation before database open", result, opened)
}
store, err := app.ensureImportJobStore()
if err != nil {
t.Fatal(err)
}
job, err := store.Get("sql-import-preflight-cancel")
if err != nil {
t.Fatal(err)
}
if job.Status != importjob.StatusCancelled {
t.Fatalf("status = %q, want cancelled", job.Status)
}
}
func TestImportDatabaseSQLMarksInFlightCancellationOutcomeUnknown(t *testing.T) {
path := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(path, []byte("INSERT INTO users(id) VALUES (1);"), 0o600); err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
release := make(chan struct{})
var once sync.Once
database := &fakeSQLFileBatchDB{execError: func(query string) error {
if strings.Contains(query, "INSERT INTO users") {
once.Do(func() { close(entered) })
<-release
return context.Canceled
}
return nil
}}
installSQLImportTestDatabase(t, database)
app := newManagedSQLImportTestApp(t)
resultCh := make(chan connection.QueryResult, 1)
go func() {
resultCh <- app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306},
"app", path, "sql-import-write-cancel", true,
)
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("SQL write did not start")
}
if cancelResult := app.CancelSQLFileExecution("sql-import-write-cancel"); !cancelResult.Success {
t.Fatalf("cancel failed: %#v", cancelResult)
}
close(release)
var result connection.QueryResult
select {
case result = <-resultCh:
case <-time.After(5 * time.Second):
t.Fatal("cancelled SQL write did not unwind")
}
payload, _ := result.Data.(map[string]interface{})
if result.Success || payload["cancelled"] != true || payload["outcomeUnknown"] != true {
t.Fatalf("in-flight cancellation must report an unknown outcome: %#v", result)
}
store, err := app.ensureImportJobStore()
if err != nil {
t.Fatal(err)
}
job, err := store.Get("sql-import-write-cancel")
if err != nil {
t.Fatal(err)
}
if job.Status != importjob.StatusUnknown || !job.OutcomeUnknown {
t.Fatalf("managed job must preserve the unknown cancellation outcome: %#v", job)
}
}