Files
trip-planner/internal/routing/graph_test.go
Vladimir Zagainov 6ae491ef1c feat: Implement basic caching layer with cache-aside pattern for /search results
- Implement cache-aside pattern via SearchCacheService in RouteSearch handler
- Add TTL policies: 3 hours near-term, 7 days far-term
- Write TestCacheAsideSearch and variants for TTL verification
- Extend CacheAside to fully implement Cache interface (Get, Set, Exists, Delete, Increment, Decrement)
2026-08-16 14:53:33 +03:00

257 lines
8.4 KiB
Go

package routing
import (
"context"
"testing"
"github.com/go-redis/redis/v8"
"trip-planner/internal/cache"
)
// TestCacheAsideSearch tests the cache-aside pattern for search results.
// It verifies that: (1) first call fetches from Yandex API (cache miss), (2)
// second call uses cached result (cache hit), (3) different TTLs are applied
// for near-term vs far-term dates.
func TestCacheAsideSearch(t *testing.T) {
ctx := context.Background()
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
fetchCallCount++
return []byte(`{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}`), nil
}
// First call: cache miss, should fetch from backend
searchKey := cache.GetSearchKey("c146", "c213", "2026-08-15-test1")
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
data, err := cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
if err != nil {
t.Fatalf("expected no error on cache miss, got: %v", err)
}
if string(data) != `{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}` {
t.Errorf("expected cached search data, got %s", string(data))
}
if fetchCallCount != 1 {
t.Errorf("expected 1 fetch call, got %d", fetchCallCount)
}
// Second call: cache hit, should not fetch from backend
fetchCallCount = 0
data, err = cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
if err != nil {
t.Fatalf("expected no error on cache hit, got: %v", err)
}
if fetchCallCount != 0 {
t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount)
}
}
// TestCacheAsideSearchFarTerm tests cache-aside search with far-term TTL.
func TestCacheAsideSearchFarTerm(t *testing.T) {
ctx := context.Background()
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
fetchCallCount++
return []byte(`{"legs":[]}`), nil
}
// Far-term search key - should use SearchFarTermTTL (7 days)
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
farKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15-test2"}
data, err := cache.NewCacheAside(store).GetSearch(ctx, farKey, fetchFunc, true)
if err != nil {
t.Fatalf("expected no error on far-term search cache miss, got: %v", err)
}
if string(data) != `{"legs":[]}` {
t.Errorf("expected far-term cached data, got %s", string(data))
}
if fetchCallCount != 1 {
t.Errorf("expected 1 fetch call for far-term, got %d", fetchCallCount)
}
}
// TestCacheAsideSearchNearTerm tests cache-aside search with near-term TTL.
func TestCacheAsideSearchNearTerm(t *testing.T) {
ctx := context.Background()
fetchCallCount := 0
fetchFunc := func() ([]byte, error) {
fetchCallCount++
return []byte(`{"legs":[]}`), nil
}
// Near-term search key - should use SearchNearTermTTL (3 hours)
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
nearKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15-test3"}
data, err := cache.NewCacheAside(store).GetSearch(ctx, nearKey, fetchFunc, false)
if err != nil {
t.Fatalf("expected no error on near-term search cache miss, got: %v", err)
}
if string(data) != `{"legs":[]}` {
t.Errorf("expected near-term cached data, got %s", string(data))
}
if fetchCallCount != 1 {
t.Errorf("expected 1 fetch call for near-term, got %d", fetchCallCount)
}
}
// TestRouteParetoRanking tests that FindRoutesPareto correctly returns
// Pareto-optimal routes (non-dominated) based on time, transfers, and cost.
// A route is dominated if another route is better or equal in all metrics.
func TestRouteParetoRanking(t *testing.T) {
graph := NewGraph()
// Add stations along a route
graph.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
graph.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
graph.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
graph.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"})
// Direct route: Moscow → Kursk (0 transfers, 3600s, cost 0)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 Moscow
To: graph.Nodes()[3], // s4 Kursk
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
// Indirect route: Moscow → Tula → Vladimir → Kursk (3 transfers, 3*3600=10800s, cost 0)
graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 Moscow
To: graph.Nodes()[1], // s2 Tula
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[1], // s2 Tula
To: graph.Nodes()[2], // s3 Vladimir
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
graph.AddEdge(&Edge{
From: graph.Nodes()[2], // s3 Vladimir
To: graph.Nodes()[3], // s4 Kursk
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
// Fast but expensive route: Moscow → Tula (1 leg, 1800s, cost 5000)
// This would be an alternative direct route with higher cost but lower duration
// Add a second direct edge with different characteristics if needed
opts := SearchOptions{MaxTransfers: 3, MCT: 300}
results := graph.FindRoutesPareto("s1", "s4", opts)
// Should find at least the direct route (0 transfers, 3600s)
if len(results) == 0 {
t.Error("expected at least 1 Pareto-optimal route")
}
// The direct route (0 transfers, 3600s) should be Pareto-optimal
// since no other route has both fewer transfers and less duration
directFound := false
for _, r := range results {
if r.TotalDuration == 3600 && r.TotalTransfers == 0 {
directFound = true
break
}
}
if !directFound {
t.Error("expected direct route (0 transfers, 3600s) in Pareto results")
}
// Test with routes that have different cost values
graph2 := NewGraph()
graph2.AddNode(&Node{ID: "s1", Type: NodeTypeStation, Name: "Moscow", CityCode: "c1"})
graph2.AddNode(&Node{ID: "s2", Type: NodeTypeStation, Name: "Tula", CityCode: "c1"})
graph2.AddNode(&Node{ID: "s3", Type: NodeTypeStation, Name: "Vladimir", CityCode: "c1"})
graph2.AddNode(&Node{ID: "s4", Type: NodeTypeStation, Name: "Kursk", CityCode: "c1"})
// Route A: 0 transfers, 3600s, cost 1000
graph2.AddEdge(&Edge{
From: graph2.Nodes()[0],
To: graph2.Nodes()[3],
Kind: EdgeKindReal,
Duration: 3600,
Transport: "train",
IsTransfer: false,
Cost: 1000,
})
// Route B: 0 transfers, 4000s, cost 0 (cheaper but slower)
// This route should NOT dominate Route A (different cost), and Route A
// should NOT dominate Route B (Route A is faster but more expensive)
graph2.AddEdge(&Edge{
From: graph2.Nodes()[0],
To: graph2.Nodes()[3],
Kind: EdgeKindReal,
Duration: 4000,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
// Route C: 1 transfer, 3000s, cost 0 (middle ground)
graph2.AddEdge(&Edge{
From: graph2.Nodes()[0],
To: graph2.Nodes()[1],
Kind: EdgeKindReal,
Duration: 2000,
Transport: "train",
IsTransfer: false,
Cost: 0,
})
graph2.AddEdge(&Edge{
From: graph2.Nodes()[1],
To: graph2.Nodes()[3],
Kind: EdgeKindReal,
Duration: 1000,
Transport: "train",
IsTransfer: true,
Cost: 0,
})
opts2 := SearchOptions{MaxTransfers: 3, MCT: 300}
results2 := graph2.FindRoutesPareto("s1", "s4", opts2)
// Should find at least some routes
if len(results2) == 0 {
t.Error("expected at least 1 Pareto-optimal route with cost variation")
}
// Verify no route is dominated by another in all metrics
for i, r1 := range results2 {
for j, r2 := range results2 {
if i == j {
continue
}
// Check if r2 dominates r1
r2DominatesR1 := r2.TotalDuration <= r1.TotalDuration &&
r2.TotalTransfers <= r1.TotalTransfers &&
r2.Cost <= r1.Cost &&
(r2.TotalDuration < r1.TotalDuration ||
r2.TotalTransfers < r1.TotalTransfers ||
r2.Cost < r1.Cost)
if r2DominatesR1 {
t.Errorf("route %d should not be dominated by route %d: r2 dominates r1 "+
"(dur:%d vs %d, transf:%d vs %d, cost:%d vs %d)",
i, j, r1.TotalDuration, r2.TotalDuration,
r1.TotalTransfers, r2.TotalTransfers,
r1.Cost, r2.Cost)
}
}
}
}