Skip to content

Commit b5672d7

Browse files
committed
fix ci workflow
1 parent fa49fb2 commit b5672d7

File tree

16 files changed

+961
-69
lines changed

16 files changed

+961
-69
lines changed

.github/ci.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Backend CI (E2E Checkout)
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
e2e:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v4
16+
17+
- name: Set up Docker Buildx
18+
uses: docker/setup-buildx-action@v3
19+
20+
- name: Build and start services
21+
run: |
22+
docker compose up -d --build
23+
24+
- name: Wait for services to be healthy
25+
run: |
26+
echo "Waiting for services..."
27+
for i in {1..30}; do
28+
if curl -s http://localhost:8080/healthz \
29+
&& curl -s http://localhost:8081/healthz \
30+
&& curl -s http://localhost:8082/healthz \
31+
&& curl -s http://localhost:8083/healthz; then
32+
echo "All services are up"
33+
exit 0
34+
fi
35+
sleep 5
36+
done
37+
echo "Services failed to start"
38+
docker compose logs
39+
exit 1
40+
41+
- name: Run Full E2E Checkout Flow
42+
run: |
43+
go run ./tools/e2e_checkout/full_flow.go
44+
45+
- name: Dump logs on failure
46+
if: failure()
47+
run: |
48+
docker compose logs
49+
50+
- name: Shutdown services
51+
if: always()
52+
run: |
53+
docker compose down -v

services/orders/internal/api/handlers.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ func (h *Handler) MarkOrderPaid(w http.ResponseWriter, r *http.Request) {
208208

209209
// deduct stock
210210
for _, it := range order.Items {
211-
if err := h.pclient.DeductReservedStock(ctx, it.VariantID, it.Quantity); err != nil {
211+
if err := h.pclient.DeductStock(ctx, it.VariantID, it.Quantity); err != nil {
212212
http.Error(w, "stock deduction failed", http.StatusBadGateway)
213213
return
214214
}
@@ -247,3 +247,44 @@ func (h *Handler) GetOrderInternal(w http.ResponseWriter, r *http.Request) {
247247
"currency": order.Currency,
248248
})
249249
}
250+
251+
func (h *Handler) ReleaseOrder(w http.ResponseWriter, r *http.Request) {
252+
orderID := chi.URLParam(r, "orderID")
253+
254+
if !isValidUUID(orderID) {
255+
http.Error(w, "invalid order id", http.StatusBadRequest)
256+
return
257+
}
258+
259+
if r.Header.Get("X-INTERNAL-KEY") != os.Getenv("INTERNAL_SERVICE_KEY") {
260+
http.Error(w, "unauthorized", http.StatusUnauthorized)
261+
return
262+
}
263+
264+
if err := h.store.ReleaseOrder(r.Context(), orderID); err != nil {
265+
// idempotent: already released / already paid
266+
w.WriteHeader(http.StatusOK)
267+
return
268+
}
269+
270+
w.WriteHeader(http.StatusOK)
271+
}
272+
273+
func (h *Handler) RefundOrder(w http.ResponseWriter, r *http.Request) {
274+
orderID := chi.URLParam(r, "orderID")
275+
276+
// Internal auth
277+
if r.Header.Get("X-INTERNAL-KEY") != os.Getenv("INTERNAL_SERVICE_KEY") {
278+
http.Error(w, "unauthorized", http.StatusUnauthorized)
279+
return
280+
}
281+
282+
// Business logic
283+
if err := h.store.RefundOrder(r.Context(), orderID); err != nil {
284+
// Idempotent: already refunded / not refundable
285+
w.WriteHeader(http.StatusOK)
286+
return
287+
}
288+
289+
w.WriteHeader(http.StatusOK)
290+
}

services/orders/internal/api/routes.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ func RegisterRoutes(r *chi.Mux, h *Handler) {
1010
r.Route("/v1/orders", func(r chi.Router) {
1111
r.Post("/prepare", h.PrepareOrder)
1212
r.Post("/confirm", h.ConfirmOrder)
13+
r.Post("/{orderID}/refund", h.RefundOrder)
14+
1315
r.Get("/internal/orders/{orderID}", h.GetOrderInternal)
1416

1517
r.Post("/{orderID}/paid", h.MarkOrderPaid)
16-
18+
r.Post("/{orderID}/release", h.ReleaseOrder)
1719
})
20+
1821
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
1922
w.WriteHeader(200)
2023
w.Write([]byte("ok"))

services/orders/internal/client/cart_client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type CartClient struct {
1414
c *http.Client
1515
}
1616

17-
func NewCartClientFromEnv() *CartClient {
17+
func NewCartClient() *CartClient {
1818
base := os.Getenv("CART_SVC_BASE")
1919
if base == "" {
2020
base = "http://localhost:8081"

services/orders/internal/client/product_client.go

Lines changed: 70 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -8,116 +8,123 @@ import (
88
"net/http"
99
"os"
1010
"time"
11-
12-
"github.com/joho/godotenv"
1311
)
1412

1513
type ProductClient struct {
1614
base string
17-
c *http.Client
1815
adminKey string
16+
c *http.Client
1917
}
2018

21-
func NewProductClientFromEnv() *ProductClient {
22-
godotenv.Load()
19+
func NewProductClient() *ProductClient {
2320
base := os.Getenv("PRODUCT_SVC_BASE")
2421
if base == "" {
25-
base = "http://localhost:8080"
22+
// Docker DNS default
23+
base = "http://product:8080"
24+
}
25+
26+
adminKey := os.Getenv("ADMIN_KEY")
27+
if adminKey == "" {
28+
adminKey = os.Getenv("PRODUCT_ADMIN_KEY")
2629
}
2730

28-
adminKey := os.Getenv("PRODUCT_ADMIN_KEY")
2931
if adminKey == "" {
30-
fmt.Println("WARNING: PRODUCT_ADMIN_KEY is not set")
32+
fmt.Println("WARNING: ADMIN_KEY not set for ProductClient")
3133
}
3234

3335
return &ProductClient{
3436
base: base,
35-
c: &http.Client{Timeout: 5 * time.Second},
3637
adminKey: adminKey,
38+
c: &http.Client{
39+
Timeout: 5 * time.Second,
40+
},
3741
}
3842
}
3943

40-
func (p *ProductClient) GetProductDetail(ctx context.Context, productID string) (map[string]any, error) {
44+
/* -------------------- READ APIs -------------------- */
45+
46+
func (p *ProductClient) GetProductDetail(
47+
ctx context.Context,
48+
productID string,
49+
) (map[string]any, error) {
50+
4151
url := fmt.Sprintf("%s/v1/products/%s", p.base, productID)
42-
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
52+
53+
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
54+
4355
resp, err := p.c.Do(req)
4456
if err != nil {
4557
return nil, err
4658
}
4759
defer resp.Body.Close()
48-
if resp.StatusCode != 200 {
60+
61+
if resp.StatusCode != http.StatusOK {
4962
return nil, fmt.Errorf("product service returned %d", resp.StatusCode)
5063
}
64+
5165
var out map[string]any
5266
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
5367
return nil, err
5468
}
69+
5570
return out, nil
5671
}
5772

58-
func (p *ProductClient) DeductStock(ctx context.Context, variantID string, quantity int) error {
59-
url := fmt.Sprintf("%s/v1/admin/variants/%s/deduct", p.base, variantID)
60-
body := map[string]any{"quantity": quantity}
61-
b, _ := json.Marshal(body)
62-
63-
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
64-
req.Header.Set("Content-Type", "application/json")
65-
66-
// 💥 THE FIX: FORWARD ADMIN KEY
67-
req.Header.Set("X-ADMIN-KEY", p.adminKey)
68-
69-
resp, err := p.c.Do(req)
70-
if err != nil {
71-
return err
72-
}
73-
defer resp.Body.Close()
73+
/* -------------------- STOCK APIs -------------------- */
7474

75-
if resp.StatusCode != 200 {
76-
return fmt.Errorf("deduct returned %d", resp.StatusCode)
77-
}
78-
return nil
75+
func (p *ProductClient) ReserveStock(
76+
ctx context.Context,
77+
variantID string,
78+
quantity int,
79+
) error {
80+
return p.postStockAction(ctx, "reserve", variantID, quantity)
7981
}
8082

81-
func (p *ProductClient) ReserveStock(ctx context.Context, variantID string, quantity int) error {
82-
url := fmt.Sprintf("%s/v1/admin/variants/%s/reserve", p.base, variantID)
83-
body := map[string]any{"quantity": quantity}
84-
b, _ := json.Marshal(body)
85-
86-
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
87-
req.Header.Set("Content-Type", "application/json")
88-
89-
// 💥 THE FIX AGAIN
90-
req.Header.Set("X-ADMIN-KEY", p.adminKey)
91-
92-
resp, err := p.c.Do(req)
93-
if err != nil {
94-
return err
95-
}
96-
defer resp.Body.Close()
83+
func (p *ProductClient) DeductStock(
84+
ctx context.Context,
85+
variantID string,
86+
quantity int,
87+
) error {
88+
return p.postStockAction(ctx, "deduct", variantID, quantity)
89+
}
9790

98-
if resp.StatusCode != 200 {
99-
return fmt.Errorf("reserve returned %d", resp.StatusCode)
100-
}
101-
return nil
91+
func (p *ProductClient) ReleaseStock(
92+
ctx context.Context,
93+
variantID string,
94+
quantity int,
95+
) error {
96+
return p.postStockAction(ctx, "release", variantID, quantity)
10297
}
10398

104-
func (p *ProductClient) DeductReservedStock(
99+
/* -------------------- INTERNAL HELPER -------------------- */
100+
101+
func (p *ProductClient) postStockAction(
105102
ctx context.Context,
103+
action string,
106104
variantID string,
107105
quantity int,
108106
) error {
109107

110-
url := fmt.Sprintf("%s/v1/admin/variants/%s/deduct", p.base, variantID)
108+
url := fmt.Sprintf(
109+
"%s/v1/admin/variants/%s/%s",
110+
p.base,
111+
variantID,
112+
action,
113+
)
111114

112-
body := map[string]any{
115+
body := map[string]int{
113116
"quantity": quantity,
114117
}
115118
b, _ := json.Marshal(body)
116119

117-
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
118-
req.Header.Set("Content-Type", "application/json")
120+
req, _ := http.NewRequestWithContext(
121+
ctx,
122+
http.MethodPost,
123+
url,
124+
bytes.NewReader(b),
125+
)
119126

120-
// INTERNAL AUTH
127+
req.Header.Set("Content-Type", "application/json")
121128
req.Header.Set("X-ADMIN-KEY", p.adminKey)
122129

123130
resp, err := p.c.Do(req)
@@ -127,7 +134,11 @@ func (p *ProductClient) DeductReservedStock(
127134
defer resp.Body.Close()
128135

129136
if resp.StatusCode != http.StatusOK {
130-
return fmt.Errorf("deduct reserved stock failed: %d", resp.StatusCode)
137+
return fmt.Errorf(
138+
"product %s failed: %d",
139+
action,
140+
resp.StatusCode,
141+
)
131142
}
132143

133144
return nil

0 commit comments

Comments
 (0)