package catalog import ( "context" "errors" "fmt" "testing" "time" "bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" ) func boolPtr(v bool) *bool { return &v } var owner = domain.Principal{Subject: "demo-owner", Email: "demo@bookra.dev", Role: "owner"} type stubBilling struct { entitlements domain.PlanEntitlements } func (s stubBilling) GetEntitlements(_ context.Context, _ string) (domain.PlanEntitlements, error) { return s.entitlements, nil } func newCatalogService(repo db.Repository) *Service { return NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: -1}}, nil) } func TestListServicesReturnsSeededService(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) services, err := svc.ListServices(context.Background(), owner) if err != nil { t.Fatalf("list services: %v", err) } if len(services) != 1 { t.Fatalf("expected 1 seeded service, got %d", len(services)) } if services[0].Name != "Signature treatment" { t.Fatalf("unexpected service name %q", services[0].Name) } } func TestServiceCRUDLifecycle(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() created, err := svc.CreateService(ctx, owner, domain.CreateServiceRequest{ Name: " Haircut ", DurationMinutes: 45, BufferBeforeMinutes: 5, BufferAfterMinutes: 10, PriceCents: 90000, }) if err != nil { t.Fatalf("create service: %v", err) } if created.Name != "Haircut" { t.Fatalf("expected trimmed name, got %q", created.Name) } if created.TenantID == "" { t.Fatal("expected tenant id on created service") } services, err := svc.ListServices(ctx, owner) if err != nil { t.Fatalf("list services: %v", err) } if len(services) != 2 { t.Fatalf("expected 2 services, got %d", len(services)) } zero := 0 if _, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{DurationMinutes: &zero}); !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput for zero duration, got %v", err) } name := "Deluxe haircut" price := 110000 updated, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{Name: &name, PriceCents: &price}) if err != nil { t.Fatalf("update service: %v", err) } if updated.Name != "Deluxe haircut" || updated.PriceCents != 110000 { t.Fatalf("unexpected update result %+v", updated) } if updated.DurationMinutes != 45 || updated.BufferAfterMinutes != 10 { t.Fatalf("partial update clobbered untouched fields %+v", updated) } if err := svc.DeleteService(ctx, owner, created.ID); err != nil { t.Fatalf("delete service: %v", err) } services, err = svc.ListServices(ctx, owner) if err != nil { t.Fatalf("list services: %v", err) } if len(services) != 1 { t.Fatalf("expected 1 service after delete, got %d", len(services)) } } func TestServiceValidation(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() cases := []struct { name string req domain.CreateServiceRequest }{ {"empty name", domain.CreateServiceRequest{Name: " ", DurationMinutes: 30}}, {"zero duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 0}}, {"huge duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 2000}}, {"negative buffer", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, BufferBeforeMinutes: -1}}, {"negative price", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, PriceCents: -5}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, err := svc.CreateService(ctx, owner, tc.req); !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput, got %v", err) } }) } } func TestServiceNotFoundAndMembership(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() missingID := "00000000-0000-0000-0000-000000000001" if _, err := svc.UpdateService(ctx, owner, missingID, domain.UpdateServiceRequest{}); !errors.Is(err, ErrServiceNotFound) { t.Fatalf("expected ErrServiceNotFound, got %v", err) } if err := svc.DeleteService(ctx, owner, missingID); !errors.Is(err, ErrServiceNotFound) { t.Fatalf("expected ErrServiceNotFound, got %v", err) } stranger := domain.Principal{Subject: "someone-else", Role: "owner"} if _, err := svc.ListServices(ctx, stranger); !errors.Is(err, ErrTenantMembership) { t.Fatalf("expected ErrTenantMembership, got %v", err) } if _, err := svc.CreateService(ctx, stranger, domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30}); !errors.Is(err, ErrTenantMembership) { t.Fatalf("expected ErrTenantMembership, got %v", err) } } func TestStaffCRUDLifecycle(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() locations, err := svc.ListLocations(ctx, owner) if err != nil || len(locations) == 0 { t.Fatalf("expected seeded location, got %v err=%v", locations, err) } locationID := locations[0].ID created, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{ DisplayName: " Anna Novak ", LocationID: &locationID, }) if err != nil { t.Fatalf("create staff: %v", err) } if created.DisplayName != "Anna Novak" { t.Fatalf("expected trimmed name, got %q", created.DisplayName) } if !created.Active { t.Fatal("new staff member should default to active") } if created.LocationID == nil || *created.LocationID != locationID { t.Fatalf("expected location assignment %s, got %+v", locationID, created.LocationID) } staff, err := svc.ListStaff(ctx, owner) if err != nil { t.Fatalf("list staff: %v", err) } if len(staff) != 2 { t.Fatalf("expected seeded member + new member, got %d", len(staff)) } inactive := false cleared := "" updated, err := svc.UpdateStaff(ctx, owner, created.ID, domain.UpdateStaffRequest{ Active: &inactive, LocationID: &cleared, }) if err != nil { t.Fatalf("update staff: %v", err) } if updated.Active { t.Fatal("expected staff member to be deactivated") } if updated.LocationID != nil { t.Fatalf("expected cleared location, got %+v", updated.LocationID) } if updated.DisplayName != "Anna Novak" { t.Fatalf("name should be untouched, got %q", updated.DisplayName) } if err := svc.DeleteStaff(ctx, owner, created.ID); err != nil { t.Fatalf("delete staff: %v", err) } staff, err = svc.ListStaff(ctx, owner) if err != nil { t.Fatalf("list staff: %v", err) } if len(staff) != 1 { t.Fatalf("expected 1 staff member after delete, got %d", len(staff)) } } func TestCreateStaffEnforcesPlanLimit(t *testing.T) { repo := db.NewMemoryRepository() // Demo tenant seeds one member; a limit of 1 must reject a second. svc := NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: 1}}, nil) _, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{DisplayName: "Second"}) if !errors.Is(err, ErrPlanLimitReached) { t.Fatalf("expected ErrPlanLimitReached, got %v", err) } } func TestCreateStaffRejectsForeignLocation(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) foreign := "00000000-0000-0000-0000-000000000099" _, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{ DisplayName: "Anna", LocationID: &foreign, }) if !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput, got %v", err) } } func TestStaffNotFoundAndValidation(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() if _, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{DisplayName: " "}); !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput for blank name, got %v", err) } missingID := "00000000-0000-0000-0000-000000000001" if _, err := svc.UpdateStaff(ctx, owner, missingID, domain.UpdateStaffRequest{}); !errors.Is(err, ErrStaffNotFound) { t.Fatalf("expected ErrStaffNotFound, got %v", err) } if err := svc.DeleteStaff(ctx, owner, missingID); !errors.Is(err, ErrStaffNotFound) { t.Fatalf("expected ErrStaffNotFound, got %v", err) } stranger := domain.Principal{Subject: "someone-else", Role: "staff"} if _, err := svc.ListStaff(ctx, stranger); !errors.Is(err, ErrTenantMembership) { t.Fatalf("expected ErrTenantMembership, got %v", err) } } const demoStaffID = "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" func TestListStaffWorkingHours(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) hours, err := svc.ListStaffWorkingHours(context.Background(), owner, demoStaffID) if err != nil { t.Fatalf("list staff working hours: %v", err) } if len(hours) != 5 { t.Fatalf("expected 5 seeded weekday rules, got %d", len(hours)) } for _, wh := range hours { if wh.Open != "09:00" || wh.Close != "17:00" { t.Fatalf("day %d: expected 09:00-17:00, got %s-%s", wh.DayOfWeek, wh.Open, wh.Close) } } } func TestUpdateStaffWorkingHours(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() err := svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{ IsOpen: boolPtr(true), Open: "08:00", Close: "12:00", }) if err != nil { t.Fatalf("update staff hours: %v", err) } hours, err := svc.ListStaffWorkingHours(ctx, owner, demoStaffID) if err != nil { t.Fatalf("list staff hours: %v", err) } found := false for _, wh := range hours { if wh.DayOfWeek == 1 { found = true if wh.Open != "08:00" || wh.Close != "12:00" { t.Fatalf("expected Monday 08:00-12:00, got %s-%s", wh.Open, wh.Close) } } } if !found { t.Fatal("Monday rule missing after update") } // Closing a day removes the rule. err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 2, domain.UpdateWorkingHoursRequest{ IsOpen: boolPtr(false), }) if err != nil { t.Fatalf("close staff day: %v", err) } hours, _ = svc.ListStaffWorkingHours(ctx, owner, demoStaffID) for _, wh := range hours { if wh.DayOfWeek == 2 { t.Fatal("Tuesday rule should have been removed") } } } func TestStaffWorkingHoursRejectsForeignStaff(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() missing := "00000000-0000-0000-0000-000000000099" if _, err := svc.ListStaffWorkingHours(ctx, owner, missing); !errors.Is(err, ErrStaffNotFound) { t.Fatalf("expected ErrStaffNotFound, got %v", err) } err := svc.UpdateStaffWorkingHours(ctx, owner, missing, 1, domain.UpdateWorkingHoursRequest{ IsOpen: boolPtr(true), Open: "09:00", Close: "17:00", }) if !errors.Is(err, ErrStaffNotFound) { t.Fatalf("expected ErrStaffNotFound, got %v", err) } err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{ IsOpen: boolPtr(true), Open: "9am", Close: "17:00", }) if !errors.Is(err, ErrInvalidBooking) { t.Fatalf("expected ErrInvalidBooking for bad time, got %v", err) } } func TestClassTemplateCRUD(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() templates, err := svc.ListClassTemplates(ctx, owner) if err != nil { t.Fatalf("list templates: %v", err) } if len(templates) != 1 || templates[0].Title != "Small group mobility class" { t.Fatalf("expected seeded template, got %+v", templates) } created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{ Title: " Yoga Basics ", DurationMinutes: 45, Capacity: 8, }) if err != nil { t.Fatalf("create template: %v", err) } if created.Title != "Yoga Basics" { t.Fatalf("expected trimmed title, got %q", created.Title) } if err := svc.DeleteClassTemplate(ctx, owner, created.ID); err != nil { t.Fatalf("delete template: %v", err) } templates, _ = svc.ListClassTemplates(ctx, owner) if len(templates) != 1 { t.Fatalf("expected 1 template after delete, got %d", len(templates)) } } func TestClassTemplateValidation(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() cases := []domain.UpsertClassTemplateRequest{ {Title: " ", DurationMinutes: 60, Capacity: 4}, {Title: "X", DurationMinutes: 0, Capacity: 4}, {Title: "X", DurationMinutes: 60, Capacity: 0}, } for i, req := range cases { if _, err := svc.CreateClassTemplate(ctx, owner, req); !errors.Is(err, ErrInvalidInput) { t.Fatalf("case %d: expected ErrInvalidInput, got %v", i, err) } } missing := "00000000-0000-0000-0000-000000000099" if err := svc.DeleteClassTemplate(ctx, owner, missing); !errors.Is(err, ErrClassTemplateNotFound) { t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) } } func TestDeleteClassTemplateCascadesSessions(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() if err := svc.DeleteClassTemplate(ctx, owner, "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"); err != nil { t.Fatalf("delete template: %v", err) } sessions, err := svc.ListClassSessions(ctx, owner) if err != nil { t.Fatalf("list sessions: %v", err) } if len(sessions) != 0 { t.Fatalf("expected sessions to cascade-delete, got %d", len(sessions)) } } func TestListClassSessionsEnrichment(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) sessions, err := svc.ListClassSessions(context.Background(), owner) if err != nil { t.Fatalf("list sessions: %v", err) } if len(sessions) != 1 { t.Fatalf("expected 1 seeded session, got %d", len(sessions)) } s := sessions[0] if s.TemplateTitle != "Small group mobility class" { t.Fatalf("expected template title, got %q", s.TemplateTitle) } if s.LocationName != "Main Studio" { t.Fatalf("expected location name, got %q", s.LocationName) } if s.Capacity != 4 || s.BookedCount != 0 { t.Fatalf("expected capacity 4 booked 0, got %d/%d", s.Capacity, s.BookedCount) } } func TestCreateClassSession(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() startsAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339) cap := 6 created, err := svc.CreateClassSession(ctx, owner, domain.CreateClassSessionRequest{ TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", StartsAt: startsAt, Capacity: &cap, }) if err != nil { t.Fatalf("create session: %v", err) } if created.Capacity != 6 { t.Fatalf("expected override capacity 6, got %d", created.Capacity) } if !created.EndsAt.Equal(created.StartsAt.Add(60 * time.Minute)) { t.Fatalf("expected end = start + 60m, got %s -> %s", created.StartsAt, created.EndsAt) } } func TestCreateClassSessionValidation(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) template := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7" badLocation := "00000000-0000-0000-0000-000000000099" badCap := 0 cases := []struct { name string req domain.CreateClassSessionRequest want error }{ {"missing template", domain.CreateClassSessionRequest{TemplateID: "00000000-0000-0000-0000-000000000099", StartsAt: future}, ErrClassTemplateNotFound}, {"bad start format", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: "tomorrow"}, ErrInvalidInput}, {"past start", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: past}, ErrInvalidInput}, {"foreign location", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, LocationID: &badLocation}, ErrLocationNotFound}, {"zero capacity", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, Capacity: &badCap}, ErrInvalidInput}, } for _, tc := range cases { if _, err := svc.CreateClassSession(ctx, owner, tc.req); !errors.Is(err, tc.want) { t.Fatalf("%s: expected %v, got %v", tc.name, tc.want, err) } } } func TestDeleteClassSessionPreservesBooking(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() // Book the seeded class session via the public booking service. bookingSvc := bookings.NewService(repo, nil, "") sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435" session, err := repo.GetClassSessionByID(ctx, sessionID) if err != nil { t.Fatalf("get seeded session: %v", err) } _, err = bookingSvc.Create(ctx, domain.CreateBookingRequest{ TenantSlug: "studio-atelier", CustomerName: "Kate", CustomerEmail: "kate@example.com", BookingMode: "class", ClassSessionID: &sessionID, StartsAt: session.StartsAt.UTC().Format(time.RFC3339), EndsAt: session.EndsAt.UTC().Format(time.RFC3339), }) if err != nil { t.Fatalf("book class session: %v", err) } sessions, _ := svc.ListClassSessions(ctx, owner) if sessions[0].BookedCount != 1 { t.Fatalf("expected bookedCount 1, got %d", sessions[0].BookedCount) } if err := svc.DeleteClassSession(ctx, owner, sessionID); err != nil { t.Fatalf("delete session: %v", err) } // The booking survives but no longer references the session. bookingsList, err := repo.ListBookingsByTenantBetween(ctx, sessions[0].TenantID, time.Now().Add(-time.Hour), time.Now().Add(200*time.Hour)) if err != nil { t.Fatalf("list bookings: %v", err) } if len(bookingsList) != 1 { t.Fatalf("expected booking to survive, got %d", len(bookingsList)) } if bookingsList[0].ClassSessionID != nil { t.Fatal("expected booking class_session_id to be nulled") } } const demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435" func bookClass(t *testing.T, repo db.Repository, email string) domain.CreateBookingResponse { t.Helper() session, err := repo.GetClassSessionByID(context.Background(), demoSessionID) if err != nil { t.Fatalf("get session: %v", err) } bookingSvc := bookings.NewService(repo, nil, "") resp, err := bookingSvc.Create(context.Background(), domain.CreateBookingRequest{ TenantSlug: "studio-atelier", CustomerName: "Test User", CustomerEmail: email, BookingMode: "class", ClassSessionID: &[]string{demoSessionID}[0], StartsAt: session.StartsAt.UTC().Format(time.RFC3339), EndsAt: session.EndsAt.UTC().Format(time.RFC3339), }) if err != nil { t.Fatalf("book class for %s: %v", email, err) } return resp } func TestCancelBookingLifecycle(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() resp := bookClass(t, repo, "cancel-me@example.com") if err := svc.CancelBooking(ctx, owner, resp.BookingID); err != nil { t.Fatalf("cancel booking: %v", err) } booking, _ := repo.GetBookingByID(ctx, resp.BookingID) if booking.Status != "cancelled" { t.Fatalf("expected cancelled, got %q", booking.Status) } if err := svc.CancelBooking(ctx, owner, resp.BookingID); !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput on second cancel, got %v", err) } if err := svc.CancelBooking(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrBookingNotFound) { t.Fatalf("expected ErrBookingNotFound, got %v", err) } stranger := domain.Principal{Subject: "someone-else", Role: "staff"} if err := svc.CancelBooking(ctx, stranger, resp.BookingID); !errors.Is(err, ErrTenantMembership) { t.Fatalf("expected ErrTenantMembership, got %v", err) } } func TestCancelBookingPromotesWaitlist(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() // Seeded session capacity is 4 — fill it, then overflow onto the waitlist. var confirmed []domain.CreateBookingResponse for i := 0; i < 4; i++ { confirmed = append(confirmed, bookClass(t, repo, fmt.Sprintf("seat%d@example.com", i))) } first := bookClass(t, repo, "first-waiter@example.com") second := bookClass(t, repo, "second-waiter@example.com") booking, _ := repo.GetBookingByID(ctx, first.BookingID) if booking.Status != "waitlisted" { t.Fatalf("expected waitlisted, got %q", booking.Status) } // Cancel a confirmed seat -> earliest waiter is promoted, second stays queued. if err := svc.CancelBooking(ctx, owner, confirmed[0].BookingID); err != nil { t.Fatalf("cancel: %v", err) } booking, _ = repo.GetBookingByID(ctx, first.BookingID) if booking.Status != "confirmed" { t.Fatalf("expected first waiter promoted, got %q", booking.Status) } booking, _ = repo.GetBookingByID(ctx, second.BookingID) if booking.Status != "waitlisted" { t.Fatalf("expected second waiter still queued, got %q", booking.Status) } // Free another seat -> second waiter promoted. if err := svc.CancelBooking(ctx, owner, confirmed[1].BookingID); err != nil { t.Fatalf("cancel second: %v", err) } booking, _ = repo.GetBookingByID(ctx, second.BookingID) if booking.Status != "confirmed" { t.Fatalf("expected second waiter promoted, got %q", booking.Status) } } func TestUpdateClassTemplate(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) ctx := context.Background() created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{ Title: "Yin Yoga", DurationMinutes: 75, Capacity: 10, }) if err != nil { t.Fatalf("create template: %v", err) } updated, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{ Title: "Yin Yoga (večer)", DurationMinutes: 90, Capacity: 12, }) if err != nil { t.Fatalf("update template: %v", err) } if updated.Title != "Yin Yoga (večer)" || updated.DurationMinutes != 90 || updated.Capacity != 12 { t.Fatalf("unexpected updated template: %+v", updated) } if _, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{ Title: "", DurationMinutes: 90, Capacity: 12, }); !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput, got %v", err) } stranger := domain.Principal{Subject: "someone-else", Role: "staff"} if _, err := svc.UpdateClassTemplate(ctx, stranger, created.ID, domain.UpsertClassTemplateRequest{ Title: "X", DurationMinutes: 60, Capacity: 5, }); !errors.Is(err, ErrTenantMembership) { t.Fatalf("expected ErrTenantMembership, got %v", err) } if _, err := svc.UpdateClassTemplate(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.UpsertClassTemplateRequest{ Title: "X", DurationMinutes: 60, Capacity: 5, }); !errors.Is(err, ErrClassTemplateNotFound) { t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) } }