handle dynamic json data and examples for subscription details
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m28s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m14s

This commit is contained in:
matst80
2025-10-16 12:09:47 +02:00
parent 8682daf481
commit 9ab0c08e79
13 changed files with 934 additions and 307 deletions

View File

@@ -61,25 +61,33 @@ type CartNotification struct {
Content string `json:"content"`
}
type SubscriptionDetails struct {
Id string `json:"id"`
OfferingCode string `json:"offeringCode,omitempty"`
SigningType string `json:"signingType,omitempty"`
Meta json.RawMessage `json:"data,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
}
type Voucher struct {
@@ -139,17 +147,18 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
func NewCartGrain(id uint64, ts time.Time) *CartGrain {
return &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
lastVoucherId: 0,
lastAccess: ts,
lastChange: ts,
TotalDiscount: NewPrice(),
Vouchers: []*Voucher{},
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
lastItemId: 0,
lastDeliveryId: 0,
lastVoucherId: 0,
lastAccess: ts,
lastChange: ts,
TotalDiscount: NewPrice(),
Vouchers: []*Voucher{},
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
SubscriptionDetails: make(map[string]*SubscriptionDetails),
}
}

View File

@@ -42,6 +42,9 @@ func NewCartMultationRegistry() actor.MutationRegistry {
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
return &messages.RemoveVoucher{}
}),
actor.NewMutation(UpsertSubscriptionDetails, func() *messages.UpsertSubscriptionDetails {
return &messages.UpsertSubscriptionDetails{}
}),
)
return reg

View File

@@ -28,14 +28,23 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Fast path: merge with existing item having same SKU
if existing, found := g.FindItemWithSku(m.Sku); found {
if existing.StoreId == m.StoreId {
existing.Quantity += int(m.Quantity)
existing.Stock = StockStatus(m.Stock)
existing.StoreId = m.StoreId
return nil
// Merge with any existing item having same SKU and matching StoreId (including both nil).
for _, existing := range g.Items {
if existing.Sku != m.Sku {
continue
}
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
if !sameStore {
continue
}
existing.Quantity += int(m.Quantity)
existing.Stock = StockStatus(m.Stock)
// If existing had nil store but new has one, adopt it.
if existing.StoreId == nil && m.StoreId != nil {
existing.StoreId = m.StoreId
}
return nil
}
g.mu.Lock()

520
pkg/cart/mutation_test.go Normal file
View File

@@ -0,0 +1,520 @@
package cart
import (
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/gogo/protobuf/proto"
anypb "google.golang.org/protobuf/types/known/anypb"
"git.tornberg.me/go-cart-actor/pkg/actor"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// ----------------------
// Helper constructors
// ----------------------
func newTestGrain() *CartGrain {
return NewCartGrain(123, time.Now())
}
func newRegistry() actor.MutationRegistry {
return NewCartMultationRegistry()
}
func msgAddItem(sku string, price int64, qty int32, storePtr *string) *messages.AddItem {
return &messages.AddItem{
Sku: sku,
Price: price,
Quantity: qty,
// Tax left 0 -> handler uses default 25%
StoreId: storePtr,
}
}
func msgChangeQty(id uint32, qty int32) *messages.ChangeQuantity {
return &messages.ChangeQuantity{Id: id, Quantity: qty}
}
func msgRemoveItem(id uint32) *messages.RemoveItem {
return &messages.RemoveItem{Id: id}
}
func msgSetDelivery(provider string, items ...uint32) *messages.SetDelivery {
uitems := make([]uint32, len(items))
copy(uitems, items)
return &messages.SetDelivery{Provider: provider, Items: uitems}
}
func msgSetPickupPoint(deliveryId uint32, id string) *messages.SetPickupPoint {
return &messages.SetPickupPoint{
DeliveryId: deliveryId,
Id: id,
Name: ptr("Pickup"),
Address: ptr("Street 1"),
City: ptr("Town"),
Zip: ptr("12345"),
Country: ptr("SE"),
}
}
func msgClearCart() *messages.ClearCartRequest {
return &messages.ClearCartRequest{}
}
func msgAddVoucher(code string, value int64, rules ...*messages.VoucherRule) *messages.AddVoucher {
return &messages.AddVoucher{Code: code, Value: value, VoucherRules: rules}
}
func msgRemoveVoucher(id uint32) *messages.RemoveVoucher {
return &messages.RemoveVoucher{Id: id}
}
func msgInitializeCheckout(orderId, status string, inProgress bool) *messages.InitializeCheckout {
return &messages.InitializeCheckout{OrderId: orderId, Status: status, PaymentInProgress: inProgress}
}
func msgOrderCreated(orderId, status string) *messages.OrderCreated {
return &messages.OrderCreated{OrderId: orderId, Status: status}
}
func ptr[T any](v T) *T { return &v }
// ----------------------
// Apply helpers
// ----------------------
func applyOne(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) actor.ApplyResult {
t.Helper()
results, err := reg.Apply(g, msg)
if err != nil {
t.Fatalf("unexpected registry-level error applying %T: %v", msg, err)
}
if len(results) != 1 {
t.Fatalf("expected exactly one ApplyResult, got %d", len(results))
}
return results[0]
}
// Expect success (nil error inside ApplyResult).
func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error != nil {
t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error)
}
}
// Expect an error matching substring.
func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) {
t.Helper()
res := applyOne(t, reg, g, msg)
if res.Error == nil {
t.Fatalf("expected error applying %T, got nil", msg)
}
if substr != "" && !strings.Contains(res.Error.Error(), substr) {
t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error())
}
}
// ----------------------
// Tests
// ----------------------
func TestMutationRegistryCoverage(t *testing.T) {
reg := newRegistry()
expected := []string{
"AddItem",
"ChangeQuantity",
"RemoveItem",
"InitializeCheckout",
"OrderCreated",
"RemoveDelivery",
"SetDelivery",
"SetPickupPoint",
"ClearCartRequest",
"AddVoucher",
"RemoveVoucher",
"UpsertSubscriptionDetails",
}
names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations()
for _, want := range expected {
if !slices.Contains(names, want) {
t.Fatalf("registry missing mutation %s; got %v", want, names)
}
}
// Create() by name returns correct concrete type.
for _, name := range expected {
msg, ok := reg.Create(name)
if !ok {
t.Fatalf("Create failed for %s", name)
}
rt := reflect.TypeOf(msg)
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
if rt.Name() != name {
t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name())
}
}
// Unregistered create
if m, ok := reg.Create("DoesNotExist"); ok || m != nil {
t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok)
}
// GetTypeName sanity
add := &messages.AddItem{}
nm, ok := reg.GetTypeName(add)
if !ok || nm != "AddItem" {
t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok)
}
// Apply unregistered message -> result should contain ErrMutationNotRegistered, no top-level error
results, err := reg.Apply(newTestGrain(), &messages.Noop{})
if err != nil {
t.Fatalf("unexpected top-level error applying unregistered mutation: %v", err)
}
if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered {
t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results)
}
}
func TestAddItemAndMerging(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Merge scenario (same SKU + same store pointer)
add1 := msgAddItem("SKU-1", 1000, 2, nil)
applyOK(t, reg, g, add1)
if len(g.Items) != 1 || g.Items[0].Quantity != 2 {
t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge
if len(g.Items) != 1 || g.Items[0].Quantity != 5 {
t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
}
// Different store pointer -> new line
store := "S1"
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store))
if len(g.Items) != 2 {
t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items))
}
// Same store pointer & SKU -> merge with second line
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store))
if len(g.Items) != 2 || g.Items[1].Quantity != 5 {
t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity)
}
// Invalid quantity
applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity")
}
func TestChangeQuantityBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil))
id := g.Items[0].Id
// Increase quantity
applyOK(t, reg, g, msgChangeQty(id, 5))
if g.Items[0].Quantity != 5 {
t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity)
}
// Remove item by setting <=0
applyOK(t, reg, g, msgChangeQty(id, 0))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
// Not found
applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found")
}
func TestRemoveItemBehavior(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil))
id := g.Items[0].Id
applyOK(t, reg, g, msgRemoveItem(id))
if len(g.Items) != 0 {
t.Fatalf("expected item removed; items=%d", len(g.Items))
}
applyErrorContains(t, reg, g, msgRemoveItem(id), "not found")
}
func TestDeliveryMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil))
applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil))
i1 := g.Items[0].Id
// Explicit items
applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1))
if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 {
t.Fatalf("delivery not created as expected: %+v", g.Deliveries)
}
// Attempt to attach an already-delivered item
applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery")
// Attach remaining item via empty list (auto include items without delivery)
applyOK(t, reg, g, msgSetDelivery("DHL"))
if len(g.Deliveries) != 2 {
t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries))
}
// Non-existent item
applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found")
// No eligible items left
applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items")
// Set pickup point on first delivery
did := g.Deliveries[0].Id
applyOK(t, reg, g, msgSetPickupPoint(did, "PP1"))
if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" {
t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint)
}
// Bad delivery id
applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id")
// Remove delivery
applyOK(t, reg, g, &messages.RemoveDelivery{Id: did})
if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did {
t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries)
}
// Remove delivery not found
applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found")
}
func TestClearCart(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil))
applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id))
applyOK(t, reg, g, msgClearCart())
if len(g.Items) != 0 || len(g.Deliveries) != 0 {
t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries))
}
}
func TestVoucherMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil))
applyOK(t, reg, g, msgAddVoucher("PROMO", 5000))
if len(g.Vouchers) != 1 {
t.Fatalf("voucher not stored")
}
if g.TotalDiscount.IncVat != 5000 {
t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat)
}
if g.TotalPrice.IncVat != 5000 {
t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat)
}
// Duplicate voucher code
applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied")
// Add a large voucher (should not apply because value > total price)
applyOK(t, reg, g, msgAddVoucher("BIG", 100000))
if len(g.Vouchers) != 2 {
t.Fatalf("expected second voucher stored")
}
if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 {
t.Fatalf("large voucher incorrectly applied discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove existing voucher
firstId := g.Vouchers[0].Id
applyOK(t, reg, g, msgRemoveVoucher(firstId))
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) {
t.Fatalf("voucher id %d not removed", firstId)
}
// After removing PROMO, BIG remains but is not applied (exceeds price)
if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 {
t.Fatalf("totals incorrect after removal discount=%d total=%d",
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
}
// Remove not applied
applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied")
}
func TestCheckoutMutations(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true))
if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress {
t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v",
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
}
applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED"))
if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress {
t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v",
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
}
applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId")
applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId")
}
func TestSubscriptionDetailsMutation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Upsert new (Id == nil)
msgNew := &messages.UpsertSubscriptionDetails{
OfferingCode: "OFF1",
SigningType: "TYPE1",
}
applyOK(t, reg, g, msgNew)
if len(g.SubscriptionDetails) != 1 {
t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails))
}
// Capture created id
var createdId string
for k := range g.SubscriptionDetails {
createdId = k
}
// Update existing
msgUpdate := &messages.UpsertSubscriptionDetails{
Id: &createdId,
OfferingCode: "OFF2",
SigningType: "TYPE2",
}
applyOK(t, reg, g, msgUpdate)
if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" ||
g.SubscriptionDetails[createdId].SigningType != "TYPE2" {
t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId])
}
// Update non-existent
badId := "NON_EXISTENT"
applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found")
// Nil mutation should be ignored and produce zero results.
resultsNil, errNil := reg.Apply(g, (*messages.UpsertSubscriptionDetails)(nil))
if errNil != nil {
t.Fatalf("unexpected error for nil mutation element: %v", errNil)
}
if len(resultsNil) != 0 {
t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil))
}
}
// Ensure registry Apply handles nil grain and nil message defensive errors consistently.
func TestRegistryDefensiveErrors(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Nil grain
results, err := reg.Apply(nil, &messages.AddItem{})
if err == nil {
t.Fatalf("expected error for nil grain")
}
if len(results) != 0 {
t.Fatalf("expected no results for nil grain")
}
// Nil message slice
results, _ = reg.Apply(g, nil)
if len(results) != 0 {
t.Fatalf("expected no results when message slice nil")
}
}
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
reg := newRegistry()
g := newTestGrain()
// Valid JSON on create
validCreate := &messages.UpsertSubscriptionDetails{
OfferingCode: "OFFJSON",
SigningType: "TYPEJSON",
Data: &anypb.Any{Value: []byte(`{"ok":true}`)},
}
applyOK(t, reg, g, validCreate)
if len(g.SubscriptionDetails) != 1 {
t.Fatalf("expected one subscription detail after valid create, got %d", len(g.SubscriptionDetails))
}
var id string
for k := range g.SubscriptionDetails {
id = k
}
if string(g.SubscriptionDetails[id].Meta) != `{"ok":true}` {
t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Update with valid JSON replaces meta
updateValid := &messages.UpsertSubscriptionDetails{
Id: &id,
Data: &anypb.Any{Value: []byte(`{"changed":123}`)},
}
applyOK(t, reg, g, updateValid)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta))
}
// Invalid JSON on create
invalidCreate := &messages.UpsertSubscriptionDetails{
OfferingCode: "BAD",
Data: &anypb.Any{Value: []byte(`{"broken":}`)},
}
res := applyOne(t, reg, g, invalidCreate)
if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on create, got %v", res.Error)
}
// Invalid JSON on update
badUpdate := &messages.UpsertSubscriptionDetails{
Id: &id,
Data: &anypb.Any{Value: []byte(`{oops`)},
}
res2 := applyOne(t, reg, g, badUpdate)
if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") {
t.Fatalf("expected invalid json error on update, got %v", res2.Error)
}
// Empty Data.Value should not overwrite existing meta
emptyUpdate := &messages.UpsertSubscriptionDetails{
Id: &id,
Data: &anypb.Any{Value: []byte{}},
}
applyOK(t, reg, g, emptyUpdate)
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta))
}
}

View File

@@ -0,0 +1,57 @@
package cart
import (
"encoding/json"
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
if m == nil {
return nil
}
// Create new subscription details when Id is nil.
if m.Id == nil {
// Validate JSON if provided.
var meta json.RawMessage
if m.Data != nil && len(m.Data.Value) > 0 {
if !json.Valid(m.Data.Value) {
return fmt.Errorf("subscription details invalid json")
}
meta = json.RawMessage(m.Data.Value)
}
id := MustNewCartId().String()
g.SubscriptionDetails[id] = &SubscriptionDetails{
Id: id,
OfferingCode: m.OfferingCode,
SigningType: m.SigningType,
Meta: meta,
}
return nil
}
// Update existing entry.
existing, ok := g.SubscriptionDetails[*m.Id]
if !ok {
return fmt.Errorf("subscription details not found")
}
if m.OfferingCode != "" {
existing.OfferingCode = m.OfferingCode
}
if m.SigningType != "" {
existing.SigningType = m.SigningType
}
if m.Data != nil {
// Only validate & assign if there is content; empty -> leave as-is.
if len(m.Data.Value) > 0 {
if !json.Valid(m.Data.Value) {
return fmt.Errorf("subscription details invalid json")
}
existing.Meta = m.Data.Value
}
}
return nil
}

135
pkg/cart/price_test.go Normal file
View File

@@ -0,0 +1,135 @@
package cart
import (
"encoding/json"
"testing"
)
func TestPriceMarshalJSON(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
// ExVat = 13700 - (2500+1200) = 10000
data, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
// Unmarshal into a generic struct to validate fields
var out struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if out.ExVat != 10000 {
t.Fatalf("expected exVat 10000 got %d", out.ExVat)
}
if out.IncVat != 13700 {
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
}
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
t.Fatalf("unexpected vat map: %#v", out.Vat)
}
}
func TestNewPriceFromIncVat(t *testing.T) {
p := NewPriceFromIncVat(1250, 25)
if p.IncVat != 1250 {
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
}
if p.VatRates[25] != 250 {
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
}
if p.ValueExVat() != 1000 {
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
}
}
func TestSumPrices(t *testing.T) {
// We'll construct prices via raw struct since constructor expects tax math.
// IncVat already includes vat portions.
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
c := Price{IncVat: 0, VatRates: nil}
sum := SumPrices(a, b, c)
if sum.IncVat != 3990 { // 1250+2740
t.Fatalf("expected incVat 3990 got %d", sum.IncVat)
}
if len(sum.VatRates) != 2 {
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
}
if sum.VatRates[25] != 750 {
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
}
if sum.VatRates[12] != 240 {
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
}
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
}
}
func TestSumPricesEmpty(t *testing.T) {
sum := SumPrices()
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
t.Fatalf("expected zero price got %#v", sum)
}
}
func TestMultiplyPriceFunction(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
multiplied := MultiplyPrice(base, 3)
if multiplied.IncVat != 1250*3 {
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
}
if multiplied.VatRates[25] != 250*3 {
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
}
if multiplied.ValueExVat() != (1250-250)*3 {
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
}
}
func TestPriceAddSubtract(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
acc := NewPrice()
acc.Add(a)
acc.Add(b)
if acc.IncVat != 1500 {
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
}
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
}
// Subtract b then a returns to zero
acc.Subtract(b)
acc.Subtract(a)
if acc.IncVat != 0 {
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
}
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
}
}
func TestPriceMultiplyMethod(t *testing.T) {
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
// Value before multiply
exBefore := p.ValueExVat()
p.Multiply(2)
if p.IncVat != 4000 {
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
}
if p.VatRates[25] != 800 {
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
}
if p.ValueExVat() != exBefore*2 {
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
}
}