package app import ( "context" "errors" "os" "path/filepath" "reflect" "testing" ) func TestChatMediaObjectKeys(t *testing.T) { tests := []struct { name string objectKey string mediaType string want []string }{ {name: "chat image includes thumbnail", objectKey: "media/2026/09/1-2-im1.png", mediaType: "image", want: []string{"media/2026/09/1-2-im1.png", "media/2026/09/1-2-im1-thumb.jpg"}}, {name: "voice only has original", objectKey: "media/2026/09/1-2.m4a", mediaType: "voice", want: []string{"media/2026/09/1-2.m4a"}}, {name: "unrecognized image does not guess siblings", objectKey: "imported/photo.jpg", mediaType: "image", want: []string{"imported/photo.jpg"}}, {name: "empty key", objectKey: "", mediaType: "image", want: nil}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := chatMediaObjectKeys(test.objectKey, test.mediaType); !reflect.DeepEqual(got, test.want) { t.Fatalf("chatMediaObjectKeys() = %#v, want %#v", got, test.want) } }) } } func TestChatMediaRetentionClamp(t *testing.T) { for input, want := range map[int]int{-1: 90, 0: 90, 1: 1, 3650: 3650, 3651: 90} { if got := clampChatMediaRetentionDays(input); got != want { t.Fatalf("clampChatMediaRetentionDays(%d) = %d, want %d", input, got, want) } } } func TestMissingMediaObjectDetection(t *testing.T) { if !isMissingMediaObject(os.ErrNotExist) || !isMissingMediaObject(errors.New("NoSuchKey")) || isMissingMediaObject(errors.New("permission denied")) { t.Fatal("missing-object detection did not classify storage errors correctly") } } func TestChatMediaCleanupMySQL(t *testing.T) { db := isolatedIMDatabase(t) directory := t.TempDir() if _, err := db.Exec(`UPDATE system_configs SET config_value=? WHERE config_key='storage.local.directory'`, directory); err != nil { t.Fatal(err) } const mainKey = "1-100-im1.png" const thumbnailKey = "1-100-im1-thumb.jpg" for _, name := range []string{mainKey, thumbnailKey} { if err := os.WriteFile(filepath.Join(directory, name), []byte("media"), 0o600); err != nil { t.Fatal(err) } } if _, err := db.Exec(`INSERT INTO im_conversations(id,conversation_type,last_seq) VALUES(100,1,1)`); err != nil { t.Fatal(err) } result, err := db.Exec(`INSERT INTO media_assets(owner_user_id,media_type,storage_provider,object_key,public_url,mime_type,file_size,status) VALUES(1,'image','local',?,'https://media.example/chat.png','image/png',5,1)`, mainKey) if err != nil { t.Fatal(err) } assetID, _ := result.LastInsertId() result, err = db.Exec(`INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body) VALUES(100,1,1,'cleanup-test-message-0001',2,'{"url":"https://media.example/chat.png"}')`) if err != nil { t.Fatal(err) } messageID, _ := result.LastInsertId() if _, err = db.Exec(`INSERT INTO im_message_media(message_id,media_asset_id,media_type,public_url,status) VALUES(?,?,'image','https://media.example/chat.png',1)`, messageID, assetID); err != nil { t.Fatal(err) } a := &App{db: db, config: Config{MediaDir: directory}, hub: NewHub()} deleted, err := a.cleanupChatMedia(context.Background(), messageID, "test cleanup") if err != nil || !deleted { t.Fatalf("cleanupChatMedia() deleted=%v err=%v", deleted, err) } for _, name := range []string{mainKey, thumbnailKey} { if _, statErr := os.Stat(filepath.Join(directory, name)); !os.IsNotExist(statErr) { t.Fatalf("expected %s to be removed, stat error=%v", name, statErr) } } var mediaStatus, assetStatus int if err = db.QueryRow(`SELECT status FROM im_message_media WHERE message_id=?`, messageID).Scan(&mediaStatus); err != nil { t.Fatal(err) } if err = db.QueryRow(`SELECT status FROM media_assets WHERE id=?`, assetID).Scan(&assetStatus); err != nil { t.Fatal(err) } if mediaStatus != 0 || assetStatus != 0 { t.Fatalf("cleanup statuses media=%d asset=%d, want both 0", mediaStatus, assetStatus) } loaded := a.loadMessageContext(context.Background(), messageID) if !loaded.MediaDeleted || !loaded.Recalled || loaded.Content != nil { t.Fatalf("deleted media must be hidden from clients: %#v", loaded) } }