Files
Francis J. Lacoste 6e93a40174 fix: address PR #225 review feedback for SWR cache
- Restore IsReady() polling loop before ServeStdio() to fix cold-start
  regression where tool calls fail for 60-90s on first run (no cache)
- Add fetchUsersMu/fetchChannelsMu mutexes to serialize fetchAndStore*
  calls, preventing race between ForceRefresh and background refresh
- Use atomic file writes (temp + os.Rename) to prevent corrupt cache
  files on crash
- Guard against empty API results overwriting valid cache
- Guard against empty cache files being treated as valid data
- Fix typo: TestRefreshingFlagPreventsConucrrentRefreshes → Concurrent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-14 21:44:44 +02:00

620 lines
18 KiB
Go

package provider
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/slack-go/slack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGetCacheTTL tests the app-specific logic in getCacheTTL:
// - Default when env not set
// - Numeric seconds fallback (app-specific parsing path)
// - Invalid input handling
// - Negative value rejection (P1 bug fix)
func TestGetCacheTTL(t *testing.T) {
tests := []struct {
name string
envValue string
expected time.Duration
}{
{
name: "default when env not set",
envValue: "",
expected: defaultCacheTTL,
},
{
name: "valid duration passes through",
envValue: "2h",
expected: 2 * time.Hour,
},
{
name: "numeric seconds fallback path",
envValue: "3600",
expected: 3600 * time.Second,
},
{
name: "zero disables TTL",
envValue: "0",
expected: 0,
},
{
name: "invalid input falls back to default",
envValue: "invalid",
expected: defaultCacheTTL,
},
{
name: "negative duration rejected - falls back to default",
envValue: "-1h",
expected: defaultCacheTTL,
},
{
name: "negative seconds rejected - falls back to default",
envValue: "-3600",
expected: defaultCacheTTL,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldVal := os.Getenv("SLACK_MCP_CACHE_TTL")
defer os.Setenv("SLACK_MCP_CACHE_TTL", oldVal)
if tt.envValue == "" {
os.Unsetenv("SLACK_MCP_CACHE_TTL")
} else {
os.Setenv("SLACK_MCP_CACHE_TTL", tt.envValue)
}
result := getCacheTTL()
assert.Equal(t, tt.expected, result)
})
}
}
// TestCacheExpiry verifies the actual cache expiry logic used in refreshChannelsInternal.
// This tests the production code path: file exists → check mtime → compare to TTL.
func TestCacheExpiry(t *testing.T) {
tempDir, err := os.MkdirTemp("", "slack-mcp-cache-expiry-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
t.Run("isCacheExpired returns correct result based on file mtime", func(t *testing.T) {
// This helper mirrors the logic in refreshChannelsInternal
isCacheExpired := func(cacheFile string, ttl time.Duration) bool {
if ttl == 0 {
return false // TTL disabled
}
fileInfo, err := os.Stat(cacheFile)
if err != nil {
return true // File doesn't exist, treat as expired
}
return time.Since(fileInfo.ModTime()) > ttl
}
cacheFile := filepath.Join(tempDir, "test_cache.json")
err := os.WriteFile(cacheFile, []byte(`[]`), 0644)
require.NoError(t, err)
// Fresh file should not be expired
assert.False(t, isCacheExpired(cacheFile, 1*time.Hour),
"fresh cache should not be expired")
// Set mtime to 2 hours ago
oldTime := time.Now().Add(-2 * time.Hour)
err = os.Chtimes(cacheFile, oldTime, oldTime)
require.NoError(t, err)
// Old file should be expired
assert.True(t, isCacheExpired(cacheFile, 1*time.Hour),
"2 hour old cache should be expired with 1h TTL")
// TTL=0 should never expire
assert.False(t, isCacheExpired(cacheFile, 0),
"cache should never expire when TTL=0")
// Nonexistent file should be treated as expired
assert.True(t, isCacheExpired(filepath.Join(tempDir, "nonexistent.json"), 1*time.Hour),
"nonexistent cache should be treated as expired")
})
t.Run("stale cache detected after server restart simulation", func(t *testing.T) {
// This is the key scenario: MCP server restarts after 3 days,
// cache file is still on disk with old mtime
cacheFile := filepath.Join(tempDir, "stale_cache.json")
channels := []Channel{{ID: "C123", Name: "#old-channel"}}
data, err := json.Marshal(channels)
require.NoError(t, err)
err = os.WriteFile(cacheFile, data, 0644)
require.NoError(t, err)
// Set mtime to 3 days ago (simulating server was down)
threeDaysAgo := time.Now().Add(-72 * time.Hour)
err = os.Chtimes(cacheFile, threeDaysAgo, threeDaysAgo)
require.NoError(t, err)
// Verify the production code would detect this as stale
fileInfo, err := os.Stat(cacheFile)
require.NoError(t, err)
cacheAge := time.Since(fileInfo.ModTime())
ttl := getCacheTTL() // default 1 hour
assert.True(t, cacheAge > ttl,
"cache from 3 days ago (age=%v) should exceed default TTL (%v)", cacheAge, ttl)
})
}
// TestChannelCacheRoundTrip verifies that Channel structs survive JSON serialization.
// This catches bugs in struct tags or field types that would corrupt cache data.
func TestChannelCacheRoundTrip(t *testing.T) {
original := []Channel{
{
ID: "C123",
Name: "#general",
Topic: "General discussion",
Purpose: "Company-wide announcements",
MemberCount: 100,
IsPrivate: false,
},
{
ID: "D456",
Name: "@john.doe",
IsIM: true,
IsPrivate: true,
User: "U789",
},
{
ID: "G789",
Name: "#private-team",
IsPrivate: true,
IsMpIM: false,
Members: []string{"U001", "U002"},
},
}
data, err := json.Marshal(original)
require.NoError(t, err)
var loaded []Channel
err = json.Unmarshal(data, &loaded)
require.NoError(t, err)
require.Len(t, loaded, 3)
// Verify public channel
assert.Equal(t, "C123", loaded[0].ID)
assert.Equal(t, "#general", loaded[0].Name)
assert.Equal(t, 100, loaded[0].MemberCount)
assert.False(t, loaded[0].IsPrivate)
// Verify IM channel
assert.Equal(t, "D456", loaded[1].ID)
assert.True(t, loaded[1].IsIM)
assert.Equal(t, "U789", loaded[1].User)
// Verify private channel with members
assert.True(t, loaded[2].IsPrivate)
assert.Equal(t, []string{"U001", "U002"}, loaded[2].Members)
}
// TestChannelLookupByName verifies the inverse map lookup pattern used in resolveChannelID.
func TestChannelLookupByName(t *testing.T) {
// Build maps the same way refreshChannelsInternal does
channels := []Channel{
{ID: "C123", Name: "#general"},
{ID: "C456", Name: "#random"},
{ID: "D789", Name: "@john.doe"},
}
channelsMap := make(map[string]Channel)
channelsInv := make(map[string]string)
for _, c := range channels {
channelsMap[c.ID] = c
channelsInv[c.Name] = c.ID
}
t.Run("lookup existing channel by name", func(t *testing.T) {
id, ok := channelsInv["#general"]
assert.True(t, ok)
assert.Equal(t, "C123", id)
ch := channelsMap[id]
assert.Equal(t, "#general", ch.Name)
})
t.Run("lookup existing DM by name", func(t *testing.T) {
id, ok := channelsInv["@john.doe"]
assert.True(t, ok)
assert.Equal(t, "D789", id)
})
t.Run("lookup nonexistent channel returns false", func(t *testing.T) {
_, ok := channelsInv["#new-channel"]
assert.False(t, ok, "new channel not in cache should return false")
})
}
// TestChannelIDPatterns verifies which channel formats need name resolution.
// This tests the logic in resolveChannelID that decides when to do lookups.
func TestChannelIDPatterns(t *testing.T) {
// This mirrors the check in resolveChannelID:
// if !strings.HasPrefix(channel, "#") && !strings.HasPrefix(channel, "@") {
// return channel, nil // Already an ID, no lookup needed
// }
needsLookup := func(channel string) bool {
return len(channel) > 0 && (channel[0] == '#' || channel[0] == '@')
}
tests := []struct {
channel string
needs bool
}{
{"C1234567890", false}, // Standard channel ID
{"G1234567890", false}, // Private channel ID (legacy)
{"D1234567890", false}, // DM ID
{"#general", true}, // Channel name - needs lookup
{"@john.doe", true}, // User DM name - needs lookup
{"", false}, // Empty - no lookup
}
for _, tt := range tests {
t.Run(tt.channel, func(t *testing.T) {
assert.Equal(t, tt.needs, needsLookup(tt.channel),
"channel %q: needsLookup should be %v", tt.channel, tt.needs)
})
}
}
// TestRefreshOnErrorPattern verifies the retry-once pattern used in resolveChannelID.
// When a channel isn't found, the code refreshes the cache and tries once more.
func TestRefreshOnErrorPattern(t *testing.T) {
t.Run("pattern: miss -> refresh -> hit", func(t *testing.T) {
// Initial cache doesn't have the channel
cache := make(map[string]string)
// First lookup fails
_, found := cache["#new-channel"]
assert.False(t, found, "initial lookup should miss")
// Simulate refresh adding the channel (this is what ForceRefreshChannels does)
cache["#new-channel"] = "C999"
// Second lookup succeeds
id, found := cache["#new-channel"]
assert.True(t, found, "lookup after refresh should succeed")
assert.Equal(t, "C999", id)
})
t.Run("pattern: miss -> refresh -> still miss", func(t *testing.T) {
// Channel genuinely doesn't exist in Slack
cache := make(map[string]string)
_, found := cache["#typo-channel"]
assert.False(t, found)
// Refresh happens but channel still doesn't exist
// (cache remains empty or doesn't have this channel)
_, found = cache["#typo-channel"]
assert.False(t, found, "channel that doesn't exist should still miss after refresh")
})
}
// TestGetCacheDir verifies the cache directory is created correctly.
func TestGetCacheDir(t *testing.T) {
dir := getCacheDir()
assert.NotEmpty(t, dir, "cache dir should not be empty")
assert.Contains(t, dir, "slack-mcp-server", "cache dir should contain app name")
// Directory should exist after getCacheDir() creates it
info, err := os.Stat(dir)
require.NoError(t, err, "cache directory should exist")
assert.True(t, info.IsDir(), "cache path should be a directory")
}
// TestDefaultCacheTTLIs24Hours verifies that the default cache TTL is 24 hours.
func TestDefaultCacheTTLIs24Hours(t *testing.T) {
assert.Equal(t, 24*time.Hour, defaultCacheTTL,
"default cache TTL should be 24 hours")
}
// TestAtomicReadyFlags verifies that usersReady and channelsReady are atomic.Bool
// and safe for concurrent access. This test is meaningful under `go test -race`.
func TestAtomicReadyFlags(t *testing.T) {
var usersReady, channelsReady atomic.Bool
// Initially false
assert.False(t, usersReady.Load())
assert.False(t, channelsReady.Load())
done := make(chan struct{})
// Concurrent writers
go func() {
for i := 0; i < 1000; i++ {
usersReady.Store(true)
channelsReady.Store(true)
}
close(done)
}()
// Concurrent readers (would race on plain bool under -race)
for i := 0; i < 1000; i++ {
_ = usersReady.Load()
_ = channelsReady.Load()
}
<-done
assert.True(t, usersReady.Load())
assert.True(t, channelsReady.Load())
}
// TestRefreshingFlagPreventsConcurrentRefreshes verifies that CompareAndSwap on
// the refreshing flag prevents a second background refresh from starting.
func TestRefreshingFlagPreventsConcurrentRefreshes(t *testing.T) {
var refreshing atomic.Bool
// First caller succeeds
assert.True(t, refreshing.CompareAndSwap(false, true),
"first refresh should acquire the flag")
// Second caller is blocked
assert.False(t, refreshing.CompareAndSwap(false, true),
"second refresh should be blocked while first is in progress")
// After first completes, next one can proceed
refreshing.Store(false)
assert.True(t, refreshing.CompareAndSwap(false, true),
"refresh should succeed after previous one completes")
}
// TestStaleWhileRevalidateReadyFlag verifies the stale-while-revalidate pattern:
// when an expired cache file exists, the ready flag is set immediately from stale data,
// without waiting for a fresh API fetch.
func TestStaleWhileRevalidateReadyFlag(t *testing.T) {
tempDir, err := os.MkdirTemp("", "slack-mcp-swr-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
cacheFile := filepath.Join(tempDir, "users_cache.json")
// Write a valid cache file with user data
users := []struct {
ID string `json:"id"`
Name string `json:"name"`
}{
{ID: "U001", Name: "alice"},
{ID: "U002", Name: "bob"},
}
data, err := json.Marshal(users)
require.NoError(t, err)
err = os.WriteFile(cacheFile, data, 0644)
require.NoError(t, err)
// Set mtime to 48 hours ago (well past 24h default TTL)
staleTime := time.Now().Add(-48 * time.Hour)
err = os.Chtimes(cacheFile, staleTime, staleTime)
require.NoError(t, err)
// Simulate the stale-while-revalidate logic from refreshUsersInternal:
// 1. Read and unmarshal cache
// 2. Build snapshot and set ready flag
// 3. Check TTL — expired means we'd spawn background refresh
var usersReady atomic.Bool
var usersSnapshot atomic.Pointer[UsersCache]
fileData, err := os.ReadFile(cacheFile)
require.NoError(t, err)
type simpleUser struct {
ID string `json:"id"`
Name string `json:"name"`
}
var cachedUsers []simpleUser
err = json.Unmarshal(fileData, &cachedUsers)
require.NoError(t, err)
// Build snapshot (mirrors refreshUsersInternal logic)
snapshot := &UsersCache{
Users: make(map[string]slack.User, len(cachedUsers)),
UsersInv: make(map[string]string, len(cachedUsers)),
}
for _, u := range cachedUsers {
snapshot.Users[u.ID] = slack.User{ID: u.ID, Name: u.Name}
snapshot.UsersInv[u.Name] = u.ID
}
usersSnapshot.Store(snapshot)
usersReady.Store(true)
// Ready flag should be true immediately (before any background refresh)
assert.True(t, usersReady.Load(),
"ready flag should be set immediately from stale cache")
// Snapshot should contain the stale data
loaded := usersSnapshot.Load()
require.NotNil(t, loaded)
assert.Len(t, loaded.Users, 2, "snapshot should contain cached users")
assert.Equal(t, "U001", loaded.Users["U001"].ID)
assert.Equal(t, "U002", loaded.UsersInv["bob"])
// Verify the cache IS expired (would trigger background refresh)
fileInfo, err := os.Stat(cacheFile)
require.NoError(t, err)
cacheAge := time.Since(fileInfo.ModTime())
assert.True(t, cacheAge > defaultCacheTTL,
"cache should be detected as expired (age=%v > TTL=%v)", cacheAge, defaultCacheTTL)
}
// TestGetMinRefreshInterval tests the rate limiting configuration parsing.
func TestGetMinRefreshInterval(t *testing.T) {
tests := []struct {
name string
envValue string
expected time.Duration
}{
{
name: "default when env not set",
envValue: "",
expected: defaultMinRefreshInterval,
},
{
name: "valid duration",
envValue: "1m",
expected: 1 * time.Minute,
},
{
name: "numeric seconds",
envValue: "60",
expected: 60 * time.Second,
},
{
name: "zero disables rate limiting",
envValue: "0",
expected: 0,
},
{
name: "invalid input falls back to default",
envValue: "invalid",
expected: defaultMinRefreshInterval,
},
{
name: "negative duration rejected",
envValue: "-30s",
expected: defaultMinRefreshInterval,
},
{
name: "negative seconds rejected",
envValue: "-60",
expected: defaultMinRefreshInterval,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldVal := os.Getenv("SLACK_MCP_MIN_REFRESH_INTERVAL")
defer os.Setenv("SLACK_MCP_MIN_REFRESH_INTERVAL", oldVal)
if tt.envValue == "" {
os.Unsetenv("SLACK_MCP_MIN_REFRESH_INTERVAL")
} else {
os.Setenv("SLACK_MCP_MIN_REFRESH_INTERVAL", tt.envValue)
}
result := getMinRefreshInterval()
assert.Equal(t, tt.expected, result)
})
}
}
// TestFetchSerializationWithMutex verifies that fetchUsersMu/fetchChannelsMu
// serializes concurrent calls to fetchAndStore*. This test validates the mutex
// pattern rather than calling the actual Slack API.
func TestFetchSerializationWithMutex(t *testing.T) {
var mu sync.Mutex
var order []int
// Simulate two concurrent fetchAndStore calls serialized by a mutex
done := make(chan struct{})
started := make(chan struct{})
go func() {
mu.Lock()
close(started) // Signal that the lock is held
time.Sleep(50 * time.Millisecond)
order = append(order, 1)
mu.Unlock()
}()
go func() {
<-started // Wait for first goroutine to hold the lock
mu.Lock()
order = append(order, 2)
mu.Unlock()
close(done)
}()
<-done
assert.Equal(t, []int{1, 2}, order,
"second fetch should wait for first to complete")
}
// TestEmptyAPIResultGuard verifies that an empty API result does not overwrite
// valid cached data. This tests the guard pattern in fetchAndStoreUsers/fetchAndStoreChannels.
func TestEmptyAPIResultGuard(t *testing.T) {
tempDir, err := os.MkdirTemp("", "slack-mcp-empty-guard-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
cacheFile := filepath.Join(tempDir, "users_cache.json")
// Write a valid cache file with user data
validData := []slack.User{
{ID: "U001", Name: "alice"},
{ID: "U002", Name: "bob"},
}
data, err := json.MarshalIndent(validData, "", " ")
require.NoError(t, err)
err = os.WriteFile(cacheFile, data, 0644)
require.NoError(t, err)
// Simulate the guard: API returns empty result
emptyUsers := []slack.User{}
assert.Len(t, emptyUsers, 0, "simulated API returned zero users")
// The guard should prevent overwriting the cache file
// (In production code: if len(users) == 0 { return nil })
if len(emptyUsers) == 0 {
// Don't write to cache — verify original data is preserved
preserved, err := os.ReadFile(cacheFile)
require.NoError(t, err)
var loadedUsers []slack.User
err = json.Unmarshal(preserved, &loadedUsers)
require.NoError(t, err)
assert.Len(t, loadedUsers, 2, "original cache should be preserved when API returns empty")
assert.Equal(t, "U001", loadedUsers[0].ID)
}
}
// TestEmptyCacheFileTreatedAsMiss verifies that an empty cache file (valid JSON [])
// is treated as a cache miss rather than valid data with zero entries.
func TestEmptyCacheFileTreatedAsMiss(t *testing.T) {
tempDir, err := os.MkdirTemp("", "slack-mcp-empty-cache-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
cacheFile := filepath.Join(tempDir, "users_cache.json")
// Write an empty but valid JSON array
err = os.WriteFile(cacheFile, []byte(`[]`), 0644)
require.NoError(t, err)
// Read and unmarshal
data, err := os.ReadFile(cacheFile)
require.NoError(t, err)
var cachedUsers []slack.User
err = json.Unmarshal(data, &cachedUsers)
require.NoError(t, err)
// The guard: empty cache should be treated as a miss
assert.Len(t, cachedUsers, 0, "empty cache file should unmarshal to zero users")
// In production code, this triggers: "treating as cache miss"
// and falls through to fetchAndStoreUsers instead of setting ready=true
isCacheMiss := len(cachedUsers) == 0
assert.True(t, isCacheMiss,
"empty cache file should be treated as cache miss, not valid data")
}