ga-plugins-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/config-patcher.d.ts +20 -50
  2. package/dist/config-patcher.d.ts.map +1 -1
  3. package/dist/config-patcher.js +138 -102
  4. package/dist/config-patcher.js.map +1 -1
  5. package/dist/index.js +41 -5
  6. package/dist/index.js.map +1 -1
  7. package/dist/installer.d.ts +0 -18
  8. package/dist/installer.d.ts.map +1 -1
  9. package/dist/installer.js +19 -39
  10. package/dist/installer.js.map +1 -1
  11. package/dist/types.d.ts +10 -6
  12. package/dist/types.d.ts.map +1 -1
  13. package/dist/uninstaller.d.ts +0 -23
  14. package/dist/uninstaller.d.ts.map +1 -1
  15. package/dist/uninstaller.js +22 -68
  16. package/dist/uninstaller.js.map +1 -1
  17. package/package.json +3 -2
  18. package/plugins/go-reviewer/.claude-plugin/plugin.json +12 -0
  19. package/plugins/go-reviewer/commands/go-review.md +424 -0
  20. package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/README.md +236 -0
  21. package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/main.go +678 -0
  22. package/plugins/go-scaffolder/.claude-plugin/plugin.json +12 -0
  23. package/plugins/go-scaffolder/commands/scaffold-service.md +802 -0
  24. package/plugins/go-scaffolder/reference-service/.env.example +27 -0
  25. package/plugins/go-scaffolder/reference-service/Dockerfile +55 -0
  26. package/plugins/go-scaffolder/reference-service/REFERENCE-SERVICE-NOTICE.md +104 -0
  27. package/plugins/go-scaffolder/reference-service/cmd/server/main.go +266 -0
  28. package/plugins/go-scaffolder/reference-service/config/config.go +67 -0
  29. package/plugins/go-scaffolder/reference-service/go.mod +17 -0
  30. package/plugins/go-scaffolder/reference-service/internal/domain/booking.go +118 -0
  31. package/plugins/go-scaffolder/reference-service/internal/handler/booking.go +242 -0
  32. package/plugins/go-scaffolder/reference-service/internal/handler/booking_test.go +451 -0
  33. package/plugins/go-scaffolder/reference-service/internal/repository/booking_postgres.go +124 -0
  34. package/plugins/go-scaffolder/reference-service/internal/usecase/booking.go +181 -0
  35. package/plugins/go-standards/.claude-plugin/plugin.json +22 -0
  36. package/plugins/go-standards/commands/go-standards-check.md +232 -0
  37. package/plugins/go-standards/skills/concurrency.md +336 -0
  38. package/plugins/go-standards/skills/config.md +267 -0
  39. package/plugins/go-standards/skills/error-handling.md +286 -0
  40. package/plugins/go-standards/skills/http-chi.md +390 -0
  41. package/plugins/go-standards/skills/logging-observability.md +340 -0
  42. package/plugins/go-standards/skills/naming-and-style.md +315 -0
  43. package/plugins/go-standards/skills/project-layout.md +313 -0
  44. package/plugins/go-standards/skills/testing.md +366 -0
  45. package/plugins/java2go-porter/.claude-plugin/plugin.json +21 -0
  46. package/plugins/java2go-porter/agents/analyzer.md +232 -0
  47. package/plugins/java2go-porter/agents/reviewer.md +241 -0
  48. package/plugins/java2go-porter/agents/test-pairer.md +365 -0
  49. package/plugins/java2go-porter/agents/translator.md +419 -0
  50. package/plugins/java2go-porter/commands/port-java-service.md +149 -0
  51. package/plugins/java2go-porter/skills/idiom-mapping.md +75 -0
  52. package/plugins/migration-safety/.claude-plugin/plugin.json +20 -0
  53. package/plugins/migration-safety/commands/gen-characterization-test.md +452 -0
  54. package/plugins/migration-safety/commands/strangler-plan.md +356 -0
  55. package/plugins/migration-safety/skills/strangler-fig.md +382 -0
@@ -0,0 +1,242 @@
1
+ package handler
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "strconv"
10
+
11
+ "github.com/go-chi/chi/v5"
12
+ "github.com/go-chi/chi/v5/middleware"
13
+ "go.uber.org/zap"
14
+
15
+ "github.com/zokypesch/booking-service/internal/domain"
16
+ )
17
+
18
+ const maxBodyBytes = 1 << 20 // 1 MB — prevent OOM from oversized payloads
19
+
20
+ // BookingHandler implements the HTTP layer for booking resources.
21
+ // It is intentionally thin: decode → call usecase → encode. No business logic here.
22
+ type BookingHandler struct {
23
+ uc domain.BookingUsecase
24
+ logger *zap.Logger
25
+ }
26
+
27
+ // NewBookingHandler constructs a BookingHandler wired to the given usecase.
28
+ func NewBookingHandler(uc domain.BookingUsecase, logger *zap.Logger) *BookingHandler {
29
+ if uc == nil {
30
+ panic("handler: uc must not be nil")
31
+ }
32
+ if logger == nil {
33
+ panic("handler: logger must not be nil")
34
+ }
35
+ return &BookingHandler{uc: uc, logger: logger}
36
+ }
37
+
38
+ // Routes mounts all booking CRUD routes on the provided chi.Router.
39
+ // Call as: r.Route("/bookings", bookingHandler.Routes)
40
+ func (h *BookingHandler) Routes(r chi.Router) {
41
+ r.Get("/", h.List)
42
+ r.Post("/", h.Create)
43
+ r.Get("/{id}", h.GetByID)
44
+ r.Put("/{id}", h.Update)
45
+ r.Delete("/{id}", h.Cancel)
46
+ }
47
+
48
+ // Create handles POST /bookings — creates a new PENDING booking.
49
+ //
50
+ // Request body: {"customer_id":"...","flight_id":"...","seat_count":2,"total_price":450.00}
51
+ // Response 201: domain.Booking JSON
52
+ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
53
+ var input domain.CreateBookingInput
54
+ if err := decodeJSON(r, &input); err != nil {
55
+ respondErrorStatus(w, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
56
+ return
57
+ }
58
+
59
+ booking, err := h.uc.CreateBooking(r.Context(), input)
60
+ if err != nil {
61
+ h.logger.Error("create booking",
62
+ zap.Error(err),
63
+ zap.String("request_id", middleware.GetReqID(r.Context())),
64
+ )
65
+ respondError(w, err)
66
+ return
67
+ }
68
+
69
+ respondJSON(w, http.StatusCreated, booking)
70
+ }
71
+
72
+ // GetByID handles GET /bookings/{id} — retrieves a single booking.
73
+ //
74
+ // Response 200: domain.Booking JSON
75
+ // Response 404: when booking not found
76
+ func (h *BookingHandler) GetByID(w http.ResponseWriter, r *http.Request) {
77
+ id := chi.URLParam(r, "id")
78
+
79
+ booking, err := h.uc.GetBooking(r.Context(), id)
80
+ if err != nil {
81
+ h.logger.Error("get booking",
82
+ zap.Error(err),
83
+ zap.String("id", id),
84
+ zap.String("request_id", middleware.GetReqID(r.Context())),
85
+ )
86
+ respondError(w, err)
87
+ return
88
+ }
89
+
90
+ respondJSON(w, http.StatusOK, booking)
91
+ }
92
+
93
+ // Update handles PUT /bookings/{id} — updates mutable fields of a booking.
94
+ //
95
+ // Request body: {"seat_count":3,"total_price":675.00} (all fields optional)
96
+ // Response 200: updated domain.Booking JSON
97
+ func (h *BookingHandler) Update(w http.ResponseWriter, r *http.Request) {
98
+ id := chi.URLParam(r, "id")
99
+
100
+ var input domain.UpdateBookingInput
101
+ if err := decodeJSON(r, &input); err != nil {
102
+ respondErrorStatus(w, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
103
+ return
104
+ }
105
+
106
+ booking, err := h.uc.UpdateBooking(r.Context(), id, input)
107
+ if err != nil {
108
+ h.logger.Error("update booking",
109
+ zap.Error(err),
110
+ zap.String("id", id),
111
+ zap.String("request_id", middleware.GetReqID(r.Context())),
112
+ )
113
+ respondError(w, err)
114
+ return
115
+ }
116
+
117
+ respondJSON(w, http.StatusOK, booking)
118
+ }
119
+
120
+ // List handles GET /bookings — returns a paginated list of bookings.
121
+ //
122
+ // Query params: limit (default 20, max 100), offset (default 0)
123
+ // Response 200: []domain.Booking JSON
124
+ func (h *BookingHandler) List(w http.ResponseWriter, r *http.Request) {
125
+ limit := queryInt(r, "limit", 20)
126
+ offset := queryInt(r, "offset", 0)
127
+
128
+ bookings, err := h.uc.ListBookings(r.Context(), limit, offset)
129
+ if err != nil {
130
+ h.logger.Error("list bookings",
131
+ zap.Error(err),
132
+ zap.String("request_id", middleware.GetReqID(r.Context())),
133
+ )
134
+ respondError(w, err)
135
+ return
136
+ }
137
+
138
+ respondJSON(w, http.StatusOK, bookings)
139
+ }
140
+
141
+ // Cancel handles DELETE /bookings/{id} — transitions a booking to CANCELLED status.
142
+ //
143
+ // Response 200: cancelled domain.Booking JSON
144
+ // Response 422: when status transition is invalid
145
+ func (h *BookingHandler) Cancel(w http.ResponseWriter, r *http.Request) {
146
+ id := chi.URLParam(r, "id")
147
+
148
+ booking, err := h.uc.CancelBooking(r.Context(), id)
149
+ if err != nil {
150
+ h.logger.Error("cancel booking",
151
+ zap.Error(err),
152
+ zap.String("id", id),
153
+ zap.String("request_id", middleware.GetReqID(r.Context())),
154
+ )
155
+ respondError(w, err)
156
+ return
157
+ }
158
+
159
+ respondJSON(w, http.StatusOK, booking)
160
+ }
161
+
162
+ // --- Request helpers ---
163
+
164
+ // decodeJSON decodes the JSON request body into dst.
165
+ // Enforces a 1 MB body size limit and disallows unknown fields.
166
+ func decodeJSON(r *http.Request, dst any) error {
167
+ r.Body = http.MaxBytesReader(nil, r.Body, maxBodyBytes)
168
+ dec := json.NewDecoder(r.Body)
169
+ dec.DisallowUnknownFields()
170
+
171
+ if err := dec.Decode(dst); err != nil {
172
+ var syntaxErr *json.SyntaxError
173
+ var unmarshalErr *json.UnmarshalTypeError
174
+ switch {
175
+ case errors.As(err, &syntaxErr):
176
+ return fmt.Errorf("malformed JSON at position %d", syntaxErr.Offset)
177
+ case errors.As(err, &unmarshalErr):
178
+ return fmt.Errorf("invalid value for field %q", unmarshalErr.Field)
179
+ case errors.Is(err, io.EOF):
180
+ return errors.New("request body must not be empty")
181
+ default:
182
+ return fmt.Errorf("decoding request: %w", err)
183
+ }
184
+ }
185
+ return nil
186
+ }
187
+
188
+ // queryInt reads a query parameter as an integer, returning defaultVal on missing or invalid.
189
+ func queryInt(r *http.Request, key string, defaultVal int) int {
190
+ raw := r.URL.Query().Get(key)
191
+ if raw == "" {
192
+ return defaultVal
193
+ }
194
+ v, err := strconv.Atoi(raw)
195
+ if err != nil || v < 0 {
196
+ return defaultVal
197
+ }
198
+ return v
199
+ }
200
+
201
+ // --- Response helpers ---
202
+
203
+ type errorBody struct {
204
+ Error string `json:"error"`
205
+ Code string `json:"code"`
206
+ }
207
+
208
+ // respondJSON serialises data as JSON with the given HTTP status code.
209
+ func respondJSON(w http.ResponseWriter, status int, data any) {
210
+ w.Header().Set("Content-Type", "application/json")
211
+ w.WriteHeader(status)
212
+ if data != nil {
213
+ _ = json.NewEncoder(w).Encode(data)
214
+ }
215
+ }
216
+
217
+ // respondError maps domain sentinel errors to HTTP status codes and writes an error body.
218
+ func respondError(w http.ResponseWriter, err error) {
219
+ switch {
220
+ case errors.Is(err, domain.ErrBookingNotFound):
221
+ respondErrorStatus(w, http.StatusNotFound, err.Error(), "NOT_FOUND")
222
+ case errors.Is(err, domain.ErrInvalidStatus):
223
+ respondErrorStatus(w, http.StatusUnprocessableEntity, err.Error(), "INVALID_STATUS_TRANSITION")
224
+ case errors.Is(err, domain.ErrAlreadyExists):
225
+ respondErrorStatus(w, http.StatusConflict, err.Error(), "CONFLICT")
226
+ case errors.Is(err, domain.ErrInvalidInput):
227
+ respondErrorStatus(w, http.StatusBadRequest, err.Error(), "BAD_REQUEST")
228
+ case errors.Is(err, domain.ErrUnauthorized):
229
+ respondErrorStatus(w, http.StatusUnauthorized, err.Error(), "UNAUTHORIZED")
230
+ case errors.Is(err, domain.ErrForbidden):
231
+ respondErrorStatus(w, http.StatusForbidden, err.Error(), "FORBIDDEN")
232
+ default:
233
+ respondErrorStatus(w, http.StatusInternalServerError, "internal server error", "INTERNAL")
234
+ }
235
+ }
236
+
237
+ // respondErrorStatus writes a JSON error body with the given status, message, and machine code.
238
+ func respondErrorStatus(w http.ResponseWriter, status int, message, code string) {
239
+ w.Header().Set("Content-Type", "application/json")
240
+ w.WriteHeader(status)
241
+ _ = json.NewEncoder(w).Encode(errorBody{Error: message, Code: code})
242
+ }
@@ -0,0 +1,451 @@
1
+ package handler
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/go-chi/chi/v5"
15
+ "github.com/stretchr/testify/assert"
16
+ "github.com/stretchr/testify/require"
17
+ "go.uber.org/zap"
18
+
19
+ "github.com/zokypesch/booking-service/internal/domain"
20
+ )
21
+
22
+ // -------------------------------------------------------------------------
23
+ // Hand-rolled mock for domain.BookingUsecase
24
+ // -------------------------------------------------------------------------
25
+
26
+ // mockBookingUsecase is a simple, test-local mock — no code-generation required.
27
+ // Each method field is a function variable; tests override only what they need.
28
+ type mockBookingUsecase struct {
29
+ createFn func(ctx context.Context, input domain.CreateBookingInput) (*domain.Booking, error)
30
+ getFn func(ctx context.Context, id string) (*domain.Booking, error)
31
+ updateFn func(ctx context.Context, id string, input domain.UpdateBookingInput) (*domain.Booking, error)
32
+ listFn func(ctx context.Context, limit, offset int) ([]*domain.Booking, error)
33
+ cancelFn func(ctx context.Context, id string) (*domain.Booking, error)
34
+ }
35
+
36
+ func (m *mockBookingUsecase) CreateBooking(ctx context.Context, input domain.CreateBookingInput) (*domain.Booking, error) {
37
+ if m.createFn != nil {
38
+ return m.createFn(ctx, input)
39
+ }
40
+ return nil, fmt.Errorf("CreateBooking not stubbed")
41
+ }
42
+
43
+ func (m *mockBookingUsecase) GetBooking(ctx context.Context, id string) (*domain.Booking, error) {
44
+ if m.getFn != nil {
45
+ return m.getFn(ctx, id)
46
+ }
47
+ return nil, fmt.Errorf("GetBooking not stubbed")
48
+ }
49
+
50
+ func (m *mockBookingUsecase) UpdateBooking(ctx context.Context, id string, input domain.UpdateBookingInput) (*domain.Booking, error) {
51
+ if m.updateFn != nil {
52
+ return m.updateFn(ctx, id, input)
53
+ }
54
+ return nil, fmt.Errorf("UpdateBooking not stubbed")
55
+ }
56
+
57
+ func (m *mockBookingUsecase) ListBookings(ctx context.Context, limit, offset int) ([]*domain.Booking, error) {
58
+ if m.listFn != nil {
59
+ return m.listFn(ctx, limit, offset)
60
+ }
61
+ return nil, fmt.Errorf("ListBookings not stubbed")
62
+ }
63
+
64
+ func (m *mockBookingUsecase) CancelBooking(ctx context.Context, id string) (*domain.Booking, error) {
65
+ if m.cancelFn != nil {
66
+ return m.cancelFn(ctx, id)
67
+ }
68
+ return nil, fmt.Errorf("CancelBooking not stubbed")
69
+ }
70
+
71
+ // -------------------------------------------------------------------------
72
+ // Test helpers
73
+ // -------------------------------------------------------------------------
74
+
75
+ // newTestHandler builds a BookingHandler backed by the provided mock and a no-op logger.
76
+ func newTestHandler(mock *mockBookingUsecase) *BookingHandler {
77
+ return NewBookingHandler(mock, zap.NewNop())
78
+ }
79
+
80
+ // newChiRouter mounts the handler on a chi router, mirroring production routing.
81
+ func newChiRouter(h *BookingHandler) http.Handler {
82
+ r := chi.NewRouter()
83
+ r.Route("/bookings", h.Routes)
84
+ return r
85
+ }
86
+
87
+ // fixedBooking returns a deterministic Booking for use in test assertions.
88
+ func fixedBooking(id string) *domain.Booking {
89
+ return &domain.Booking{
90
+ ID: id,
91
+ CustomerID: "cust-001",
92
+ FlightID: "flight-GA123",
93
+ Status: domain.StatusPending,
94
+ SeatCount: 2,
95
+ TotalPrice: 450.00,
96
+ CreatedAt: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC),
97
+ UpdatedAt: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC),
98
+ }
99
+ }
100
+
101
+ // -------------------------------------------------------------------------
102
+ // Tests: Create
103
+ // -------------------------------------------------------------------------
104
+
105
+ func TestBookingHandler_Create_Success(t *testing.T) {
106
+ const bookingID = "booking-abc-123"
107
+
108
+ mock := &mockBookingUsecase{
109
+ createFn: func(_ context.Context, input domain.CreateBookingInput) (*domain.Booking, error) {
110
+ // Validate the handler correctly decoded the request body.
111
+ assert.Equal(t, "cust-001", input.CustomerID)
112
+ assert.Equal(t, "flight-GA123", input.FlightID)
113
+ assert.Equal(t, 2, input.SeatCount)
114
+ assert.InDelta(t, 450.00, input.TotalPrice, 0.001)
115
+ return fixedBooking(bookingID), nil
116
+ },
117
+ }
118
+
119
+ body := `{"customer_id":"cust-001","flight_id":"flight-GA123","seat_count":2,"total_price":450.00}`
120
+ req := httptest.NewRequest(http.MethodPost, "/bookings", strings.NewReader(body))
121
+ req.Header.Set("Content-Type", "application/json")
122
+ rec := httptest.NewRecorder()
123
+
124
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
125
+
126
+ require.Equal(t, http.StatusCreated, rec.Code)
127
+
128
+ var got domain.Booking
129
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
130
+ assert.Equal(t, bookingID, got.ID)
131
+ assert.Equal(t, "cust-001", got.CustomerID)
132
+ assert.Equal(t, domain.StatusPending, got.Status)
133
+ }
134
+
135
+ func TestBookingHandler_Create_InvalidBody(t *testing.T) {
136
+ tests := []struct {
137
+ name string
138
+ body string
139
+ wantStatus int
140
+ wantCode string
141
+ }{
142
+ {
143
+ name: "empty body",
144
+ body: "",
145
+ wantStatus: http.StatusBadRequest,
146
+ wantCode: "BAD_REQUEST",
147
+ },
148
+ {
149
+ name: "malformed JSON",
150
+ body: `{"customer_id": "cust-001"`,
151
+ wantStatus: http.StatusBadRequest,
152
+ wantCode: "BAD_REQUEST",
153
+ },
154
+ {
155
+ name: "unknown field",
156
+ body: `{"customer_id":"cust-001","unknown_field":"x"}`,
157
+ wantStatus: http.StatusBadRequest,
158
+ wantCode: "BAD_REQUEST",
159
+ },
160
+ }
161
+
162
+ for _, tc := range tests {
163
+ t.Run(tc.name, func(t *testing.T) {
164
+ // The mock's createFn should never be called for invalid bodies.
165
+ mock := &mockBookingUsecase{
166
+ createFn: func(_ context.Context, _ domain.CreateBookingInput) (*domain.Booking, error) {
167
+ t.Fatal("CreateBooking should not be called with an invalid body")
168
+ return nil, nil
169
+ },
170
+ }
171
+
172
+ req := httptest.NewRequest(http.MethodPost, "/bookings", strings.NewReader(tc.body))
173
+ req.Header.Set("Content-Type", "application/json")
174
+ rec := httptest.NewRecorder()
175
+
176
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
177
+
178
+ assert.Equal(t, tc.wantStatus, rec.Code, "status mismatch for case: %s", tc.name)
179
+
180
+ var errResp errorBody
181
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&errResp))
182
+ assert.Equal(t, tc.wantCode, errResp.Code)
183
+ assert.NotEmpty(t, errResp.Error)
184
+ })
185
+ }
186
+ }
187
+
188
+ func TestBookingHandler_Create_UsecaseValidationError(t *testing.T) {
189
+ mock := &mockBookingUsecase{
190
+ createFn: func(_ context.Context, _ domain.CreateBookingInput) (*domain.Booking, error) {
191
+ return nil, fmt.Errorf("%w: seat_count must be at least 1", domain.ErrInvalidInput)
192
+ },
193
+ }
194
+
195
+ body := `{"customer_id":"cust-001","flight_id":"GA123","seat_count":0,"total_price":0}`
196
+ req := httptest.NewRequest(http.MethodPost, "/bookings", strings.NewReader(body))
197
+ req.Header.Set("Content-Type", "application/json")
198
+ rec := httptest.NewRecorder()
199
+
200
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
201
+
202
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
203
+
204
+ var errResp errorBody
205
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&errResp))
206
+ assert.Equal(t, "BAD_REQUEST", errResp.Code)
207
+ }
208
+
209
+ // -------------------------------------------------------------------------
210
+ // Tests: GetByID
211
+ // -------------------------------------------------------------------------
212
+
213
+ func TestBookingHandler_GetByID_Success(t *testing.T) {
214
+ const bookingID = "booking-xyz-789"
215
+
216
+ mock := &mockBookingUsecase{
217
+ getFn: func(_ context.Context, id string) (*domain.Booking, error) {
218
+ assert.Equal(t, bookingID, id)
219
+ return fixedBooking(bookingID), nil
220
+ },
221
+ }
222
+
223
+ req := httptest.NewRequest(http.MethodGet, "/bookings/"+bookingID, nil)
224
+ rec := httptest.NewRecorder()
225
+
226
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
227
+
228
+ require.Equal(t, http.StatusOK, rec.Code)
229
+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
230
+
231
+ var got domain.Booking
232
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
233
+ assert.Equal(t, bookingID, got.ID)
234
+ assert.Equal(t, domain.StatusPending, got.Status)
235
+ }
236
+
237
+ func TestBookingHandler_GetByID_NotFound(t *testing.T) {
238
+ mock := &mockBookingUsecase{
239
+ getFn: func(_ context.Context, id string) (*domain.Booking, error) {
240
+ return nil, domain.ErrBookingNotFound
241
+ },
242
+ }
243
+
244
+ req := httptest.NewRequest(http.MethodGet, "/bookings/does-not-exist", nil)
245
+ rec := httptest.NewRecorder()
246
+
247
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
248
+
249
+ assert.Equal(t, http.StatusNotFound, rec.Code)
250
+
251
+ var errResp errorBody
252
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&errResp))
253
+ assert.Equal(t, "NOT_FOUND", errResp.Code)
254
+ assert.NotEmpty(t, errResp.Error)
255
+ }
256
+
257
+ // -------------------------------------------------------------------------
258
+ // Tests: Cancel
259
+ // -------------------------------------------------------------------------
260
+
261
+ func TestBookingHandler_Cancel_Success(t *testing.T) {
262
+ const bookingID = "booking-to-cancel"
263
+
264
+ cancelled := fixedBooking(bookingID)
265
+ cancelled.Status = domain.StatusCancelled
266
+
267
+ mock := &mockBookingUsecase{
268
+ cancelFn: func(_ context.Context, id string) (*domain.Booking, error) {
269
+ assert.Equal(t, bookingID, id)
270
+ return cancelled, nil
271
+ },
272
+ }
273
+
274
+ req := httptest.NewRequest(http.MethodDelete, "/bookings/"+bookingID, nil)
275
+ rec := httptest.NewRecorder()
276
+
277
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
278
+
279
+ require.Equal(t, http.StatusOK, rec.Code)
280
+
281
+ var got domain.Booking
282
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
283
+ assert.Equal(t, bookingID, got.ID)
284
+ assert.Equal(t, domain.StatusCancelled, got.Status)
285
+ }
286
+
287
+ func TestBookingHandler_Cancel_InvalidStatusTransition(t *testing.T) {
288
+ mock := &mockBookingUsecase{
289
+ cancelFn: func(_ context.Context, id string) (*domain.Booking, error) {
290
+ return nil, fmt.Errorf("%w: cannot cancel a booking with status CANCELLED",
291
+ domain.ErrInvalidStatus)
292
+ },
293
+ }
294
+
295
+ req := httptest.NewRequest(http.MethodDelete, "/bookings/already-cancelled", nil)
296
+ rec := httptest.NewRecorder()
297
+
298
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
299
+
300
+ assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
301
+
302
+ var errResp errorBody
303
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&errResp))
304
+ assert.Equal(t, "INVALID_STATUS_TRANSITION", errResp.Code)
305
+ }
306
+
307
+ func TestBookingHandler_Cancel_NotFound(t *testing.T) {
308
+ mock := &mockBookingUsecase{
309
+ cancelFn: func(_ context.Context, id string) (*domain.Booking, error) {
310
+ return nil, domain.ErrBookingNotFound
311
+ },
312
+ }
313
+
314
+ req := httptest.NewRequest(http.MethodDelete, "/bookings/ghost-booking", nil)
315
+ rec := httptest.NewRecorder()
316
+
317
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
318
+
319
+ assert.Equal(t, http.StatusNotFound, rec.Code)
320
+
321
+ var errResp errorBody
322
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&errResp))
323
+ assert.Equal(t, "NOT_FOUND", errResp.Code)
324
+ }
325
+
326
+ // -------------------------------------------------------------------------
327
+ // Tests: List
328
+ // -------------------------------------------------------------------------
329
+
330
+ func TestBookingHandler_List_Success(t *testing.T) {
331
+ bookings := []*domain.Booking{
332
+ fixedBooking("booking-1"),
333
+ fixedBooking("booking-2"),
334
+ }
335
+
336
+ mock := &mockBookingUsecase{
337
+ listFn: func(_ context.Context, limit, offset int) ([]*domain.Booking, error) {
338
+ assert.Equal(t, 10, limit)
339
+ assert.Equal(t, 0, offset)
340
+ return bookings, nil
341
+ },
342
+ }
343
+
344
+ req := httptest.NewRequest(http.MethodGet, "/bookings?limit=10&offset=0", nil)
345
+ rec := httptest.NewRecorder()
346
+
347
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
348
+
349
+ require.Equal(t, http.StatusOK, rec.Code)
350
+
351
+ var got []*domain.Booking
352
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
353
+ require.Len(t, got, 2)
354
+ assert.Equal(t, "booking-1", got[0].ID)
355
+ assert.Equal(t, "booking-2", got[1].ID)
356
+ }
357
+
358
+ func TestBookingHandler_List_DefaultPagination(t *testing.T) {
359
+ mock := &mockBookingUsecase{
360
+ listFn: func(_ context.Context, limit, offset int) ([]*domain.Booking, error) {
361
+ // Without query params, handler should default to limit=20, offset=0
362
+ assert.Equal(t, 20, limit)
363
+ assert.Equal(t, 0, offset)
364
+ return []*domain.Booking{}, nil
365
+ },
366
+ }
367
+
368
+ req := httptest.NewRequest(http.MethodGet, "/bookings", nil)
369
+ rec := httptest.NewRecorder()
370
+
371
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
372
+
373
+ assert.Equal(t, http.StatusOK, rec.Code)
374
+ }
375
+
376
+ // -------------------------------------------------------------------------
377
+ // Tests: Update
378
+ // -------------------------------------------------------------------------
379
+
380
+ func TestBookingHandler_Update_Success(t *testing.T) {
381
+ const bookingID = "booking-to-update"
382
+
383
+ updatedPrice := 675.00
384
+ updated := fixedBooking(bookingID)
385
+ updated.TotalPrice = updatedPrice
386
+
387
+ mock := &mockBookingUsecase{
388
+ updateFn: func(_ context.Context, id string, input domain.UpdateBookingInput) (*domain.Booking, error) {
389
+ assert.Equal(t, bookingID, id)
390
+ require.NotNil(t, input.TotalPrice)
391
+ assert.InDelta(t, updatedPrice, *input.TotalPrice, 0.001)
392
+ return updated, nil
393
+ },
394
+ }
395
+
396
+ body, _ := json.Marshal(map[string]interface{}{"total_price": updatedPrice})
397
+ req := httptest.NewRequest(http.MethodPut, "/bookings/"+bookingID, bytes.NewReader(body))
398
+ req.Header.Set("Content-Type", "application/json")
399
+ rec := httptest.NewRecorder()
400
+
401
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
402
+
403
+ require.Equal(t, http.StatusOK, rec.Code)
404
+
405
+ var got domain.Booking
406
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
407
+ assert.InDelta(t, updatedPrice, got.TotalPrice, 0.001)
408
+ }
409
+
410
+ // -------------------------------------------------------------------------
411
+ // Tests: Content-Type and response shape invariants
412
+ // -------------------------------------------------------------------------
413
+
414
+ func TestBookingHandler_AlwaysSetsContentType(t *testing.T) {
415
+ // Any response — success or error — must set Content-Type: application/json.
416
+ mock := &mockBookingUsecase{
417
+ getFn: func(_ context.Context, _ string) (*domain.Booking, error) {
418
+ return nil, domain.ErrBookingNotFound // trigger a 404 error path
419
+ },
420
+ }
421
+
422
+ req := httptest.NewRequest(http.MethodGet, "/bookings/any-id", nil)
423
+ rec := httptest.NewRecorder()
424
+
425
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
426
+
427
+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
428
+ }
429
+
430
+ func TestBookingHandler_InternalError_HidesDetails(t *testing.T) {
431
+ // Unexpected errors must not leak internal details to the client.
432
+ mock := &mockBookingUsecase{
433
+ getFn: func(_ context.Context, _ string) (*domain.Booking, error) {
434
+ return nil, fmt.Errorf("internal db connection error: socket timeout to 10.0.0.5:5432")
435
+ },
436
+ }
437
+
438
+ req := httptest.NewRequest(http.MethodGet, "/bookings/any-id", nil)
439
+ rec := httptest.NewRecorder()
440
+
441
+ newChiRouter(newTestHandler(mock)).ServeHTTP(rec, req)
442
+
443
+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
444
+
445
+ var errResp errorBody
446
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&errResp))
447
+ assert.Equal(t, "INTERNAL", errResp.Code)
448
+ // The internal message must NOT appear in the response body.
449
+ assert.NotContains(t, errResp.Error, "10.0.0.5")
450
+ assert.NotContains(t, errResp.Error, "socket timeout")
451
+ }