1290 lines
48 KiB
Go
1290 lines
48 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// mockAIService stands in for ai-service/ during tests -- it echoes back
|
|
// a canned response so core-backend's HTTP client code (not the Python
|
|
// service itself) is what's under test here.
|
|
func mockAIService(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/draft":
|
|
var req draftRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Echo relationship_tier and relationship_note into the response
|
|
// text (roadmap.md §2.7-B, §2.7-E) so tests can assert what
|
|
// core-backend resolved and forwarded, without needing a real
|
|
// ai-service.
|
|
json.NewEncoder(w).Encode(draftResponse{
|
|
Status: "ok",
|
|
Text: "mock draft [tier=" + req.RelationshipTier + "] [note=" + req.RelationshipNote + "] for: " + strings.Join(req.ContextLines, " | "),
|
|
})
|
|
case "/escalate/check":
|
|
// Mirrors escalation_filter.py's rule set closely enough to
|
|
// exercise core-backend's gating logic; the rules themselves
|
|
// are verified against ai-service/app/escalation_filter.py directly.
|
|
var req escalationCheckRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
out := escalationCheckResponse{}
|
|
switch {
|
|
case strings.Contains(req.Text, "계좌") || strings.Contains(req.Text, "송금"):
|
|
out = escalationCheckResponse{Escalate: true, Reason: "금전"}
|
|
case strings.Contains(req.Text, "힘들어"):
|
|
out = escalationCheckResponse{Escalate: true, Reason: "감정적으로 무거운 주제"}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(out)
|
|
case "/summarize":
|
|
var req summaryRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(summaryResponse{
|
|
Status: "ok",
|
|
Summary: "mock summary for " + req.MyDisplayName + ": " + strings.Join(req.ContextLines, " | "),
|
|
})
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) {
|
|
t.Helper()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(allModels...); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
t.Setenv("ADMIN_API_TOKEN", "test-admin-token")
|
|
|
|
ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient}
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
router := setupRouter(db, newConnectionManager(), ai)
|
|
server := httptest.NewServer(router)
|
|
t.Cleanup(server.Close)
|
|
return server, db
|
|
}
|
|
|
|
func postJSON(t *testing.T, url string, body interface{}) *http.Response {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
resp, err := http.Post(url, "application/json", bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Fatalf("post %s: %v", url, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("post %s: %v", url, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func patchJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
req, _ := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("patch %s: %v", url, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
|
|
t.Helper()
|
|
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("delete %s: %v", url, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func mintInvite(t *testing.T, serverURL string) string {
|
|
t.Helper()
|
|
resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 minting invite, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
return out["code"].(string)
|
|
}
|
|
|
|
func mustSignup(t *testing.T, serverURL, displayName string) (uint, string) {
|
|
t.Helper()
|
|
resp := postJSON(t, serverURL+"/auth/signup", signupRequest{InviteCode: mintInvite(t, serverURL), DisplayName: displayName})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 signing up %s, got %d", displayName, resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
token, _ := out["token"].(string)
|
|
if token == "" {
|
|
t.Fatalf("signup response missing token: %v", out)
|
|
}
|
|
return uint(out["id"].(float64)), token
|
|
}
|
|
|
|
func setAutonomyLevel(t *testing.T, serverURL string, userID uint, token string, level AutonomyLevel) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level})
|
|
req, _ := http.NewRequest(http.MethodPatch, serverURL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("set autonomy level: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 setting autonomy level, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp, err := http.Get(server.URL + "/health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestSignupAndDuplicateRejected(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
code := mintInvite(t, server.URL)
|
|
|
|
resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "지우"})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
if out["display_name"] != "지우" {
|
|
t.Fatalf("unexpected body: %v", out)
|
|
}
|
|
|
|
dup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "dup"})
|
|
if dup.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expected 409 for a reused invite code, got %d", dup.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestSignupRejectsUnknownInviteCode(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "never-minted", DisplayName: "누구"})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for an invite code nobody minted, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestMintInviteReturnsUniqueCodes(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
a := mintInvite(t, server.URL)
|
|
b := mintInvite(t, server.URL)
|
|
if a == b {
|
|
t.Fatalf("expected distinct invite codes, got %q twice", a)
|
|
}
|
|
}
|
|
|
|
func TestSendMessageToMissingConversation(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp := postJSON(t, server.URL+"/conversations/9999/messages", sendMessageRequest{SenderID: 1, Text: "hi"})
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, token := mustSignup(t, server.URL, "정우")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
|
|
strconv.FormatUint(uint64(conv.ID), 10)
|
|
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("dial ws: %v", err)
|
|
}
|
|
defer ws.Close()
|
|
|
|
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID,
|
|
Text: "안녕하세요",
|
|
SenderMode: SenderTwin,
|
|
Approved: true,
|
|
})
|
|
if sendResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode)
|
|
}
|
|
|
|
var received map[string]interface{}
|
|
if err := ws.ReadJSON(&received); err != nil {
|
|
t.Fatalf("read ws message: %v", err)
|
|
}
|
|
if received["text"] != "안녕하세요" || received["sender_mode"] != "twin" {
|
|
t.Fatalf("unexpected ws payload: %v", received)
|
|
}
|
|
}
|
|
|
|
func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, _ := mustSignup(t, server.URL, "민지")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
|
|
strconv.FormatUint(uint64(conv.ID), 10)
|
|
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("dial ws: %v", err)
|
|
}
|
|
defer ws.Close()
|
|
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID,
|
|
Text: "계좌번호 알려줄게",
|
|
SenderMode: SenderTwin,
|
|
})
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for escalated twin send, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var count int64
|
|
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
|
|
if count != 0 {
|
|
t.Fatalf("escalated twin message must not be persisted, found %d rows", count)
|
|
}
|
|
|
|
var logs []EscalationLog
|
|
db.Where("conversation_id = ?", conv.ID).Find(&logs)
|
|
if len(logs) != 1 || logs[0].Reason != "금전" {
|
|
t.Fatalf("expected one EscalationLog row with reason 금전, got %+v", logs)
|
|
}
|
|
|
|
ws.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
|
if _, _, err := ws.ReadMessage(); err == nil {
|
|
t.Fatal("expected no broadcast for a blocked escalated message")
|
|
}
|
|
}
|
|
|
|
// The four tests below cover PRD.md §2.1/§2.2's L0->L1->L2 autonomy flow:
|
|
// L0 (기본값) never auto-sends, L1 requires explicit approval, L2 auto-sends
|
|
// only for whitelisted topics and otherwise behaves like L1.
|
|
|
|
func TestTwinSendBlockedAtDefaultL0(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, _ := mustSignup(t, server.URL, "하늘")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
// New users default to L0 (PRD.md §2.1) -- a non-escalating twin send
|
|
// must still be blocked, since L0 means no auto-send at all.
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID,
|
|
Text: "ㅇㅇ 알겠어",
|
|
SenderMode: SenderTwin,
|
|
})
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 blocking twin auto-send at L0, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var count int64
|
|
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
|
|
if count != 0 {
|
|
t.Fatalf("L0 must never persist a twin auto-send, found %d rows", count)
|
|
}
|
|
}
|
|
|
|
func TestTwinSendRequiresApprovalAtL1(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, token := mustSignup(t, server.URL, "서준")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convPath := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + "/messages"
|
|
|
|
unapproved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin})
|
|
if unapproved.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 without approval at L1, got %d", unapproved.StatusCode)
|
|
}
|
|
|
|
approved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true})
|
|
if approved.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 with approval at L1, got %d", approved.StatusCode)
|
|
}
|
|
|
|
var count int64
|
|
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
|
|
if count != 1 {
|
|
t.Fatalf("expected exactly 1 persisted message (the approved one), got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, token := mustSignup(t, server.URL, "가은")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
// No approved:true -- L2 + whitelist match should auto-send without it.
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin,
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 auto-sending whitelisted topic at L2, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var count int64
|
|
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
|
|
if count != 1 {
|
|
t.Fatalf("expected 1 auto-sent message, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, token := mustSignup(t, server.URL, "도윤")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convPath := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + "/messages"
|
|
|
|
// Text doesn't match any whitelist keyword -- L2 falls back to L1
|
|
// behavior (approval required), it does not just allow or just block.
|
|
unapproved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "주말에 영화 볼래?", SenderMode: SenderTwin})
|
|
if unapproved.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-whitelisted topic at L2 without approval, got %d", unapproved.StatusCode)
|
|
}
|
|
|
|
approved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "주말에 영화 볼래?", SenderMode: SenderTwin, Approved: true})
|
|
if approved.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 for non-whitelisted topic at L2 with approval, got %d", approved.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, token := mustSignup(t, server.URL, "은서")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
|
// Whitelisted keyword happens to be the same word that also triggers
|
|
// the money escalation pattern in the mock AI service.
|
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "계좌"})
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "계좌번호 알려줄게", SenderMode: SenderTwin, Approved: true,
|
|
})
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403: escalation must override L2 whitelist match and approved:true, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var count int64
|
|
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
|
|
if count != 0 {
|
|
t.Fatalf("escalated message must never be sent regardless of level/whitelist/approval, found %d rows", count)
|
|
}
|
|
}
|
|
|
|
func TestHumanMessageBypassesEscalationGate(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, _ := mustSignup(t, server.URL, "재민")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
// A human typing their own money-related message is never gated --
|
|
// only unattended (twin) sends are.
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID,
|
|
Text: "계좌번호 불러줄게",
|
|
SenderMode: SenderHuman,
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 for human-authored send, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
|
t.Setenv("ADMIN_API_TOKEN", "test-admin-token")
|
|
dbPath := t.TempDir() + "/test.db"
|
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(allModels...); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
unreachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
unreachable.Close() // closed immediately -- BaseURL now points at nothing listening
|
|
|
|
ai := &AIServiceClient{BaseURL: unreachable.URL, HTTP: http.DefaultClient}
|
|
gin.SetMode(gin.TestMode)
|
|
router := setupRouter(db, newConnectionManager(), ai)
|
|
server := httptest.NewServer(router)
|
|
t.Cleanup(server.Close)
|
|
|
|
senderID, _ := mustSignup(t, server.URL, "소연")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID,
|
|
Text: "ㅇㅇ 알겠어",
|
|
SenderMode: SenderTwin,
|
|
})
|
|
if resp.StatusCode != http.StatusBadGateway {
|
|
t.Fatalf("expected 502 fail-closed when escalation gate is unreachable, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var count int64
|
|
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
|
|
if count != 0 {
|
|
t.Fatalf("must not persist a twin message when the safety gate couldn't be checked, found %d rows", count)
|
|
}
|
|
}
|
|
|
|
func TestDeleteUserPurgesData(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
userID, token := mustSignup(t, server.URL, "유나")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: userID,
|
|
Text: "안녕",
|
|
})
|
|
if sendResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode)
|
|
}
|
|
db.Create(&WhitelistRule{UserID: userID, TopicKeyword: "저녁 약속"})
|
|
db.Create(&EscalationLog{UserID: userID, ConversationID: conv.ID, Reason: "금전", MessageSnippet: "..."})
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("delete request: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
if out["messages_deleted"].(float64) != 1 || out["escalation_logs_deleted"].(float64) != 1 {
|
|
t.Fatalf("unexpected purge summary: %v", out)
|
|
}
|
|
|
|
var userCount, msgCount, settingsCount, whitelistCount, logCount int64
|
|
db.Model(&User{}).Where("id = ?", userID).Count(&userCount)
|
|
db.Model(&Message{}).Where("sender_id = ?", userID).Count(&msgCount)
|
|
db.Model(&TwinSettings{}).Where("user_id = ?", userID).Count(&settingsCount)
|
|
db.Model(&WhitelistRule{}).Where("user_id = ?", userID).Count(&whitelistCount)
|
|
db.Model(&EscalationLog{}).Where("user_id = ?", userID).Count(&logCount)
|
|
if userCount != 0 || msgCount != 0 || settingsCount != 0 || whitelistCount != 0 || logCount != 0 {
|
|
t.Fatalf("expected all user-linked rows purged, got user=%d msg=%d settings=%d whitelist=%d log=%d",
|
|
userCount, msgCount, settingsCount, whitelistCount, logCount)
|
|
}
|
|
|
|
req2, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
|
req2.Header.Set("Authorization", "Bearer "+token)
|
|
resp2, err := http.DefaultClient.Do(req2)
|
|
if err != nil {
|
|
t.Fatalf("second delete request: %v", err)
|
|
}
|
|
// After delete, session is gone -- unauthorized (or 404 if we checked user first).
|
|
if resp2.StatusCode != http.StatusUnauthorized && resp2.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 401/404 on repeat delete, got %d", resp2.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, token := mustSignup(t, server.URL, "지민")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
vetoResp := postJSON(t, convBase+"/veto", nil)
|
|
if vetoResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 triggering veto, got %d", vetoResp.StatusCode)
|
|
}
|
|
|
|
// Would otherwise auto-send (L2 + whitelist match + approved), but the
|
|
// veto must override every other check.
|
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin, Approved: true,
|
|
})
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403: peer veto must override L2 whitelist + approval, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var count int64
|
|
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
|
|
if count != 0 {
|
|
t.Fatalf("no twin message should be sent after veto, found %d rows", count)
|
|
}
|
|
}
|
|
|
|
func TestPeerVetoDoesNotBlockHumanMessages(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, _ := mustSignup(t, server.URL, "다은")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
postJSON(t, convBase+"/veto", nil)
|
|
|
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("veto must not block the human's own messages, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestVetoMissingConversation(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp := postJSON(t, server.URL+"/conversations/9999/veto", nil)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
|
// runtimeMetrics is a package-level in-process counter shared across the
|
|
// whole test binary (not reset per-test like the per-test sqlite db), so
|
|
// tests that assert on its exact totals must reset it first to avoid
|
|
// picking up counts left behind by earlier tests in this file.
|
|
runtimeMetrics = &RuntimeMetrics{}
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, token := mustSignup(t, server.URL, "메트릭")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman})
|
|
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ", SenderMode: SenderTwin, Approved: true})
|
|
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "계좌번호 알려줄게", SenderMode: SenderTwin})
|
|
|
|
conv2 := Conversation{IsGroup: false}
|
|
db.Create(&conv2)
|
|
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2.ID), 10)+"/veto", nil)
|
|
|
|
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
|
mreq.Header.Set("Authorization", "Bearer test-admin-token")
|
|
resp, err := http.DefaultClient.Do(mreq)
|
|
if err != nil {
|
|
t.Fatalf("get metrics: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
|
|
if out["messages_human_total"].(float64) != 1 {
|
|
t.Fatalf("expected 1 human message, got %v", out["messages_human_total"])
|
|
}
|
|
if out["messages_twin_total"].(float64) != 1 {
|
|
t.Fatalf("expected 1 twin message, got %v", out["messages_twin_total"])
|
|
}
|
|
if out["escalations_total"].(float64) != 1 {
|
|
t.Fatalf("expected 1 escalation, got %v", out["escalations_total"])
|
|
}
|
|
byReason := out["escalations_by_reason"].(map[string]interface{})
|
|
if byReason["금전"].(float64) != 1 {
|
|
t.Fatalf("expected 1 금전 escalation, got %v", byReason)
|
|
}
|
|
if out["conversations_total"].(float64) != 2 || out["conversations_vetoed"].(float64) != 1 {
|
|
t.Fatalf("expected 2 conversations, 1 vetoed, got %v", out)
|
|
}
|
|
if out["peer_veto_rate"].(float64) != 0.5 {
|
|
t.Fatalf("expected 0.5 veto rate, got %v", out["peer_veto_rate"])
|
|
}
|
|
if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 {
|
|
t.Fatalf("expected at least 1 minted/used invite, got %v", out)
|
|
}
|
|
// The "계좌번호 알려줄게" twin send above was blocked by the escalation
|
|
// gate, not by veto/flood/autonomy -- confirm it landed under the right
|
|
// by-reason key (roadmap.md monitoring gap: distinguish *why* a
|
|
// twin-authored send was blocked, not just a raw total).
|
|
blockedByReason := out["twin_sends_blocked_by_reason"].(map[string]interface{})
|
|
if blockedByReason["escalated"].(float64) != 1 {
|
|
t.Fatalf("expected 1 escalated block, got %v", blockedByReason)
|
|
}
|
|
if out["twin_sends_blocked"].(float64) != 1 {
|
|
t.Fatalf("expected top-line twin_sends_blocked to match the sum of by-reason counts, got %v", out["twin_sends_blocked"])
|
|
}
|
|
}
|
|
|
|
// TestAdminMetricsBlockedByReasonAttributesDistinctCauses exercises two
|
|
// different twin-send block paths (peer veto, autonomy L0) through the real
|
|
// HTTP handler and checks /admin/metrics attributes each to its own reason
|
|
// key instead of collapsing them into one undifferentiated total.
|
|
func TestAdminMetricsBlockedByReasonAttributesDistinctCauses(t *testing.T) {
|
|
runtimeMetrics = &RuntimeMetrics{} // see comment in TestAdminMetricsCountsMessagesEscalationsAndVeto
|
|
server, db := setupTestServer(t)
|
|
|
|
senderID, _ := mustSignup(t, server.URL, "차단사유") // defaults to AutonomyL0
|
|
|
|
vetoedConv := Conversation{IsGroup: false}
|
|
if err := db.Create(&vetoedConv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
vetoedBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(vetoedConv.ID), 10)
|
|
postJSON(t, vetoedBase+"/veto", nil)
|
|
if resp := postJSON(t, vetoedBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderTwin}); resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected peer-veto block, got %d", resp.StatusCode)
|
|
}
|
|
|
|
l0Conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&l0Conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
l0Base := server.URL + "/conversations/" + strconv.FormatUint(uint64(l0Conv.ID), 10)
|
|
if resp := postJSON(t, l0Base+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderTwin}); resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected L0 autonomy block, got %d", resp.StatusCode)
|
|
}
|
|
|
|
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
|
mreq.Header.Set("Authorization", "Bearer test-admin-token")
|
|
resp, err := http.DefaultClient.Do(mreq)
|
|
if err != nil {
|
|
t.Fatalf("get metrics: %v", err)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
|
|
blockedByReason := out["twin_sends_blocked_by_reason"].(map[string]interface{})
|
|
if blockedByReason["peer_veto"].(float64) != 1 {
|
|
t.Fatalf(`expected twin_sends_blocked_by_reason["peer_veto"] == 1, got %v`, blockedByReason)
|
|
}
|
|
if blockedByReason["autonomy_l0"].(float64) != 1 {
|
|
t.Fatalf(`expected twin_sends_blocked_by_reason["autonomy_l0"] == 1, got %v`, blockedByReason)
|
|
}
|
|
if out["twin_sends_blocked"].(float64) != 2 {
|
|
t.Fatalf("expected top-line twin_sends_blocked == 2, got %v", out["twin_sends_blocked"])
|
|
}
|
|
}
|
|
|
|
func TestWhitelistRuleCRUD(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
userID, token := mustSignup(t, server.URL, "화이트")
|
|
base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules"
|
|
|
|
createResp := postJSONAuth(t, base, token, createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
|
if createResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode)
|
|
}
|
|
var created map[string]interface{}
|
|
json.NewDecoder(createResp.Body).Decode(&created)
|
|
if created["topic_keyword"] != "저녁" || created["contact_id"] != nil {
|
|
t.Fatalf("unexpected created rule: %v", created)
|
|
}
|
|
ruleID := uint(created["id"].(float64))
|
|
|
|
listReq, _ := http.NewRequest(http.MethodGet, base, nil)
|
|
listReq.Header.Set("Authorization", "Bearer "+token)
|
|
listResp, err := http.DefaultClient.Do(listReq)
|
|
if err != nil {
|
|
t.Fatalf("list rules: %v", err)
|
|
}
|
|
var list map[string]interface{}
|
|
json.NewDecoder(listResp.Body).Decode(&list)
|
|
rules := list["whitelist_rules"].([]interface{})
|
|
if len(rules) != 1 {
|
|
t.Fatalf("expected 1 rule listed, got %d", len(rules))
|
|
}
|
|
|
|
delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
|
delReq.Header.Set("Authorization", "Bearer "+token)
|
|
delResp, err := http.DefaultClient.Do(delReq)
|
|
if err != nil {
|
|
t.Fatalf("delete rule: %v", err)
|
|
}
|
|
if delResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode)
|
|
}
|
|
|
|
listReq2, _ := http.NewRequest(http.MethodGet, base, nil)
|
|
listReq2.Header.Set("Authorization", "Bearer "+token)
|
|
listResp2, err := http.DefaultClient.Do(listReq2)
|
|
if err != nil {
|
|
t.Fatalf("list after delete: %v", err)
|
|
}
|
|
var list2 map[string]interface{}
|
|
json.NewDecoder(listResp2.Body).Decode(&list2)
|
|
if len(list2["whitelist_rules"].([]interface{})) != 0 {
|
|
t.Fatalf("expected 0 rules after delete, got %v", list2)
|
|
}
|
|
|
|
delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
|
delAgain.Header.Set("Authorization", "Bearer "+token)
|
|
delAgainResp, _ := http.DefaultClient.Do(delAgain)
|
|
if delAgainResp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestWhitelistRuleCRUDMissingUser(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp := postJSON(t, server.URL+"/users/9999/whitelist-rules", createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without token, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestRetractTwinMessage(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, token := mustSignup(t, server.URL, "되돌리기")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
|
|
strconv.FormatUint(uint64(conv.ID), 10)
|
|
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("dial ws: %v", err)
|
|
}
|
|
defer ws.Close()
|
|
|
|
sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin})
|
|
if sendResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 auto-sending, got %d", sendResp.StatusCode)
|
|
}
|
|
var sent map[string]interface{}
|
|
json.NewDecoder(sendResp.Body).Decode(&sent)
|
|
msgID := uint(sent["id"].(float64))
|
|
|
|
var sendBroadcast map[string]interface{}
|
|
if err := ws.ReadJSON(&sendBroadcast); err != nil {
|
|
t.Fatalf("read send broadcast: %v", err)
|
|
}
|
|
if sendBroadcast["type"] != "message" {
|
|
t.Fatalf("expected type:message on send broadcast, got %v", sendBroadcast)
|
|
}
|
|
|
|
retractResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
|
|
if retractResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 retracting, got %d", retractResp.StatusCode)
|
|
}
|
|
|
|
var retractBroadcast map[string]interface{}
|
|
if err := ws.ReadJSON(&retractBroadcast); err != nil {
|
|
t.Fatalf("read retract broadcast: %v", err)
|
|
}
|
|
if retractBroadcast["type"] != "retraction" || uint(retractBroadcast["id"].(float64)) != msgID {
|
|
t.Fatalf("unexpected retraction broadcast: %v", retractBroadcast)
|
|
}
|
|
|
|
var message Message
|
|
db.First(&message, msgID)
|
|
if !message.Retracted {
|
|
t.Fatalf("expected message.Retracted true after retract")
|
|
}
|
|
|
|
// Already retracted -- must not succeed again.
|
|
again := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
|
|
if again.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expected 409 retracting an already-retracted message, got %d", again.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestRetractRejectsHumanMessage(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, _ := mustSignup(t, server.URL, "사람")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
|
|
})
|
|
var sent map[string]interface{}
|
|
json.NewDecoder(sendResp.Body).Decode(&sent)
|
|
msgID := uint(sent["id"].(float64))
|
|
|
|
resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 retracting a human message, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestRetractMissingMessage(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp := postJSON(t, server.URL+"/messages/9999/retract", nil)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- Draft-edited implicit signal (PRD.md §5, deploy-checklist.md N4-12) ---
|
|
|
|
func TestDraftEditedTrueWhenSentTextDiffersFromOriginalDraft(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, token := mustSignup(t, server.URL, "수정함")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "ㅇㅇ 좋아 이따 보자", SenderMode: SenderTwin, Approved: true,
|
|
OriginalDraftText: "ㅇㅇ 좋아",
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
msgID := uint(out["id"].(float64))
|
|
|
|
var message Message
|
|
db.First(&message, msgID)
|
|
if message.DraftEdited == nil || *message.DraftEdited != true {
|
|
t.Fatalf("expected DraftEdited=true, got %v", message.DraftEdited)
|
|
}
|
|
}
|
|
|
|
func TestDraftEditedFalseWhenSentTextMatchesOriginalDraft(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, token := mustSignup(t, server.URL, "그대로")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true,
|
|
OriginalDraftText: "ㅇㅇ 좋아",
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
msgID := uint(out["id"].(float64))
|
|
|
|
var message Message
|
|
db.First(&message, msgID)
|
|
if message.DraftEdited == nil || *message.DraftEdited != false {
|
|
t.Fatalf("expected DraftEdited=false, got %v", message.DraftEdited)
|
|
}
|
|
}
|
|
|
|
func TestDraftEditedNilWhenNoOriginalDraftTextSent(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, token := mustSignup(t, server.URL, "구버전클라")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
// No OriginalDraftText field at all (e.g. an older client) -- must not
|
|
// be guessed as edited or unedited, stays nil.
|
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "ㅇㅇ 알겠음", SenderMode: SenderTwin, Approved: true,
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
msgID := uint(out["id"].(float64))
|
|
if _, present := out["draft_edited"]; !present || out["draft_edited"] != nil {
|
|
t.Fatalf("expected draft_edited to be null in response, got %v", out["draft_edited"])
|
|
}
|
|
|
|
var message Message
|
|
db.First(&message, msgID)
|
|
if message.DraftEdited != nil {
|
|
t.Fatalf("expected DraftEdited nil, got %v", *message.DraftEdited)
|
|
}
|
|
}
|
|
|
|
func TestDraftEditedNotSetForHumanMessages(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, _ := mustSignup(t, server.URL, "사람메시지")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
// Even if a caller weirdly sent original_draft_text on a human message,
|
|
// it must be ignored -- DraftEdited is a twin-only signal.
|
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
|
|
OriginalDraftText: "다른 텍스트",
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
msgID := uint(out["id"].(float64))
|
|
|
|
var message Message
|
|
db.First(&message, msgID)
|
|
if message.DraftEdited != nil {
|
|
t.Fatalf("expected DraftEdited nil for a human message, got %v", *message.DraftEdited)
|
|
}
|
|
}
|
|
|
|
// --- Explicit naturalness feedback ("이 답장 나답아요?", vision.md, N4-12) ---
|
|
|
|
func TestMessageFeedbackOnTwinMessageReflectedOnRefetch(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, token := mustSignup(t, server.URL, "피드백")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
if err := db.Create(&ConversationParticipant{ConversationID: conv.ID, UserID: senderID}).Error; err != nil {
|
|
t.Fatalf("create participant: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true,
|
|
})
|
|
var sent map[string]interface{}
|
|
json.NewDecoder(sendResp.Body).Decode(&sent)
|
|
msgID := uint(sent["id"].(float64))
|
|
|
|
feedbackResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
|
|
if feedbackResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 submitting feedback, got %d", feedbackResp.StatusCode)
|
|
}
|
|
|
|
// Re-fetch via the conversation history endpoint (authenticated) and
|
|
// confirm the rating stuck.
|
|
req, _ := http.NewRequest(http.MethodGet, convBase+"/messages", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
listResp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("list messages: %v", err)
|
|
}
|
|
var listOut map[string]interface{}
|
|
json.NewDecoder(listResp.Body).Decode(&listOut)
|
|
messages := listOut["messages"].([]interface{})
|
|
var found map[string]interface{}
|
|
for _, m := range messages {
|
|
mm := m.(map[string]interface{})
|
|
if uint(mm["id"].(float64)) == msgID {
|
|
found = mm
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("sent message not found in refetch: %v", messages)
|
|
}
|
|
if found["naturalness_rating"] != true {
|
|
t.Fatalf("expected naturalness_rating=true on refetch, got %v", found["naturalness_rating"])
|
|
}
|
|
|
|
// Resubmission overwrites rather than 409ing (documented choice: a
|
|
// bounded rating change isn't unsafe the way re-retracting would be).
|
|
overwrite := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: false})
|
|
if overwrite.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 overwriting feedback, got %d", overwrite.StatusCode)
|
|
}
|
|
var overwriteOut map[string]interface{}
|
|
json.NewDecoder(overwrite.Body).Decode(&overwriteOut)
|
|
if overwriteOut["naturalness_rating"] != false {
|
|
t.Fatalf("expected naturalness_rating=false after overwrite, got %v", overwriteOut["naturalness_rating"])
|
|
}
|
|
}
|
|
|
|
func TestMessageFeedbackRejectsHumanMessage(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
senderID, _ := mustSignup(t, server.URL, "사람피드백")
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
|
|
})
|
|
var sent map[string]interface{}
|
|
json.NewDecoder(sendResp.Body).Decode(&sent)
|
|
msgID := uint(sent["id"].(float64))
|
|
|
|
resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 rating a human message, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestMessageFeedbackMissingMessage(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp := postJSON(t, server.URL+"/messages/9999/feedback", messageFeedbackRequest{Natural: true})
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// --- /admin/metrics rate calculations for the two signals above ---
|
|
|
|
func TestAdminMetricsDraftUneditedAndNaturalnessRates(t *testing.T) {
|
|
runtimeMetrics = &RuntimeMetrics{} // see comment in TestAdminMetricsCountsMessagesEscalationsAndVeto
|
|
server, db := setupTestServer(t)
|
|
senderID, token := mustSignup(t, server.URL, "지표계산")
|
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
|
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
|
|
|
// 2 edited + 3 unedited twin sends -> draft_unedited_rate should be
|
|
// 3/5 = 0.6. A plain human message and a twin send with no
|
|
// original_draft_text must not affect the denominator either way.
|
|
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "인간이 직접", SenderMode: SenderHuman})
|
|
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "초안없음", SenderMode: SenderTwin, Approved: true})
|
|
for i := 0; i < 2; i++ {
|
|
postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "수정된 문구", SenderMode: SenderTwin, Approved: true,
|
|
OriginalDraftText: "원래 초안",
|
|
})
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
postJSON(t, convBase+"/messages", sendMessageRequest{
|
|
SenderID: senderID, Text: "그대로", SenderMode: SenderTwin, Approved: true,
|
|
OriginalDraftText: "그대로",
|
|
})
|
|
}
|
|
|
|
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
|
mreq.Header.Set("Authorization", "Bearer test-admin-token")
|
|
resp, err := http.DefaultClient.Do(mreq)
|
|
if err != nil {
|
|
t.Fatalf("get metrics: %v", err)
|
|
}
|
|
var out map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
|
|
if out["draft_edited_tracked_total"].(float64) != 5 {
|
|
t.Fatalf("expected 5 draft-tracked messages, got %v", out["draft_edited_tracked_total"])
|
|
}
|
|
if got := out["draft_unedited_rate"].(float64); got != 0.6 {
|
|
t.Fatalf("expected draft_unedited_rate 0.6, got %v", got)
|
|
}
|
|
|
|
// No naturalness ratings submitted yet -- must not divide by zero.
|
|
if out["naturalness_ratings_total"].(float64) != 0 {
|
|
t.Fatalf("expected 0 naturalness ratings, got %v", out["naturalness_ratings_total"])
|
|
}
|
|
if out["naturalness_positive_rate"].(float64) != 0 {
|
|
t.Fatalf("expected naturalness_positive_rate 0 (zero-guarded), got %v", out["naturalness_positive_rate"])
|
|
}
|
|
|
|
// Rate a couple of the twin messages we just sent and check the rate
|
|
// updates too (1 positive out of 2 rated -> 0.5).
|
|
var twinMessages []Message
|
|
db.Where("sender_mode = ?", SenderTwin).Order("id asc").Find(&twinMessages)
|
|
if len(twinMessages) < 2 {
|
|
t.Fatalf("expected at least 2 twin messages, got %d", len(twinMessages))
|
|
}
|
|
postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[0].ID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
|
|
postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[1].ID), 10)+"/feedback", messageFeedbackRequest{Natural: false})
|
|
|
|
resp2, err := http.DefaultClient.Do(mreq)
|
|
if err != nil {
|
|
t.Fatalf("get metrics again: %v", err)
|
|
}
|
|
var out2 map[string]interface{}
|
|
json.NewDecoder(resp2.Body).Decode(&out2)
|
|
if out2["naturalness_ratings_total"].(float64) != 2 {
|
|
t.Fatalf("expected 2 naturalness ratings, got %v", out2["naturalness_ratings_total"])
|
|
}
|
|
if got := out2["naturalness_positive_rate"].(float64); got != 0.5 {
|
|
t.Fatalf("expected naturalness_positive_rate 0.5, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestDraftMissingConversation(t *testing.T) {
|
|
server, _ := setupTestServer(t)
|
|
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{
|
|
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
|
|
StyleExamples: []string{"ㅇㅇ 좋지"},
|
|
})
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDraftRequiresStyleSource(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{
|
|
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
|
|
})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDraftProxiesToAIService(t *testing.T) {
|
|
server, db := setupTestServer(t)
|
|
conv := Conversation{IsGroup: false}
|
|
if err := db.Create(&conv).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
|
|
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{
|
|
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
|
|
StyleExamples: []string{"ㅇㅇ 좋지"},
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out draftResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if out.Status != "ok" || !strings.Contains(out.Text, "오늘 저녁에 뭐 먹을래") {
|
|
t.Fatalf("unexpected draft response: %+v", out)
|
|
}
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
os.Exit(m.Run())
|
|
}
|