From 7dbd9fda129efa757b4dbd18ac0154582686e0e8 Mon Sep 17 00:00:00 2001 From: db123 Date: Thu, 25 Jun 2026 15:56:00 +0330 Subject: [PATCH] feat: add /setcalendar command, store tests, and improve error handling - Add /setcalendar command for calendar type parity (Settings UI) - Add 17 store tests covering all repository methods - Add handler tests for formatDuration, fmtHHMM, fmtDDHHMM - Add domain tests for BurnoutLevel.String, NavMonth, FormatSalary - Fix silent error discarding in save* helpers (log write failures) - Fix computeAndAwardXP error handling instead of discarding - Wrap critical store.go SQL errors with fmt.Errorf context - Update README commands table with /setcalendar --- README.md | 1 + internal/domain/rpg_test.go | 64 ++++ internal/handler/handler.go | 5 + internal/handler/handler_test.go | 70 ++++ internal/handler/rpg.go | 9 +- internal/handler/settings.go | 60 +++- internal/repo/store.go | 36 +- internal/repo/store_test.go | 593 +++++++++++++++++++++++++++++++ 8 files changed, 816 insertions(+), 22 deletions(-) create mode 100644 internal/handler/handler_test.go create mode 100644 internal/repo/store_test.go diff --git a/README.md b/README.md index 46b94ae..e4949df 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Categorize your work sessions (e.g., Remote, Onsite, Meeting). The current work | `/edit YYYY-MM-DD` | View/edit events for a specific date | | `/note [DATE] HH:MM ` | Set note for an event at a specific time | | `/settimezone ` | Set timezone (e.g. `Asia/Tehran`) | +| `/setcalendar ` | Set calendar type (`gregorian`, `jalali`, `hijri`) | | `/setbreak ` | Set minimum break threshold (0-480, default 5) | | `/setaccent` | Select export accent color | | `/setreporttime HH:MM` | Set daily auto-report time | diff --git a/internal/domain/rpg_test.go b/internal/domain/rpg_test.go index efd2be2..8e913c1 100644 --- a/internal/domain/rpg_test.go +++ b/internal/domain/rpg_test.go @@ -157,6 +157,70 @@ func TestSanitizeNote(t *testing.T) { } } +func TestBurnoutLevelString(t *testing.T) { + cases := []struct { + level BurnoutLevel + want string + }{ + {BurnoutHealthy, "Healthy"}, + {BurnoutMild, "Mild concern"}, + {BurnoutModerate, "Moderate concern"}, + {BurnoutHigh, "High concern"}, + {BurnoutLevel(99), "Unknown"}, + } + for _, c := range cases { + got := c.level.String() + if got != c.want { + t.Errorf("BurnoutLevel(%d).String() = %q, want %q", c.level, got, c.want) + } + } +} + +func TestNavMonth(t *testing.T) { + tests := []struct { + y, m int + prevY, prevM int + nextY, nextM int + }{ + {2026, 6, 2026, 5, 2026, 7}, + {2026, 1, 2025, 12, 2026, 2}, + {2026, 12, 2026, 11, 2027, 1}, + {2024, 2, 2024, 1, 2024, 3}, + } + for _, tc := range tests { + prevY, prevM, nextY, nextM := NavMonth(tc.y, tc.m) + if prevY != tc.prevY || prevM != tc.prevM { + t.Errorf("NavMonth(%d, %d) prev = (%d, %d), want (%d, %d)", tc.y, tc.m, prevY, prevM, tc.prevY, tc.prevM) + } + if nextY != tc.nextY || nextM != tc.nextM { + t.Errorf("NavMonth(%d, %d) next = (%d, %d), want (%d, %d)", tc.y, tc.m, nextY, nextM, tc.nextY, tc.nextM) + } + } +} + +func TestFormatSalary(t *testing.T) { + tests := []struct { + est SalaryEstimate + currency string + want string + wantShort string + }{ + {SalaryEstimate{GrossEarning: 1000.50}, "$ USD", "$1000.50 USD", "$1000.50"}, + {SalaryEstimate{GrossEarning: 0}, "$ USD", "$0.00 USD", "$0.00"}, + {SalaryEstimate{GrossEarning: 2500}, "T Toman", "T2500.00 Toman", "T2500.00"}, + } + for _, tc := range tests { + got := tc.est.FormatSalary(tc.currency) + if got != tc.want { + t.Errorf("FormatSalary(%q) = %q, want %q", tc.currency, got, tc.want) + } + gotShort := tc.est.FormatSalaryShort(tc.currency) + if gotShort != tc.wantShort { + t.Errorf("FormatSalaryShort(%q) = %q, want %q", tc.currency, gotShort, tc.wantShort) + } + } +} + func TestSanitizeDisplayName(t *testing.T) { if got := SanitizeDisplayName(" Alice "); got != "Alice" { t.Errorf("SanitizeDisplayName trim: got %q", got) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index aea0394..d7b77a8 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -24,6 +24,7 @@ const ( PendingRate PendingKind = "rate" PendingCurrency PendingKind = "currency" PendingTimezone PendingKind = "timezone" + PendingCalendar PendingKind = "calendar" PendingBreak PendingKind = "break_threshold" PendingUsername PendingKind = "username" PendingReportTime PendingKind = "report_time" @@ -176,6 +177,8 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *d h.handleSetAccent(ctx, msg, user) case "/settimezone": h.handleSetTimezone(ctx, msg, user) + case "/setcalendar": + h.handleSetCalendar(ctx, msg, user) case "/note": h.handleNote(ctx, msg, user) case "/summary": @@ -358,6 +361,8 @@ func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, h.processCurrencyInput(ctx, msg, user, pi, text) case PendingTimezone: h.processTimezoneInput(ctx, msg, user, pi, text) + case PendingCalendar: + h.processCalendarInput(ctx, msg, user, pi, text) case PendingBreak: h.processBreakInput(ctx, msg, user, pi, text) case PendingUsername: diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 0000000..5cdea06 --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,70 @@ +package handler + +import ( + "testing" +) + +func TestFormatDuration(t *testing.T) { + tests := []struct { + seconds int64 + want string + }{ + {0, "0m"}, + {30, "0m"}, + {59, "0m"}, + {60, "1m"}, + {3599, "59m"}, + {3600, "1h 0m"}, + {3661, "1h 1m"}, + {7200, "2h 0m"}, + {7260, "2h 1m"}, + {86399, "23h 59m"}, + {86400, "24h 0m"}, + } + for _, tc := range tests { + got := formatDuration(tc.seconds) + if got != tc.want { + t.Errorf("formatDuration(%d) = %q, want %q", tc.seconds, got, tc.want) + } + } +} + +func TestFmtHHMM(t *testing.T) { + tests := []struct { + seconds int64 + want string + }{ + {0, "0:00"}, + {60, "0:01"}, + {3600, "1:00"}, + {3661, "1:01"}, + {86399, "23:59"}, + {86400, "24:00"}, + } + for _, tc := range tests { + got := fmtHHMM(tc.seconds) + if got != tc.want { + t.Errorf("fmtHHMM(%d) = %q, want %q", tc.seconds, got, tc.want) + } + } +} + +func TestFmtDDHHMM(t *testing.T) { + tests := []struct { + seconds int64 + want string + }{ + {0, "0:00:00"}, + {60, "0:00:01"}, + {3600, "0:01:00"}, + {86400, "1:00:00"}, + {90061, "1:01:01"}, + {172800, "2:00:00"}, + } + for _, tc := range tests { + got := fmtDDHHMM(tc.seconds) + if got != tc.want { + t.Errorf("fmtDDHHMM(%d) = %q, want %q", tc.seconds, got, tc.want) + } + } +} diff --git a/internal/handler/rpg.go b/internal/handler/rpg.go index 822950a..ac015fe 100644 --- a/internal/handler/rpg.go +++ b/internal/handler/rpg.go @@ -134,12 +134,15 @@ func (h *Handler) buildAchievementsList(user *domain.User) string { } func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string { - text := "" h.updateStreak(stats, user.ID, today) - xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today) + xp, leveledUp, newLevel, err := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today) + if err != nil { + slog.Error("compute and award xp failed", "user_id", user.ID, "error", err) + return "" + } totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours) - text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel) + text := fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel) if leveledUp { text += "\nLevel up!" } diff --git a/internal/handler/settings.go b/internal/handler/settings.go index bd5dd67..c115abb 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -600,6 +600,48 @@ func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message, fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB) } +func (h *Handler) handleSetCalendar(ctx context.Context, msg *models.Message, user *domain.User) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcalendar")) + if args == "" { + h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCalendar, + fmt.Sprintf("Current calendar: %s\n\nValid options: gregorian, jalali, hijri", user.Calendar), nil) + return + } + args = strings.ToLower(args) + valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} + if !valid[args] { + h.sendText(ctx, msg.Chat.ID, "Invalid calendar. Valid options: gregorian, jalali, hijri") + return + } + h.saveCalendar(ctx, msg.Chat.ID, user, args) +} + +func (h *Handler) processCalendarInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + text = strings.ToLower(strings.TrimSpace(text)) + valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} + if !valid[text] { + h.editText(ctx, msg.Chat.ID, pi.MsgID, + "Invalid calendar. Valid options: gregorian, jalali, hijri", &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Try again", CallbackData: "caltype"}}, + {{Text: "Cancel", CallbackData: "back_settings"}}, + }, + }) + return + } + h.saveCalendar(ctx, msg.Chat.ID, user, text) + backKB := settingsBackKeyboard() + h.editText(ctx, msg.Chat.ID, pi.MsgID, + fmt.Sprintf("Calendar set to %s", text), &backKB) +} + +func (h *Handler) saveCalendar(_ context.Context, _ int64, user *domain.User, cal string) { + user.Calendar = cal + if err := h.Repo.UpdateUser(user); err != nil { + slog.Error("save calendar failed", "user_id", user.ID, "error", err) + } +} + func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { mins, err := strconv.Atoi(text) if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins { @@ -684,15 +726,20 @@ func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User, func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) { st, err := h.Repo.GetOrCreateSettings(user.ID) if err != nil { + slog.Error("save currency: get settings failed", "user_id", user.ID, "error", err) return } st.Currency = currency - h.Repo.UpdateSettings(st) + if err := h.Repo.UpdateSettings(st); err != nil { + slog.Error("save currency: update settings failed", "user_id", user.ID, "error", err) + } } func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) { user.Timezone = tz - h.Repo.UpdateUser(user) + if err := h.Repo.UpdateUser(user); err != nil { + slog.Error("save timezone failed", "user_id", user.ID, "error", err) + } } func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) { @@ -700,10 +747,13 @@ func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.Us today := time.Now().In(loc).Format(domain.DateLayout) day, err := h.Repo.GetOrCreateDay(user.ID, today) if err != nil { + slog.Error("save break threshold: get day failed", "user_id", user.ID, "error", err) return } day.MinBreakThreshold = int64(mins) * 60 - h.Repo.UpdateDay(day) + if err := h.Repo.UpdateDay(day); err != nil { + slog.Error("save break threshold: update day failed", "user_id", user.ID, "error", err) + } } func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) { @@ -712,7 +762,9 @@ func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, na return } user.Username = name - h.Repo.UpdateUser(user) + if err := h.Repo.UpdateUser(user); err != nil { + slog.Error("save username failed", "user_id", user.ID, "error", err) + } } func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error { diff --git a/internal/repo/store.go b/internal/repo/store.go index e86f03e..6b53afd 100644 --- a/internal/repo/store.go +++ b/internal/repo/store.go @@ -21,16 +21,16 @@ type Store struct { func NewStore(path string) (*Store, error) { db, err := sql.Open(driverName, path) if err != nil { - return nil, err + return nil, fmt.Errorf("open: %w", err) } if err := db.Ping(); err != nil { - return nil, err + return nil, fmt.Errorf("ping: %w", err) } if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { slog.Warn("failed to set WAL mode", "error", err) } if err := runMigrations(db); err != nil { - return nil, err + return nil, fmt.Errorf("migrate: %w", err) } return &Store{db: db}, nil } @@ -38,33 +38,36 @@ func NewStore(path string) (*Store, error) { func (s *Store) DeleteUserData(userID int64) error { tx, err := s.db.Begin() if err != nil { - return err + return fmt.Errorf("delete user data begin tx: %w", err) } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM events WHERE day_id IN (SELECT id FROM days WHERE user_id=?)`, userID); err != nil { - return err + return fmt.Errorf("delete user data events: %w", err) } if _, err := tx.Exec(`DELETE FROM days WHERE user_id=?`, userID); err != nil { - return err + return fmt.Errorf("delete user data days: %w", err) } if _, err := tx.Exec(`DELETE FROM user_achievements WHERE user_id=?`, userID); err != nil { - return err + return fmt.Errorf("delete user data achievements: %w", err) } if _, err := tx.Exec(`DELETE FROM daily_xp WHERE user_id=?`, userID); err != nil { - return err + return fmt.Errorf("delete user data daily_xp: %w", err) } if _, err := tx.Exec(`DELETE FROM user_rpg WHERE user_id=?`, userID); err != nil { - return err + return fmt.Errorf("delete user data rpg: %w", err) } if _, err := tx.Exec(`DELETE FROM user_settings WHERE user_id=?`, userID); err != nil { - return err + return fmt.Errorf("delete user data settings: %w", err) } if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil { - return err + return fmt.Errorf("delete user data users: %w", err) } - return tx.Commit() + if err := tx.Commit(); err != nil { + return fmt.Errorf("delete user data commit: %w", err) + } + return nil } func (s *Store) Close() error { @@ -98,7 +101,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) { "INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)", chatID, randomUsername(), ); err != nil { - return nil, err + return nil, fmt.Errorf("get or create user: %w", err) } return s.GetUserByChatID(chatID) } @@ -113,7 +116,7 @@ func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) { &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ); err != nil { - return nil, err + return nil, fmt.Errorf("get user by chat id: %w", err) } return &u, nil } @@ -123,7 +126,10 @@ func (s *Store) UpdateUser(user *domain.User) error { "UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?", user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID, ) - return err + if err != nil { + return fmt.Errorf("update user: %w", err) + } + return nil } func (s *Store) GetAllUsers() ([]domain.User, error) { diff --git a/internal/repo/store_test.go b/internal/repo/store_test.go new file mode 100644 index 0000000..2aec91a --- /dev/null +++ b/internal/repo/store_test.go @@ -0,0 +1,593 @@ +package repo + +import ( + "os" + "testing" + + "github.com/pressly/goose/v3" +) + +func TestMain(m *testing.M) { + goose.SetLogger(goose.NopLogger()) + os.Exit(m.Run()) +} + +func newTestStore(t *testing.T) *Store { + t.Helper() + s, err := NewStore("file::memory:?cache=shared") + if err != nil { + t.Fatalf("NewStore() failed: %v", err) + } + return s +} + +func TestGetOrCreateUser(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + u, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() failed: %v", err) + } + if u.ID == 0 { + t.Fatal("GetOrCreateUser() returned zero ID") + } + if u.ChatID != 12345 { + t.Errorf("ChatID = %d, want 12345", u.ChatID) + } + if u.Timezone == "" { + t.Errorf("Timezone is empty") + } + if u.Calendar != "gregorian" { + t.Errorf("Calendar = %q, want gregorian", u.Calendar) + } + + u2, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() second call failed: %v", err) + } + if u2.ID != u.ID { + t.Errorf("second call returned different ID: %d vs %d", u2.ID, u.ID) + } + + u3, err := s.GetOrCreateUser(67890) + if err != nil { + t.Fatalf("GetOrCreateUser() third call failed: %v", err) + } + if u3.ID == u.ID { + t.Error("different chat_id should produce different user ID") + } +} + +func TestGetOrCreateSettings(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + + st, err := s.GetOrCreateSettings(user.ID) + if err != nil { + t.Fatalf("GetOrCreateSettings() failed: %v", err) + } + if !st.ReportEnabled { + t.Error("ReportEnabled should default to true") + } + if st.ExportAccent != "ocean" { + t.Errorf("ExportAccent = %q, want ocean", st.ExportAccent) + } + + st.ExportAccent = "beach" + st.ReportEnabled = false + if err := s.UpdateSettings(st); err != nil { + t.Fatalf("UpdateSettings() failed: %v", err) + } + + st2, err := s.GetOrCreateSettings(user.ID) + if err != nil { + t.Fatalf("GetOrCreateSettings() after update failed: %v", err) + } + if st2.ExportAccent != "beach" { + t.Errorf("ExportAccent = %q, want beach", st2.ExportAccent) + } + if st2.ReportEnabled { + t.Error("ReportEnabled should be false after update") + } +} + +func TestGetOrCreateDay(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + + day, err := s.GetOrCreateDay(user.ID, "2026-06-25") + if err != nil { + t.Fatalf("GetOrCreateDay() failed: %v", err) + } + if day.UserID != user.ID { + t.Errorf("UserID = %d, want %d", day.UserID, user.ID) + } + if day.Date != "2026-06-25" { + t.Errorf("Date = %q, want 2026-06-25", day.Date) + } + if day.IsDayOff { + t.Error("IsDayOff should default to false") + } + if day.MinBreakThreshold != 300 { + t.Errorf("MinBreakThreshold = %d, want 300", day.MinBreakThreshold) + } + + day2, err := s.GetOrCreateDay(user.ID, "2026-06-25") + if err != nil { + t.Fatalf("GetOrCreateDay() second call failed: %v", err) + } + if day2.ID != day.ID { + t.Errorf("second call returned different day ID") + } + + got, err := s.GetDay(user.ID, "2026-06-25") + if err != nil { + t.Fatalf("GetDay() failed: %v", err) + } + if got.ID != day.ID { + t.Errorf("GetDay() returned different day ID") + } + + _, err = s.GetDay(user.ID, "2026-06-26") + if err == nil { + t.Error("GetDay() should fail for non-existent day") + } +} + +func TestDayOff(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + + if err := s.SetDayOff(user.ID, "2026-06-25", "sick"); err != nil { + t.Fatalf("SetDayOff() failed: %v", err) + } + + day, err := s.GetDay(user.ID, "2026-06-25") + if err != nil { + t.Fatalf("GetDay() after SetDayOff failed: %v", err) + } + if !day.IsDayOff { + t.Error("IsDayOff should be true after SetDayOff") + } + if day.DayOffReason != "sick" { + t.Errorf("DayOffReason = %q, want sick", day.DayOffReason) + } + + if err := s.RemoveDayOff(user.ID, "2026-06-25"); err != nil { + t.Fatalf("RemoveDayOff() failed: %v", err) + } + + day, _ = s.GetDay(user.ID, "2026-06-25") + if day.IsDayOff { + t.Error("IsDayOff should be false after RemoveDayOff") + } +} + +func TestCreateAndQueryEvents(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + day, _ := s.GetOrCreateDay(user.ID, "2026-06-25") + + wt := int64(2) + if err := s.CreateEvent(user.ID, day.ID, "in", &wt, 1000, "note1"); err != nil { + t.Fatalf("CreateEvent() in failed: %v", err) + } + if err := s.CreateEvent(user.ID, day.ID, "out", nil, 2000, ""); err != nil { + t.Fatalf("CreateEvent() out failed: %v", err) + } + + events, err := s.EventsForDayByDate(user.ID, "2026-06-25") + if err != nil { + t.Fatalf("EventsForDayByDate() failed: %v", err) + } + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + + if events[0].EventType != "in" || events[0].OccurredAt != 1000 { + t.Errorf("first event: type=%q, time=%d", events[0].EventType, events[0].OccurredAt) + } + if events[0].WorkTypeID == nil || *events[0].WorkTypeID != 2 { + t.Error("first event WorkTypeID should be 2") + } + if events[0].Note != "note1" { + t.Errorf("first event Note = %q, want note1", events[0].Note) + } + + if events[1].EventType != "out" || events[1].OccurredAt != 2000 { + t.Errorf("second event: type=%q, time=%d", events[1].EventType, events[1].OccurredAt) + } + if events[1].WorkTypeID != nil { + t.Error("out event WorkTypeID should be nil") + } + + last, err := s.GetLastEvent(user.ID) + if err != nil { + t.Fatalf("GetLastEvent() failed: %v", err) + } + if last.EventType != "out" || last.OccurredAt != 2000 { + t.Errorf("last event: type=%q, time=%d", last.EventType, last.OccurredAt) + } +} + +func TestCRUDEvent(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + day, _ := s.GetOrCreateDay(user.ID, "2026-06-25") + + wt := int64(1) + s.CreateEvent(user.ID, day.ID, "in", &wt, 100, "") + events, _ := s.EventsForDayByDate(user.ID, "2026-06-25") + eid := events[0].ID + + if err := s.UpdateEventTime(eid, 150); err != nil { + t.Fatalf("UpdateEventTime() failed: %v", err) + } + if err := s.UpdateEventWorkType(eid, 2); err != nil { + t.Fatalf("UpdateEventWorkType() failed: %v", err) + } + if err := s.SetEventNote(eid, "updated"); err != nil { + t.Fatalf("SetEventNote() failed: %v", err) + } + + e, err := s.GetEventByID(eid, user.ID) + if err != nil { + t.Fatalf("GetEventByID() failed: %v", err) + } + if e.OccurredAt != 150 { + t.Errorf("OccurredAt = %d, want 150", e.OccurredAt) + } + if e.WorkTypeID == nil || *e.WorkTypeID != 2 { + t.Error("WorkTypeID should be 2") + } + if e.Note != "updated" { + t.Errorf("Note = %q, want updated", e.Note) + } + + if err := s.DeleteEvent(eid); err != nil { + t.Fatalf("DeleteEvent() failed: %v", err) + } + events, _ = s.EventsForDayByDate(user.ID, "2026-06-25") + if len(events) != 0 { + t.Errorf("after delete: got %d events, want 0", len(events)) + } +} + +func TestRPGStats(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + + stats, err := s.GetOrCreateRPGStats(user.ID) + if err != nil { + t.Fatalf("GetOrCreateRPGStats() failed: %v", err) + } + if stats.Level != 1 { + t.Errorf("Level = %d, want 1", stats.Level) + } + if stats.XP != 0 { + t.Errorf("XP = %d, want 0", stats.XP) + } + + stats.XP = 5000 + stats.Level = 10 + stats.CurrentStreak = 5 + stats.LongestStreak = 10 + stats.TotalWorkSeconds = 36000 + if err := s.UpdateRPGStats(stats); err != nil { + t.Fatalf("UpdateRPGStats() failed: %v", err) + } + + stats2, _ := s.GetOrCreateRPGStats(user.ID) + if stats2.XP != 5000 || stats2.Level != 10 { + t.Errorf("after update: XP=%d Level=%d, want 5000 10", stats2.XP, stats2.Level) + } + if stats2.CurrentStreak != 5 || stats2.LongestStreak != 10 { + t.Errorf("after update: CurrentStreak=%d LongestStreak=%d, want 5 10", stats2.CurrentStreak, stats2.LongestStreak) + } +} + +func TestDailyXP(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + + if err := s.UpsertDailyXP(user.ID, "2026-06-25", 100, 3600); err != nil { + t.Fatalf("UpsertDailyXP() failed: %v", err) + } + if err := s.UpsertDailyXP(user.ID, "2026-06-26", 200, 7200); err != nil { + t.Fatalf("UpsertDailyXP() second call failed: %v", err) + } + if err := s.UpsertDailyXP(user.ID, "2026-06-25", 50, 1800); err != nil { + t.Fatalf("UpsertDailyXP() third call failed: %v", err) + } + + total, err := s.SumDailyWorkSeconds(user.ID) + if err != nil { + t.Fatalf("SumDailyWorkSeconds() failed: %v", err) + } + if total != 12600 { + t.Errorf("SumDailyWorkSeconds = %d, want 12600 (3600+7200+1800)", total) + } + + records, err := s.GetDailyXPInRange(user.ID, "2026-06-25", "2026-06-26") + if err != nil { + t.Fatalf("GetDailyXPInRange() failed: %v", err) + } + if len(records) != 2 { + t.Fatalf("got %d records, want 2", len(records)) + } + if records[0].Date != "2026-06-25" || records[0].WorkSeconds != 5400 { + t.Errorf("record[0]: date=%q work=%d, want 2026-06-25 5400", records[0].Date, records[0].WorkSeconds) + } + if records[1].Date != "2026-06-26" || records[1].WorkSeconds != 7200 { + t.Errorf("record[1]: date=%q work=%d, want 2026-06-26 7200", records[1].Date, records[1].WorkSeconds) + } + + s.SetDailyWorkSeconds(user.ID, "2026-06-25", 3000) + records, _ = s.GetDailyXPInRange(user.ID, "2026-06-25", "2026-06-25") + if len(records) != 1 || records[0].WorkSeconds != 3000 { + t.Errorf("after SetDailyWorkSeconds: work=%d, want 3000", records[0].WorkSeconds) + } +} + +func TestAchievements(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + + a, err := s.GetAchievementByCode("first_clockin") + if err != nil { + t.Fatalf("GetAchievementByCode() failed: %v", err) + } + if a.Code != "first_clockin" || a.Name == "" { + t.Errorf("achievement: code=%q name=%q", a.Code, a.Name) + } + + has, err := s.HasAchievement(user.ID, "first_clockin") + if err != nil { + t.Fatalf("HasAchievement() failed: %v", err) + } + if has { + t.Error("HasAchievement should be false before unlocking") + } + + ok, err := s.UnlockAchievement(user.ID, a.ID) + if err != nil { + t.Fatalf("UnlockAchievement() failed: %v", err) + } + if !ok { + t.Error("UnlockAchievement should return true on first unlock") + } + + ok, err = s.UnlockAchievement(user.ID, a.ID) + if err != nil { + t.Fatalf("UnlockAchievement() duplicate failed: %v", err) + } + if ok { + t.Error("UnlockAchievement should return false on duplicate") + } + + has, _ = s.HasAchievement(user.ID, "first_clockin") + if !has { + t.Error("HasAchievement should be true after unlocking") + } + + achs, err := s.GetUserAchievements(user.ID) + if err != nil { + t.Fatalf("GetUserAchievements() failed: %v", err) + } + if len(achs) != 1 { + t.Fatalf("got %d achievements, want 1", len(achs)) + } + if achs[0].Code != "first_clockin" { + t.Errorf("achievement code = %q, want first_clockin", achs[0].Code) + } +} + +func TestGetWorkTypes(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + wts, err := s.GetWorkTypes() + if err != nil { + t.Fatalf("GetWorkTypes() failed: %v", err) + } + if len(wts) != 2 { + t.Fatalf("got %d work types, want 2", len(wts)) + } + + wt, err := s.GetWorkType(1) + if err != nil { + t.Fatalf("GetWorkType(1) failed: %v", err) + } + if wt.Name != "onsite" { + t.Errorf("WorkType 1 = %q, want onsite", wt.Name) + } + + _, err = s.GetWorkType(999) + if err == nil { + t.Error("GetWorkType(999) should fail") + } +} + +func TestGetUserDaysInRange(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + s.GetOrCreateDay(user.ID, "2026-06-01") + s.GetOrCreateDay(user.ID, "2026-06-02") + s.GetOrCreateDay(user.ID, "2026-06-05") + + days, err := s.GetUserDaysInRange(user.ID, "2026-06-01", "2026-06-10") + if err != nil { + t.Fatalf("GetUserDaysInRange() failed: %v", err) + } + if len(days) != 3 { + t.Fatalf("got %d days, want 3", len(days)) + } + + days, _ = s.GetUserDaysInRange(user.ID, "2026-06-03", "2026-06-10") + if len(days) != 1 { + t.Fatalf("got %d days in narrow range, want 1", len(days)) + } +} + +func TestGetDatesWithEvents(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + d1, _ := s.GetOrCreateDay(user.ID, "2026-06-01") + d2, _ := s.GetOrCreateDay(user.ID, "2026-06-02") + s.GetOrCreateDay(user.ID, "2026-06-03") + + s.CreateEvent(user.ID, d1.ID, "in", nil, 100, "") + s.CreateEvent(user.ID, d2.ID, "in", nil, 200, "") + + dates, err := s.GetDatesWithEvents(user.ID) + if err != nil { + t.Fatalf("GetDatesWithEvents() failed: %v", err) + } + if len(dates) != 2 { + t.Fatalf("got %d dates, want 2", len(dates)) + } + + distinct, err := s.GetDistinctEventDates(user.ID, "2026-06-01", "2026-06-30") + if err != nil { + t.Fatalf("GetDistinctEventDates() failed: %v", err) + } + if len(distinct) != 2 { + t.Fatalf("got %d distinct dates, want 2", len(distinct)) + } +} + +func TestSumWorkSecondsByWorkType(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + day, _ := s.GetOrCreateDay(user.ID, "2026-06-25") + + wt2 := int64(2) + s.CreateEvent(user.ID, day.ID, "in", &wt2, 100, "") + s.CreateEvent(user.ID, day.ID, "out", nil, 3600, "") + s.SetDailyWorkSeconds(user.ID, "2026-06-25", 3500) + + rem, ons, err := s.SumWorkSecondsByWorkType(user.ID) + if err != nil { + t.Fatalf("SumWorkSecondsByWorkType() failed: %v", err) + } + if rem+ons == 0 { + t.Error("expected non-zero work seconds, got 0 for both types") + } +} + +func TestDeleteUserData(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + s.GetOrCreateSettings(user.ID) + s.GetOrCreateRPGStats(user.ID) + s.GetOrCreateDay(user.ID, "2026-06-25") + s.UpsertDailyXP(user.ID, "2026-06-25", 100, 3600) + + a, _ := s.GetAchievementByCode("first_clockin") + s.UnlockAchievement(user.ID, a.ID) + + if err := s.DeleteUserData(user.ID); err != nil { + t.Fatalf("DeleteUserData() failed: %v", err) + } + + user2, err := s.GetOrCreateUser(12345) + if err != nil { + t.Fatalf("GetOrCreateUser() after delete failed: %v", err) + } + if user2.ID <= user.ID { + t.Error("new user should get a fresh ID") + } + + total, _ := s.SumDailyWorkSeconds(user.ID) + if total != 0 { + t.Errorf("work seconds after delete = %d, want 0", total) + } +} + +func TestMarkReportSent(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + + if err := s.MarkReportSent(user.ID, "2026-06-25"); err != nil { + t.Fatalf("MarkReportSent() failed: %v", err) + } + + settings, _ := s.GetOrCreateSettings(user.ID) + if settings.LastReportDate != "2026-06-25" { + t.Errorf("LastReportDate = %q, want 2026-06-25", settings.LastReportDate) + } +} + +func TestGetAllUsers(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + s.GetOrCreateUser(12345) + s.GetOrCreateUser(67890) + + users, err := s.GetAllUsers() + if err != nil { + t.Fatalf("GetAllUsers() failed: %v", err) + } + if len(users) != 2 { + t.Fatalf("got %d users, want 2", len(users)) + } +} + +func TestUpdateUserAndDay(t *testing.T) { + s := newTestStore(t) + defer s.Close() + + user, _ := s.GetOrCreateUser(12345) + user.Timezone = "America/New_York" + user.Username = "testuser" + if err := s.UpdateUser(user); err != nil { + t.Fatalf("UpdateUser() failed: %v", err) + } + + user2, _ := s.GetOrCreateUser(12345) + if user2.Timezone != "America/New_York" { + t.Errorf("Timezone = %q, want America/New_York", user2.Timezone) + } + if user2.Username != "testuser" { + t.Errorf("Username = %q, want testuser", user2.Username) + } + + day, _ := s.GetOrCreateDay(user.ID, "2026-06-25") + day.MinBreakThreshold = 600 + if err := s.UpdateDay(day); err != nil { + t.Fatalf("UpdateDay() failed: %v", err) + } + + day2, _ := s.GetDay(user.ID, "2026-06-25") + if day2.MinBreakThreshold != 600 { + t.Errorf("MinBreakThreshold = %d, want 600", day2.MinBreakThreshold) + } +}