feat: Implement user preferences (saved cities, search history) with Redis storage and API handlers
This commit is contained in:
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"trip-planner/internal/cache"
|
||||
"trip-planner/internal/yandex"
|
||||
)
|
||||
|
||||
// stationStatusKey returns the Redis key for station status.
|
||||
func stationStatusKey(id string) *cache.CacheKey {
|
||||
return &cache.CacheKey{
|
||||
Kind: "station",
|
||||
Code: id,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize Redis cache
|
||||
redisClient := cache.NewRedisCache(&cache.RedisConfig{
|
||||
Addr: "localhost:6379",
|
||||
Password: "",
|
||||
DB: 0,
|
||||
})
|
||||
|
||||
// Initialize Yandex client
|
||||
yandexClient := yandex.NewClient("test-key")
|
||||
|
||||
// Initialize station monitors for monitored stations
|
||||
monitors := []*cron.StationMonitor{
|
||||
{
|
||||
ID: "station-moscow-kiev",
|
||||
Yandex: yandexClient,
|
||||
Cache: redisClient,
|
||||
},
|
||||
{
|
||||
ID: "station-petersburg-moscow",
|
||||
Yandex: yandexClient,
|
||||
Cache: redisClient,
|
||||
},
|
||||
}
|
||||
|
||||
// Process all stations - this is the main cron job function
|
||||
if err := cron.ProcessAllStations(ctx, monitors); err != nil {
|
||||
log.Printf("ERROR: failed to process stations: %v", err)
|
||||
}
|
||||
|
||||
// Log the status of all monitored stations
|
||||
for _, monitor := range monitors {
|
||||
statusKey := stationStatusKey(monitor.ID)
|
||||
statusData, err := monitor.Cache.Get(ctx, statusKey)
|
||||
if err == nil && statusData != nil {
|
||||
log.Printf("INFO: station %s status: %s", monitor.ID, string(statusData))
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Cron job completed")
|
||||
}
|
||||
@@ -19,6 +19,13 @@ type StationMonitor struct {
|
||||
// ScheduleFunc is the function used to check a station's schedule.
|
||||
// Defaults to checkStationSchedule if not set.
|
||||
ScheduleFunc func(context.Context, string) (int, error)
|
||||
|
||||
// ZeroSince is the timestamp when the current zero-trip streak began.
|
||||
// Zero if the station is not in a zero-trip streak.
|
||||
ZeroSince time.Time
|
||||
|
||||
// LastSeenFlight is the timestamp of the last successful schedule check.
|
||||
LastSeenFlight time.Time
|
||||
}
|
||||
|
||||
// Status represents the current status of a station.
|
||||
@@ -47,6 +54,22 @@ func zeroDaysKey(id string) *cache.CacheKey {
|
||||
}
|
||||
}
|
||||
|
||||
// zeroSinceKey returns the Redis key for tracking the zero-trip streak start timestamp.
|
||||
func zeroSinceKey(id string) *cache.CacheKey {
|
||||
return &cache.CacheKey{
|
||||
Kind: "station_zero_since",
|
||||
Code: id,
|
||||
}
|
||||
}
|
||||
|
||||
// lastSeenFlightKey returns the Redis key for tracking the last seen flight timestamp.
|
||||
func lastSeenFlightKey(id string) *cache.CacheKey {
|
||||
return &cache.CacheKey{
|
||||
Kind: "station_last_seen_flight",
|
||||
Code: id,
|
||||
}
|
||||
}
|
||||
|
||||
// checkStationSchedule queries the Yandex /schedule endpoint for a station
|
||||
// and returns the number of trips found.
|
||||
func checkStationSchedule(ctx context.Context, yc *yandex.Client, stationID string) (int, error) {
|
||||
@@ -66,11 +89,14 @@ func checkStationSchedule(ctx context.Context, yc *yandex.Client, stationID stri
|
||||
}
|
||||
|
||||
// updateStationStatus updates the station's status in cache based on trip count.
|
||||
// It returns the new status. Writes status and zero-days count separately;
|
||||
// partial failures may leave cache inconsistent but do not lose the core state.
|
||||
// It returns the new status. Writes status, zero-days count, zero-since timestamp,
|
||||
// and last-seen-flight timestamp separately; partial failures may leave cache
|
||||
// inconsistent but do not lose the core state.
|
||||
func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int) (Status, error) {
|
||||
cacheKey := stationStatusKey(sm.ID)
|
||||
zeroDaysKey := zeroDaysKey(sm.ID)
|
||||
zeroSinceKey := zeroSinceKey(sm.ID)
|
||||
lastSeenFlightKey := lastSeenFlightKey(sm.ID)
|
||||
|
||||
// Get current status from cache
|
||||
data, err := sm.Cache.Get(ctx, cacheKey)
|
||||
@@ -101,14 +127,39 @@ func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int
|
||||
}
|
||||
}
|
||||
|
||||
// Get current zero-since timestamp
|
||||
zeroSinceData, err := sm.Cache.Get(ctx, zeroSinceKey)
|
||||
var zeroSince time.Time
|
||||
if err == nil && zeroSinceData != nil {
|
||||
_, err := fmt.Sscanf(string(zeroSinceData), "%d", (&zeroSince).Unix())
|
||||
if err != nil {
|
||||
zeroSince = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current last-seen-flight timestamp
|
||||
lastSeenFlightData, err := sm.Cache.Get(ctx, lastSeenFlightKey)
|
||||
var lastSeenFlight time.Time
|
||||
if err == nil && lastSeenFlightData != nil {
|
||||
_, err := fmt.Sscanf(string(lastSeenFlightData), "%d", (&lastSeenFlight).Unix())
|
||||
if err != nil {
|
||||
lastSeenFlight = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// Update status based on trip count
|
||||
var newStatus Status
|
||||
|
||||
if tripCount > 0 {
|
||||
newStatus = StatusActive
|
||||
zeroDays = 0
|
||||
zeroSince = time.Time{}
|
||||
lastSeenFlight = time.Now()
|
||||
} else {
|
||||
zeroDays++
|
||||
if zeroSince.IsZero() {
|
||||
zeroSince = time.Now()
|
||||
}
|
||||
if zeroDays >= 3 {
|
||||
newStatus = StatusClosed
|
||||
} else {
|
||||
@@ -126,6 +177,16 @@ func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int
|
||||
return "", fmt.Errorf("cache set zero days: %w", err)
|
||||
}
|
||||
|
||||
// Write updated zero-since timestamp to cache with 24h TTL
|
||||
if err := sm.Cache.Set(ctx, zeroSinceKey, []byte(fmt.Sprintf("%d", zeroSince.Unix())), 24*time.Hour); err != nil {
|
||||
return "", fmt.Errorf("cache set zero since: %w", err)
|
||||
}
|
||||
|
||||
// Write updated last-seen-flight timestamp to cache with 24h TTL
|
||||
if err := sm.Cache.Set(ctx, lastSeenFlightKey, []byte(fmt.Sprintf("%d", lastSeenFlight.Unix())), 24*time.Hour); err != nil {
|
||||
return "", fmt.Errorf("cache set last seen flight: %w", err)
|
||||
}
|
||||
|
||||
return newStatus, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -233,3 +233,107 @@ func TestProcessStation_Reactivation_AfterClosure(t *testing.T) {
|
||||
t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestAutoClosureChronology verifies the chronology of auto-closure detection.
|
||||
// It tests that a station closes after exactly N=3 consecutive zero-trip days,
|
||||
// and that it reactivates when trips resume.
|
||||
func TestAutoClosureChronology(t *testing.T) {
|
||||
t.Helper()
|
||||
rc := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
|
||||
defer rc.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Flush Redis database for test isolation
|
||||
rc.FlushDB(ctx)
|
||||
|
||||
// Monitor that returns 0 trips
|
||||
monitor := newMockMonitor("test-cha", 0, nil)
|
||||
|
||||
// Day 1: 0 trips - err declared with :=
|
||||
err := ProcessStation(ctx, monitor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error day 1: %v", err)
|
||||
}
|
||||
|
||||
var zeroDays1 int
|
||||
zeroDaysData, _ := monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse zero days: %v", err)
|
||||
}
|
||||
if zeroDays1 != 1 {
|
||||
t.Errorf("day 1: expected zero days 1, got %d", zeroDays1)
|
||||
}
|
||||
|
||||
// Day 2: 0 trips - assign to err (already declared)
|
||||
err = ProcessStation(ctx, monitor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error day 2: %v", err)
|
||||
}
|
||||
|
||||
var zeroDays2 int
|
||||
zeroDaysData, _ = monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse zero days: %v", err)
|
||||
}
|
||||
if zeroDays2 != 2 {
|
||||
t.Errorf("day 2: expected zero days 2, got %d", zeroDays2)
|
||||
}
|
||||
|
||||
// Day 3: 0 trips - assign to err (already declared), station closes
|
||||
err = ProcessStation(ctx, monitor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error day 3: %v", err)
|
||||
}
|
||||
|
||||
var zeroDays3 int
|
||||
zeroDaysData, _ = monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays3)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse zero days: %v", err)
|
||||
}
|
||||
if zeroDays3 != 3 {
|
||||
t.Errorf("day 3: expected zero days 3, got %d", zeroDays3)
|
||||
}
|
||||
|
||||
// Status should be closed
|
||||
statusData, err := monitor.Cache.Get(ctx, stationStatusKey("test-cha"))
|
||||
if err != nil {
|
||||
t.Fatalf("cache get status error: %v", err)
|
||||
}
|
||||
if string(statusData) != string(StatusClosed) {
|
||||
t.Errorf("expected status closed, got %s", string(statusData))
|
||||
}
|
||||
|
||||
// Day 4: trips resume - should reactivate
|
||||
monitor.ScheduleFunc = func(ctx context.Context, stationID string) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
err = ProcessStation(ctx, monitor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error reactivation: %v", err)
|
||||
}
|
||||
|
||||
// Status should be active again
|
||||
statusData, err = monitor.Cache.Get(ctx, stationStatusKey("test-cha"))
|
||||
if err != nil {
|
||||
t.Fatalf("cache get status error: %v", err)
|
||||
}
|
||||
if string(statusData) != string(StatusActive) {
|
||||
t.Errorf("expected status active after reactivation, got %s", string(statusData))
|
||||
}
|
||||
|
||||
var zeroDays4 int
|
||||
zeroDaysData, _ = monitor.Cache.Get(ctx, zeroDaysKey("test-cha"))
|
||||
_, err = fmt.Sscanf(string(zeroDaysData), "%d", &zeroDays4)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse zero days: %v", err)
|
||||
}
|
||||
if zeroDays4 != 0 {
|
||||
t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays4)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user