@@ -42,6 +42,7 @@ logs/
|
|||||||
# ==================== 测试 ====================
|
# ==================== 测试 ====================
|
||||||
/test/
|
/test/
|
||||||
*_test.go
|
*_test.go
|
||||||
|
!tests/acceptance/*_test.go
|
||||||
*_test_config.go
|
*_test_config.go
|
||||||
**/logtest/
|
**/logtest/
|
||||||
*_test.yaml
|
*_test.yaml
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Acceptance Tests
|
||||||
|
|
||||||
|
This package contains black-box Go tests for the staging API surface.
|
||||||
|
|
||||||
|
## Local smoke run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./tests/acceptance/... -staging-url=https://tapi.hifast.biz
|
||||||
|
```
|
||||||
|
|
||||||
|
Without credentials, tests that require admin/user login or NodeSecret are skipped and unauthenticated error-path checks still run.
|
||||||
|
|
||||||
|
## Required secrets
|
||||||
|
|
||||||
|
The staging workflow should provide these values through GitHub Actions secrets or environment variables:
|
||||||
|
|
||||||
|
- `ACCEPTANCE_ADMIN_EMAIL`
|
||||||
|
- `ACCEPTANCE_ADMIN_PASSWORD`
|
||||||
|
- `ACCEPTANCE_USER_EMAIL`
|
||||||
|
- `ACCEPTANCE_USER_PASSWORD`
|
||||||
|
- `STAGING_DB_HOST`
|
||||||
|
- `STAGING_DB_USER`
|
||||||
|
- `STAGING_DB_PASSWORD`
|
||||||
|
- `STAGING_DB_NAME`
|
||||||
|
- `STAGING_REDIS_ADDR`
|
||||||
|
- `STAGING_REDIS_PASSWORD`
|
||||||
|
- `STAGING_BASE_URL`
|
||||||
|
|
||||||
|
`ACCEPTANCE_NODE_SECRET` is optional. When DB credentials are present, the loader reads NodeSecret from the staging `system` table instead of storing it as a long-lived secret.
|
||||||
|
|
||||||
|
## Useful flags
|
||||||
|
|
||||||
|
- `-staging-url`: API base URL.
|
||||||
|
- `-run-id`: run identifier used for QA-owned transient data.
|
||||||
|
- `-report-path`: write a JSON summary artifact.
|
||||||
|
- `-seed-sql`: seed SQL path, default `fixtures/seed.sql`.
|
||||||
|
- `-node-server-id` and `-node-protocol`: node smoke target.
|
||||||
|
|
||||||
|
## Fixture rules
|
||||||
|
|
||||||
|
Seed data must use a `qa_acceptance_` prefix or the provided `RUN_ID`. Cleanup must only delete QA-prefixed rows and Redis keys. Do not update or delete production-like staging data.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package acceptance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
code := m.Run()
|
||||||
|
if err := writeReport(testConfig); err != nil && code == 0 {
|
||||||
|
code = 1
|
||||||
|
}
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixtureSeedLoader(t *testing.T) {
|
||||||
|
c := cfg(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
newFixtureLoader(c).SeedIfConfigured(t, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicSmoke(t *testing.T) {
|
||||||
|
c := cfg(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
client := newClient(c)
|
||||||
|
|
||||||
|
t.Run("happy path user info", func(t *testing.T) {
|
||||||
|
token := loginUser(t, ctx, client, c)
|
||||||
|
envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/public/user/info", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
assertOK(t, envelope, raw, status)
|
||||||
|
recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "200 envelope")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error path missing auth", func(t *testing.T) {
|
||||||
|
envelope, raw, status, err := client.Get(ctx, "/v1/public/user/info", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
assertAPIError(t, envelope, raw, status)
|
||||||
|
recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "non-200 envelope without auth")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSmoke(t *testing.T) {
|
||||||
|
c := cfg(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
client := newClient(c)
|
||||||
|
|
||||||
|
t.Run("happy path current admin", func(t *testing.T) {
|
||||||
|
token := loginAdmin(t, ctx, client, c)
|
||||||
|
envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/admin/user/current", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
assertOK(t, envelope, raw, status)
|
||||||
|
recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "200 envelope")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error path missing auth", func(t *testing.T) {
|
||||||
|
envelope, raw, status, err := client.Get(ctx, "/v1/admin/user/current", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
assertAPIError(t, envelope, raw, status)
|
||||||
|
recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "non-200 envelope without auth")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeSmoke(t *testing.T) {
|
||||||
|
c := cfg(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
client := newClient(c)
|
||||||
|
loader := newFixtureLoader(c)
|
||||||
|
|
||||||
|
t.Run("happy path server config", func(t *testing.T) {
|
||||||
|
secret, err := loader.NodeSecret(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load node secret: %v", err)
|
||||||
|
}
|
||||||
|
secret = requireSecret(t, "node secret", secret)
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("secret_key", secret)
|
||||||
|
query.Set("protocols", c.NodeProtocol)
|
||||||
|
envelope, raw, status, err := client.Get(ctx, "/v2/server/"+c.NodeServerID, query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
assertOK(t, envelope, raw, status)
|
||||||
|
recordCase(t, "node", http.MethodGet, "/v2/server/{server_id}", "200 envelope")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error path invalid secret", func(t *testing.T) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("secret_key", "invalid-"+c.RunID)
|
||||||
|
query.Set("protocols", c.NodeProtocol)
|
||||||
|
_, raw, status, err := client.Get(ctx, "/v1/server/config", query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Fatalf("http status = %d, want 403; body=%s", status, sanitizeBody(raw))
|
||||||
|
}
|
||||||
|
recordCase(t, "node", http.MethodGet, "/v1/server/config", "403 with invalid secret")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package acceptance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginAdmin(t *testing.T, ctx context.Context, client *Client, c Config) string {
|
||||||
|
t.Helper()
|
||||||
|
return login(t, ctx, client, "/v1/auth/admin/login", c.AdminEmail, c.AdminPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginUser(t *testing.T, ctx context.Context, client *Client, c Config) string {
|
||||||
|
t.Helper()
|
||||||
|
return login(t, ctx, client, "/v1/auth/login", c.UserEmail, c.UserPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(t *testing.T, ctx context.Context, client *Client, path string, email string, password string) string {
|
||||||
|
t.Helper()
|
||||||
|
requireSecret(t, "login email", email)
|
||||||
|
requireSecret(t, "login password", password)
|
||||||
|
|
||||||
|
envelope, raw, status, err := client.PostJSON(ctx, path, map[string]string{
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login request failed: %v", err)
|
||||||
|
}
|
||||||
|
assertOK(t, envelope, raw, status)
|
||||||
|
|
||||||
|
var data loginResponse
|
||||||
|
if err := json.Unmarshal(envelope.Data, &data); err != nil {
|
||||||
|
t.Fatalf("decode login response: %v", err)
|
||||||
|
}
|
||||||
|
if data.Token == "" {
|
||||||
|
t.Fatal("login response token is empty")
|
||||||
|
}
|
||||||
|
return data.Token
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package acceptance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
Code uint32 `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(c Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(c.StagingURL, "/"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: c.HTTPTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) WithToken(token string) *Client {
|
||||||
|
next := *c
|
||||||
|
next.token = token
|
||||||
|
return &next
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Get(ctx context.Context, path string, query url.Values) (*Envelope, []byte, int, error) {
|
||||||
|
return c.Do(ctx, http.MethodGet, path, query, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) PostJSON(ctx context.Context, path string, payload any) (*Envelope, []byte, int, error) {
|
||||||
|
return c.Do(ctx, http.MethodPost, path, nil, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Do(ctx context.Context, method string, path string, query url.Values, payload any) (*Envelope, []byte, int, error) {
|
||||||
|
target, err := url.Parse(c.baseURL + "/" + strings.TrimLeft(path, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, fmt.Errorf("build request URL: %w", err)
|
||||||
|
}
|
||||||
|
if query != nil {
|
||||||
|
target.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, fmt.Errorf("marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, target.String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "hifast-acceptance/1.0")
|
||||||
|
req.Header.Set("Login-Type", "email")
|
||||||
|
if payload != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", c.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, fmt.Errorf("%s %s: %w", method, target.Redacted(), err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envelope := &Envelope{}
|
||||||
|
if strings.Contains(resp.Header.Get("Content-Type"), "application/json") && len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, envelope); err != nil {
|
||||||
|
return nil, raw, resp.StatusCode, fmt.Errorf("decode response envelope: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return envelope, raw, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOK(t *testing.T, envelope *Envelope, raw []byte, status int) {
|
||||||
|
t.Helper()
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("http status = %d, want 200; body=%s", status, sanitizeBody(raw))
|
||||||
|
}
|
||||||
|
if envelope == nil || envelope.Code != 200 {
|
||||||
|
t.Fatalf("api code = %d, want 200; msg=%q body=%s", envelope.Code, envelope.Msg, sanitizeBody(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAPIError(t *testing.T, envelope *Envelope, raw []byte, status int) {
|
||||||
|
t.Helper()
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("http status = %d, want 200 API envelope; body=%s", status, sanitizeBody(raw))
|
||||||
|
}
|
||||||
|
if envelope == nil || envelope.Code == 200 {
|
||||||
|
t.Fatalf("api code = %d, want non-200; body=%s", envelope.Code, sanitizeBody(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeBody(raw []byte) string {
|
||||||
|
value := string(raw)
|
||||||
|
if len(value) > 512 {
|
||||||
|
value = value[:512] + "...<truncated>"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package acceptance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
StagingURL string
|
||||||
|
AdminEmail string
|
||||||
|
AdminPassword string
|
||||||
|
UserEmail string
|
||||||
|
UserPassword string
|
||||||
|
NodeSecret string
|
||||||
|
NodeServerID string
|
||||||
|
NodeProtocol string
|
||||||
|
RunID string
|
||||||
|
ReportPath string
|
||||||
|
SeedSQLPath string
|
||||||
|
DB DBConfig
|
||||||
|
Redis RedisConfig
|
||||||
|
HTTPTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type DBConfig struct {
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisConfig struct {
|
||||||
|
Addr string
|
||||||
|
Password string
|
||||||
|
DB int
|
||||||
|
}
|
||||||
|
|
||||||
|
var testConfig = Config{
|
||||||
|
HTTPTimeout: 15 * time.Second,
|
||||||
|
NodeServerID: "31",
|
||||||
|
NodeProtocol: "trojan",
|
||||||
|
SeedSQLPath: "fixtures/seed.sql",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.StringVar(&testConfig.StagingURL, "staging-url", getenv("STAGING_BASE_URL", "https://tapi.hifast.biz"), "staging base URL")
|
||||||
|
flag.StringVar(&testConfig.AdminEmail, "admin-email", os.Getenv("ACCEPTANCE_ADMIN_EMAIL"), "admin login email")
|
||||||
|
flag.StringVar(&testConfig.AdminPassword, "admin-password", os.Getenv("ACCEPTANCE_ADMIN_PASSWORD"), "admin login password")
|
||||||
|
flag.StringVar(&testConfig.UserEmail, "user-email", os.Getenv("ACCEPTANCE_USER_EMAIL"), "user login email")
|
||||||
|
flag.StringVar(&testConfig.UserPassword, "user-password", os.Getenv("ACCEPTANCE_USER_PASSWORD"), "user login password")
|
||||||
|
flag.StringVar(&testConfig.NodeSecret, "node-secret", os.Getenv("ACCEPTANCE_NODE_SECRET"), "node secret; read from staging DB when omitted")
|
||||||
|
flag.StringVar(&testConfig.NodeServerID, "node-server-id", getenv("ACCEPTANCE_NODE_SERVER_ID", testConfig.NodeServerID), "server id for node smoke tests")
|
||||||
|
flag.StringVar(&testConfig.NodeProtocol, "node-protocol", getenv("ACCEPTANCE_NODE_PROTOCOL", testConfig.NodeProtocol), "server protocol for node smoke tests")
|
||||||
|
flag.StringVar(&testConfig.RunID, "run-id", getenv("ACCEPTANCE_RUN_ID", defaultRunID()), "acceptance run id")
|
||||||
|
flag.StringVar(&testConfig.ReportPath, "report-path", os.Getenv("ACCEPTANCE_REPORT_PATH"), "optional JSON report path")
|
||||||
|
flag.StringVar(&testConfig.SeedSQLPath, "seed-sql", getenv("ACCEPTANCE_SEED_SQL", testConfig.SeedSQLPath), "seed SQL file path")
|
||||||
|
flag.StringVar(&testConfig.DB.Host, "db-host", os.Getenv("STAGING_DB_HOST"), "staging DB host")
|
||||||
|
flag.StringVar(&testConfig.DB.User, "db-user", os.Getenv("STAGING_DB_USER"), "staging DB user")
|
||||||
|
flag.StringVar(&testConfig.DB.Password, "db-password", os.Getenv("STAGING_DB_PASSWORD"), "staging DB password")
|
||||||
|
flag.StringVar(&testConfig.DB.Name, "db-name", os.Getenv("STAGING_DB_NAME"), "staging DB name")
|
||||||
|
flag.StringVar(&testConfig.Redis.Addr, "redis-addr", os.Getenv("STAGING_REDIS_ADDR"), "staging Redis addr")
|
||||||
|
flag.StringVar(&testConfig.Redis.Password, "redis-password", os.Getenv("STAGING_REDIS_PASSWORD"), "staging Redis password")
|
||||||
|
flag.IntVar(&testConfig.Redis.DB, "redis-db", getenvInt("STAGING_REDIS_DB", 0), "staging Redis DB")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cfg(t *testing.T) Config {
|
||||||
|
t.Helper()
|
||||||
|
if strings.TrimSpace(testConfig.StagingURL) == "" {
|
||||||
|
t.Fatal("staging-url is required")
|
||||||
|
}
|
||||||
|
return testConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSecret(t *testing.T, name string, value string) string {
|
||||||
|
t.Helper()
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
t.Skipf("%s is required for this acceptance test", name)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key string, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvInt(key string, fallback int) int {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRunID() string {
|
||||||
|
runID := os.Getenv("GITHUB_RUN_ID")
|
||||||
|
attempt := getenv("GITHUB_RUN_ATTEMPT", "1")
|
||||||
|
if runID == "" {
|
||||||
|
return "qa_local"
|
||||||
|
}
|
||||||
|
return "qa_" + runID + "_" + attempt
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package acceptance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FixtureLoader struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixtureLoader(c Config) *FixtureLoader {
|
||||||
|
return &FixtureLoader{cfg: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FixtureLoader) SeedIfConfigured(t *testing.T, ctx context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
if !l.hasDBConfig() {
|
||||||
|
t.Log("staging DB config not provided; skipping seed loader")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sqlBytes, err := os.ReadFile(l.cfg.SeedSQLPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read seed SQL %q: %v", l.cfg.SeedSQLPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("mysql", l.dsn(true))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open staging DB: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = db.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
t.Fatalf("ping staging DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := splitSQL(string(sqlBytes))
|
||||||
|
for _, statement := range statements {
|
||||||
|
statement = strings.ReplaceAll(statement, "{{RUN_ID}}", l.cfg.RunID)
|
||||||
|
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||||
|
t.Fatalf("execute seed SQL: %v; statement=%s", err, statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FixtureLoader) NodeSecret(ctx context.Context) (string, error) {
|
||||||
|
if l.cfg.NodeSecret != "" {
|
||||||
|
return l.cfg.NodeSecret, nil
|
||||||
|
}
|
||||||
|
if !l.hasDBConfig() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("mysql", l.dsn(false))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open staging DB: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = db.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var secret string
|
||||||
|
err = db.QueryRowContext(ctx, "SELECT value FROM system WHERE category = 'server' AND `key` = 'NodeSecret' LIMIT 1").Scan(&secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read NodeSecret from staging DB: %w", err)
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FixtureLoader) RedisClient() *redis.Client {
|
||||||
|
if l.cfg.Redis.Addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return redis.NewClient(&redis.Options{
|
||||||
|
Addr: l.cfg.Redis.Addr,
|
||||||
|
Password: l.cfg.Redis.Password,
|
||||||
|
DB: l.cfg.Redis.DB,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FixtureLoader) hasDBConfig() bool {
|
||||||
|
return l.cfg.DB.Host != "" && l.cfg.DB.User != "" && l.cfg.DB.Name != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FixtureLoader) dsn(multiStatements bool) string {
|
||||||
|
params := "parseTime=true&timeout=5s&readTimeout=10s&writeTimeout=10s"
|
||||||
|
if multiStatements {
|
||||||
|
params += "&multiStatements=true"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s",
|
||||||
|
l.cfg.DB.User,
|
||||||
|
l.cfg.DB.Password,
|
||||||
|
l.cfg.DB.Host,
|
||||||
|
l.cfg.DB.Name,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSQL(script string) []string {
|
||||||
|
parts := strings.Split(script, ";")
|
||||||
|
statements := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
lines := strings.Split(part, "\n")
|
||||||
|
kept := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
statement := strings.Join(kept, "\n")
|
||||||
|
if statement == "" || strings.HasPrefix(statement, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
statements = append(statements, statement)
|
||||||
|
}
|
||||||
|
return statements
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Idempotent staging acceptance fixture seed.
|
||||||
|
-- HIF-5 lands the loader and smoke skeleton; HIF-6/HIF-7 can extend this file
|
||||||
|
-- with concrete qa_acceptance_ rows after the MVP surface is expanded.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package acceptance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitSQLDropsCommentsAndKeepsStatements(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
-- comment before first statement
|
||||||
|
SELECT 1;
|
||||||
|
|
||||||
|
-- comment before second statement
|
||||||
|
SELECT '{{RUN_ID}}';
|
||||||
|
`
|
||||||
|
|
||||||
|
statements := splitSQL(script)
|
||||||
|
if len(statements) != 2 {
|
||||||
|
t.Fatalf("len(statements) = %d, want 2: %#v", len(statements), statements)
|
||||||
|
}
|
||||||
|
if statements[0] != "SELECT 1" {
|
||||||
|
t.Fatalf("first statement = %q", statements[0])
|
||||||
|
}
|
||||||
|
if statements[1] != "SELECT '{{RUN_ID}}'" {
|
||||||
|
t.Fatalf("second statement = %q", statements[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeBodyTruncatesLongBodies(t *testing.T) {
|
||||||
|
raw := []byte(strings.Repeat("a", 600))
|
||||||
|
got := sanitizeBody(raw)
|
||||||
|
if len(got) <= 512 {
|
||||||
|
t.Fatalf("sanitized body length = %d, want truncation marker", len(got))
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<truncated>") {
|
||||||
|
t.Fatalf("sanitized body missing truncation marker: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteReportCreatesJSONArtifact(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
reportPath := filepath.Join(dir, "report.json")
|
||||||
|
|
||||||
|
original := acceptanceReporter
|
||||||
|
acceptanceReporter = &Reporter{started: time.Now()}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
acceptanceReporter = original
|
||||||
|
})
|
||||||
|
|
||||||
|
recordCase(t, "public", "GET", "/v1/public/user/info", "200 envelope")
|
||||||
|
if err := writeReport(Config{
|
||||||
|
RunID: "qa_test",
|
||||||
|
StagingURL: "https://tapi.hifast.biz",
|
||||||
|
ReportPath: reportPath,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("write report: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(reportPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read report: %v", err)
|
||||||
|
}
|
||||||
|
var report Report
|
||||||
|
if err := json.Unmarshal(raw, &report); err != nil {
|
||||||
|
t.Fatalf("decode report: %v", err)
|
||||||
|
}
|
||||||
|
if report.RunID != "qa_test" {
|
||||||
|
t.Fatalf("run id = %q", report.RunID)
|
||||||
|
}
|
||||||
|
if len(report.Cases) != 1 {
|
||||||
|
t.Fatalf("cases len = %d, want 1", len(report.Cases))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package acceptance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Reporter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
started time.Time
|
||||||
|
cases []ReportCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportCase struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Expected string `json:"expected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Cases []ReportCase `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var acceptanceReporter = &Reporter{started: time.Now()}
|
||||||
|
|
||||||
|
func recordCase(t *testing.T, domain string, method string, path string, expected string) {
|
||||||
|
t.Helper()
|
||||||
|
acceptanceReporter.mu.Lock()
|
||||||
|
defer acceptanceReporter.mu.Unlock()
|
||||||
|
acceptanceReporter.cases = append(acceptanceReporter.cases, ReportCase{
|
||||||
|
Name: t.Name(),
|
||||||
|
Domain: domain,
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
Expected: expected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReport(c Config) error {
|
||||||
|
if c.ReportPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
acceptanceReporter.mu.Lock()
|
||||||
|
defer acceptanceReporter.mu.Unlock()
|
||||||
|
report := Report{
|
||||||
|
RunID: c.RunID,
|
||||||
|
BaseURL: c.StagingURL,
|
||||||
|
Duration: time.Since(acceptanceReporter.started).String(),
|
||||||
|
Cases: append([]ReportCase(nil), acceptanceReporter.cases...),
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(report, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(c.ReportPath, out, 0o600)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user