mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-13 18:14:24 +08:00
- SQL、CSV、JSON 与 XLSX 改为资源受限的流式解析,并支持压缩、编码和方言预检 - 增加遇错停止/继续策略、持久任务、错误行导出、源文件身份与安全取消 - 为数据库驱动补齐上下文事务和写入结果未知语义,避免失败重放与脏会话复用
309 lines
10 KiB
Go
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)
|
|
}
|
|
}
|