summaryrefslogtreecommitdiff
path: root/internal/integration
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-22 15:52:06 -0600
committerT <t@tjp.lol>2025-08-28 22:02:13 -0600
commitadd7c1a8126733dd86282f443dc53127888c06af (patch)
tree7141166363b333a4c65e64785fdf4f5a08350a8d /internal/integration
parent275cbc0b30121d3273f7fd428583e8c48ce7d017 (diff)
loads of testing
Diffstat (limited to 'internal/integration')
-rw-r--r--internal/integration/timezone_test.go503
1 files changed, 503 insertions, 0 deletions
diff --git a/internal/integration/timezone_test.go b/internal/integration/timezone_test.go
new file mode 100644
index 0000000..695fb04
--- /dev/null
+++ b/internal/integration/timezone_test.go
@@ -0,0 +1,503 @@
+package integration
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ "git.tjp.lol/punchcard/internal/database"
+ "git.tjp.lol/punchcard/internal/queries"
+ "git.tjp.lol/punchcard/internal/reports"
+ _ "modernc.org/sqlite"
+)
+
+// setupIntegrationDB creates a full database setup for integration testing
+func setupIntegrationDB(t *testing.T) (*queries.Queries, func()) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatalf("Failed to open in-memory sqlite db: %v", err)
+ }
+
+ if err := database.InitializeDB(db); err != nil {
+ t.Fatalf("Failed to initialize database: %v", err)
+ }
+
+ q := queries.New(db)
+
+ cleanup := func() {
+ if err := db.Close(); err != nil {
+ t.Logf("error closing database: %v", err)
+ }
+ }
+
+ return q, cleanup
+}
+
+func TestEndToEndTimezoneWorkflow(t *testing.T) {
+ // Integration test: complete workflow from punch in/out to report generation
+ // with timezone consistency verification
+
+ q, cleanup := setupIntegrationDB(t)
+ defer cleanup()
+
+ // Setup: Create client and contractor
+ client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "E2E Test Client",
+ Email: sql.NullString{String: "test@client.com", Valid: true},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create client: %v", err)
+ }
+
+ contractor, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{
+ Name: "E2E Contractor",
+ Label: "Software Development",
+ Email: "contractor@example.com",
+ })
+ if err != nil {
+ t.Fatalf("Failed to create contractor: %v", err)
+ }
+
+ // Step 1: Create time entries with specific times for predictable testing
+ // Use a known time range to make verification easier
+ baseTime := time.Date(2024, time.August, 22, 14, 0, 0, 0, time.UTC)
+
+ // Create time entries that span different timezone scenarios
+ entries := []struct {
+ start, end time.Time
+ desc string
+ }{
+ {baseTime, baseTime.Add(2 * time.Hour), "Morning work"},
+ {baseTime.Add(4 * time.Hour), baseTime.Add(6 * time.Hour), "Afternoon work"},
+ {baseTime.AddDate(0, 0, 1), baseTime.AddDate(0, 0, 1).Add(3 * time.Hour), "Next day work"},
+ }
+
+ var entryIDs []int64
+ for _, entry := range entries {
+ // Insert entries directly with specific times for predictable testing
+ result, err := q.DBTX().(*sql.DB).Exec(`
+ INSERT INTO time_entry (start_time, end_time, description, client_id)
+ VALUES (?, ?, ?, ?)
+ `, entry.start.Format("2006-01-02 15:04:05"), entry.end.Format("2006-01-02 15:04:05"), entry.desc, client.ID)
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+
+ entryID, err := result.LastInsertId()
+ if err != nil {
+ t.Fatalf("Failed to get entry ID: %v", err)
+ }
+
+ entryIDs = append(entryIDs, entryID)
+ }
+
+ // Step 2: Test report generation in different timezones
+ testTimezones := []*time.Location{
+ time.UTC,
+ time.Local,
+ }
+
+ // Try to load some interesting timezones for testing
+ extraTimezones := []string{
+ "America/New_York",
+ "America/Los_Angeles",
+ "Asia/Tokyo",
+ "Europe/London",
+ }
+
+ for _, tzName := range extraTimezones {
+ if tz, err := time.LoadLocation(tzName); err == nil {
+ testTimezones = append(testTimezones, tz)
+ } else {
+ t.Logf("Skipping timezone %s: %v", tzName, err)
+ }
+ }
+
+ for _, tz := range testTimezones {
+ t.Run("timezone_"+tz.String(), func(t *testing.T) {
+ // Test timesheet generation
+ dateRange := reports.DateRange{
+ Start: baseTime.AddDate(0, 0, -1), // Day before to capture all entries
+ End: baseTime.AddDate(0, 0, 2), // Day after to capture all entries
+ }
+
+ // Generate timesheet data
+ timesheetEntries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{
+ ClientID: client.ID,
+ StartTime: dateRange.Start,
+ EndTime: dateRange.End,
+ })
+ if err != nil {
+ t.Fatalf("Failed to get timesheet data: %v", err)
+ }
+
+ if len(timesheetEntries) == 0 {
+ t.Fatalf("No timesheet entries found - date filtering may be incorrect")
+ }
+
+ timesheetData, err := reports.GenerateTimesheetData(
+ timesheetEntries,
+ client.ID,
+ client.Name,
+ "",
+ contractor,
+ dateRange,
+ tz,
+ )
+ if err != nil {
+ t.Fatalf("Failed to generate timesheet data: %v", err)
+ }
+
+ // Verify timezone consistency
+ if len(timesheetData.Entries) != len(entries) {
+ t.Errorf("Expected %d entries, got %d in timezone %s", len(entries), len(timesheetData.Entries), tz)
+ }
+
+ // Verify that total hours calculation is consistent regardless of timezone
+ expectedTotalHours := 7.0 // 2 + 2 + 3 hours from our test data
+ if timesheetData.TotalHours != expectedTotalHours {
+ t.Errorf("Expected total hours %v, got %v in timezone %s", expectedTotalHours, timesheetData.TotalHours, tz)
+ }
+
+ // Verify timezone field is set correctly
+ expectedTimezoneStr := tz.String()
+ if expectedTimezoneStr == "Local" {
+ zone, _ := time.Now().Zone()
+ expectedTimezoneStr = zone
+ }
+
+ if timesheetData.Timezone != expectedTimezoneStr {
+ t.Errorf("Expected timezone %s, got %s", expectedTimezoneStr, timesheetData.Timezone)
+ }
+
+ // Verify individual entry times are converted to the target timezone
+ for i, entry := range timesheetData.Entries {
+ // Verify date is in the target timezone context
+ originalStart := entries[i].start
+ expectedDate := originalStart.In(tz).Format("2006-01-02")
+
+ if entry.Date != expectedDate {
+ t.Errorf("Entry %d: expected date %s, got %s (timezone %s)", i, expectedDate, entry.Date, tz)
+ }
+
+ t.Logf("Timezone %s: Entry %d on %s (%s-%s) = %v hours",
+ tz, i, entry.Date, entry.StartTime, entry.EndTime, entry.Hours)
+ }
+ })
+ }
+}
+
+func TestCrossTImezoneReportConsistency(t *testing.T) {
+ // Test that the same data produces consistent reports across different timezones
+ // (with appropriate timezone conversions for display)
+
+ q, cleanup := setupIntegrationDB(t)
+ defer cleanup()
+
+ // Setup test data
+ client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "Consistency Test Client",
+ })
+ if err != nil {
+ t.Fatalf("Failed to create client: %v", err)
+ }
+
+ contractor, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{
+ Name: "Test Contractor",
+ Label: "Development",
+ Email: "test@example.com",
+ })
+ if err != nil {
+ t.Fatalf("Failed to create contractor: %v", err)
+ }
+
+ // Create a time entry that spans multiple hours for clear testing
+ startTime := time.Date(2024, time.August, 22, 14, 30, 0, 0, time.UTC) // 2:30 PM UTC
+ endTime := startTime.Add(3*time.Hour + 30*time.Minute) // 6:00 PM UTC
+
+ _, err = q.DBTX().(*sql.DB).Exec(`
+ INSERT INTO time_entry (start_time, end_time, client_id, description)
+ VALUES (?, ?, ?, 'Cross-timezone test work')
+ `, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), client.ID)
+ if err != nil {
+ t.Fatalf("Failed to create test entry: %v", err)
+ }
+
+ // Test in multiple timezones
+ timezones := []*time.Location{time.UTC}
+ tzNames := []string{"America/New_York", "Asia/Tokyo", "Australia/Sydney"}
+
+ for _, tzName := range tzNames {
+ if tz, err := time.LoadLocation(tzName); err == nil {
+ timezones = append(timezones, tz)
+ }
+ }
+
+ dateRange := reports.DateRange{
+ Start: startTime.AddDate(0, 0, -1),
+ End: startTime.AddDate(0, 0, 1),
+ }
+
+ var timesheetDatas []*reports.TimesheetData
+ for _, tz := range timezones {
+ // Get entries for this timezone
+ entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{
+ ClientID: client.ID,
+ StartTime: dateRange.Start,
+ EndTime: dateRange.End,
+ })
+ if err != nil {
+ t.Fatalf("Failed to get entries for timezone %s: %v", tz, err)
+ }
+
+ // Generate timesheet data
+ data, err := reports.GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, dateRange, tz)
+ if err != nil {
+ t.Fatalf("Failed to generate timesheet data for timezone %s: %v", tz, err)
+ }
+
+ timesheetDatas = append(timesheetDatas, data)
+ }
+
+ // Verify consistency across timezones
+ if len(timesheetDatas) < 2 {
+ t.Skip("Need at least 2 timezones for consistency testing")
+ }
+
+ firstData := timesheetDatas[0]
+ for i, data := range timesheetDatas[1:] {
+ // Total hours should be the same regardless of timezone
+ if data.TotalHours != firstData.TotalHours {
+ t.Errorf("Timezone %d: total hours %v != baseline %v", i+1, data.TotalHours, firstData.TotalHours)
+ }
+
+ // Should have same number of entries
+ if len(data.Entries) != len(firstData.Entries) {
+ t.Errorf("Timezone %d: entry count %d != baseline %d", i+1, len(data.Entries), len(firstData.Entries))
+ }
+
+ // Each entry should have same duration (hours)
+ for j, entry := range data.Entries {
+ if j < len(firstData.Entries) {
+ if entry.Hours != firstData.Entries[j].Hours {
+ t.Errorf("Timezone %d, entry %d: hours %v != baseline %v", i+1, j, entry.Hours, firstData.Entries[j].Hours)
+ }
+ }
+ }
+
+ t.Logf("Timezone %s: %d entries, %.2f total hours", data.Timezone, len(data.Entries), data.TotalHours)
+ }
+}
+
+func TestTimezoneFilteringEdgeCases(t *testing.T) {
+ // Test edge cases where timezone differences could affect filtering
+ q, cleanup := setupIntegrationDB(t)
+ defer cleanup()
+
+ // Create test client
+ client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "Edge Case Client",
+ })
+ if err != nil {
+ t.Fatalf("Failed to create client: %v", err)
+ }
+
+ // Test scenario: entry that occurs on different calendar dates in different timezones
+ // e.g., 11 PM Pacific on July 31 = 6 AM UTC on August 1
+
+ // Create entry at midnight UTC (edge of day boundary)
+ edgeTime := time.Date(2024, time.August, 1, 0, 30, 0, 0, time.UTC) // 12:30 AM UTC on Aug 1
+
+ _, err = q.DBTX().(*sql.DB).Exec(`
+ INSERT INTO time_entry (start_time, end_time, client_id, description)
+ VALUES (?, ?, ?, 'Timezone edge case work')
+ `, edgeTime.Format("2006-01-02 15:04:05"), edgeTime.Add(time.Hour).Format("2006-01-02 15:04:05"), client.ID)
+ if err != nil {
+ t.Fatalf("Failed to create edge case entry: %v", err)
+ }
+
+ // Test filtering with different timezone contexts
+ testCases := []struct {
+ name string
+ timezone string
+ expectFind bool // Whether we expect to find the entry in July or August reports
+ monthYear string
+ }{
+ {"UTC August", "UTC", true, "august 2024"},
+ {"UTC July", "UTC", false, "july 2024"},
+ // Note: The actual filtering is done at database level using UTC dates,
+ // not timezone-aware dates. So the entry at UTC 2024-08-01 00:30:00
+ // will appear in August reports regardless of target timezone
+ {"Pacific July", "America/Los_Angeles", false, "july 2024"}, // UTC date is August, not July
+ {"Pacific August", "America/Los_Angeles", true, "august 2024"}, // UTC date is August
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ var tz *time.Location
+ if tc.timezone == "UTC" {
+ tz = time.UTC
+ } else {
+ var err error
+ tz, err = time.LoadLocation(tc.timezone)
+ if err != nil {
+ t.Skipf("Timezone %s not available: %v", tc.timezone, err)
+ }
+ }
+
+ // Parse the month range
+ dateRange, err := reports.ParseDateRange(tc.monthYear)
+ if err != nil {
+ t.Fatalf("Failed to parse date range %s: %v", tc.monthYear, err)
+ }
+
+ // Get entries for this month and timezone
+ entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{
+ ClientID: client.ID,
+ StartTime: dateRange.Start,
+ EndTime: dateRange.End,
+ })
+ if err != nil {
+ t.Fatalf("Failed to get entries: %v", err)
+ }
+
+ found := len(entries) > 0
+
+ if found != tc.expectFind {
+ t.Errorf("Expected to find entry: %v, actually found: %v (timezone: %s, month: %s)",
+ tc.expectFind, found, tc.timezone, tc.monthYear)
+
+ // Log the actual time in both timezones for debugging
+ utcTime := edgeTime
+ localTime := edgeTime.In(tz)
+ t.Logf("Entry time: UTC=%s, %s=%s",
+ utcTime.Format("2006-01-02 15:04:05"),
+ tc.timezone,
+ localTime.Format("2006-01-02 15:04:05"))
+ }
+ })
+ }
+}
+
+func TestReportGenerationTimezoneAccuracy(t *testing.T) {
+ // Verify that report generation maintains accuracy across timezone conversions
+ q, cleanup := setupIntegrationDB(t)
+ defer cleanup()
+
+ // Setup comprehensive test data
+ client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "Accuracy Test Client",
+ BillableRate: sql.NullInt64{Int64: 15000, Valid: true}, // $150.00/hour
+ })
+ if err != nil {
+ t.Fatalf("Failed to create client: %v", err)
+ }
+
+ contractor, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{
+ Name: "Accuracy Contractor",
+ Label: "Testing",
+ Email: "accuracy@test.com",
+ })
+ if err != nil {
+ t.Fatalf("Failed to create contractor: %v", err)
+ }
+
+ // Create entries with precise timing for accurate verification
+ preciseEntries := []struct {
+ start time.Time
+ duration time.Duration
+ desc string
+ }{
+ {time.Date(2024, time.August, 22, 9, 0, 0, 0, time.UTC), 2*time.Hour + 30*time.Minute, "Morning session"},
+ {time.Date(2024, time.August, 22, 13, 15, 0, 0, time.UTC), 1*time.Hour + 45*time.Minute, "Afternoon session"},
+ {time.Date(2024, time.August, 23, 10, 30, 0, 0, time.UTC), 3 * time.Hour, "Next day session"},
+ }
+
+ var totalExpectedSeconds int64
+ for _, entry := range preciseEntries {
+ endTime := entry.start.Add(entry.duration)
+ totalExpectedSeconds += int64(entry.duration.Seconds())
+
+ _, err = q.DBTX().(*sql.DB).Exec(`
+ INSERT INTO time_entry (start_time, end_time, client_id, description)
+ VALUES (?, ?, ?, ?)
+ `, entry.start.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), client.ID, entry.desc)
+ if err != nil {
+ t.Fatalf("Failed to create precision entry: %v", err)
+ }
+ }
+
+ expectedTotalHours := float64(totalExpectedSeconds) / 3600.0
+
+ // Test accuracy across different timezones
+ testTimezones := []string{"UTC", "America/New_York", "Asia/Tokyo"}
+
+ for _, tzName := range testTimezones {
+ t.Run("accuracy_"+tzName, func(t *testing.T) {
+ var tz *time.Location
+ if tzName == "UTC" {
+ tz = time.UTC
+ } else {
+ var err error
+ tz, err = time.LoadLocation(tzName)
+ if err != nil {
+ t.Skipf("Timezone %s not available: %v", tzName, err)
+ }
+ }
+
+ // Generate timesheet
+ dateRange := reports.DateRange{
+ Start: time.Date(2024, time.August, 22, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2024, time.August, 23, 23, 59, 59, 999999999, time.UTC),
+ }
+
+ entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{
+ ClientID: client.ID,
+ StartTime: dateRange.Start,
+ EndTime: dateRange.End,
+ })
+ if err != nil {
+ t.Fatalf("Failed to get entries: %v", err)
+ }
+
+ timesheetData, err := reports.GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, dateRange, tz)
+ if err != nil {
+ t.Fatalf("Failed to generate timesheet: %v", err)
+ }
+
+ // Verify total hours accuracy (within small tolerance for rounding)
+ tolerance := 0.001
+ if abs(timesheetData.TotalHours-expectedTotalHours) > tolerance {
+ t.Errorf("Total hours accuracy error in timezone %s: expected %.6f, got %.6f (diff: %.6f)",
+ tzName, expectedTotalHours, timesheetData.TotalHours, abs(timesheetData.TotalHours-expectedTotalHours))
+ }
+
+ // Verify individual entry accuracy
+ if len(timesheetData.Entries) != len(preciseEntries) {
+ t.Errorf("Entry count mismatch in timezone %s: expected %d, got %d", tzName, len(preciseEntries), len(timesheetData.Entries))
+ }
+
+ for i, entry := range timesheetData.Entries {
+ if i < len(preciseEntries) {
+ expectedHours := preciseEntries[i].duration.Hours()
+ if abs(entry.Hours-expectedHours) > tolerance {
+ t.Errorf("Entry %d hours accuracy error in timezone %s: expected %.6f, got %.6f",
+ i, tzName, expectedHours, entry.Hours)
+ }
+ }
+ }
+
+ t.Logf("Timezone %s: %.6f hours total (expected %.6f)", tzName, timesheetData.TotalHours, expectedTotalHours)
+ })
+ }
+}
+
+// Helper function for floating point comparison
+func abs(x float64) float64 {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
+