Files
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

132 lines
4.8 KiB
Go

package vikingmemory
import (
"context"
"data-terminal-cloud/internal/outbox"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
)
func testClient(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
c, err := NewClient(Options{BaseURL: server.URL, ResourceID: "mem-test", APIKey: "test-secret", EventType: "event_v1", ProfileType: "profile_v1", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
c.http = server.Client()
return c
}
func TestSearchBindsUserAndRejectsForeignResults(t *testing.T) {
calls := 0
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
var input map[string]any
json.NewDecoder(r.Body).Decode(&input)
filter := input["filter"].(map[string]any)
if filter["user_id"].([]any)[0] != "alice" || filter["assistant_id"].([]any)[0] != AssistantID || input["resource_id"] != "mem-test" {
t.Errorf("incorrect scope: %v", input)
}
if r.Header.Get("Authorization") != "Bearer test-secret" {
t.Error("missing auth")
}
fmt.Fprint(w, `{"code":0,"data":{"result_list":[{"id":"foreign","user_id":["bob"],"assistant_id":["platform_companion_v1"],"memory_info":{"summary":"must not return"}},{"id":"ours","user_id":["alice"],"assistant_id":["platform_companion_v1"],"memory_info":{"summary":"喜欢茶"}}]}}`)
})
items, err := c.Search(context.Background(), "alice", "茶", 2)
if err != nil || len(items) != 2 || items[0].ID != "ours" || calls != 2 {
t.Fatalf("items=%v calls=%d err=%v", items, calls, err)
}
if _, err = c.Search(context.Background(), "", "", 1); err == nil {
t.Fatal("empty identity accepted")
}
}
func TestVikingErrorsDoNotLeakServerBodyOrKey(t *testing.T) {
for _, status := range []int{200, 401, 429, 503} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
fmt.Fprint(w, `{"code":900,"message":"test-secret private transcript"}`)
})
err := c.AddSession(context.Background(), "c_test", "alice", []Message{{Role: "user", Content: "test"}}, 1)
if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "transcript") {
t.Fatalf("unsafe error %v", err)
}
if outbox.IsRetryable(err) != (status == 429 || status >= 500) {
t.Fatalf("retry classification %v", err)
}
})
}
}
func TestStableNamespacedIdentityAndSessionIDs(t *testing.T) {
if UserID("a", "x") == UserID("b", "x") || UserID("a", "x") == UserID("a", "y") {
t.Fatal("identity collision")
}
id := SessionID("12345678-1234-1234-1234-123456789abc", 1)
if !regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,127}$`).MatchString(id) || id == SessionID("12345678-1234-1234-1234-123456789abc", 0) {
t.Fatal(id)
}
}
func TestDeleteUsesExactUserForBothMemoryTypes(t *testing.T) {
calls := 0
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
var input map[string]any
json.NewDecoder(r.Body).Decode(&input)
f := input["filter"].(map[string]any)
if f["user_id"].([]any)[0] != "alice" {
t.Error("unscoped delete")
}
fmt.Fprint(w, `{"code":0}`)
})
if err := c.DeleteUser(context.Background(), ""); err == nil || calls != 0 {
t.Fatal("empty deletion accepted")
}
if err := c.DeleteUser(context.Background(), "alice"); err != nil || calls != 2 {
t.Fatal(err, calls)
}
}
func TestVikingDeletionRateLimitPreservesCooldownAndFailure(t *testing.T) {
for _, status := range []int{http.StatusTooManyRequests, http.StatusOK} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Retry-After", "120")
w.WriteHeader(status)
fmt.Fprint(w, `{"code":1000029,"message":"private provider body"}`)
})
err := c.DeleteUser(context.Background(), "alice")
if err == nil || !outbox.IsRetryable(err) || outbox.RetryDelay(err) != 2*time.Minute || calls != 1 {
t.Fatalf("rate limit must remain an incomplete deletion: calls=%d delay=%s err=%v", calls, outbox.RetryDelay(err), err)
}
if strings.Contains(err.Error(), "private") {
t.Fatal("provider body leaked into retained error")
}
})
}
}
func TestVikingRetryDelayParsesSecondsAndDates(t *testing.T) {
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
for _, tt := range []struct {
header string
want time.Duration
}{
{"", time.Minute}, {"invalid", time.Minute}, {"-1", time.Minute},
{"120", 2 * time.Minute}, {now.Add(90 * time.Second).Format(http.TimeFormat), 90 * time.Second},
{now.Add(-time.Minute).Format(http.TimeFormat), time.Minute}, {"9223372036854775807", 24 * time.Hour},
} {
if got := vikingRetryDelay(tt.header, now); got != tt.want {
t.Errorf("Retry-After %q = %s, want %s", tt.header, got, tt.want)
}
}
}