@@ -42,6 +42,7 @@ logs/
|
||||
# ==================== 测试 ====================
|
||||
/test/
|
||||
*_test.go
|
||||
!tests/acceptance/*_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_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