Builds on cf70021, which added the read-only yeeke client/session/sync core
but left it unreachable and unconfigurable. This commit:
- Wires config.ExtConfig.Yeeke (settings.yml + config.yaml + GOAUTO_YEEKE_*
env vars), mirroring the existing SYB credential pattern exactly, with a
dedicated OcrURL and shared OCR client from sybclient.
- Adds yeeke.StartSync as the single entry point for both a manual admin
trigger and the scheduled job, sharing one in-memory gate plus the existing
DB-level unique active_slot lease so they can never run concurrently.
- Fixes sync.go bugs found in review: Service.Sync always returned a nil
error even when the run failed (start/resume semantics were untestable),
item upserts on ctx-less s.db calls, and no error_message/last_success_at
was ever recorded on the run row.
- Adds status_unrecognized to yeeke_return_package: an unknown claim status
is preserved verbatim and flagged rather than silently bucketed.
- Adds the admin read-only surface (GET .../sync-runs, GET
.../sync-runs/:runId, POST .../sync) under /api/admin/v1/yeeke-returns,
visible to admin and purchaser per the #336 review comment, registered as
a GoAuto access module/menu group and purchaser API.
- Registers GoAutoYeekeReturnSync in the existing job/lease framework
(app/jobs), seeded disabled (Status 2) by a new version-local migration,
following 1786701600000_syb_hourly_sync_job.go's pattern exactly.
- Expands tests: session reuse/bounded re-login/timeout-preserves-cache in
yeekeclient; paging robustness (total changing mid-run, duplicate page,
empty page, timeout, simulated restart/resume), idempotent upserts,
unrecognized-status flagging, active_slot lease contention, StartSync gate
contention, and a credential/captcha redaction check in yeeke; settings.yml
binding and env var precedence in config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
176 lines
5.9 KiB
Go
176 lines
5.9 KiB
Go
package config
|
|
|
|
import (
|
|
"testing"
|
|
|
|
sdkconfig "github.com/go-admin-team/go-admin-core/sdk/config"
|
|
)
|
|
|
|
func TestApplyEnvironment(t *testing.T) {
|
|
originalDefault := *sdkconfig.DatabaseConfig
|
|
originalDatabases := sdkconfig.DatabasesConfig
|
|
originalApplication := *sdkconfig.ApplicationConfig
|
|
t.Cleanup(func() {
|
|
*sdkconfig.DatabaseConfig = originalDefault
|
|
sdkconfig.DatabasesConfig = originalDatabases
|
|
*sdkconfig.ApplicationConfig = originalApplication
|
|
})
|
|
sdkconfig.DatabasesConfig = map[string]*sdkconfig.Database{"*": {Driver: "sqlite3", Source: "old.db"}}
|
|
t.Setenv("GOAUTO_DB_DSN", "user:secret@tcp(127.0.0.1:3306)/goauto")
|
|
t.Setenv("GOAUTO_DB_DRIVER", "mysql")
|
|
t.Setenv("GOAUTO_SERVER_PORT", "18000")
|
|
|
|
ApplyEnvironment()
|
|
|
|
if sdkconfig.DatabaseConfig.Driver != "mysql" || sdkconfig.DatabaseConfig.Source == "" {
|
|
t.Fatal("default database was not overridden")
|
|
}
|
|
if database := sdkconfig.DatabasesConfig["*"]; database.Driver != "mysql" || database.Source != sdkconfig.DatabaseConfig.Source {
|
|
t.Fatal("host database was not overridden")
|
|
}
|
|
if sdkconfig.ApplicationConfig.Port != 18000 {
|
|
t.Fatalf("application port = %d, want 18000", sdkconfig.ApplicationConfig.Port)
|
|
}
|
|
}
|
|
|
|
func TestApplyEnvironmentIgnoresEmptyDatabaseAndInvalidPort(t *testing.T) {
|
|
original := *sdkconfig.DatabaseConfig
|
|
originalApplication := *sdkconfig.ApplicationConfig
|
|
t.Cleanup(func() {
|
|
*sdkconfig.DatabaseConfig = original
|
|
*sdkconfig.ApplicationConfig = originalApplication
|
|
})
|
|
sdkconfig.DatabaseConfig.Driver = "sqlite3"
|
|
sdkconfig.DatabaseConfig.Source = "local.db"
|
|
sdkconfig.ApplicationConfig.Port = 8000
|
|
t.Setenv("GOAUTO_DB_DSN", "")
|
|
t.Setenv("GOAUTO_SERVER_PORT", "70000")
|
|
|
|
ApplyEnvironment()
|
|
|
|
if sdkconfig.DatabaseConfig.Driver != "sqlite3" || sdkconfig.DatabaseConfig.Source != "local.db" {
|
|
t.Fatal("empty environment unexpectedly changed database config")
|
|
}
|
|
if sdkconfig.ApplicationConfig.Port != 8000 {
|
|
t.Fatal("invalid environment unexpectedly changed application port")
|
|
}
|
|
}
|
|
|
|
// 凭据只能来自环境变量:settings.yml 里不放账号密码,避免它们进 Git。
|
|
func TestApplyEnvironmentLoadsSYBCredentials(t *testing.T) {
|
|
original := ExtConfig.SYB
|
|
t.Cleanup(func() { ExtConfig.SYB = original })
|
|
|
|
t.Setenv("GOAUTO_SYB_USERNAME", " operator ")
|
|
t.Setenv("GOAUTO_SYB_PASSWORD", " se cret ")
|
|
ApplyEnvironment()
|
|
|
|
if ExtConfig.SYB.Username != "operator" {
|
|
t.Fatalf("账号应去掉首尾空白: %q", ExtConfig.SYB.Username)
|
|
}
|
|
// 密码原样保留:首尾空白可能是密码的一部分,剪掉就是悄悄改了凭据。
|
|
if ExtConfig.SYB.Password != " se cret " {
|
|
t.Fatalf("密码不应被修改: %q", ExtConfig.SYB.Password)
|
|
}
|
|
}
|
|
|
|
// 同上,yeeke 凭据也只能来自环境变量 (#336)。
|
|
func TestApplyEnvironmentLoadsYeekeCredentials(t *testing.T) {
|
|
original := ExtConfig.Yeeke
|
|
t.Cleanup(func() { ExtConfig.Yeeke = original })
|
|
|
|
t.Setenv("GOAUTO_YEEKE_USERNAME", " operator ")
|
|
t.Setenv("GOAUTO_YEEKE_PASSWORD", " se cret ")
|
|
ApplyEnvironment()
|
|
|
|
if ExtConfig.Yeeke.Username != "operator" {
|
|
t.Fatalf("账号应去掉首尾空白: %q", ExtConfig.Yeeke.Username)
|
|
}
|
|
if ExtConfig.Yeeke.Password != " se cret " {
|
|
t.Fatalf("密码不应被修改: %q", ExtConfig.Yeeke.Password)
|
|
}
|
|
}
|
|
|
|
func TestYeekeResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
|
|
resolved := Yeeke{}.Resolved()
|
|
|
|
if resolved.BaseURL != DefaultYeekeBaseURL {
|
|
t.Fatalf("BaseURL 默认值不对: %q", resolved.BaseURL)
|
|
}
|
|
if resolved.PageSize != DefaultYeekePageSize || resolved.MaxPages != DefaultYeekeMaxPages {
|
|
t.Fatalf("分页默认值不对: %+v", resolved)
|
|
}
|
|
if resolved.OcrMaxAttempts != DefaultYeekeOcrMaxAttempts {
|
|
t.Fatalf("OCR 重试次数默认值不对: %d", resolved.OcrMaxAttempts)
|
|
}
|
|
if resolved.Username != "" || resolved.Password != "" {
|
|
t.Fatal("Resolved 不得给账号密码编造默认值")
|
|
}
|
|
if resolved.OcrURL != "" {
|
|
t.Fatalf("空 OcrURL 不应被填充: %q", resolved.OcrURL)
|
|
}
|
|
}
|
|
|
|
func TestYeekeHasCredentialsRequiresBothFields(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
yeeke Yeeke
|
|
want bool
|
|
}{
|
|
{"都有", Yeeke{Username: "a", Password: "b"}, true},
|
|
{"缺密码", Yeeke{Username: "a"}, false},
|
|
{"缺账号", Yeeke{Password: "b"}, false},
|
|
{"账号只有空白", Yeeke{Username: " ", Password: "b"}, false},
|
|
{"都没有", Yeeke{}, false},
|
|
} {
|
|
if got := c.yeeke.HasCredentials(); got != c.want {
|
|
t.Fatalf("%s: 期望 %v,实际 %v", c.name, c.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSYBResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
|
|
resolved := SYB{}.Resolved()
|
|
|
|
if resolved.BaseURL != DefaultSYBBaseURL {
|
|
t.Fatalf("BaseURL 默认值不对: %q", resolved.BaseURL)
|
|
}
|
|
if resolved.PageSize != DefaultSYBPageSize || resolved.MaxMatches != DefaultSYBMaxMatches {
|
|
t.Fatalf("分页默认值不对: %+v", resolved)
|
|
}
|
|
if resolved.OcrMaxAttempts != DefaultSYBOcrMaxAttempts {
|
|
t.Fatalf("OCR 重试次数默认值不对: %d", resolved.OcrMaxAttempts)
|
|
}
|
|
if resolved.Username != "" || resolved.Password != "" {
|
|
t.Fatal("Resolved 不得给账号密码编造默认值")
|
|
}
|
|
// ocr_url 留空是合法配置(降级到手工输入),不能被填上默认值。
|
|
if resolved.OcrURL != "" {
|
|
t.Fatalf("空 OcrURL 不应被填充: %q", resolved.OcrURL)
|
|
}
|
|
}
|
|
|
|
func TestSYBResolvedNormalisesBaseURLTrailingSlash(t *testing.T) {
|
|
if got := (SYB{BaseURL: " https://example.com/ "}).Resolved().BaseURL; got != "https://example.com" {
|
|
t.Fatalf("BaseURL 规范化不对: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSYBHasCredentialsRequiresBothFields(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
syb SYB
|
|
want bool
|
|
}{
|
|
{"都有", SYB{Username: "a", Password: "b"}, true},
|
|
{"缺密码", SYB{Username: "a"}, false},
|
|
{"缺账号", SYB{Password: "b"}, false},
|
|
{"账号只有空白", SYB{Username: " ", Password: "b"}, false},
|
|
{"都没有", SYB{}, false},
|
|
} {
|
|
if got := c.syb.HasCredentials(); got != c.want {
|
|
t.Fatalf("%s: 期望 %v,实际 %v", c.name, c.want, got)
|
|
}
|
|
}
|
|
}
|