diff --git a/api.go b/api.go index 9a9320a..38aca86 100644 --- a/api.go +++ b/api.go @@ -49,6 +49,7 @@ func registerBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cache[[] log.Println("Making booking") if err := makeBooking(taskCtx, auth, locationName, dateString, cacheManager); err != nil { + log.Printf("Booking failed for date %q at %q: %v", dateString, locationName, err) if errors.Is(err, ErrDateInOlderThanOneMonthFuture) { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -121,6 +122,7 @@ func registerBatchBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cac bearerToken, weworkLocation, err := prepareBooking(taskCtx, auth, payload.Wework, cacheManager) if err != nil { + log.Printf("Preparing batch booking failed at %q: %v", payload.Wework, err) if errors.Is(err, ErrWeWorkLocationNotFound) { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -284,6 +286,7 @@ func runBatchBookings(ctx context.Context, token string, location WeWorkLocation if err := makeBookingRequestFunc(ctx, token, parsedDates[i], location); err != nil { results[i].Status = "error" results[i].Error = err.Error() + log.Printf("Batch booking failed for date %q at %q: %v", dates[i], location.Location.Name, err) } }(i) } diff --git a/weworkrequests.go b/weworkrequests.go index e0fdd64..d194b73 100644 --- a/weworkrequests.go +++ b/weworkrequests.go @@ -23,136 +23,36 @@ var ErrWeWorkLocationNotFound = errors.New("wework location not found") type WeWorkLocation struct { Reservable struct { - Capacity int `json:"capacity"` - KubeID string `json:"KubeId"` - CwmSpaceID int `json:"cwmSpaceId"` - CwmSpaceCount int `json:"cwmSpaceCount"` + KubeID string `json:"KubeId"` } `json:"reservable"` - UUID string `json:"uuid"` - InventoryUUID string `json:"inventoryUuid"` - ImageURL string `json:"imageUrl"` - HeaderImageURL string `json:"headerImageUrl"` - Capacity int `json:"capacity"` - Credits int `json:"credits"` - Location struct { - Description string `json:"description"` - SupportEmail string `json:"supportEmail"` - PhoneNormalized string `json:"phoneNormalized"` - Currency string `json:"currency"` - PrimaryTeamMember struct { - Name string `json:"name"` - BusinessTitle string `json:"businessTitle"` - ImageURL string `json:"imageUrl"` - } `json:"primaryTeamMember"` - Amenities []struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Highlight bool `json:"highlight"` - } `json:"amenities"` - Details struct { - HasExtendedHours bool `json:"hasExtendedHours"` - } `json:"details"` - TransitInfo struct { - Bike string `json:"bike"` - Bus string `json:"bus"` - Ferry string `json:"ferry"` - Freeway string `json:"freeway"` - Metro string `json:"metro"` - Parking string `json:"parking"` - } `json:"transitInfo"` - MemberEntranceInstructions string `json:"memberEntranceInstructions"` - ParkingInstructions string `json:"parkingInstructions"` - CommunityBarFloor struct { - Name string `json:"name"` - } `json:"communityBarFloor"` + UUID string `json:"uuid"` + Location struct { TimezoneOffset string `json:"timezoneOffset"` TimeZoneIdentifier string `json:"timeZoneIdentifier"` TimeZoneWinID string `json:"timeZoneWinId"` - Images []struct { - UUID string `json:"uuid"` - Caption string `json:"caption"` - Category string `json:"category"` - URL string `json:"url"` - } `json:"images"` - UUID string `json:"uuid"` - Name string `json:"name"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Address struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Address struct { Line1 string `json:"line1"` - Line2 string `json:"line2"` City string `json:"city"` State string `json:"state"` Country string `json:"country"` - Zip string `json:"zip"` } `json:"address"` - TimeZone string `json:"timeZone"` - Distance float32 `json:"distance"` - HasThirdPartyDisplay bool `json:"hasThirdPartyDisplay"` - IsMigrated bool `json:"isMigrated"` - SpaceAvailabilityCount int `json:"spaceAvailabilityCount"` - Franchise string `json:"franchise"` - AccountType int `json:"accountType"` - AffiliateSpaceType int `json:"affiliateSpaceType"` } `json:"location"` - OpenTime string `json:"openTime"` - CloseTime string `json:"closeTime"` - CancellationPolicy string `json:"cancellationPolicy"` - OperatingHours []struct { - DayOfWeek int `json:"dayOfWeek"` - Day string `json:"day"` - Open string `json:"open"` - Close string `json:"close"` - IsClosed bool `json:"isClosed"` - } `json:"operatingHours"` - ProductPrice struct { - UUID string `json:"uuid"` - ProductUUID string `json:"productUuid"` - Price struct { - Currency string `json:"currency"` - Amount float32 `json:"amount"` - } `json:"price"` - RateUnit int `json:"rateUnit"` - HalfHourCreditPrices []struct { - Offset int `json:"offset"` - Amount float64 `json:"amount"` - } `json:"halfHourCreditPrices"` - } `json:"productPrice"` - Seat struct { - Total int `json:"total"` - Available int `json:"available"` - } `json:"seat"` - SeatsAvailable int `json:"seatsAvailable"` - Order int `json:"order"` - IsHybridSpace bool `json:"isHybridSpace"` - AffiliateSpaceType int `json:"affiliateSpaceType"` - SpaceTypeID int `json:"SpaceTypeID"` + OpenTime string `json:"openTime"` + CloseTime string `json:"closeTime"` } type WeWorkLocationsResponse struct { - Limit int `json:"limit"` - Offset int `json:"offset"` GetSharedWorkspaces struct { Workspaces []WeWorkLocation `json:"workspaces"` } `json:"getSharedWorkspaces"` } type WeWorkProperty struct { - ID string `json:"id"` - Title string `json:"title"` - Address string `json:"address"` - City string `json:"city"` - Country string `json:"country"` - CoworkingOperatorName string `json:"coworkingOperatorName"` - PropertyTimezoneIANA string `json:"propertyTimezoneIana"` - PropertyTimezoneWin string `json:"propertyTimezoneWin"` - PropertyTimezoneOffset string `json:"propertyTimezoneOffset"` - Latitude float64 `json:"-"` - Longitude float64 `json:"-"` - Position struct { - Latitude float64 `json:"lat"` - Longitude float64 `json:"lng"` - } `json:"position"` + ID string `json:"id"` + Title string `json:"title"` + Address string `json:"address"` } func FetchWeWorkLocation(ctx context.Context, token string, locationID string) (WeWorkLocation, error) { @@ -164,11 +64,11 @@ func FetchWeWorkLocation(ctx context.Context, token string, locationID string) ( Get(fmt.Sprintf("https://members.wework.com/workplaceone/api/spaces/get-spaces?locationUUIDs=%s", locationID)) if err != nil { - return WeWorkLocation{}, err + return WeWorkLocation{}, weWorkRequestError("fetching locations", response, err) } if response.IsError() { - return WeWorkLocation{}, fmt.Errorf("error fetching locations: %s", response.Status()) + return WeWorkLocation{}, weWorkRequestError("fetching locations", response, nil) } if len(locationsResponse.GetSharedWorkspaces.Workspaces) == 0 { @@ -201,11 +101,11 @@ func fetchWeWorkProperties(ctx context.Context, token string) ([]WeWorkProperty, response, err := request.SetResult(&properties).Get("https://members.wework.com/workplaceone/api/Workspace/get-property-list-google-map?offloadToServer=true&isPropSvcCl=false") if err != nil { - return nil, err + return nil, weWorkRequestError("fetching properties", response, err) } if response.IsError() { - return nil, fmt.Errorf("error fetching properties: %s", response.Status()) + return nil, weWorkRequestError("fetching properties", response, nil) } return properties, nil @@ -337,10 +237,10 @@ type MailData struct { } type BookingResponse struct { - BookingStatus string `json:"BookingStatus"` - Errors []string `json:"Errors"` - ReservationID string `json:"ReservationID"` - WeworkUUID string `json:"WeWorkUUID"` + BookingStatus string `json:"BookingStatus"` + Errors json.RawMessage `json:"Errors"` + ReservationID string `json:"ReservationID"` + WeworkUUID string `json:"WeWorkUUID"` } func FetchNextBookings(ctx context.Context, token string, startDate string, endDate string) (json.RawMessage, error) { @@ -497,16 +397,38 @@ func makeBookingRequest(ctx context.Context, token string, date time.Time, space Post("https://members.wework.com/workplaceone/api/common-booking/") if err != nil { - return err + return weWorkRequestError("making booking", response, err) } if response.IsError() { - return fmt.Errorf("error making booking request: %s", response.Status()) + return weWorkRequestError("making booking", response, nil) } if bookingResponse.BookingStatus != "BookingSuccess" { - return fmt.Errorf("booking not confirmed: %v", bookingResponse.Errors) + return fmt.Errorf("booking not confirmed: status=%q errors=%s response=%q", bookingResponse.BookingStatus, bookingResponse.Errors, truncateForLog(response.String(), 2000)) } return nil } + +func weWorkRequestError(operation string, response *resty.Response, requestErr error) error { + if response == nil { + return fmt.Errorf("%s: %w", operation, requestErr) + } + + details := truncateForLog(response.String(), 2000) + if requestErr != nil { + return fmt.Errorf("%s: %w (status=%s response=%q)", operation, requestErr, response.Status(), details) + } + + return fmt.Errorf("%s: status=%s response=%q", operation, response.Status(), details) +} + +func truncateForLog(value string, limit int) string { + value = strings.TrimSpace(value) + if len(value) <= limit { + return value + } + + return value[:limit] + "..." +} diff --git a/weworkrequests_test.go b/weworkrequests_test.go index 1957268..95e672c 100644 --- a/weworkrequests_test.go +++ b/weworkrequests_test.go @@ -2,8 +2,6 @@ package main import ( "encoding/json" - "net/http" - "net/http/httptest" "testing" "time" ) @@ -205,7 +203,7 @@ func TestFindWeWorkPropertyByNameRejectsAmbiguousPartialMatches(t *testing.T) { func TestWeWorkPropertyAllowsStringCoworkingPropertyID(t *testing.T) { var property WeWorkProperty - if err := json.Unmarshal([]byte(`{"id":"location-id","title":"33 Rue la Fayette","coworkingPropertyId":"456"}`), &property); err != nil { + if err := json.Unmarshal([]byte(`{"id":"location-id","title":"33 Rue la Fayette","coworkingPropertyId":"456","position":{"lat":"48.87","lng":"2.33"}}`), &property); err != nil { t.Fatalf("Unexpected error decoding property: %v", err) } @@ -219,162 +217,29 @@ func newTestWeWorkProperty(title string, address string) WeWorkProperty { } func TestMakeBookingRequestUsesLocationTimezone(t *testing.T) { - // Test that the booking request uses the location's timezone offset - // instead of a hardcoded value, which is important for DST transitions - - tests := []struct { - name string - timezoneOffset string - expectedTimezone string - }{ - { - name: "Summer time (DST active)", - timezoneOffset: "GMT +02:00", - expectedTimezone: "GMT +02:00", - }, - { - name: "Winter time (DST inactive)", - timezoneOffset: "GMT +01:00", - expectedTimezone: "GMT +01:00", - }, - { - name: "Different timezone", - timezoneOffset: "GMT -05:00", - expectedTimezone: "GMT -05:00", - }, + var location WeWorkLocation + response := `{"uuid":"space-id","capacity":"schema-change","reservable":{"KubeId":"kube-id","capacity":"1"},"location":{"uuid":"location-id","name":"Test Location","timezoneOffset":"GMT +02:00","latitude":"48.87","address":{"line1":"Test Street","city":"Paris","country":"France"}},"openTime":"08:00","closeTime":"18:00"}` + if err := json.Unmarshal([]byte(response), &location); err != nil { + t.Fatalf("Unexpected error decoding location: %v", err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a mock HTTP server to capture the booking request - var capturedRequest BookingRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Decode the request body - if err := json.NewDecoder(r.Body).Decode(&capturedRequest); err != nil { - t.Fatalf("Failed to decode request: %v", err) - } - - // Return a success response - response := BookingResponse{ - BookingStatus: "BookingSuccess", - ReservationID: "test-reservation-id", - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Create a test WeWorkLocation with the specified timezone offset - space := WeWorkLocation{ - UUID: "test-space-uuid", - Reservable: struct { - Capacity int `json:"capacity"` - KubeID string `json:"KubeId"` - CwmSpaceID int `json:"cwmSpaceId"` - CwmSpaceCount int `json:"cwmSpaceCount"` - }{ - KubeID: "test-kube-id", - }, - Location: struct { - Description string `json:"description"` - SupportEmail string `json:"supportEmail"` - PhoneNormalized string `json:"phoneNormalized"` - Currency string `json:"currency"` - PrimaryTeamMember struct { - Name string `json:"name"` - BusinessTitle string `json:"businessTitle"` - ImageURL string `json:"imageUrl"` - } `json:"primaryTeamMember"` - Amenities []struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Highlight bool `json:"highlight"` - } `json:"amenities"` - Details struct { - HasExtendedHours bool `json:"hasExtendedHours"` - } `json:"details"` - TransitInfo struct { - Bike string `json:"bike"` - Bus string `json:"bus"` - Ferry string `json:"ferry"` - Freeway string `json:"freeway"` - Metro string `json:"metro"` - Parking string `json:"parking"` - } `json:"transitInfo"` - MemberEntranceInstructions string `json:"memberEntranceInstructions"` - ParkingInstructions string `json:"parkingInstructions"` - CommunityBarFloor struct { - Name string `json:"name"` - } `json:"communityBarFloor"` - TimezoneOffset string `json:"timezoneOffset"` - TimeZoneIdentifier string `json:"timeZoneIdentifier"` - TimeZoneWinID string `json:"timeZoneWinId"` - Images []struct { - UUID string `json:"uuid"` - Caption string `json:"caption"` - Category string `json:"category"` - URL string `json:"url"` - } `json:"images"` - UUID string `json:"uuid"` - Name string `json:"name"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Address struct { - Line1 string `json:"line1"` - Line2 string `json:"line2"` - City string `json:"city"` - State string `json:"state"` - Country string `json:"country"` - Zip string `json:"zip"` - } `json:"address"` - TimeZone string `json:"timeZone"` - Distance float32 `json:"distance"` - HasThirdPartyDisplay bool `json:"hasThirdPartyDisplay"` - IsMigrated bool `json:"isMigrated"` - SpaceAvailabilityCount int `json:"spaceAvailabilityCount"` - Franchise string `json:"franchise"` - AccountType int `json:"accountType"` - AffiliateSpaceType int `json:"affiliateSpaceType"` - }{ - UUID: "test-location-uuid", - Name: "Test Location", - TimezoneOffset: tt.timezoneOffset, - TimeZoneIdentifier: "Europe/Brussels", - TimeZoneWinID: "Romance Standard Time", - Address: struct { - Line1 string `json:"line1"` - Line2 string `json:"line2"` - City string `json:"city"` - State string `json:"state"` - Country string `json:"country"` - Zip string `json:"zip"` - }{ - Line1: "Test Street 1", - City: "Test City", - Country: "Test Country", - State: "Test State", - }, - }, - OpenTime: "08:00", - CloseTime: "18:00", - } - - // Note: We can't easily mock the HTTP client in makeBookingRequest - // without refactoring the function, so this test is primarily - // demonstrating the structure. In a real scenario, we'd need to - // refactor makeBookingRequest to accept a custom HTTP client or URL. - // For now, we'll just verify the space object has the correct timezone. + if location.Location.TimezoneOffset != "GMT +02:00" { + t.Fatalf("Unexpected timezone offset: %q", location.Location.TimezoneOffset) + } + if location.Reservable.KubeID != "kube-id" { + t.Fatalf("Unexpected Kube ID: %q", location.Reservable.KubeID) + } +} - // Verify that the space has the expected timezone - if space.Location.TimezoneOffset != tt.expectedTimezone { - t.Errorf("Expected timezone offset %s, got %s", tt.expectedTimezone, space.Location.TimezoneOffset) - } +func TestBookingResponseAllowsStructuredErrors(t *testing.T) { + var response BookingResponse + if err := json.Unmarshal([]byte(`{"BookingStatus":"BookingFailed","Errors":[{"code":"NO_CREDITS","message":"No credits remaining"}]}`), &response); err != nil { + t.Fatalf("Unexpected error decoding booking response: %v", err) + } +} - // Note: This test validates the structure. The actual fix ensures - // that TimezoneUsed uses space.Location.TimezoneOffset instead of - // a hardcoded "GMT +02:00" value, which will automatically handle - // DST transitions when the API provides updated timezone offsets. - t.Logf("Test passed for %s: timezone offset correctly set to %s", tt.name, space.Location.TimezoneOffset) - }) +func TestTruncateForLog(t *testing.T) { + if got := truncateForLog(" 123456 ", 4); got != "1234..." { + t.Fatalf("Unexpected truncated value: %q", got) } }