Files
t5de-proxy/internal/hosted/hosted_test.go
T
dhkatzandClaude Opus 5.5 6aac769246
CI / build (push) Successful in 29s
Full hosted rooms are refused with IMVU's own full-room code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:07:31 -07:00

444 lines
20 KiB
Go

package hosted
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
)
// fakeHub answers joins at once (through rooms.FromHub) and records what it's sent.
type fakeHub struct {
mu sync.Mutex
rooms *Rooms
sent []map[string]any
scene []any
}
func (h *fakeHub) Send(v any) bool {
m := v.(map[string]any)
h.mu.Lock()
h.sent = append(h.sent, m)
h.mu.Unlock()
if m["type"] == "room.join" {
n := m["room"].(int64)
var answer string
if n == 9 {
answer = `{"type": "room.error", "room": 9, "reason": "full", "message": "This room is full."}`
} else {
answer = fmt.Sprintf(`{"type": "room.joined", "room": %d, "chat": %d, "sequence": 4,
"participants": [{"imvu_id": 7, "seat": 1}, {"imvu_id": 88, "seat": 2}]}`, n, Base+n)
}
go h.rooms.FromHub([]byte(answer))
}
return true
}
func (h *fakeHub) Call(ctx context.Context, method, path string, body, out any) error {
switch {
case method == "GET" && path == "/api/v1/hosted-rooms/3":
return json.Unmarshal([]byte(`{"room": {"n": 3, "name": "Lounge <b>", "ownerImvuId": 88, "ownerName": "Erin",
"roomPid": 50168116, "rating": "general", "maxUsers": 10, "occupancy": 2},
"scene": {"contents": [[1, 47477426, "37207-46"], [2, "coatcoat22", "37-11"], [3, "notherenn2", "37-12"]], "hash": "h1"}}`), out)
case method == "GET" && strings.HasPrefix(path, "/api/v1/decorations?instances="):
return json.Unmarshal([]byte(`{"rooms": [{"instance": "1-644", "contents": [[1, "coatcoat22", "x"]]}, {"instance": "2-17", "contents": []}]}`), out)
case method == "GET" && path == "/api/v1/hosted-rooms/3/participants":
return json.Unmarshal([]byte(`{"participants": [{"imvu_id": 88, "seat": 1, "name": "Erin<"}]}`), out)
case method == "GET" && path == "/api/v1/hosted-rooms":
return json.Unmarshal([]byte(`{"rooms": [{"n": 3, "name": "Lounge", "ownerImvuId": 88, "ownerName": "Erin", "maxUsers": 10, "occupancy": 2}]}`), out)
case (method == "PUT" || method == "DELETE") && (path == "/api/v1/hosted-rooms/3/favorite" || path == "/api/v1/hosted-rooms/3/rating"):
h.mu.Lock()
h.sent = append(h.sent, map[string]any{"type": method + " " + path, "body": body})
h.mu.Unlock()
return nil
case method == "PUT" && path == "/api/v1/hosted-rooms/3/scene":
h.mu.Lock()
h.scene = append(h.scene, body)
h.mu.Unlock()
return nil
}
return fmt.Errorf("unexpected %s %s", method, path)
}
func (h *fakeHub) sentOf(kind string) []map[string]any {
h.mu.Lock()
defer h.mu.Unlock()
var out []map[string]any
for _, m := range h.sent {
if m["type"] == kind {
out = append(out, m)
}
}
return out
}
func setup() (*Rooms, *fakeHub, *[]string) {
h := &fakeHub{}
var events []string
r := &Rooms{Hub: h, User: func() int64 { return 7 },
OnJoin: func(chat, room string) { events = append(events, "join "+chat+" "+room) },
OnLeave: func(chat string) { events = append(events, "leave "+chat) },
LocalProduct: func(id string) (int64, bool) { return 2000000005, id == "coatcoat22" },
GlobalProduct: func(local int64) (string, bool) { return "coatcoat22", local == 2000000005 }}
h.rooms = r
return r, h, &events
}
func xmlrpcCall(t *testing.T, r *Rooms, host, method string, args xmlrpc.Struct) (*xmlrpc.Response, bool) {
t.Helper()
body := (&xmlrpc.Call{Method: method, Params: []xmlrpc.Value{args}}).Marshal()
u, _ := url.Parse("https://" + host + "/api/xmlrpc/" + map[bool]string{true: "chat.php", false: "client.php"}[strings.HasPrefix(method, "chat.")])
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
x := &proxy.Exchange{URL: u, Request: req, Body: body}
r.Intercept(x)
if x.Response == nil {
return nil, false
}
b, _ := io.ReadAll(x.Response.Body)
resp, err := xmlrpc.ParseResponse(b)
if err != nil {
t.Fatalf("%s: %v\n%s", method, err, b)
}
return resp, true
}
func get(v xmlrpc.Value, name string) xmlrpc.Value {
s, _ := v.(xmlrpc.Struct)
out, _ := s.Get(name)
return out
}
// frames reads the frames the proxy sent the client.
func frames(t *testing.T, b []byte) []imq.Frame {
var out []imq.Frame
for len(b) > 0 {
f, n, err := imq.Next(b)
if err != nil || n == 0 {
t.Fatalf("frames: %v", err)
}
out = append(out, f)
b = b[n:]
}
return out
}
func TestHostedRoom(t *testing.T) {
r, h, events := setup()
// IMVU's rooms are left alone.
if _, answered := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-170879926-5533"}}); answered {
t.Fatal("answered an IMVU room")
}
// Joining: the hub lets the client in; it sits in the first free seat.
resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
if get(resp.Params[0], "chatId") != int64(2100000003) || get(resp.Params[0], "seat") != int64(1) || get(resp.Params[0], "lastMessageId") != int64(4) {
t.Fatalf("join answer %v", resp.Params[0])
}
if len(*events) != 1 || (*events)[0] != "join 2100000003 88-2100000003" {
t.Fatalf("events %v", *events)
}
// A full room: the client is told why.
if resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000009"}}); get(resp.Params[0], "response") != "declined" || get(resp.Params[0], "explanation") != "This room is full." || get(resp.Params[0], "reason") != int64(5) {
t.Fatalf("full room: %+v", resp)
}
// Others in the room, without the client.
resp, _ = xmlrpcCall(t, r, "chat.imvu.com", "chat.getParticipants", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
others := resp.Params[0].([]xmlrpc.Value)
if len(others) != 1 || get(others[0], "userId") != int64(88) {
t.Fatalf("participants %v", others)
}
// IMQ: the room's queues are answered here; IMVU gets the rest.
var toClient bytes.Buffer
c := proxy.NewIMQConn(1, &toClient)
sub := imq.Subscription{Queues: []string{"/user/7"}, WithResults: []imq.QueueOp{{Queue: "/chat/2100000003", OpID: 5}, {Queue: "/chat/1385863547", OpID: 6}}}
rest := r.Route(c, false, sub)
if fmt.Sprint(rest.All()) != "[/user/7 /chat/1385863547]" {
t.Fatalf("for IMVU: %v", rest.All())
}
var types []string
for _, f := range frames(t, toClient.Bytes()) {
types = append(types, imq.TypeName(f.Type))
}
if got := strings.Join(types, " "); got != "G2cJoinedQueue G2cCreateMount G2cCreateMount G2cCreateMount G2cResult" {
t.Fatalf("frames %s", got)
}
// Chat goes to the hub; sitting down too.
if !r.Send("/chat/2100000003", "messages", []byte(`{"message": "*seat 2", "userId": 7}`), []byte(`{"message": "*seat 2", "userId": 7}`), false) || r.Send("/chat/1385863547", "messages", []byte(`{}`), []byte(`{}`), false) {
t.Fatal("Send's answer")
}
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*msg SeatAssignment 0 7 3 0", "userId": 7}`), nil, false)
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*msg SeatAssignment 0 7 1 5521", "userId": 7}`), nil, false) // on furniture
// The seat chosen on joining (1), "*seat 2", and the room seat 3.
if seats, sends := h.sentOf("room.seat"), h.sentOf("room.send"); len(seats) != 3 || seats[0]["seat"] != 1 || seats[1]["seat"] != 2 || seats[2]["seat"] != 3 || len(sends) != 3 {
t.Fatalf("seats %v, sends %v", seats, sends)
}
// Others' chat comes back as the gateway would deliver it.
toClient.Reset()
r.FromHub([]byte(`{"type": "room.message", "room": 3, "from": 88, "mount": "messages", "message": "{\"message\":\"hi\",\"userId\":88}", "sequence": 5}`))
fs := frames(t, toClient.Bytes())
if len(fs) != 1 {
t.Fatalf("%d frames", len(fs))
}
d, _ := imq.ParseDelivered(fs[0].Data)
if d.UserID != "/user/88" || d.Queue != "/chat/2100000003" || d.Sequence != 5 || !strings.Contains(string(d.Message), "hi") {
t.Fatalf("delivered %+v", d)
}
// The room's card and scene come from the hub; the scene's community products as local IDs (missing ones left out).
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomCardInfo", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
if get(resp.Params[0], "name") != "Lounge b" || get(resp.Params[0], "owner_cid") != int64(88) {
t.Fatalf("card %v", resp.Params[0])
}
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
contents := get(get(resp.Params[0], "room_state"), "room_contents").([]xmlrpc.Value)
if len(contents) != 2 || fmt.Sprint(contents[1]) != "[2 2000000005 37-11]" || get(get(get(resp.Params[0], "room_state"), "room_info"), "room_pid") != "50168116" {
t.Fatalf("scene %v", resp.Params[0])
}
// Saving the scene: community products as the hub's IDs.
xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.setRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"},
{Name: "room_contents", Value: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(47477426), "37-1"}, []xmlrpc.Value{int64(2), int64(2000000005), "37-2"}}}})
if b, _ := json.Marshal(h.scene); string(b) != `[{"contents":[[1,47477426,"37-1"],[2,"coatcoat22","37-2"]]}]` {
t.Fatalf("saved %s", b)
}
// The participant post for a hosted chat is answered here.
u, _ := url.Parse("https://api.imvu.com/chat/chat-2100000003/participants/user-7")
req, _ := http.NewRequest("POST", u.String(), nil)
x := &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
if x.Response == nil || x.Response.StatusCode != 200 {
t.Fatal("participant post not answered")
}
// Anything else naming a hosted room is answered here, not sent to IMVU.
for _, raw := range []string{"https://api.imvu.com/room/room-88-2100000003", "https://client-dynamic.imvu.com/api/rooms/room_rating.php?room_instance_id=88-2100000003",
"https://client-dynamic.imvu.com/api/whatever.php?chat=2100000003"} {
u, _ := url.Parse(raw)
req, _ := http.NewRequest("GET", u.String(), nil)
x := &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
if x.Response == nil {
t.Errorf("%s went to IMVU", raw)
}
}
// Leaving.
xmlrpcCall(t, r, "chat.imvu.com", "chat.terminateChat", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
if len(h.sentOf("room.leave")) != 1 || (*events)[len(*events)-1] != "leave 2100000003" {
t.Fatalf("leave: %v %v", h.sentOf("room.leave"), *events)
}
}
func TestRoomList(t *testing.T) {
r, _, _ := setup()
u, _ := url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=1")
req, _ := http.NewRequest("GET", u.String(), nil)
x := &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
body := []byte(`{"customers_room_id": ["644", "17"], "customers_id": ["1", "2"], "num_participants": [4, 1], "name": ["A", "B"], "participants": [[], []], "is_ap": ["0", "0"], "odd": [{"x": 1}, {}], "number_of_rooms": 2}`)
out, err := x.ModifyResponse(&http.Response{StatusCode: 200}, body)
if err != nil {
t.Fatal(err)
}
var l map[string]any
json.Unmarshal(out, &l)
if fmt.Sprint(l["customers_room_id"]) != "[644 17 2100000003]" || fmt.Sprint(l["customers_id"]) != "[1 2 88]" ||
fmt.Sprint(l["num_participants"]) != "[4 1 2]" || fmt.Sprint(l["odd"]) != "[map[x:1] map[] map[]]" || l["number_of_rooms"] != float64(2) ||
fmt.Sprint(l["t5de_t5"]) != "[1 0 1]" { // decorated, plain, hosted
t.Fatalf("list %s", out)
}
// Other pages get no hosted rooms, but their decorated rooms are marked.
u, _ = url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=2")
x = &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
out, _ = x.ModifyResponse(&http.Response{StatusCode: 200}, body)
json.Unmarshal(out, &l)
if fmt.Sprint(l["customers_room_id"]) != "[644 17]" || fmt.Sprint(l["t5de_t5"]) != "[1 0]" {
t.Fatalf("page 2: %s", out)
}
}
func TestBadgesAndRoomInfo(t *testing.T) {
r, _, _ := setup()
do := func(raw string, body []byte, modified []byte) []byte {
t.Helper()
u, _ := url.Parse(raw)
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
x := &proxy.Exchange{URL: u, Request: req, Body: body}
r.Intercept(x)
if x.Response != nil {
b, _ := io.ReadAll(x.Response.Body)
return b
}
if x.ModifyResponse == nil {
t.Fatalf("%s: left alone", raw)
}
out, _ := x.ModifyResponse(&http.Response{StatusCode: 200}, modified)
return out
}
// A decorated IMVU room's card gets t5de_t5.
card := (&xmlrpc.Call{Method: "test.getRoomCardInfo", Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "room_instance_id", Value: "1-644"}}}}).Marshal()
answer := (&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "name", Value: "A"}}}}).Marshal()
if out := do("https://client-dynamic.imvu.com/api/xmlrpc/client.php", card, answer); !strings.Contains(string(out), "t5de_t5") {
t.Fatalf("card %s", out)
}
// The Manage tab's own decorated rooms get it too.
managed := []byte(`{"result": {"limit": 1, "rooms": [{"room_instance_id": "1-644"}, {"room_instance_id": "2-17"}]}}`)
out := do("https://client-dynamic.imvu.com/api/room_management_info.php", nil, managed)
if !strings.Contains(string(out), `{"room_instance_id":"1-644","t5de_t5":1}`) || strings.Count(string(out), "t5de_t5") != 1 {
t.Fatalf("managed %s", out)
}
// A decorated IMVU room's room_info.php gets t5de_t5.
if out := do("https://client-dynamic.imvu.com/api/rooms/room_info.php?room_id=1-644", nil, []byte(`{"participants": []}`)); !strings.Contains(string(out), `"t5de_t5":1`) {
t.Fatalf("room info %s", out)
}
// room_info.php for a hosted room: its people, from the hub.
out = do("https://client-dynamic.imvu.com/api/rooms/room_info.php?room_id=88-2100000003", nil, nil)
if !strings.Contains(string(out), `"avatar_name":"Erin"`) || !strings.Contains(string(out), `"customers_id":"88"`) ||
!strings.Contains(string(out), `"max_users":10`) || !strings.Contains(string(out), `"t5de_t5":1`) {
t.Fatalf("room info %s", out)
}
}
func TestPhase2(t *testing.T) {
r, h, _ := setup()
imvu := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, `{"denormalized": {"https://api.imvu.com/user/user-88": {"data": {"username": "Erin", "age": 30, "gender": "f", "thumbnail_url": "https://x/p.png"}}}}`)
}))
defer imvu.Close()
r.HTTPClient = &http.Client{Transport: rewrite{imvu.URL}}
var userMsgs []string
r.UserDeliver = func(mount string, b []byte) error { userMsgs = append(userMsgs, mount+" "+string(b)); return nil }
call := func(method string, args xmlrpc.Struct) xmlrpc.Value {
t.Helper()
resp, ok := xmlrpcCall(t, r, "chat.imvu.com", method, args)
if !ok {
t.Fatalf("%s left to IMVU", method)
}
return resp.Params[0]
}
xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
// Inviting: waiting until the hub says it was accepted.
first := call("chat.attemptInvite", xmlrpc.Struct{{Name: "partnerId", Value: int64(55)}, {Name: "location", Value: "88-2100000003"}})
id := get(first, "inviteId")
if get(first, "waiting") == nil || len(h.sentOf("room.invite")) != 1 || h.sentOf("room.invite")[0]["imvu_id"] != int64(55) {
t.Fatalf("first attempt %v, sent %v", first, h.sentOf("room.invite"))
}
r.FromHub([]byte(`{"type": "room.invite_result", "room": 3, "imvu_id": 55, "ok": true, "invite": "abc"}`))
if again := call("chat.attemptInvite", xmlrpc.Struct{{Name: "inviteId", Value: id}, {Name: "location", Value: "88-2100000003"}}); get(again, "waiting") == nil {
t.Fatalf("still waiting expected: %v", again)
}
r.FromHub([]byte(`{"type": "room.invite_answer", "invite": "abc", "to": 55, "accept": true}`))
if done := call("chat.attemptInvite", xmlrpc.Struct{{Name: "inviteId", Value: id}, {Name: "location", Value: "88-2100000003"}}); get(done, "waiting") != nil || get(done, "response") != nil {
t.Fatalf("accepted expected: %v", done)
}
// Invited: the client gets chatInvite on its user queue; accepting joins with the invite.
r.FromHub([]byte(`{"type": "room.invited", "room": 3, "invite": "xyz", "from": 88, "room_name": "Lounge"}`))
for i := 0; i < 100 && len(userMsgs) == 0; i++ {
time.Sleep(10 * time.Millisecond)
}
if len(userMsgs) != 1 || !strings.HasPrefix(userMsgs[0], "web_msg chatInvite ") || !strings.Contains(userMsgs[0], `"room_instance_id":"88-2100000003"`) || !strings.Contains(userMsgs[0], `"maxOccupancy":10`) || !strings.Contains(userMsgs[0], `"gender":"f"`) || !strings.Contains(userMsgs[0], `"name":"Erin"`) {
t.Fatalf("invitation %v", userMsgs)
}
var inv map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(userMsgs[0], "web_msg chatInvite ")), &inv)
// Before accepting (not in the room), getParticipants says who's in it, so the client sees its inviter there.
r.leave(3)
if ps := call("chat.getParticipants", xmlrpc.Struct{{Name: "chatId", Value: int64(2100000003)}}); !strings.Contains(fmt.Sprint(ps), "88") {
t.Fatalf("participants before joining %v", ps)
}
accepted := call("chat.acceptInvite", xmlrpc.Struct{{Name: "inviteId", Value: int64(inv["inviteId"].(float64))}, {Name: "chatId", Value: int64(2100000003)}})
if get(accepted, "room_instance_id") != "88-2100000003" || h.sentOf("room.invite_reply")[0]["accept"] != true {
t.Fatalf("accepting: %v, sent %v", accepted, h.sent)
}
// The client then joins the room, with the invite.
xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
if joins := h.sentOf("room.join"); joins[len(joins)-1]["invite"] != "xyz" {
t.Fatalf("join after accepting %v", joins)
}
// Booting: the *boot line also tells the hub; bootOutOfChat is answered here.
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*boot 7 88", "userId": 7}`), nil, false)
if boots := h.sentOf("room.boot"); len(boots) != 1 || boots[0]["imvu_id"] != int64(88) {
t.Fatalf("boot %v", boots)
}
call("chat.bootOutOfChat", xmlrpc.Struct{{Name: "userId", Value: int64(88)}, {Name: "roomInstanceId", Value: "88-2100000003"}})
// Favorites.
resp, _ := xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.addFavoriteRoom", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
if resp == nil || len(h.sentOf("PUT /api/v1/hosted-rooms/3/favorite")) != 1 {
t.Fatal("favorite not added")
}
// Favorites from the room list.
fu, _ := url.Parse("https://client-dynamic.imvu.com/api/favorite_rooms.php")
fbody := []byte(`{"action": "remove", "room_instance_id": "88-2100000003"}`)
freq, _ := http.NewRequest("POST", fu.String(), bytes.NewReader(fbody))
fx := &proxy.Exchange{URL: fu, Request: freq, Body: fbody}
r.Intercept(fx)
if fx.Response == nil || len(h.sentOf("DELETE /api/v1/hosted-rooms/3/favorite")) != 1 {
t.Fatal("favorite not removed")
}
// Rating.
u, _ := url.Parse("https://client-dynamic.imvu.com/api/rooms/room_rating.php")
body := []byte("room_instance_id=88-2100000003&score=4")
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
x := &proxy.Exchange{URL: u, Request: req, Body: body}
r.Intercept(x)
if x.Response == nil || len(h.sentOf("PUT /api/v1/hosted-rooms/3/rating")) != 1 {
t.Fatal("rating not sent")
}
// Occupancy: watching tells the hub; changes reach the watcher as state changes.
var toClient bytes.Buffer
c := proxy.NewIMQConn(2, &toClient)
r.Route(c, false, imq.Subscription{Queues: []string{"room_occupancy/88-2100000003"}})
if w := h.sentOf("room.watch"); len(w) == 0 || fmt.Sprint(w[len(w)-1]["rooms"]) != "[3]" {
t.Fatalf("watch %v", w)
}
toClient.Reset()
r.FromHub([]byte(`{"type": "room.occupancy", "room": 3, "participants": [{"imvu_id": 88, "seat": 1}, {"imvu_id": 7, "seat": 2}]}`))
fs := frames(t, toClient.Bytes())
if len(fs) != 1 || fs[0].Type != imq.G2cStateChange || !strings.Contains(string(fs[0].Data), "room_occupancy/88-2100000003") {
t.Fatalf("occupancy frames %v", fs)
}
}
func TestAppendRoomToEmptyList(t *testing.T) {
list := map[string]json.RawMessage{"customers_id": json.RawMessage(`[]`), "is_ap": json.RawMessage(`[]`), "is_vip": json.RawMessage(`[]`), "name": json.RawMessage(`[]`)}
appendRoom(list, Room{N: 3, OwnerImvuID: 88, Name: "Lounge"})
if string(list["is_ap"]) != "[0]" || string(list["is_vip"]) != "[0]" || string(list["name"]) != `["Lounge"]` {
t.Fatalf("list %s %s %s", list["is_ap"], list["is_vip"], list["name"])
}
}
// rewrite sends every request to a test server.
type rewrite struct{ to string }
func (t rewrite) RoundTrip(req *http.Request) (*http.Response, error) {
u, _ := url.Parse(t.to + req.URL.Path)
req = req.Clone(req.Context())
req.URL, req.Host = u, u.Host
return http.DefaultTransport.RoundTrip(req)
}