| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627 |
- package meetroom
- import (
- "bytes"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
- "imuslab.com/arozos/mod/sharedspace"
- )
- func newTestManager(t *testing.T) *Manager {
- t.Helper()
- return NewManager(filepath.Join(t.TempDir(), "attachments"))
- }
- // newSpaceBoundManager returns a room manager wired to a shared-space manager,
- // the way MeetRoomInit binds them in production.
- func newSpaceBoundManager(t *testing.T) (*Manager, *sharedspace.Manager) {
- t.Helper()
- m := NewManager(filepath.Join(t.TempDir(), "attachments"))
- sm := sharedspace.NewManager(filepath.Join(t.TempDir(), "spaces"), 0)
- m.BindSpaceManager(sm)
- return m, sm
- }
- func TestCreateRoom(t *testing.T) {
- m := newTestManager(t)
- tests := []struct {
- name string
- host string
- title string
- password string
- wantTitle string
- wantProtected bool
- }{
- {"open room with title", "alice", "Standup", "", "Standup", false},
- {"password room", "bob", "Secret sync", "hunter2", "Secret sync", true},
- {"default title", "carol", "", "", "carol's Meeting", false},
- {"overlong title clipped", "dave", strings.Repeat("x", 200), "", strings.Repeat("x", maxTitleLength), false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- room := m.CreateRoom(tt.host, tt.title, tt.password)
- if len(room.ID) != roomIDLength {
- t.Errorf("room ID %q length = %d, want %d", room.ID, len(room.ID), roomIDLength)
- }
- for _, c := range room.ID {
- if c < '0' || c > '9' {
- t.Errorf("room ID %q contains non-digit %q", room.ID, c)
- }
- }
- if room.Title != tt.wantTitle {
- t.Errorf("title = %q, want %q", room.Title, tt.wantTitle)
- }
- if room.Host != tt.host {
- t.Errorf("host = %q, want %q", room.Host, tt.host)
- }
- if room.HasPassword() != tt.wantProtected {
- t.Errorf("HasPassword() = %v, want %v", room.HasPassword(), tt.wantProtected)
- }
- if got, ok := m.GetRoom(room.ID); !ok || got != room {
- t.Errorf("GetRoom(%q) did not return the created room", room.ID)
- }
- })
- }
- }
- func TestCreateRoomUniqueIDs(t *testing.T) {
- m := newTestManager(t)
- seen := map[string]bool{}
- for i := 0; i < 100; i++ {
- room := m.CreateRoom("host", "", "")
- if seen[room.ID] {
- t.Fatalf("duplicate room ID generated: %s", room.ID)
- }
- seen[room.ID] = true
- }
- if m.RoomCount() != 100 {
- t.Errorf("RoomCount() = %d, want 100", m.RoomCount())
- }
- }
- func TestValidateJoin(t *testing.T) {
- m := newTestManager(t)
- open := m.CreateRoom("alice", "Open", "")
- locked := m.CreateRoom("bob", "Locked", "hunter2")
- tests := []struct {
- name string
- roomID string
- password string
- wantErr error
- }{
- {"open room no password", open.ID, "", nil},
- {"open room ignores password", open.ID, "whatever", nil},
- {"locked room correct password", locked.ID, "hunter2", nil},
- {"locked room wrong password", locked.ID, "letmein", ErrInvalidPassword},
- {"locked room empty password", locked.ID, "", ErrInvalidPassword},
- {"unknown room", "000000000", "", ErrRoomNotFound},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- _, err := m.ValidateJoin(tt.roomID, tt.password)
- if err != tt.wantErr {
- t.Errorf("ValidateJoin(%q, %q) error = %v, want %v", tt.roomID, tt.password, err, tt.wantErr)
- }
- })
- }
- }
- func TestParticipantLifecycle(t *testing.T) {
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- host, err := room.AddParticipant("alice")
- if err != nil {
- t.Fatalf("AddParticipant(alice) error = %v", err)
- }
- guest, err := room.AddParticipant("bob")
- if err != nil {
- t.Fatalf("AddParticipant(bob) error = %v", err)
- }
- if !host.IsHost {
- t.Errorf("host participant IsHost = false, want true")
- }
- if guest.IsHost {
- t.Errorf("guest participant IsHost = true, want false")
- }
- if host.PeerID == guest.PeerID {
- t.Errorf("peer IDs collide: %d", host.PeerID)
- }
- if room.ParticipantCount() != 2 {
- t.Errorf("ParticipantCount() = %d, want 2", room.ParticipantCount())
- }
- if p, ok := room.GetParticipant(guest.PeerID); !ok || p != guest {
- t.Errorf("GetParticipant(%d) did not return the guest", guest.PeerID)
- }
- room.RemoveParticipant(guest.PeerID)
- if room.ParticipantCount() != 1 {
- t.Errorf("ParticipantCount() after remove = %d, want 1", room.ParticipantCount())
- }
- if _, open := <-guest.Send; open {
- t.Errorf("removed participant's send channel still open")
- }
- //Removing twice must not panic
- room.RemoveParticipant(guest.PeerID)
- }
- func TestKickParticipant(t *testing.T) {
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- host, _ := room.AddParticipant("alice")
- guest, _ := room.AddParticipant("bob")
- //The host cannot be kicked, even by peer ID
- if _, ok := room.KickParticipant(host.PeerID); ok {
- t.Errorf("KickParticipant(host) = true, want false")
- }
- if room.ParticipantCount() != 2 {
- t.Errorf("ParticipantCount() after failed host kick = %d, want 2", room.ParticipantCount())
- }
- //A regular guest is removed and returned
- kicked, ok := room.KickParticipant(guest.PeerID)
- if !ok || kicked != guest {
- t.Fatalf("KickParticipant(guest) = (%v, %v), want the guest / true", kicked, ok)
- }
- if room.ParticipantCount() != 1 {
- t.Errorf("ParticipantCount() after kick = %d, want 1", room.ParticipantCount())
- }
- if _, open := <-guest.Send; open {
- t.Errorf("kicked participant's send channel still open")
- }
- //The kick is recorded in the attendance log as a leave
- for _, record := range room.Attendance() {
- if record.PeerID == guest.PeerID && record.Present() {
- t.Errorf("kicked participant still marked present in attendance")
- }
- }
- //Kicking an unknown peer, or the same peer twice, is a safe no-op
- if _, ok := room.KickParticipant(guest.PeerID); ok {
- t.Errorf("KickParticipant(already kicked) = true, want false")
- }
- if _, ok := room.KickParticipant(9999); ok {
- t.Errorf("KickParticipant(unknown) = true, want false")
- }
- }
- func TestBroadcastAndSendTo(t *testing.T) {
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- a, _ := room.AddParticipant("alice")
- b, _ := room.AddParticipant("bob")
- c, _ := room.AddParticipant("carol")
- room.Broadcast([]byte("hello"), a.PeerID)
- select {
- case msg := <-b.Send:
- if string(msg) != "hello" {
- t.Errorf("b received %q, want %q", msg, "hello")
- }
- default:
- t.Errorf("b received nothing from broadcast")
- }
- select {
- case msg := <-c.Send:
- if string(msg) != "hello" {
- t.Errorf("c received %q, want %q", msg, "hello")
- }
- default:
- t.Errorf("c received nothing from broadcast")
- }
- select {
- case msg := <-a.Send:
- t.Errorf("excluded sender received %q", msg)
- default:
- }
- if !room.SendTo(b.PeerID, []byte("direct")) {
- t.Errorf("SendTo(%d) = false, want true", b.PeerID)
- }
- if msg := <-b.Send; string(msg) != "direct" {
- t.Errorf("b received %q, want %q", msg, "direct")
- }
- if room.SendTo(9999, []byte("direct")) {
- t.Errorf("SendTo(9999) = true for unknown peer, want false")
- }
- }
- func TestAttachmentLifecycle(t *testing.T) {
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- tests := []struct {
- name string
- roomID string
- fileName string
- content string
- maxSize int64
- wantErr error
- }{
- {"normal upload", room.ID, "notes.txt", "meeting notes", 1024, nil},
- {"unknown room", "000000000", "notes.txt", "data", 1024, ErrRoomNotFound},
- {"oversized upload", room.ID, "big.bin", strings.Repeat("A", 100), 10, ErrAttachmentTooLarge},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- att, err := m.SaveAttachment(tt.roomID, tt.fileName, "alice", strings.NewReader(tt.content), tt.maxSize)
- if err != tt.wantErr {
- t.Fatalf("SaveAttachment() error = %v, want %v", err, tt.wantErr)
- }
- if err != nil {
- return
- }
- if att.Size != int64(len(tt.content)) {
- t.Errorf("attachment size = %d, want %d", att.Size, len(tt.content))
- }
- stored, ok := m.GetAttachment(tt.roomID, att.ID)
- if !ok {
- t.Fatalf("GetAttachment(%q) not found", att.ID)
- }
- data, err := os.ReadFile(stored.DiskPath)
- if err != nil {
- t.Fatalf("reading stored attachment: %v", err)
- }
- if !bytes.Equal(data, []byte(tt.content)) {
- t.Errorf("stored content = %q, want %q", data, tt.content)
- }
- })
- }
- if _, ok := m.GetAttachment(room.ID, "nonexistent"); ok {
- t.Errorf("GetAttachment returned ok for unknown file ID")
- }
- }
- func TestCloseRoomCleansUp(t *testing.T) {
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- p, _ := room.AddParticipant("alice")
- att, err := m.SaveAttachment(room.ID, "doc.pdf", "alice", strings.NewReader("content"), 1024)
- if err != nil {
- t.Fatalf("SaveAttachment() error = %v", err)
- }
- members := m.CloseRoom(room.ID)
- if len(members) != 1 || members[0] != p {
- t.Errorf("CloseRoom returned %d members, want the 1 participant", len(members))
- }
- if _, ok := m.GetRoom(room.ID); ok {
- t.Errorf("room still registered after CloseRoom")
- }
- if _, open := <-p.Send; open {
- t.Errorf("participant send channel still open after CloseRoom")
- }
- if _, err := os.Stat(att.DiskPath); !os.IsNotExist(err) {
- t.Errorf("attachment file still on disk after CloseRoom: %v", err)
- }
- if _, err := room.AddParticipant("bob"); err != ErrRoomClosed {
- t.Errorf("AddParticipant on closed room error = %v, want ErrRoomClosed", err)
- }
- //Closing an unknown room must be a no-op
- if members := m.CloseRoom("000000000"); members != nil {
- t.Errorf("CloseRoom on unknown ID returned %v, want nil", members)
- }
- }
- func TestSweepIdleRooms(t *testing.T) {
- m := newTestManager(t)
- idle := m.CreateRoom("alice", "Idle", "")
- occupied := m.CreateRoom("bob", "Busy", "")
- occupied.AddParticipant("bob")
- fresh := m.CreateRoom("carol", "Fresh", "")
- //Backdate the idle room's activity clock
- idle.mu.Lock()
- idle.lastActivity = time.Now().Add(-time.Hour)
- idle.mu.Unlock()
- occupied.mu.Lock()
- occupied.lastActivity = time.Now().Add(-time.Hour)
- occupied.mu.Unlock()
- closed := m.SweepIdleRooms(30 * time.Minute)
- if len(closed) != 1 || closed[0] != idle.ID {
- t.Errorf("SweepIdleRooms closed %v, want [%s]", closed, idle.ID)
- }
- if _, ok := m.GetRoom(occupied.ID); !ok {
- t.Errorf("occupied room was swept")
- }
- if _, ok := m.GetRoom(fresh.ID); !ok {
- t.Errorf("fresh room was swept")
- }
- }
- func TestRoomIDFormatting(t *testing.T) {
- tests := []struct {
- name string
- input string
- wantFormat string
- wantNormalize string
- }{
- {"standard ID", "123456789", "123-456-789", "123456789"},
- {"dashed input", "123-456-789", "123-456-789", "123456789"},
- {"spaced input", "123 456 789", "123 456 789", "123456789"},
- {"short ID passthrough", "1234", "1234", "1234"},
- {"junk stripped", "12a34!56789", "12a34!56789", "123456789"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := NormalizeRoomID(tt.input); got != tt.wantNormalize {
- t.Errorf("NormalizeRoomID(%q) = %q, want %q", tt.input, got, tt.wantNormalize)
- }
- })
- }
- //FormatRoomID only reformats full-length normalized IDs
- if got := FormatRoomID("123456789"); got != "123-456-789" {
- t.Errorf("FormatRoomID = %q, want 123-456-789", got)
- }
- if got := FormatRoomID("1234"); got != "1234" {
- t.Errorf("FormatRoomID(short) = %q, want passthrough", got)
- }
- }
- func TestAttendanceLog(t *testing.T) {
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- host, _ := room.AddParticipant("alice")
- guest, _ := room.AddParticipant("bob")
- room.RemoveParticipant(guest.PeerID)
- records := room.Attendance()
- if len(records) != 2 {
- t.Fatalf("Attendance() returned %d records, want 2", len(records))
- }
- if records[0].Username != "alice" || records[0].PeerID != host.PeerID {
- t.Errorf("first record = %+v, want alice/%d", records[0], host.PeerID)
- }
- if !records[0].Present() {
- t.Errorf("host record marked as left")
- }
- if records[1].Username != "bob" {
- t.Errorf("second record username = %q, want bob", records[1].Username)
- }
- if records[1].Present() {
- t.Errorf("removed guest still marked as present")
- }
- if records[1].LeftAt.Before(records[1].JoinedAt) {
- t.Errorf("LeftAt %v before JoinedAt %v", records[1].LeftAt, records[1].JoinedAt)
- }
- //Rejoin appends a fresh record instead of reviving the old one
- room.AddParticipant("bob")
- records = room.Attendance()
- if len(records) != 3 {
- t.Fatalf("Attendance() after rejoin returned %d records, want 3", len(records))
- }
- if records[1].Present() || !records[2].Present() {
- t.Errorf("rejoin did not append a fresh present record")
- }
- if !room.HasParticipantUsername("bob") {
- t.Errorf("HasParticipantUsername(bob) = false, want true")
- }
- if room.HasParticipantUsername("mallory") {
- t.Errorf("HasParticipantUsername(mallory) = true, want false")
- }
- }
- func TestListRoomsByHost(t *testing.T) {
- m := newTestManager(t)
- m.CreateRoom("alice", "One", "")
- m.CreateRoom("alice", "Two", "")
- m.CreateRoom("bob", "Other", "")
- if got := len(m.ListRoomsByHost("alice")); got != 2 {
- t.Errorf("alice hosts %d rooms, want 2", got)
- }
- if got := len(m.ListRoomsByHost("carol")); got != 0 {
- t.Errorf("carol hosts %d rooms, want 0", got)
- }
- }
- func TestSpaceBoundRoomLifecycle(t *testing.T) {
- m, sm := newSpaceBoundManager(t)
- room := m.CreateRoom("alice", "Standup", "")
- if room.SpaceID == "" {
- t.Fatalf("space-bound room has no SpaceID")
- }
- space, ok := sm.GetSpace(room.SpaceID)
- if !ok {
- t.Fatalf("bound space %q not registered", room.SpaceID)
- }
- if space.Owner != "alice" || space.Name != "Standup" {
- t.Errorf("space owner/name = %q/%q, want alice/Standup", space.Owner, space.Name)
- }
- //Attachments are stored in the space and readable through both APIs
- att, err := m.SaveAttachment(room.ID, "photo.png", "bob", strings.NewReader("img-bytes"), 1024)
- if err != nil {
- t.Fatalf("SaveAttachment() error = %v", err)
- }
- item, ok := space.GetItem(att.ID)
- if !ok {
- t.Fatalf("attachment not stored as a space item")
- }
- if item.Type != sharedspace.ItemTypeImage {
- t.Errorf("png attachment item type = %q, want image", item.Type)
- }
- if item.Origin != OriginMeetRoom {
- t.Errorf("attachment origin = %q, want %q", item.Origin, OriginMeetRoom)
- }
- if got, ok := m.GetAttachment(room.ID, att.ID); !ok || got.DiskPath != item.DiskPath {
- t.Errorf("GetAttachment did not resolve the space-backed file")
- }
- //Chat mirrors into the space with the meetroom origin
- m.LogChat(room.ID, "alice", "hello world")
- items := space.Items()
- if len(items) != 2 {
- t.Fatalf("space holds %d items, want 2", len(items))
- }
- if items[1].Type != sharedspace.ItemTypeText || items[1].Text != "hello world" || items[1].Origin != OriginMeetRoom {
- t.Errorf("mirrored chat item = %+v", items[1])
- }
- //Oversized uploads map back to the meetroom error
- if _, err := m.SaveAttachment(room.ID, "big.bin", "bob", strings.NewReader(strings.Repeat("A", 100)), 10); err != ErrAttachmentTooLarge {
- t.Errorf("oversized upload error = %v, want ErrAttachmentTooLarge", err)
- }
- //Closing the room deletes the bound space and its blobs
- m.CloseRoom(room.ID)
- if _, ok := sm.GetSpace(room.SpaceID); ok {
- t.Errorf("bound space still registered after CloseRoom")
- }
- if _, err := os.Stat(item.DiskPath); !os.IsNotExist(err) {
- t.Errorf("space blob still on disk after CloseRoom: %v", err)
- }
- }
- func TestSpaceItemBridge(t *testing.T) {
- m, sm := newSpaceBoundManager(t)
- var bridged []*sharedspace.Item
- var bridgedRoom *Room
- m.SetSpaceItemHandler(func(room *Room, item *sharedspace.Item) {
- bridgedRoom = room
- bridged = append(bridged, item)
- })
- room := m.CreateRoom("alice", "", "")
- space, _ := sm.GetSpace(room.SpaceID)
- //Items posted by the room itself must not echo back through the bridge
- m.LogChat(room.ID, "alice", "own message")
- m.SaveAttachment(room.ID, "notes.txt", "alice", strings.NewReader("data"), 1024)
- if len(bridged) != 0 {
- t.Fatalf("bridge fired %d times for meetroom-origin items, want 0", len(bridged))
- }
- //External (AGI) items flow through the bridge
- agiItem, err := space.AddText("scriptbot", "posted from AGI", "agi")
- if err != nil {
- t.Fatalf("AddText() error = %v", err)
- }
- if len(bridged) != 1 || bridged[0] != agiItem {
- t.Fatalf("bridge did not deliver the AGI item (fired %d times)", len(bridged))
- }
- if bridgedRoom != room {
- t.Errorf("bridge delivered wrong room")
- }
- //AGI-posted files resolve as room attachments for the download endpoint
- blob, _ := space.SaveBlob(sharedspace.ItemTypeFile, "report.pdf", "scriptbot", "agi", strings.NewReader("pdf"), 1024)
- if len(bridged) != 2 {
- t.Fatalf("bridge fired %d times, want 2", len(bridged))
- }
- if att, ok := m.GetAttachment(room.ID, blob.ID); !ok || att.Name != "report.pdf" {
- t.Errorf("AGI-posted file not resolvable via GetAttachment")
- }
- }
- func TestRoomRidesSpaceChannel(t *testing.T) {
- //The meeting's realtime transport is the bound space's channel: meeting
- //participants appear as channel subscribers, and generic subscribers on
- //the same space receive meeting frames live.
- m, sm := newSpaceBoundManager(t)
- room := m.CreateRoom("alice", "", "")
- space, ok := sm.GetSpace(room.SpaceID)
- if !ok {
- t.Fatalf("bound space not found")
- }
- channel := space.Channel()
- host, _ := room.AddParticipant("alice")
- guest, _ := room.AddParticipant("bob")
- if channel.Count() != room.ParticipantCount() {
- t.Errorf("channel has %d subscribers, room has %d participants", channel.Count(), room.ParticipantCount())
- }
- if _, ok := channel.Get(host.PeerID); !ok {
- t.Errorf("host peer ID %d not a channel subscriber", host.PeerID)
- }
- //A generic space subscriber (e.g. a sharedspace WebSocket client)
- //receives room broadcasts without being a meeting participant
- watcher, err := channel.Join("watcher")
- if err != nil {
- t.Fatalf("generic Join() error = %v", err)
- }
- if room.ParticipantCount() != 2 {
- t.Errorf("generic subscriber leaked into the meeting roster")
- }
- room.Broadcast([]byte(`{"type":"chat","text":"hi"}`), host.PeerID)
- select {
- case msg := <-watcher.Send:
- if string(msg) != `{"type":"chat","text":"hi"}` {
- t.Errorf("watcher received %q", msg)
- }
- default:
- t.Errorf("generic space subscriber did not receive the room broadcast")
- }
- //Targeted sends reach meeting participants through the channel
- if !room.SendTo(guest.PeerID, []byte("direct")) {
- t.Errorf("SendTo(guest) = false")
- }
- //Closing the room tears down the shared channel: everyone drops
- m.CloseRoom(room.ID)
- if _, open := <-watcher.Send; open {
- t.Errorf("generic subscriber still connected after CloseRoom")
- }
- if _, err := channel.Join("late"); err == nil {
- t.Errorf("channel still accepts subscribers after CloseRoom")
- }
- }
- func TestUnboundRoomStandaloneChannel(t *testing.T) {
- //Rooms without a space manager run on a standalone channel with the
- //same transport semantics.
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- a, _ := room.AddParticipant("alice")
- b, _ := room.AddParticipant("bob")
- room.Broadcast([]byte("frame"), a.PeerID)
- if msg := <-b.Send; string(msg) != "frame" {
- t.Errorf("b received %q, want frame", msg)
- }
- room.RemoveParticipant(b.PeerID)
- if room.ParticipantCount() != 1 {
- t.Errorf("ParticipantCount() = %d, want 1", room.ParticipantCount())
- }
- }
- func TestParticipantMessageIsValidJSONFrame(t *testing.T) {
- //Guards the wire contract: frames pushed by the transport layer are
- //opaque bytes; make sure Broadcast does not mutate or alias them.
- m := newTestManager(t)
- room := m.CreateRoom("alice", "", "")
- a, _ := room.AddParticipant("alice")
- original := []byte(`{"type":"chat","text":"hi"}`)
- room.Broadcast(original, -1)
- original[2] = 'X' //mutate the caller's buffer after broadcast
- got := <-a.Send
- var decoded map[string]interface{}
- if err := json.Unmarshal(got, &decoded); err != nil {
- t.Fatalf("broadcast frame corrupted by caller mutation: %v", err)
- }
- if decoded["type"] != "chat" {
- t.Errorf("frame type = %v, want chat", decoded["type"])
- }
- }
|