test(chat,playout): enable test suite, coverage gate, switch to pure-Go SQLite driver

This commit is contained in:
zwitschi committed 2026-08-07 14:06:30 +02:00
1 parent b3a10ba3ef
commit e8a43b8a79
16 files changed
+3257 -8

No files matched your search

+11 -2
View File
@@ -3,24 +3,33 @@ module chat
go 1.25.0
require (
github.com/glebarez/sqlite v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.7.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+26 -4
View File
@@ -9,6 +9,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -23,12 +33,15 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -38,6 +51,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -46,7 +62,13 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+248
View File
@@ -0,0 +1,248 @@
package auth
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestGenerateToken(t *testing.T) {
token, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
if len(token) != 64 {
t.Errorf("GenerateToken() length = %d, want 64", len(token))
}
// Uniqueness check
tokens := make(map[string]bool)
for i := 0; i < 1000; i++ {
tok, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
}
if tokens[tok] {
t.Errorf("GenerateToken() produced duplicate token at iteration %d", i)
}
tokens[tok] = true
}
}
func TestSessionStore_Validate(t *testing.T) {
store := NewSessionStore()
// Empty token
if store.Validate("") {
t.Error("Validate(\"\") = true, want false")
}
// Missing token
if store.Validate("nonexistent") {
t.Error("Validate(\"nonexistent\") = true, want false")
}
// Valid token
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) {
t.Error("Validate(valid) = false, want true")
}
// Expired token
expiredToken, _ := GenerateToken()
store.Add(expiredToken, time.Now().Add(-1*time.Hour))
if store.Validate(expiredToken) {
t.Error("Validate(expired) = true, want false")
}
// Expired token should be cleaned up
if store.Validate(expiredToken) {
t.Error("Validate(expired) second call = true, want false (should be cleaned)")
}
}
func TestSessionStore_AddRemove(t *testing.T) {
store := NewSessionStore()
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) {
t.Error("Validate after Add = false, want true")
}
store.Remove(token)
if store.Validate(token) {
t.Error("Validate after Remove = true, want false")
}
}
func TestSessionStore_CountActive(t *testing.T) {
store := NewSessionStore()
if count := store.CountActive(); count != 0 {
t.Errorf("CountActive() = %d, want 0", count)
}
t1, _ := GenerateToken()
t2, _ := GenerateToken()
t3, _ := GenerateToken()
store.Add(t1, time.Now().Add(1*time.Hour))
store.Add(t2, time.Now().Add(1*time.Hour))
store.Add(t3, time.Now().Add(-1*time.Hour)) // expired
if count := store.CountActive(); count != 2 {
t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count)
}
}
func TestCookieSecure(t *testing.T) {
tests := []struct {
name string
forwardedProto string
tls bool
expected bool
}{
{"https forwarded", "https", false, true},
{"http forwarded", "http", false, false},
{"empty forwarded, no TLS", "", false, false},
{"empty forwarded, TLS", "", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
if tt.tls {
req.TLS = &tls.ConnectionState{}
}
if got := CookieSecure(req); got != tt.expected {
t.Errorf("CookieSecure() = %v, want %v", got, tt.expected)
}
})
}
}
func TestSetCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name)
}
if c.Value != "abc123" {
t.Errorf("cookie value = %q, want abc123", c.Value)
}
if !c.HttpOnly {
t.Error("cookie HttpOnly = false, want true")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie SameSite = %v, want Strict", c.SameSite)
}
if !c.Secure {
t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)")
}
if c.Path != "/" {
t.Errorf("cookie Path = %q, want /", c.Path)
}
}
func TestClearCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
ClearCookie(w, req, "test_cookie")
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name)
}
if c.Value != "" {
t.Errorf("cookie value = %q, want empty", c.Value)
}
if c.MaxAge != -1 {
t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge)
}
}
func TestGetTokenFromCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
token, ok := GetTokenFromCookie(req, "session")
if !ok {
t.Error("GetTokenFromCookie() ok = false, want true")
}
if token != "token123" {
t.Errorf("GetTokenFromCookie() = %q, want token123", token)
}
// Missing cookie
_, ok = GetTokenFromCookie(req, "nonexistent")
if ok {
t.Error("GetTokenFromCookie() for missing cookie ok = true, want false")
}
// Empty value
req2 := httptest.NewRequest("GET", "/", nil)
req2.AddCookie(&http.Cookie{Name: "session", Value: ""})
_, ok = GetTokenFromCookie(req2, "session")
if ok {
t.Error("GetTokenFromCookie() for empty value ok = true, want false")
}
}
func TestRequireAdmin(t *testing.T) {
store := NewSessionStore()
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// Valid session
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: token})
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
// No cookie
req2 := httptest.NewRequest("GET", "/", nil)
w2 := httptest.NewRecorder()
handler(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w2.Code)
}
// Invalid token
req3 := httptest.NewRequest("GET", "/", nil)
req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
w3 := httptest.NewRecorder()
handler(w3, req3)
if w3.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w3.Code)
}
}
+267
View File
@@ -0,0 +1,267 @@
package config
import (
"os"
"testing"
"time"
)
func TestPlaylistPath(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "/app/playlist/playlist.txt"},
{"custom", "/custom/playlist.txt", "/custom/playlist.txt"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("PLAYLIST_PATH", tt.env)
defer os.Unsetenv("PLAYLIST_PATH")
} else {
os.Unsetenv("PLAYLIST_PATH")
}
if got := PlaylistPath(); got != tt.expected {
t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected)
}
})
}
}
func TestMediaDir(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "/app/media"},
{"custom", "/data/media", "/data/media"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("MEDIA_DIR", tt.env)
defer os.Unsetenv("MEDIA_DIR")
} else {
os.Unsetenv("MEDIA_DIR")
}
if got := MediaDir(); got != tt.expected {
t.Errorf("MediaDir() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminUsername(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "admin"},
{"custom", "superadmin", "superadmin"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_USERNAME", tt.env)
defer os.Unsetenv("ADMIN_USERNAME")
} else {
os.Unsetenv("ADMIN_USERNAME")
}
if got := AdminUsername(); got != tt.expected {
t.Errorf("AdminUsername() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminPassword(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "admin"},
{"custom", "secret123", "secret123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_PASSWORD", tt.env)
defer os.Unsetenv("ADMIN_PASSWORD")
} else {
os.Unsetenv("ADMIN_PASSWORD")
}
if got := AdminPassword(); got != tt.expected {
t.Errorf("AdminPassword() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminSessionTTL(t *testing.T) {
tests := []struct {
name string
env string
expected time.Duration
}{
{"default", "", 12 * time.Hour},
{"custom", "24", 24 * time.Hour},
{"zero", "0", 12 * time.Hour},
{"negative", "-5", 12 * time.Hour},
{"invalid", "abc", 12 * time.Hour},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env)
defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
} else {
os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
}
if got := AdminSessionTTL(); got != tt.expected {
t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected)
}
})
}
}
func TestRateLimitMax(t *testing.T) {
tests := []struct {
name string
env string
expected int
}{
{"default", "", 10},
{"custom", "20", 20},
{"zero", "0", 10},
{"negative", "-5", 10},
{"invalid", "abc", 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_MAX")
} else {
os.Unsetenv("CHAT_RATE_LIMIT_MAX")
}
if got := RateLimitMax(); got != tt.expected {
t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected)
}
})
}
}
func TestRateLimitWindow(t *testing.T) {
tests := []struct {
name string
env string
expected time.Duration
}{
{"default", "", 10 * time.Second},
{"custom", "30", 30 * time.Second},
{"zero", "0", 10 * time.Second},
{"negative", "-5", 10 * time.Second},
{"invalid", "abc", 10 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
} else {
os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
}
if got := RateLimitWindow(); got != tt.expected {
t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected)
}
})
}
}
func TestChatPort(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "3000"},
{"custom", "8080", "8080"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_PORT", tt.env)
defer os.Unsetenv("CHAT_PORT")
} else {
os.Unsetenv("CHAT_PORT")
}
if got := ChatPort(); got != tt.expected {
t.Errorf("ChatPort() = %q, want %q", got, tt.expected)
}
})
}
}
func TestRedisAddress(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "redis:6379"},
{"custom", "redis.example.com:6380", "redis.example.com:6380"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("REDIS_URL", tt.env)
defer os.Unsetenv("REDIS_URL")
} else {
os.Unsetenv("REDIS_URL")
}
if got := RedisAddress(); got != tt.expected {
t.Errorf("RedisAddress() = %q, want %q", got, tt.expected)
}
})
}
}
func TestDatabaseURL(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", ""},
{"sqlite", "sqlite://test.db", "sqlite://test.db"},
{"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("DATABASE_URL", tt.env)
defer os.Unsetenv("DATABASE_URL")
} else {
os.Unsetenv("DATABASE_URL")
}
if got := DatabaseURL(); got != tt.expected {
t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected)
}
})
}
}
+1 -1
View File
@@ -6,8 +6,8 @@ import (
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
+179
View File
@@ -0,0 +1,179 @@
package db
import (
"testing"
"time"
)
func TestInitDB_SQLite(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB() client = nil")
}
// Verify tables exist by running AutoMigrate (idempotent)
err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
if err != nil {
t.Errorf("AutoMigrate error = %v", err)
}
// Verify CRUD on Message
msg := Message{
Room: "test",
Username: "user1",
Text: "hello",
Timestamp: time.Now(),
Type: "public",
}
if err := client.DB.Create(&msg).Error; err != nil {
t.Fatalf("Create message error = %v", err)
}
if msg.ID == 0 {
t.Error("Create message did not set ID")
}
var count int64
client.DB.Model(&Message{}).Count(&count)
if count != 1 {
t.Errorf("Message count = %d, want 1", count)
}
// Verify read
var read Message
if err := client.DB.First(&read, msg.ID).Error; err != nil {
t.Fatalf("First message error = %v", err)
}
if read.Text != "hello" {
t.Errorf("read.Text = %q, want hello", read.Text)
}
// Verify delete
client.DB.Delete(&Message{}, msg.ID)
client.DB.Model(&Message{}).Count(&count)
if count != 0 {
t.Errorf("Message count after delete = %d, want 0", count)
}
}
func TestInitDB_ModerationLog(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
log := ModerationLog{
Action: "mute",
Target: "user1",
Moderator: "admin",
Reason: "spam",
Timestamp: time.Now(),
}
if err := client.DB.Create(&log).Error; err != nil {
t.Fatalf("Create moderation log error = %v", err)
}
if log.ID == 0 {
t.Error("Create moderation log did not set ID")
}
var count int64
client.DB.Model(&ModerationLog{}).Count(&count)
if count != 1 {
t.Errorf("ModerationLog count = %d, want 1", count)
}
}
func TestInitDB_Media(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
media := Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: "/media/test.mp4",
SizeBytes: 1024,
DurationSeconds: 60,
Status: "ready",
Title: "Test Video",
Tags: "tag1,tag2",
}
if err := client.DB.Create(&media).Error; err != nil {
t.Fatalf("Create media error = %v", err)
}
if media.ID == 0 {
t.Error("Create media did not set ID")
}
// Verify update
client.DB.Model(&media).Update("status", "needs_normalization")
var updated Media
client.DB.First(&updated, media.ID)
if updated.Status != "needs_normalization" {
t.Errorf("updated.Status = %q, want needs_normalization", updated.Status)
}
}
func TestInitDB_ScheduleOverride(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
now := time.Now()
ov := ScheduleOverride{
MediaID: 1,
StartTime: now,
EndTime: now.Add(1 * time.Hour),
Reason: "special event",
}
if err := client.DB.Create(&ov).Error; err != nil {
t.Fatalf("Create schedule override error = %v", err)
}
if ov.ID == 0 {
t.Error("Create schedule override did not set ID")
}
// Conflict detection
var conflict int64
client.DB.Model(&ScheduleOverride{}).
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
Count(&conflict)
if conflict != 1 {
t.Errorf("conflict count = %d, want 1", conflict)
}
}
func TestInitDB_EmptyURL(t *testing.T) {
// Empty URL should default to SQLite chat.db
// This creates a file, clean up after
client, driver, err := InitDB("")
if err != nil {
t.Fatalf("InitDB(\"\") error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB(\"\") client = nil")
}
}
func TestInitDB_SQLitePath(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:?cache=shared")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB() client = nil")
}
}
+947
View File
@@ -0,0 +1,947 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"chat/src/auth"
"chat/src/config"
"chat/src/db"
"chat/src/ws"
)
// mockRedis implements db.RedisClient for handler tests.
type mockRedis struct {
mu sync.Mutex
store map[string]string
}
func newMockRedis() *mockRedis {
return &mockRedis{store: make(map[string]string)}
}
func (m *mockRedis) Ping(ctx context.Context) error { return nil }
func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key] = value.(string)
return nil
}
func (m *mockRedis) Get(ctx context.Context, key string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.store[key], nil
}
func (m *mockRedis) Del(ctx context.Context, keys ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, k := range keys {
delete(m.store, k)
}
return nil
}
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key] = "1"
return 1, nil
}
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, k := range keys {
if _, ok := m.store[k]; ok {
return 1, nil
}
}
return 0, nil
}
func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
return nil
}
func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
return nil
}
func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var keys []string
for k := range m.store {
if strings.Contains(k, strings.TrimSuffix(match, "*")) {
keys = append(keys, k)
}
}
return keys, 0, nil
}
func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) {
return "connected_clients:1\nused_memory_human:1M\n", nil
}
func (m *mockRedis) Close() error { return nil }
func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
t.Helper()
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open test DB: %v", err)
}
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
mr := newMockRedis()
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
deps := &Dependencies{
DB: gormDB,
Redis: mr,
Hub: hub,
SessionStore: sessionStore,
DBDriver: "sqlite",
}
return deps, gormDB, sessionStore
}
// adminCookie creates a valid admin session and returns the cookie header.
func adminCookie(t *testing.T, store *auth.SessionStore) string {
t.Helper()
token, _ := auth.GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
return config.AdminSessionCookieName + "=" + token
}
func TestHandleAdminLogin_Success(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminLogin(deps)
// Ensure env vars are set for credential check
os.Setenv("ADMIN_USERNAME", "admin")
os.Setenv("ADMIN_PASSWORD", "admin")
defer os.Unsetenv("ADMIN_USERNAME")
defer os.Unsetenv("ADMIN_PASSWORD")
body := `{"username":"admin","password":"admin"}`
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp AdminSessionResponse
json.NewDecoder(w.Body).Decode(&resp)
if !resp.Authenticated {
t.Error("Authenticated = false, want true")
}
// Check cookie set
cookies := w.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == config.AdminSessionCookieName {
found = true
break
}
}
if !found {
t.Error("session cookie not set")
}
}
func TestHandleAdminLogin_InvalidCreds(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminLogin(deps)
body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleAdminLogin_InvalidJSON(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminLogin(deps)
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandleAdminLogin_WrongMethod(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminLogin(deps)
req := httptest.NewRequest("GET", "/api/admin/login", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", w.Code)
}
}
func TestHandleAdminLogout(t *testing.T) {
deps, _, store := setupTestDeps(t)
handler := HandleAdminLogout(deps)
cookie := adminCookie(t, store)
req := httptest.NewRequest("POST", "/api/admin/logout", nil)
req.Header.Set("Cookie", cookie)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
// Cookie should be cleared
cookies := w.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == config.AdminSessionCookieName && c.MaxAge == -1 {
found = true
break
}
}
if !found {
t.Error("session cookie not cleared")
}
}
func TestHandleAdminSession_Authenticated(t *testing.T) {
deps, _, store := setupTestDeps(t)
handler := HandleAdminSession(deps)
cookie := adminCookie(t, store)
req := httptest.NewRequest("GET", "/api/admin/session", nil)
req.Header.Set("Cookie", cookie)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp AdminSessionResponse
json.NewDecoder(w.Body).Decode(&resp)
if !resp.Authenticated {
t.Error("Authenticated = false, want true")
}
}
func TestHandleAdminSession_Unauthenticated(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminSession(deps)
req := httptest.NewRequest("GET", "/api/admin/session", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleAdminOverview(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
// Add some test data
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
handler := HandleAdminOverview(deps)
req := httptest.NewRequest("GET", "/api/admin/overview", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var overview AdminOverview
json.NewDecoder(w.Body).Decode(&overview)
if overview.StoredMessages != 1 {
t.Errorf("StoredMessages = %d, want 1", overview.StoredMessages)
}
if overview.ModerationActions != 1 {
t.Errorf("ModerationActions = %d, want 1", overview.ModerationActions)
}
}
func TestHandleAdminPlaylist_Get(t *testing.T) {
deps, _, _ := setupTestDeps(t)
// Create temp playlist
tmpDir := t.TempDir()
playlistPath := filepath.Join(tmpDir, "playlist.txt")
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
os.Setenv("PLAYLIST_PATH", playlistPath)
defer os.Unsetenv("PLAYLIST_PATH")
handler := HandleAdminPlaylist(deps)
req := httptest.NewRequest("GET", "/api/admin/playlist", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var payload PlaylistPayload
json.NewDecoder(w.Body).Decode(&payload)
if !strings.Contains(payload.Content, "test.mp4") {
t.Errorf("Content = %q, want test.mp4", payload.Content)
}
}
func TestHandleAdminPlaylist_Put(t *testing.T) {
deps, _, _ := setupTestDeps(t)
tmpDir := t.TempDir()
playlistPath := filepath.Join(tmpDir, "playlist.txt")
os.Setenv("PLAYLIST_PATH", playlistPath)
defer os.Unsetenv("PLAYLIST_PATH")
handler := HandleAdminPlaylist(deps)
body := `{"content":"file /videos/new.mp4"}`
req := httptest.NewRequest("PUT", "/api/admin/playlist", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
// Verify file written
data, _ := os.ReadFile(playlistPath)
if !strings.Contains(string(data), "new.mp4") {
t.Errorf("file content = %q, want new.mp4", string(data))
}
}
func TestHandleAdminModerationLogs(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u1", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
handler := HandleAdminModerationLogs(deps)
req := httptest.NewRequest("GET", "/api/admin/moderation/logs", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp ModerationLogsResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Logs) != 1 {
t.Errorf("len(Logs) = %d, want 1", len(resp.Logs))
}
}
func TestHandleAdminModerationAction(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
handler := HandleAdminModerationAction(deps)
body := `{"action":"mute","target":"baduser","moderator":"admin","reason":"spam","room":"main"}`
req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var count int64
gormDB.Model(&db.ModerationLog{}).Count(&count)
if count != 1 {
t.Errorf("moderation log count = %d, want 1", count)
}
}
func TestHandleAdminModerationAction_MissingFields(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminModerationAction(deps)
body := `{"action":"mute"}`
req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandleAdminAnalytics(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
handler := HandleAdminAnalytics(deps)
req := httptest.NewRequest("GET", "/api/admin/analytics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var analytics AdminAnalytics
json.NewDecoder(w.Body).Decode(&analytics)
if analytics.TotalMessages != 2 {
t.Errorf("TotalMessages = %d, want 2", analytics.TotalMessages)
}
}
func TestHandleAdminSystemSummary(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminSystemSummary(deps)
req := httptest.NewRequest("GET", "/api/admin/system", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var summary AdminSystemSummary
json.NewDecoder(w.Body).Decode(&summary)
if summary.DatabaseDriver != "sqlite" {
t.Errorf("DatabaseDriver = %q, want sqlite", summary.DatabaseDriver)
}
}
func TestHandleAdminConfig_Get(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminConfig(deps)
req := httptest.NewRequest("GET", "/api/admin/config", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminConfig_Put(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminConfig(deps)
body := `{"rateLimitMax":20}`
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminRestart(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminRestart(deps)
req := httptest.NewRequest("POST", "/api/admin/restart?service=chat", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminUsers_Get(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminUsers(deps)
req := httptest.NewRequest("GET", "/api/admin/users", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminUsers_Post(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminUsers(deps)
body := `{"action":"mute","username":"baduser","duration":10}`
req := httptest.NewRequest("POST", "/api/admin/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminSchedule(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
tmpDir := t.TempDir()
playlistPath := filepath.Join(tmpDir, "playlist.txt")
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
os.Setenv("PLAYLIST_PATH", playlistPath)
defer os.Unsetenv("PLAYLIST_PATH")
gormDB.Create(&db.Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: "/videos/test.mp4",
SizeBytes: 1024,
DurationSeconds: 60,
Status: "ready",
Title: "Test Video",
})
handler := HandleAdminSchedule(deps)
req := httptest.NewRequest("GET", "/api/admin/schedule", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp AdminScheduleResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.MatchedEntries != 1 {
t.Errorf("MatchedEntries = %d, want 1", resp.MatchedEntries)
}
}
func TestHandleAdminScheduleOverrides_CRUD(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
// Create
handler := HandleAdminScheduleOverrides(deps)
now := time.Now()
body, _ := json.Marshal(db.ScheduleOverride{
MediaID: 1,
StartTime: now,
EndTime: now.Add(1 * time.Hour),
Reason: "test",
})
req := httptest.NewRequest("POST", "/api/admin/schedule/overrides", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusCreated {
t.Errorf("POST status = %d, want 201", w.Code)
}
// List
req2 := httptest.NewRequest("GET", "/api/admin/schedule/overrides", nil)
w2 := httptest.NewRecorder()
handler(w2, req2)
if w2.Code != http.StatusOK {
t.Errorf("GET status = %d, want 200", w2.Code)
}
// Delete
var overrides []db.ScheduleOverride
gormDB.Find(&overrides)
if len(overrides) > 0 {
req3 := httptest.NewRequest("DELETE", "/api/admin/schedule/overrides?id=1", nil)
w3 := httptest.NewRecorder()
handler(w3, req3)
if w3.Code != http.StatusNoContent {
t.Errorf("DELETE status = %d, want 204", w3.Code)
}
}
}
func TestHandleAdminChatLog(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
handler := HandleAdminChatLog(deps)
req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp ChatLogListResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Total != 1 {
t.Errorf("Total = %d, want 1", resp.Total)
}
}
func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
gormDB.Create(&db.Message{Room: "lobby", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
handler := HandleAdminChatLog(deps)
req := httptest.NewRequest("GET", "/api/admin/chat-log?room=main", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp ChatLogListResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Total != 1 {
t.Errorf("Total = %d, want 1", resp.Total)
}
}
func TestHandleAdminChatLogByID_Get(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
gormDB.Create(&msg)
handler := HandleAdminChatLogByID(deps)
req := httptest.NewRequest("GET", "/api/admin/chat-log/1", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminChatLogByID_Delete(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
gormDB.Create(&msg)
handler := HandleAdminChatLogByID(deps)
req := httptest.NewRequest("DELETE", "/api/admin/chat-log/1", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
var count int64
gormDB.Model(&db.Message{}).Count(&count)
if count != 0 {
t.Errorf("message count after delete = %d, want 0", count)
}
}
func TestHandleAdminChatLogBulk(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "m1", Timestamp: time.Now(), Type: "public"})
gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "m2", Timestamp: time.Now(), Type: "public"})
handler := HandleAdminChatLogBulk(deps)
body := `{"ids":[1,2]}`
req := httptest.NewRequest("DELETE", "/api/admin/chat-log/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var count int64
gormDB.Model(&db.Message{}).Count(&count)
if count != 0 {
t.Errorf("message count after bulk delete = %d, want 0", count)
}
}
func TestHandleAdminChatLogExport(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
handler := HandleAdminChatLogExport(deps)
req := httptest.NewRequest("GET", "/api/admin/chat-log/export", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/csv") {
t.Errorf("Content-Type = %q, want text/csv", contentType)
}
body := w.Body.String()
if !strings.Contains(body, "hello") {
t.Errorf("CSV body missing 'hello': %s", body)
}
}
func TestHandlePublicSchedule(t *testing.T) {
deps, _, _ := setupTestDeps(t)
tmpDir := t.TempDir()
playlistPath := filepath.Join(tmpDir, "playlist.txt")
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
os.Setenv("PLAYLIST_PATH", playlistPath)
defer os.Unsetenv("PLAYLIST_PATH")
handler := HandlePublicSchedule(deps)
req := httptest.NewRequest("GET", "/api/schedule", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminMedia_Get(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: "/media/test.mp4",
SizeBytes: 1024,
DurationSeconds: 60,
Status: "ready",
Title: "Test",
})
handler := HandleAdminMedia(deps)
req := httptest.NewRequest("GET", "/api/admin/media", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp AdminMediaResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Files) != 1 {
t.Errorf("len(Files) = %d, want 1", len(resp.Files))
}
}
func TestHandleAdminMediaByID_Get(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: "/media/test.mp4",
SizeBytes: 1024,
DurationSeconds: 60,
Status: "ready",
Title: "Test",
})
handler := HandleAdminMediaByID(deps)
req := httptest.NewRequest("GET", "/api/admin/media/1", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestHandleAdminMediaByID_NotFound(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminMediaByID(deps)
req := httptest.NewRequest("GET", "/api/admin/media/999", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestHandleAdminMediaByID_Put(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: "/media/test.mp4",
SizeBytes: 1024,
DurationSeconds: 60,
Status: "ready",
Title: "Old Title",
})
handler := HandleAdminMediaByID(deps)
body := `{"title":"New Title","status":"needs_normalization","tags":"tag1"}`
req := httptest.NewRequest("PUT", "/api/admin/media/1", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var updated db.Media
gormDB.First(&updated, 1)
if updated.Title != "New Title" {
t.Errorf("Title = %q, want New Title", updated.Title)
}
}
func TestHandleAdminMediaByID_Delete(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
// Create a real temp file for the media record
tmpDir := t.TempDir()
os.Setenv("MEDIA_DIR", tmpDir)
defer os.Unsetenv("MEDIA_DIR")
mediaPath := filepath.Join(tmpDir, "test.mp4")
os.WriteFile(mediaPath, []byte("fake video"), 0644)
gormDB.Create(&db.Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: mediaPath,
SizeBytes: 10,
DurationSeconds: 1,
Status: "ready",
Title: "Test",
})
handler := HandleAdminMediaByID(deps)
req := httptest.NewRequest("DELETE", "/api/admin/media/1", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var count int64
gormDB.Model(&db.Media{}).Count(&count)
if count != 0 {
t.Errorf("media count after delete = %d, want 0", count)
}
}
func TestHandleAdminMediaUploads_Init(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminMediaUploads(deps)
body := `{"fileName":"video.mp4","fileSize":1024,"mimeType":"video/mp4","chunkSize":524288,"totalChunks":1}`
req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var status AdminMediaUploadStatusResponse
json.NewDecoder(w.Body).Decode(&status)
if status.FileName != "video.mp4" {
t.Errorf("FileName = %q, want video.mp4", status.FileName)
}
if status.UploadID == "" {
t.Error("UploadID is empty")
}
}
func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleAdminMediaUploads(deps)
body := `{"fileName":"bad.exe","fileSize":1024,"mimeType":"application/x-msdownload","chunkSize":524288,"totalChunks":1}`
req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
+204
View File
@@ -0,0 +1,204 @@
package media
import (
"path/filepath"
"testing"
)
func TestFormatDuration(t *testing.T) {
tests := []struct {
name string
seconds int
expected string
}{
{"zero", 0, "00:00:00"},
{"one second", 1, "00:00:01"},
{"one minute", 60, "00:01:00"},
{"one hour", 3600, "01:00:00"},
{"mixed", 3661, "01:01:01"},
{"large", 86399, "23:59:59"},
{"negative", -5, "00:00:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FormatDuration(tt.seconds); got != tt.expected {
t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected)
}
})
}
}
func TestIsAllowedExtension(t *testing.T) {
tests := []struct {
name string
expected bool
}{
{"video.mp4", true},
{"video.mov", true},
{"video.mkv", true},
{"video.webm", true},
{"video.m4v", true},
{"video.ts", true},
{"video.MP4", true},
{"video.avi", false},
{"video.flv", false},
{"video.wmv", false},
{"video", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsAllowedExtension(tt.name); got != tt.expected {
t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected)
}
})
}
}
func TestIsAllowedMime(t *testing.T) {
tests := []struct {
mime string
expected bool
}{
{"video/mp4", true},
{"video/quicktime", true},
{"video/x-matroska", true},
{"video/webm", true},
{"application/octet-stream", true},
{"", true},
{"image/jpeg", false},
{"audio/mpeg", false},
{"text/plain", false},
}
for _, tt := range tests {
t.Run(tt.mime, func(t *testing.T) {
if got := IsAllowedMime(tt.mime); got != tt.expected {
t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected)
}
})
}
}
func TestValidateUploadID(t *testing.T) {
tests := []struct {
id string
expected bool
}{
{"abcdef0123456789", true}, // 16 hex chars
{"abcdef0123456789abcdef0123456789", true}, // 32 hex chars
{"abc", false}, // too short
{"abcdef012345678", false}, // 15 chars (too short)
{"abcdef0123456789g", false}, // non-hex char
{"ABCDEF0123456789", false}, // uppercase (not allowed)
{"", false},
}
for _, tt := range tests {
t.Run(tt.id, func(t *testing.T) {
if got := ValidateUploadID(tt.id); got != tt.expected {
t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected)
}
})
}
}
func TestParsePlaylist(t *testing.T) {
tests := []struct {
name string
content string
expected int
first string
}{
{
name: "simple",
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "empty",
content: "",
expected: 0,
},
{
name: "only comments",
content: "# comment\n# another\n",
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entries := ParsePlaylist(tt.content)
if len(entries) != tt.expected {
t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected)
}
if tt.expected > 0 && entries[0].Path != tt.first {
t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first)
}
// Check indices
for i, e := range entries {
if e.Index != i+1 {
t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1)
}
}
})
}
}
func TestGetMediaIDFromPath(t *testing.T) {
tests := []struct {
path string
prefix string
expected uint
expectErr bool
}{
{"/api/admin/media/123", "/api/admin/media/", 123, false},
{"/api/admin/media/1", "/api/admin/media/", 1, false},
{"/api/admin/media/", "/api/admin/media/", 0, true},
{"/api/admin/media/abc", "/api/admin/media/", 0, true},
{"/api/admin/media/0", "/api/admin/media/", 0, true},
{"/other/path", "/api/admin/media/", 0, true},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
id, err := GetMediaIDFromPath(tt.path, tt.prefix)
if tt.expectErr && err == nil {
t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path)
}
if !tt.expectErr && err != nil {
t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err)
}
if id != tt.expected {
t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected)
}
})
}
}
func TestUniqueTargetPath(t *testing.T) {
// Test that the function returns a path with the mediaDir prefix
// and the correct filename. We can't easily test collision behavior
// without filesystem setup, but we can verify the basic path.
expected := filepath.Join("/tmp/media", "video.mp4")
path := UniqueTargetPath("/tmp/media", "video.mp4")
if path != expected {
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
}
}
+190
View File
@@ -0,0 +1,190 @@
package metrics
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"chat/src/auth"
"chat/src/db"
"chat/src/ws"
)
func TestHandleHealth(t *testing.T) {
handler := HandleHealth()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"healthy"`) {
t.Errorf("body missing healthy: %s", body)
}
if !strings.Contains(body, `"chat"`) {
t.Errorf("body missing service name: %s", body)
}
}
func TestHandleNotFound(t *testing.T) {
handler := HandleNotFound()
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"not found"`) {
t.Errorf("body missing not found: %s", body)
}
}
func TestHandleMetrics(t *testing.T) {
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open test DB: %v", err)
}
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// Add some test data
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin")
hub.DMsSent = 5
handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance")
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// Check required metric names
requiredMetrics := []string{
"linearvoid_chat_connected_clients",
"linearvoid_chat_messages_total",
"linearvoid_chat_dms_total",
"linearvoid_chat_moderation_actions_total",
"linearvoid_chat_redis_enabled",
"linearvoid_chat_redis_up",
"linearvoid_chat_redis_connected_clients",
"linearvoid_chat_admin_sessions_active",
"linearvoid_chat_build_info",
}
for _, metric := range requiredMetrics {
if !strings.Contains(body, metric) {
t.Errorf("body missing metric %s", metric)
}
}
// Check build_info has instance_id label
if !strings.Contains(body, `instance_id="test-instance"`) {
t.Error("build_info missing instance_id label")
}
// Check values
if !strings.Contains(body, "linearvoid_chat_messages_total 1") {
t.Error("messages_total should be 1")
}
if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") {
t.Error("moderation_actions_total should be 1")
}
if !strings.Contains(body, "linearvoid_chat_dms_total 5") {
t.Error("dms_total should be 5")
}
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
t.Error("redis_enabled should be 0 (nil redis)")
}
}
func TestHandleMetrics_WrongMethod(t *testing.T) {
handler := HandleMetrics(nil, nil, nil, nil, "", "")
req := httptest.NewRequest("POST", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", w.Code)
}
}
func TestHandleMetrics_WithRedis(t *testing.T) {
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// mockRedis for metrics test
mr := &metricsMockRedis{}
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123")
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") {
t.Error("redis_enabled should be 1")
}
if !strings.Contains(body, "linearvoid_chat_redis_up 1") {
t.Error("redis_up should be 1")
}
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
t.Error("redis_connected_clients should be 5")
}
}
// metricsMockRedis is a minimal mock for metrics tests.
type metricsMockRedis struct{}
func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil }
func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return nil
}
func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, error) { return "", nil }
func (m *metricsMockRedis) Del(ctx context.Context, keys ...string) error { return nil }
func (m *metricsMockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
return nil
}
func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { return nil }
func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
return nil, 0, nil
}
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
return "connected_clients:5\nused_memory_human:1M\n", nil
}
func (m *metricsMockRedis) Close() error { return nil }
@@ -0,0 +1,190 @@
package ratelimit
import (
"context"
"sync"
"testing"
"time"
)
// mockRedis implements RedisClient for testing.
type mockRedis struct {
mu sync.Mutex
store map[string]int64
}
func newMockRedis() *mockRedis {
return &mockRedis{store: make(map[string]int64)}
}
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key]++
return m.store[key], nil
}
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key]--
return m.store[key], nil
}
func TestCheckMessageRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 10 messages allowed
for i := 0; i < 10; i++ {
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckMessageRate #%d = false, want true", i+1)
}
}
// 11th message blocked
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate #11 error = %v", err)
}
if allowed {
t.Error("CheckMessageRate #11 = true, want false (rate limited)")
}
// Different user not affected
allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate user2 error = %v", err)
}
if !allowed {
t.Error("CheckMessageRate user2 = false, want true")
}
}
func TestCheckMessageRate_NilRedis(t *testing.T) {
allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckMessageRate nil redis = false, want true (fail open)")
}
}
func TestCheckLoginRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 5 attempts allowed
for i := 0; i < 5; i++ {
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckLoginRate #%d = false, want true", i+1)
}
}
// 6th attempt blocked
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate #6 error = %v", err)
}
if allowed {
t.Error("CheckLoginRate #6 = true, want false (rate limited)")
}
// Different IP not affected
allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1")
if err != nil {
t.Fatalf("CheckLoginRate other IP error = %v", err)
}
if !allowed {
t.Error("CheckLoginRate other IP = false, want true")
}
}
func TestCheckLoginRate_NilRedis(t *testing.T) {
allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckLoginRate nil redis = false, want true (fail open)")
}
}
func TestCheckWSConnRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 5 connections allowed
for i := 0; i < 5; i++ {
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckWSConnRate #%d = false, want true", i+1)
}
}
// 6th connection blocked
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate #6 error = %v", err)
}
if allowed {
t.Error("CheckWSConnRate #6 = true, want false (rate limited)")
}
}
func TestCheckWSConnRate_NilRedis(t *testing.T) {
allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckWSConnRate nil redis = false, want true (fail open)")
}
}
func TestDecrWSConn(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// Increment to 5
for i := 0; i < 5; i++ {
CheckWSConnRate(ctx, redis, "192.168.1.1")
}
// 6th should be blocked
allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1")
if allowed {
t.Error("6th connection should be blocked")
}
// Decrement twice (simulate 2 disconnects)
DecrWSConn(ctx, redis, "192.168.1.1")
DecrWSConn(ctx, redis, "192.168.1.1")
// Now should be allowed again (counter 6→4, then incr→5)
allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1")
if !allowed {
t.Error("after 2x DecrWSConn, connection should be allowed")
}
}
func TestDecrWSConn_NilRedis(t *testing.T) {
// Should not panic
DecrWSConn(context.Background(), nil, "192.168.1.1")
}
+563
View File
@@ -0,0 +1,563 @@
package ws
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"chat/src/db"
)
// mockRedis implements db.RedisClient for testing.
type mockRedis struct {
mu sync.Mutex
store map[string]string
pubsubs []*mockPubSub
}
func newMockRedis() *mockRedis {
return &mockRedis{store: make(map[string]string)}
}
func (m *mockRedis) Ping(ctx context.Context) error { return nil }
func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key] = value.(string)
return nil
}
func (m *mockRedis) Get(ctx context.Context, key string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.store[key], nil
}
func (m *mockRedis) Del(ctx context.Context, keys ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, k := range keys {
delete(m.store, k)
}
return nil
}
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, k := range keys {
if _, ok := m.store[k]; ok {
return 1, nil
}
}
return 0, nil
}
func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
payload, _ := json.Marshal(message)
for _, ps := range m.pubsubs {
ps.messages <- &db.PubSubMessage{Channel: channel, Payload: string(payload)}
}
return nil
}
func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
ps := &mockPubSub{messages: make(chan *db.PubSubMessage, 100)}
m.mu.Lock()
m.pubsubs = append(m.pubsubs, ps)
m.mu.Unlock()
return ps
}
func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var keys []string
for k := range m.store {
if strings.Contains(k, strings.TrimSuffix(match, "*")) {
keys = append(keys, k)
}
}
return keys, 0, nil
}
func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) { return "", nil }
func (m *mockRedis) Close() error { return nil }
type mockPubSub struct {
messages chan *db.PubSubMessage
}
func (m *mockPubSub) ReceiveMessage(ctx context.Context) (*db.PubSubMessage, error) {
select {
case msg := <-m.messages:
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *mockPubSub) Close() error { return nil }
func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) {
t.Helper()
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open test DB: %v", err)
}
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
mr := newMockRedis()
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
return hub, gormDB, mr
}
func setupWSServer(hub *Hub) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(hub.HandleConnection))
}
// wsClient wraps a websocket.Conn with a channel-based read helper.
type wsClient struct {
conn *websocket.Conn
msgs chan db.Message
errors chan error
done chan struct{}
}
func newWSClient(t *testing.T, url string) *wsClient {
t.Helper()
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatalf("Dial error: %v", err)
}
wc := &wsClient{
conn: conn,
msgs: make(chan db.Message, 100),
errors: make(chan error, 1),
done: make(chan struct{}),
}
go wc.readLoop()
return wc
}
func (wc *wsClient) readLoop() {
defer close(wc.msgs)
for {
var msg db.Message
if err := wc.conn.ReadJSON(&msg); err != nil {
wc.errors <- err
return
}
select {
case wc.msgs <- msg:
case <-wc.done:
return
}
}
}
func (wc *wsClient) close() {
close(wc.done)
wc.conn.Close()
}
// recvMsg reads the next message with a timeout.
func (wc *wsClient) recvMsg(timeout time.Duration) (*db.Message, error) {
select {
case msg := <-wc.msgs:
return &msg, nil
case err := <-wc.errors:
return nil, err
case <-time.After(timeout):
return nil, context.DeadlineExceeded
}
}
// skipSystem reads and discards system messages.
func (wc *wsClient) skipSystem(timeout time.Duration) {
for {
msg, err := wc.recvMsg(timeout)
if err != nil || msg.Type != "system" {
return
}
}
}
func (wc *wsClient) writeJSON(v interface{}) error {
return wc.conn.WriteJSON(v)
}
func TestHub_Broadcast(t *testing.T) {
hub, _, _ := setupTestHub(t)
msg := db.Message{
Room: "main",
Username: "user1",
Text: "hello",
Timestamp: time.Now(),
Type: "public",
}
hub.Broadcast(msg)
var count int64
hub.DB.Model(&db.Message{}).Count(&count)
if count != 1 {
t.Errorf("message count = %d, want 1", count)
}
}
func TestHub_ConnectedClients(t *testing.T) {
hub, _, _ := setupTestHub(t)
if hub.ConnectedClients() != 0 {
t.Errorf("ConnectedClients() = %d, want 0", hub.ConnectedClients())
}
}
func TestHub_WebSocketConnection(t *testing.T) {
hub, _, _ := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
wc := newWSClient(t, wsURL)
defer wc.close()
msg, err := wc.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("recvMsg error: %v", err)
}
if !strings.Contains(msg.Text, "joined") {
t.Errorf("expected join message, got text=%s", msg.Text)
}
if hub.ConnectedClients() != 1 {
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
}
}
func TestHub_SendMessage(t *testing.T) {
hub, _, _ := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
wc := newWSClient(t, wsURL)
defer wc.close()
wc.skipSystem(2 * time.Second)
if err := wc.writeJSON(db.Message{Room: "main", Text: "hello world"}); err != nil {
t.Fatalf("writeJSON error: %v", err)
}
recvMsg, err := wc.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("recvMsg error: %v", err)
}
if recvMsg.Text != "hello world" {
t.Errorf("recvMsg.Text = %q, want hello world", recvMsg.Text)
}
if recvMsg.Username != "testuser" {
t.Errorf("recvMsg.Username = %q, want testuser", recvMsg.Username)
}
if recvMsg.Type != "public" {
t.Errorf("recvMsg.Type = %q, want public", recvMsg.Type)
}
}
func TestHub_Disconnect(t *testing.T) {
hub, _, _ := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
wc := newWSClient(t, wsURL)
wc.skipSystem(2 * time.Second)
if hub.ConnectedClients() != 1 {
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
}
wc.close()
time.Sleep(200 * time.Millisecond)
if hub.ConnectedClients() != 0 {
t.Errorf("ConnectedClients() after close = %d, want 0", hub.ConnectedClients())
}
}
func TestHub_BannedUser(t *testing.T) {
hub, _, mr := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
mr.Set(context.Background(), "banned:testuser", "1", 0)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
wc := newWSClient(t, wsURL)
defer wc.close()
wc.skipSystem(2 * time.Second)
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
recvMsg, err := wc.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("recvMsg error: %v", err)
}
if !strings.Contains(recvMsg.Text, "banned") {
t.Errorf("expected banned message, got %q", recvMsg.Text)
}
}
func TestHub_MutedUser(t *testing.T) {
hub, _, mr := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
mr.Set(context.Background(), "muted:testuser", "1", 0)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
wc := newWSClient(t, wsURL)
defer wc.close()
wc.skipSystem(2 * time.Second)
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
recvMsg, err := wc.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("recvMsg error: %v", err)
}
if !strings.Contains(recvMsg.Text, "muted") {
t.Errorf("expected muted message, got %q", recvMsg.Text)
}
}
func TestHub_ModerationCommand(t *testing.T) {
hub, _, _ := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=admin&room=main"
wc := newWSClient(t, wsURL)
defer wc.close()
wc.skipSystem(2 * time.Second)
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
recvMsg, err := wc.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("recvMsg error: %v", err)
}
if !strings.Contains(recvMsg.Text, "mute") {
t.Errorf("expected mute broadcast, got text=%s", recvMsg.Text)
}
var count int64
hub.DB.Model(&db.ModerationLog{}).Count(&count)
if count != 1 {
t.Errorf("moderation log count = %d, want 1", count)
}
}
func TestHub_NonAdminModerationCommand(t *testing.T) {
hub, _, _ := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=regularuser&room=main"
wc := newWSClient(t, wsURL)
defer wc.close()
wc.skipSystem(2 * time.Second)
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
recvMsg, err := wc.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("recvMsg error: %v", err)
}
if !strings.Contains(recvMsg.Text, "Only moderators") {
t.Errorf("expected moderator-only message, got %q", recvMsg.Text)
}
}
func TestHub_PrivateDM(t *testing.T) {
hub, _, _ := setupTestHub(t)
srv := setupWSServer(hub)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
wc1 := newWSClient(t, wsURL+"?username=alice&room=main")
defer wc1.close()
wc2 := newWSClient(t, wsURL+"?username=bob&room=main")
defer wc2.close()
wc1.skipSystem(2 * time.Second)
wc2.skipSystem(2 * time.Second)
wc1.writeJSON(db.Message{Room: "dm:bob", Text: "hey bob"})
recvMsg, err := wc2.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("bob recvMsg error: %v", err)
}
if recvMsg.Type != "private" {
t.Errorf("expected private type, got %s", recvMsg.Type)
}
if recvMsg.Text != "hey bob" {
t.Errorf("expected 'hey bob', got %q", recvMsg.Text)
}
recvMsg2, err := wc1.recvMsg(2 * time.Second)
if err != nil {
t.Fatalf("alice recvMsg error: %v", err)
}
if recvMsg2.Type != "private" {
t.Errorf("expected private confirmation, got %s", recvMsg2.Type)
}
}
func TestHub_PushChatHistory(t *testing.T) {
hub, gormDB, _ := setupTestHub(t)
for i := 0; i < 5; i++ {
gormDB.Create(&db.Message{
Room: "main",
Username: "user1",
Text: "msg",
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Type: "public",
})
}
srv := setupWSServer(hub)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=newuser&room=main"
wc := newWSClient(t, wsURL)
defer wc.close()
count := 0
for i := 0; i < 10; i++ {
msg, err := wc.recvMsg(2 * time.Second)
if err != nil {
break
}
if msg.Type == "public" {
count++
}
}
if count != 5 {
t.Errorf("history message count = %d, want 5", count)
}
}
func TestHub_MuteUser_UnmuteUser(t *testing.T) {
hub, _, mr := setupTestHub(t)
ctx := context.Background()
err := hub.MuteUser(ctx, "user1", 10*time.Minute)
if err != nil {
t.Fatalf("MuteUser error = %v", err)
}
exists, _ := mr.Exists(ctx, "muted:user1")
if exists != 1 {
t.Error("muted key not found after MuteUser")
}
err = hub.UnmuteUser(ctx, "user1")
if err != nil {
t.Fatalf("UnmuteUser error = %v", err)
}
exists, _ = mr.Exists(ctx, "muted:user1")
if exists != 0 {
t.Error("muted key still exists after UnmuteUser")
}
}
func TestHub_BanUser_UnbanUser(t *testing.T) {
hub, _, mr := setupTestHub(t)
ctx := context.Background()
err := hub.BanUser(ctx, "user1")
if err != nil {
t.Fatalf("BanUser error = %v", err)
}
exists, _ := mr.Exists(ctx, "banned:user1")
if exists != 1 {
t.Error("banned key not found after BanUser")
}
err = hub.UnbanUser(ctx, "user1")
if err != nil {
t.Fatalf("UnbanUser error = %v", err)
}
exists, _ = mr.Exists(ctx, "banned:user1")
if exists != 0 {
t.Error("banned key still exists after UnbanUser")
}
}
func TestHub_ScanPresence(t *testing.T) {
hub, _, mr := setupTestHub(t)
ctx := context.Background()
mr.Set(ctx, "presence:main:user1", "online", 0)
mr.Set(ctx, "presence:main:user2", "online", 0)
mr.Set(ctx, "presence:lobby:user3", "online", 0)
presences, err := hub.ScanPresence(ctx)
if err != nil {
t.Fatalf("ScanPresence error = %v", err)
}
if len(presences) != 3 {
t.Errorf("ScanPresence len = %d, want 3", len(presences))
}
}
func TestHub_SubscribeRoomMessages(t *testing.T) {
hub, _, mr := setupTestHub(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
hub.SubscribeRoomMessages()
}()
payload, _ := json.Marshal(map[string]any{
"instance": "other-instance",
"msg": db.Message{
Room: "main",
Username: "remoteuser",
Text: "remote hello",
Timestamp: time.Now(),
Type: "public",
},
})
mr.Publish(ctx, "room:main:messages", string(payload))
time.Sleep(50 * time.Millisecond)
ownPayload, _ := json.Marshal(map[string]any{
"instance": "test-instance",
"msg": db.Message{
Room: "main",
Username: "localuser",
Text: "local hello",
Timestamp: time.Now(),
Type: "public",
},
})
mr.Publish(ctx, "room:main:messages", string(ownPayload))
time.Sleep(50 * time.Millisecond)
}
+27
View File
@@ -0,0 +1,27 @@
module playout
go 1.26.5
require (
github.com/aws/aws-sdk-go-v2 v1.43.4
github.com/aws/aws-sdk-go-v2/config v1.32.35
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
github.com/aws/smithy-go v1.27.6 // indirect
)
+36
View File
@@ -0,0 +1,36 @@
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I=
github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E=
github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0 h1:OkYV+1171za+ab9otU1tGxMXhx6uZvwVEtVddjLuYTg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+353
View File
@@ -0,0 +1,353 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLoadPlaylist(t *testing.T) {
tmpDir := t.TempDir()
tests := []struct {
name string
content string
wantLen int
wantErr bool
first string
}{
{
name: "simple paths",
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix double quotes",
content: "file \"/videos/video1.mp4\"\n",
wantLen: 1,
first: "/videos/video1.mp4",
},
{
name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another\n/videos/video2.mp4\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "empty file",
content: "",
wantLen: 0,
},
{
name: "only comments",
content: "# comment\n# another\n",
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(tmpDir, tt.name+".txt")
os.WriteFile(path, []byte(tt.content), 0644)
videos, err := LoadPlaylist(path)
if tt.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(videos) != tt.wantLen {
t.Errorf("len = %d, want %d", len(videos), tt.wantLen)
}
if tt.wantLen > 0 && videos[0].Path != tt.first {
t.Errorf("videos[0].Path = %q, want %q", videos[0].Path, tt.first)
}
})
}
}
func TestLoadPlaylist_FileNotFound(t *testing.T) {
_, err := LoadPlaylist("/nonexistent/playlist.txt")
if err == nil {
t.Error("expected error for missing file, got nil")
}
}
func TestValidateKeyframeInterval(t *testing.T) {
tests := []struct {
name string
csvData string
wantErr bool
errText string
}{
{
name: "valid keyframes (gaps <=2s)",
csvData: "1,0.00\n0,0.50\n1,1.00\n0,1.50\n1,2.00\n0,2.50\n1,3.00\n",
wantErr: false,
},
{
name: "gap too large >2s",
csvData: "1,0.00\n0,1.00\n1,3.50\n0,4.00\n1,6.00\n",
wantErr: true,
errText: "keyframe interval too large",
},
{
name: "insufficient keyframes (<2)",
csvData: "1,0.00\n",
wantErr: true,
errText: "insufficient keyframes",
},
{
name: "empty output",
csvData: "",
wantErr: true,
errText: "keyframe probe failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// create temp ffprobe that outputs csvData
tmpDir := t.TempDir()
mockProbe := filepath.Join(tmpDir, "ffprobe")
var script string
if tt.csvData == "" {
script = "@echo off\necho\nexit /b 1\n"
} else {
script = "@echo off\necho " + strings.ReplaceAll(tt.csvData, "\n", "& echo ") + "\nexit /b 0\n"
}
os.WriteFile(mockProbe+".bat", []byte(script), 0755)
os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
defer os.Unsetenv("PATH")
err := validateKeyframeInterval("test.mp4")
if tt.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
if tt.wantErr && tt.errText != "" && err != nil {
if !strings.Contains(err.Error(), tt.errText) {
t.Errorf("error = %v, want containing %q", err, tt.errText)
}
}
})
}
}
func TestValidateVideoStrict(t *testing.T) {
// ValidateVideoStrict calls ValidateVideo which calls ffprobe
// Test only the result structure when ValidateVideo returns valid=false
// Mock not feasible without full ffprobe mock, skip for now
t.Skip("requires ffprobe mock or test media fixtures")
}
func TestHealthHandler(t *testing.T) {
// Save and restore globals
prevMode := playoutMode
prevCount := restartCount
playoutMode = "live"
restartCount = 3
defer func() {
playoutMode = prevMode
restartCount = prevCount
}()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
HealthHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"healthy"`) {
t.Errorf("body missing healthy: %s", body)
}
if !strings.Contains(body, `"playout"`) {
t.Errorf("body missing service: %s", body)
}
if !strings.Contains(body, `"restarts":3`) {
t.Errorf("body missing restarts: %s", body)
}
}
func TestHealthHandler_Degraded(t *testing.T) {
prevMode := playoutMode
playoutMode = "slate"
defer func() { playoutMode = prevMode }()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
HealthHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"degraded"`) {
t.Errorf("body missing degraded: %s", body)
}
}
func TestShouldEscalateToSlate(t *testing.T) {
// Save globals
prevCount := restartCount
prevWindow := restartWindow
defer func() {
restartCount = prevCount
restartWindow = prevWindow
}()
// Under threshold
restartWindow = time.Now()
restartCount = 3
if shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = true, want false (under threshold)")
}
// At threshold
restartCount = maxRestartsPerMinute
if !shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = false, want true (at threshold)")
}
// Over threshold
restartCount = 7
if !shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = false, want true (over threshold)")
}
// Window expired
restartWindow = time.Now().Add(-61 * time.Second)
restartCount = 10
if shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = true, want false (window expired, should reset)")
}
if restartCount != 0 {
t.Errorf("restartCount after window reset = %d, want 0", restartCount)
}
}
func TestResetHLSOutput(t *testing.T) {
tmpDir := t.TempDir()
// Create some segment files
os.MkdirAll(tmpDir, 0755)
os.WriteFile(filepath.Join(tmpDir, "segment_test.ts"), []byte("data"), 0644)
os.WriteFile(filepath.Join(tmpDir, "segment_test.m4s"), []byte("data"), 0644)
os.WriteFile(filepath.Join(tmpDir, "playlist.m3u8"), []byte("#EXTM3U"), 0644)
os.WriteFile(filepath.Join(tmpDir, "init.mp4"), []byte("init"), 0644)
err := resetHLSOutput(tmpDir)
if err != nil {
t.Fatalf("resetHLSOutput error: %v", err)
}
// Verify all cleaned
for _, name := range []string{"segment_test.ts", "segment_test.m4s", "playlist.m3u8", "init.mp4"} {
if _, err := os.Stat(filepath.Join(tmpDir, name)); !os.IsNotExist(err) {
t.Errorf("file %s still exists after reset", name)
}
}
}
func TestResetHLSOutput_EmptyDir(t *testing.T) {
tmpDir := t.TempDir()
err := resetHLSOutput(tmpDir)
if err != nil {
t.Fatalf("resetHLSOutput on empty dir error: %v", err)
}
}
func TestActivateSlate_NoSlate(t *testing.T) {
prevSlateDir := slateDir
prevHLSDir := hlsDir
prevMode := playoutMode
tmpDir := t.TempDir()
slateDir = filepath.Join(tmpDir, "slate")
hlsDir = filepath.Join(tmpDir, "hls")
os.MkdirAll(hlsDir, 0755)
defer func() {
slateDir = prevSlateDir
hlsDir = prevHLSDir
playoutMode = prevMode
}()
err := activateSlate()
if err == nil {
t.Error("expected error when slate HLS missing, got nil")
}
}
func TestActivateSlate_Success(t *testing.T) {
prevSlateDir := slateDir
prevHLSDir := hlsDir
prevMode := playoutMode
tmpDir := t.TempDir()
slateDir = filepath.Join(tmpDir, "slate")
hlsDir = filepath.Join(tmpDir, "hls")
// Create slate HLS structure
slateHLS := filepath.Join(slateDir, "hls")
os.MkdirAll(slateHLS, 0755)
os.MkdirAll(hlsDir, 0755)
os.WriteFile(filepath.Join(slateHLS, "slate.m3u8"), []byte("#EXTM3U\n#EXT-X-ENDLIST\n"), 0644)
os.WriteFile(filepath.Join(slateHLS, "slate_segment.ts"), []byte("slate-data"), 0644)
defer func() {
slateDir = prevSlateDir
hlsDir = prevHLSDir
playoutMode = prevMode
}()
err := activateSlate()
if err != nil {
t.Fatalf("activateSlate error: %v", err)
}
if playoutMode != "slate" {
t.Errorf("playoutMode = %q, want slate", playoutMode)
}
// Verify files copied
if _, err := os.Stat(filepath.Join(hlsDir, "slate.m3u8")); os.IsNotExist(err) {
t.Error("slate.m3u8 not copied to hls dir")
}
}
func TestDeactivateSlate(t *testing.T) {
prevMode := playoutMode
playoutMode = "slate"
defer func() { playoutMode = prevMode }()
deactivateSlate()
if playoutMode != "live" {
t.Errorf("playoutMode = %q, want live", playoutMode)
}
}
func TestValidatePlaylist(t *testing.T) {
// ValidatePlaylist calls ValidateVideo which needs ffprobe
t.Skip("requires ffprobe mock or test media fixtures")
}
func TestSyncToR2(t *testing.T) {
// Requires R2/S3 mock
t.Skip("requires S3 client mock")
}