package app import ( "context" "encoding/json" "fmt" "net/http/httptest" "strings" "testing" ) // Opening a conversation is cheap for the sender and expensive for the reader, // so a free account may only knock a few times before the other side answers. // Once there is an answer the conversation is mutual and the cap is gone. func TestUnansweredMessageLimitMySQL(t *testing.T) { db := isolatedIMDatabase(t) a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} setLimit := func(value string) { t.Helper() if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('membership.free_unanswered_message_limit',?,'integer','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, value); err != nil { t.Fatal(err) } } conversation := func(viewer, peer int64) int64 { t.Helper() var created struct { ID int64 `json:"id"` } body := fmt.Sprintf(`{"userId":%d}`, peer) if err := json.Unmarshal(imTestCall(t, a.directConversation, viewer, "POST", "/api/v1/im/conversations", body, 200), &created); err != nil { t.Fatal(err) } return created.ID } // A direct conversation between two people is idempotent, so each subtest // starts from an empty one rather than inheriting the previous knocks. fresh := func(viewer, peer int64) int64 { t.Helper() id := conversation(viewer, peer) if _, err := db.Exec(`DELETE FROM im_messages WHERE conversation_id=?`, id); err != nil { t.Fatal(err) } return id } send := func(conversationID, sender int64, key string) (int, string) { t.Helper() w := httptest.NewRecorder() path := fmt.Sprintf("/api/v1/im/conversations/%d/messages", conversationID) payload := fmt.Sprintf(`{"clientMsgId":%q,"type":1,"content":{"text":"你好"}}`, key) a.sendMessageHTTP(w, imTestRequest(sender, "POST", path, payload)) return w.Code, w.Body.String() } member := func(userID int64, active bool) { t.Helper() if _, err := db.Exec(`DELETE FROM subscriptions WHERE user_id=?`, userID); err != nil { t.Fatal(err) } if !active { return } if _, err := db.Exec(`INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,1,'test',1,NOW(3),DATE_ADD(NOW(3),INTERVAL 30 DAY))`, userID); err != nil { t.Fatal(err) } } setLimit("3") t.Run("the fourth unanswered message is refused with its own code", func(t *testing.T) { id := fresh(1, 2) for index := 1; index <= 3; index++ { if status, body := send(id, 1, fmt.Sprintf("knock-%d", index)); status != 200 { t.Fatalf("第 %d 条应当发出: HTTP %d %s", index, status, body) } } status, body := send(id, 1, "knock-4") if status != 402 || !strings.Contains(body, "30007") || !strings.Contains(body, "对方回复前最多发送 3 条") { t.Fatalf("HTTP %d: %s", status, body) } }) t.Run("one answer lifts the cap for good", func(t *testing.T) { id := fresh(1, 3) for index := 1; index <= 3; index++ { send(id, 1, fmt.Sprintf("a-knock-%d", index)) } if status, _ := send(id, 1, "a-knock-4"); status != 402 { t.Fatalf("HTTP %d", status) } if status, body := send(id, 3, "the-answer"); status != 200 { t.Fatalf("对方回复本身不受限: HTTP %d %s", status, body) } for index := 5; index <= 8; index++ { if status, body := send(id, 1, fmt.Sprintf("a-knock-%d", index)); status != 200 { t.Fatalf("回复之后应当不限: HTTP %d %s", status, body) } } }) t.Run("a member is never capped", func(t *testing.T) { member(2, true) defer member(2, false) id := fresh(2, 3) for index := 1; index <= 6; index++ { if status, body := send(id, 2, fmt.Sprintf("vip-%d", index)); status != 200 { t.Fatalf("会员第 %d 条被拦: HTTP %d %s", index, status, body) } } }) t.Run("deleting your own message does not hand back a slot", func(t *testing.T) { id := fresh(1, 2) for index := 1; index <= 3; index++ { send(id, 1, fmt.Sprintf("recall-%d", index)) } if _, err := db.Exec(`UPDATE im_messages SET recalled_at=NOW(3) WHERE conversation_id=? AND sender_id=1`, id); err != nil { t.Fatal(err) } if status, _ := send(id, 1, "recall-4"); status != 402 { t.Fatalf("撤回不能换来新的额度: HTTP %d", status) } }) t.Run("the limit can be switched off entirely", func(t *testing.T) { setLimit("0") defer setLimit("3") id := fresh(1, 3) for index := 1; index <= 5; index++ { if status, body := send(id, 1, fmt.Sprintf("off-%d", index)); status != 200 { t.Fatalf("HTTP %d: %s", status, body) } } }) t.Run("an AI reply is an answer, not a knock", func(t *testing.T) { id := fresh(2, 3) for index := 1; index <= 4; index++ { if _, _, err := a.persistMessageContext(markAIGenerated(context.Background()), id, 3, fmt.Sprintf("ai-%d", index), 1, map[string]any{"text": "在的"}); err != nil { t.Fatalf("托管账号的回复不该受开场限制: %v", err) } } }) t.Run("the chat page is told how many opening messages are left", func(t *testing.T) { id := fresh(1, 2) read := func(user int64) map[string]any { t.Helper() var payload struct { Quota map[string]any `json:"unansweredQuota"` } path := fmt.Sprintf("/api/v1/im/conversations/%d/messages", id) if err := json.Unmarshal(imTestCall(t, a.messages, user, "GET", path, "", 200), &payload); err != nil { t.Fatal(err) } return payload.Quota } if quota := read(1); quota["remaining"] != float64(3) || quota["unlimited"] != false { t.Fatalf("quota = %v", quota) } send(id, 1, "quota-1") if quota := read(1); quota["remaining"] != float64(2) { t.Fatalf("quota = %v", read(1)) } send(id, 2, "quota-answer") if quota := read(1); quota["unlimited"] != true || quota["remaining"] != float64(-1) { t.Fatalf("对方回复后不再有额度概念: %v", quota) } }) }