CI / build (push) Successful in 31s
- The seat chosen on joining, and "*msg SeatAssignment" room seats, go to the hub, so people joining get free seats (both stood on seat 1). - Instance IDs are "<owner>-<2100000000+n>" (the hub's change): the client takes the owner from them, so owners can decorate their rooms. - A hosted room's scene carries community products as the hub's IDs (placements too), turned into local IDs by each proxy, instead of being stripped; saved and loaded scenes translate placements too. Co-Authored-By: Claude Opus 5.5 <[email protected]>
263 lines
11 KiB
Go
263 lines
11 KiB
Go
package hosted
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"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" && 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" && 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"}}); resp.Fault == nil || resp.Fault.String != "This room is full." {
|
|
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"], "customers_id": ["1"], "num_participants": [4], "name": ["A"], "participants": [[]], "is_ap": ["0"], "odd": [{"x": 1}], "number_of_rooms": 1}`)
|
|
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 2100000003]" || fmt.Sprint(l["customers_id"]) != "[1 88]" ||
|
|
fmt.Sprint(l["num_participants"]) != "[4 2]" || fmt.Sprint(l["odd"]) != "[map[x:1] map[]]" || l["number_of_rooms"] != float64(1) {
|
|
t.Fatalf("list %s", out)
|
|
}
|
|
// Other pages and searches are left alone.
|
|
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)
|
|
if x.ModifyResponse != nil {
|
|
t.Fatal("changed page 2")
|
|
}
|
|
}
|