Files
story/internal/onboarding/direct_chat_flow_test.go
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

138 lines
6.4 KiB
Go

package onboarding
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/volcsubtitle"
"github.com/google/uuid"
)
// Real database + real HTTP signature/authentication + fake supplier boundary.
// The target device is newly manufactured and never calls any startup endpoint.
func TestDirectHTTPBindChatWithoutStartupRegistration(t *testing.T) {
f := serverFixture(t)
in := InventoryInput{FactoryMAC: strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", ""))[:12], Model: "KP01", HardwareRevision: "A1", BatchNo: "direct-no-startup"}
_, err := f.s.CreateInventory(f.ctx, f.p, f.tenantPublic, uuid.NewString(), uuid.NewString(), requestJSON(in), in)
must(t, err)
f.d, err = f.s.findFactoryDevice(f.ctx, "KP01-"+in.FactoryMAC, 1)
must(t, err)
if f.d.BootID != "" {
t.Fatal("new target already has a boot registration")
}
mux := http.NewServeMux()
f.s.RegisterRoutes(mux)
chat := callmemory.NewService(f.s.pool, nil)
secret := []byte(strings.Repeat("test-callback-", 3))
chat.ConfigureDirect(callmemory.DirectOptions{CallbackURL: "https://example.com/callbacks/v1/volcengine/chat", CallbackSecret: secret})
chat.RegisterDirectRoutes(mux, f.s.AuthenticateCallRequest)
mux.HandleFunc("POST /callbacks/v1/volcengine/chat", chat.DirectCallback)
post := func(path string, body any, rid string) (int, map[string]any) {
t.Helper()
if rid == "" {
rid = uuid.NewString()
}
raw := requestJSON(body)
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
nonce, timestamp := randomToken(16), time.Now().UnixMilli()
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Device-ID", f.d.FactoryID)
r.Header.Set("X-Key-Version", "1")
r.Header.Set("X-Timestamp-Ms", strconv.FormatInt(timestamp, 10))
r.Header.Set("X-Nonce", nonce)
r.Header.Set("X-Request-ID", rid)
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, rid, raw))
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
var result map[string]any
must(t, json.Unmarshal(w.Body.Bytes(), &result))
return w.Code, result
}
status, result := post("/api/v1/device/chat/start", map[string]any{"device_id": f.d.FactoryID, "session_id": uuid.NewString()}, "")
if status != 409 || result["code"] != "DEVICE_UNBOUND" {
t.Fatal("unbound start", status, result["code"])
}
token, rid := f.bleToken(), uuid.NewString()
for i := 0; i < 2; i++ {
status, result = post("/api/v1/device/ble-bind", BLEBindInput{Token: token}, rid)
if status != 200 {
t.Fatal("bind HTTP failure", status, result["code"])
}
data := result["data"].(map[string]any)
for _, field := range []string{"instance_id", "product_key", "device_name", "device_secret", "RTCAppID"} {
if data[field] == nil || data[field] == "" {
t.Fatal("missing binding credential", field)
}
}
}
if f.registrar.calls.Load() != 1 {
t.Fatal("binding retry registered supplier twice")
}
var subject, user string
must(t, f.s.pool.QueryRow(f.ctx, `SELECT b.subject_id::text,b.bound_by::text FROM device_binding b WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL`, f.d.ID).Scan(&subject, &user))
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status) VALUES($1::uuid,$2,$2,'READY')`, subject, "flow-bot-"+subject)
must(t, err)
// Explicit test consent lets us verify the callback is persisted, not just acknowledged.
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT $1::uuid,$2::uuid,consent_type,scope,policy_version,notice_digest,$3::uuid,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, f.tenant, subject, user)
must(t, err)
sid := uuid.NewString()
body := map[string]any{"device_id": f.d.FactoryID, "session_id": sid}
status, result = post("/api/v1/device/chat/start", body, "")
if status != 200 {
t.Fatal("start without startup endpoints failed", status, result["code"])
}
first := result["data"].(map[string]any)
status, result = post("/api/v1/device/chat/start", body, "")
if status != 200 || string(requestJSON(first["params"])) != string(requestJSON(result["data"].(map[string]any)["params"])) {
t.Fatal("retry/renewal changed parameters", status)
}
n := int64(1)
packet := volcsubtitle.Packet{Type: "subtitle", Data: []volcsubtitle.Segment{{UserID: "bot_20260914095023000", RoundID: &n, Sequence: &n, Text: "你好", Definite: true}}}
envelope := volcsubtitle.Envelope{Message: base64.StdEncoding.EncodeToString(requestJSON(packet)), Signature: volcsubtitle.Signature(secret, "direct:"+sid)}
for i := 0; i < 2; i++ {
r := httptest.NewRequest("POST", "/callbacks/v1/volcengine/chat?session_id="+sid, bytes.NewReader(requestJSON(envelope)))
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != 200 {
t.Fatal("callback failed", w.Code)
}
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, sid).Scan(&count))
if count != 1 {
t.Fatal("callback record duplicated or missing", count)
}
end := map[string]any{"device_id": f.d.FactoryID, "session_id": sid, "reason": "hangup", "sdk_stopped": true}
for i := 0; i < 2; i++ {
status, result = post("/api/v1/device/chat/end", end, "")
if status != 200 {
t.Fatal("end failed", status, result["code"])
}
}
var lifecycle string
var noHandshake bool
must(t, f.s.pool.QueryRow(f.ctx, `SELECT d.activation_state,d.onboarding_boot_id IS NULL AND j.config_ack_at IS NULL AND j.engine_ready_at IS NULL FROM device d JOIN device_activation_job j ON j.device_id=d.id WHERE d.id=$1::uuid`, f.d.ID).Scan(&lifecycle, &noHandshake))
if lifecycle != "ACTIVE" || !noHandshake {
t.Fatal("old handshake required or fabricated", lifecycle, noHandshake)
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
must(t, err)
body["session_id"] = uuid.NewString()
status, result = post("/api/v1/device/chat/start", body, "")
if status != 409 || result["code"] != "DEVICE_UNBOUND" {
t.Fatal("unbound start not rejected", status)
}
status, result = post("/api/v1/device/chat/end", end, "")
if status != 200 {
t.Fatal("end after unbind failed", status)
}
}