package ucp import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/order" messages "git.k6n.net/mats/go-cart-actor/proto/order" "git.k6n.net/mats/platform/money" "google.golang.org/protobuf/proto" ) // testOrderApplier implements OrderReadApplier with an in-memory map of grains. type testOrderApplier struct { grains map[uint64]*order.OrderGrain } func newTestOrderApplier() *testOrderApplier { return &testOrderApplier{ grains: make(map[uint64]*order.OrderGrain), } } func (a *testOrderApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) { g, ok := a.grains[id] if !ok { // Auto-create so non-existent orders return StatusNew (expected 404). g = order.NewOrderGrain(id, time.UnixMilli(1000000)) a.grains[id] = g } return g, nil } func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) { g, err := a.Get(nil, id) if err != nil { return nil, err } results := make([]actor.ApplyResult, len(msgs)) for i, msg := range msgs { results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())} switch m := msg.(type) { case *messages.CancelOrder: if g.Status != order.StatusPending && g.Status != order.StatusAuthorized { results[i].Error = errRejected("cannot cancel in status " + string(g.Status)) break } g.Status = order.StatusCancelled g.UpdatedAt = time.Now().UTC().Format(time.RFC3339) case *messages.CreateFulfillment: if g.Status != order.StatusCaptured && g.Status != order.StatusPartiallyFulfilled { results[i].Error = errRejected("cannot fulfill in status " + string(g.Status)) break } fulfillment := order.Fulfillment{ ID: m.Id, Carrier: m.Carrier, TrackingNumber: m.TrackingNumber, TrackingURI: m.TrackingUri, } for _, fl := range m.Lines { fulfillment.Lines = append(fulfillment.Lines, order.FulfillmentEntry{ Reference: fl.Reference, Quantity: int(fl.Quantity), }) } g.Fulfillments = append(g.Fulfillments, fulfillment) g.Status = order.StatusFulfilled case *messages.RequestReturn: ret := order.Return{ ID: m.Id, Reason: m.Reason, } for _, fl := range m.Lines { ret.Lines = append(ret.Lines, order.FulfillmentEntry{ Reference: fl.Reference, Quantity: int(fl.Quantity), }) } g.Returns = append(g.Returns, ret) case *messages.IssueRefund: g.Refunds = append(g.Refunds, order.Refund{ Provider: m.Provider, Amount: money.Cents(m.Amount), Reference: m.Reference, }) g.RefundedAmount += money.Cents(m.Amount) if g.RefundedAmount >= g.CapturedAmount { g.Status = order.StatusRefunded } } } return &actor.MutationResult[order.OrderGrain]{ Result: *g, Mutations: results, }, nil } func errRejected(msg string) *actor.MutationError { return &actor.MutationError{Message: msg} } // seedOrder places an order in the test applier with a given status. func seedOrder(a *testOrderApplier, status order.Status) string { id, _ := order.NewOrderId() g, _ := a.Get(nil, uint64(id)) g.Status = status g.Currency = "SEK" g.TotalAmount = 10000 g.TotalTax = 2000 g.Lines = []order.Line{ {Reference: "test-1", Sku: "test-1", Name: "Test Item", Quantity: 2, UnitPrice: 5000, TaxRate: 2500, TotalAmount: 10000, TotalTax: 2000}, } if status == order.StatusCaptured { g.CapturedAmount = 10000 } return id.String() } func TestOrderHandler_GetOrder(t *testing.T) { a := newTestOrderApplier() h := OrderHandler(a) oid := seedOrder(a, order.StatusCaptured) req := httptest.NewRequest("GET", "/"+oid, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var resp OrderResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } if resp.OrderId != oid { t.Fatalf("expected orderId %q, got %q", oid, resp.OrderId) } if resp.Status != "captured" { t.Fatalf("expected status 'captured', got %q", resp.Status) } if len(resp.Lines) != 1 { t.Fatalf("expected 1 line, got %d", len(resp.Lines)) } if resp.Lines[0].Sku != "test-1" { t.Fatalf("expected sku 'test-1', got %q", resp.Lines[0].Sku) } } func TestOrderHandler_GetOrderNotFound(t *testing.T) { a := newTestOrderApplier() h := OrderHandler(a) // Non-existent order (not seeded) returns 404. req := httptest.NewRequest("GET", "/nonexistent", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404 for non-existent order (StatusNew -> 404), got %d", rec.Code) } } func TestOrderHandler_CancelOrder(t *testing.T) { a := newTestOrderApplier() h := OrderHandler(a) oid := seedOrder(a, order.StatusAuthorized) body := `{"reason": "customer request"}` req := httptest.NewRequest("POST", "/"+oid+"/cancel", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var resp OrderResponse json.NewDecoder(rec.Body).Decode(&resp) if resp.Status != "cancelled" { t.Fatalf("expected status 'cancelled', got %q", resp.Status) } } func TestOrderHandler_CancelOrder_IllegalState(t *testing.T) { a := newTestOrderApplier() h := OrderHandler(a) oid := seedOrder(a, order.StatusFulfilled) // can't cancel from fulfilled req := httptest.NewRequest("POST", "/"+oid+"/cancel", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422 for illegal transition, got %d: %s", rec.Code, rec.Body.String()) } } func TestOrderHandler_CreateFulfillment(t *testing.T) { a := newTestOrderApplier() h := OrderHandler(a) oid := seedOrder(a, order.StatusCaptured) body := `{"carrier":"PostNord","trackingNumber":"ABC123","lines":[{"reference":"test-1","quantity":2}]}` req := httptest.NewRequest("POST", "/"+oid+"/fulfillments", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var resp OrderResponse json.NewDecoder(rec.Body).Decode(&resp) if len(resp.Fulfillments) != 1 { t.Fatalf("expected 1 fulfillment, got %d", len(resp.Fulfillments)) } if resp.Fulfillments[0].Carrier != "PostNord" { t.Fatalf("expected carrier 'PostNord', got %q", resp.Fulfillments[0].Carrier) } if resp.Fulfillments[0].TrackingNumber != "ABC123" { t.Fatalf("expected tracking 'ABC123', got %q", resp.Fulfillments[0].TrackingNumber) } } func TestOrderHandler_RequestReturn(t *testing.T) { a := newTestOrderApplier() h := OrderHandler(a) oid := seedOrder(a, order.StatusFulfilled) body := `{"reason":"defective","lines":[{"reference":"test-1","quantity":1}]}` req := httptest.NewRequest("POST", "/"+oid+"/returns", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var resp OrderResponse json.NewDecoder(rec.Body).Decode(&resp) if len(resp.Returns) != 1 { t.Fatalf("expected 1 return, got %d", len(resp.Returns)) } if resp.Returns[0].Reason != "defective" { t.Fatalf("expected reason 'defective', got %q", resp.Returns[0].Reason) } } func TestOrderHandler_IssueRefund(t *testing.T) { a := newTestOrderApplier() h := OrderHandler(a) oid := seedOrder(a, order.StatusCaptured) body := `{"amount":3000}` req := httptest.NewRequest("POST", "/"+oid+"/refunds", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var resp OrderResponse json.NewDecoder(rec.Body).Decode(&resp) if len(resp.Refunds) != 1 { t.Fatalf("expected 1 refund, got %d", len(resp.Refunds)) } if resp.Refunds[0].Amount != 3000 { t.Fatalf("expected amount 3000, got %d", resp.Refunds[0].Amount) } } func TestOrderHandler_SignatureIntegration(t *testing.T) { cfg := mustLoadTestKey(t) a := newTestOrderApplier() oid := seedOrder(a, order.StatusCaptured) h := WithSigning(OrderHandler(a), cfg) req := httptest.NewRequest("GET", "/"+oid, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if rec.Header().Get("Signature-Input") == "" { t.Fatal("expected Signature-Input with signing enabled") } }