ga-plugins-cli 0.1.0 → 0.1.2
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.
- package/dist/config-patcher.d.ts +20 -50
- package/dist/config-patcher.d.ts.map +1 -1
- package/dist/config-patcher.js +138 -102
- package/dist/config-patcher.js.map +1 -1
- package/dist/index.js +75 -22
- package/dist/index.js.map +1 -1
- package/dist/installer.d.ts +0 -18
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +19 -39
- package/dist/installer.js.map +1 -1
- package/dist/types.d.ts +10 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/uninstaller.d.ts +0 -23
- package/dist/uninstaller.d.ts.map +1 -1
- package/dist/uninstaller.js +22 -68
- package/dist/uninstaller.js.map +1 -1
- package/package.json +3 -2
- package/plugins/go-reviewer/.claude-plugin/plugin.json +12 -0
- package/plugins/go-reviewer/commands/go-review.md +424 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/README.md +236 -0
- package/plugins/go-reviewer/mcp-servers/go-reviewer-mcp/main.go +678 -0
- package/plugins/go-scaffolder/.claude-plugin/plugin.json +12 -0
- package/plugins/go-scaffolder/commands/scaffold-service.md +802 -0
- package/plugins/go-scaffolder/reference-service/.env.example +27 -0
- package/plugins/go-scaffolder/reference-service/Dockerfile +55 -0
- package/plugins/go-scaffolder/reference-service/REFERENCE-SERVICE-NOTICE.md +104 -0
- package/plugins/go-scaffolder/reference-service/cmd/server/main.go +266 -0
- package/plugins/go-scaffolder/reference-service/config/config.go +67 -0
- package/plugins/go-scaffolder/reference-service/go.mod +17 -0
- package/plugins/go-scaffolder/reference-service/internal/domain/booking.go +118 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking.go +242 -0
- package/plugins/go-scaffolder/reference-service/internal/handler/booking_test.go +451 -0
- package/plugins/go-scaffolder/reference-service/internal/repository/booking_postgres.go +124 -0
- package/plugins/go-scaffolder/reference-service/internal/usecase/booking.go +181 -0
- package/plugins/go-standards/.claude-plugin/plugin.json +22 -0
- package/plugins/go-standards/commands/go-standards-check.md +232 -0
- package/plugins/go-standards/skills/concurrency.md +336 -0
- package/plugins/go-standards/skills/config.md +267 -0
- package/plugins/go-standards/skills/error-handling.md +286 -0
- package/plugins/go-standards/skills/http-chi.md +390 -0
- package/plugins/go-standards/skills/logging-observability.md +340 -0
- package/plugins/go-standards/skills/naming-and-style.md +315 -0
- package/plugins/go-standards/skills/project-layout.md +313 -0
- package/plugins/go-standards/skills/testing.md +366 -0
- package/plugins/java2go-porter/.claude-plugin/plugin.json +21 -0
- package/plugins/java2go-porter/agents/analyzer.md +232 -0
- package/plugins/java2go-porter/agents/reviewer.md +241 -0
- package/plugins/java2go-porter/agents/test-pairer.md +365 -0
- package/plugins/java2go-porter/agents/translator.md +419 -0
- package/plugins/java2go-porter/commands/port-java-service.md +149 -0
- package/plugins/java2go-porter/skills/idiom-mapping.md +75 -0
- package/plugins/migration-safety/.claude-plugin/plugin.json +20 -0
- package/plugins/migration-safety/commands/gen-characterization-test.md +452 -0
- package/plugins/migration-safety/commands/strangler-plan.md +356 -0
- 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
|
+
}
|