Compare commits
29 Commits
refactor/h
...
86f97f2888
| Author | SHA1 | Date | |
|---|---|---|---|
| 86f97f2888 | |||
| af3eb0d7bf | |||
|
|
a0c82dc351 | ||
|
|
0c127e9d38 | ||
|
|
662b381a34 | ||
|
|
e127251a60 | ||
|
|
b7f0990269 | ||
|
|
d58409e3fc | ||
|
|
2ce45656d9 | ||
|
|
dc352e3b74 | ||
|
|
915d845014 | ||
|
|
4a54661f24 | ||
|
|
918aa7d265 | ||
|
|
a1833d6685 | ||
|
|
614be25ae8 | ||
|
|
71fc23bf50 | ||
| db918730d5 | |||
| 8ecad3060f | |||
|
|
c060680768 | ||
|
|
15089862d5 | ||
|
|
cdb0241c8a | ||
|
|
07a7ec5781 | ||
|
|
fa89670553 | ||
|
|
9ab0c08e79 | ||
| 8682daf481 | |||
|
|
8c2bcf5e75 | ||
|
|
47adb12112 | ||
|
|
4835041f14 | ||
| 104f9fbb4c |
@@ -47,6 +47,9 @@ jobs:
|
||||
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
- name: Apply deployment manifests
|
||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||
- name: Rollout amd64 backoffice deployment
|
||||
run: |
|
||||
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
|
||||
- name: Rollout amd64 deployment (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
|
||||
@@ -59,6 +59,13 @@ RUN --mount=type=cache,target=/go/build-cache \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-actor ./cmd/cart
|
||||
|
||||
RUN --mount=type=cache,target=/go/build-cache \
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-backoffice ./cmd/backoffice
|
||||
|
||||
############################
|
||||
# Runtime Stage
|
||||
############################
|
||||
@@ -67,6 +74,7 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
||||
|
||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||
EXPOSE 8080 1337
|
||||
|
||||
31
README.md
31
README.md
@@ -1,36 +1,5 @@
|
||||
# Go Cart Actor
|
||||
|
||||
## Migration Notes (Ring-based Ownership Transition)
|
||||
|
||||
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
|
||||
|
||||
Summary of changes:
|
||||
- ConfirmOwner RPC removed from the ControlPlane service.
|
||||
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
|
||||
- OwnerChangeAck retained solely as the response type for the Closing RPC.
|
||||
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
|
||||
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
|
||||
- New Prometheus ring metrics:
|
||||
- cart_ring_epoch
|
||||
- cart_ring_hosts
|
||||
- cart_ring_vnodes
|
||||
- cart_ring_host_share{host}
|
||||
- cart_ring_lookup_local_total
|
||||
- cart_ring_lookup_remote_total
|
||||
|
||||
Action required for consumers:
|
||||
1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
|
||||
2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
|
||||
3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
|
||||
4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
|
||||
- Rapid changes in ring epoch (cart_ring_epoch)
|
||||
- Host churn (cart_ring_hosts)
|
||||
- Imbalance in vnode distribution (cart_ring_host_share)
|
||||
|
||||
No data migration is necessary; cart IDs and grain state are unaffected.
|
||||
|
||||
---
|
||||
|
||||
A distributed cart management system using the actor model pattern.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
280
cmd/backoffice/fileserver.go
Normal file
280
cmd/backoffice/fileserver.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
type FileServer struct {
|
||||
// Define fields here
|
||||
dataDir string
|
||||
storage actor.LogStorage[cart.CartGrain]
|
||||
}
|
||||
|
||||
func NewFileServer(dataDir string, storage actor.LogStorage[cart.CartGrain]) *FileServer {
|
||||
return &FileServer{
|
||||
dataDir: dataDir,
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
func isValidId(id string) (uint64, bool) {
|
||||
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||
return nr, true
|
||||
}
|
||||
if nr, ok := cart.ParseCartId(id); ok {
|
||||
return uint64(nr), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isValidFileId(name string) (uint64, bool) {
|
||||
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) > 1 && parts[1] == "events" {
|
||||
idStr := parts[0]
|
||||
|
||||
return isValidId(idStr)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// func AccessTime(info os.FileInfo) (time.Time, bool) {
|
||||
// switch stat := info.Sys().(type) {
|
||||
// case *syscall.Stat_t:
|
||||
// // Linux: Atim; macOS/BSD: Atimespec
|
||||
// // Use reflection or build tags if naming differs.
|
||||
// // Linux:
|
||||
// if stat.Atim.Sec != 0 || stat.Atim.Nsec != 0 {
|
||||
// return time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)), true
|
||||
// }
|
||||
// // macOS/BSD example (uncomment if needed):
|
||||
// //return time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)), true
|
||||
// }
|
||||
// return time.Time{}, false
|
||||
// }
|
||||
|
||||
func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
|
||||
//sys := info.Sys()
|
||||
//fmt.Printf("sys type %T", sys)
|
||||
out.Size = info.Size()
|
||||
out.Modified = info.ModTime()
|
||||
//out.Accessed, _ = AccessTime(info)
|
||||
return out
|
||||
}
|
||||
|
||||
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
|
||||
|
||||
func listCartFiles(dir string) ([]*CartFileInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []*CartFileInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*CartFileInfo, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id, valid := isValidFileId(e.Name())
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info.Sys()
|
||||
out = append(out, appendFileInfo(info, &CartFileInfo{
|
||||
ID: fmt.Sprintf("%d", id),
|
||||
CartId: cart.CartId(id),
|
||||
}))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readRawLogLines(path string) ([]json.RawMessage, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fh.Close()
|
||||
lines := make([]json.RawMessage, 0, 64)
|
||||
s := bufio.NewScanner(fh)
|
||||
// increase buffer to handle larger JSON lines
|
||||
buf := make([]byte, 0, 1024*1024)
|
||||
s.Buffer(buf, 1024*1024)
|
||||
for s.Scan() {
|
||||
line := s.Bytes()
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listCartFiles(fs.dataDir)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// sort by modified desc
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"count": len(list),
|
||||
"carts": list,
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
||||
if r.Method == http.MethodGet {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
file, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(file, r.Body)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Join(fs.dataDir, "vouchers.json")
|
||||
if r.Method == http.MethodGet {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
file, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(file, r.Body)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "missing id")
|
||||
return
|
||||
}
|
||||
_, ok := isValidId(idStr)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "invalid id %s", idStr)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
type JsonError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
||||
return
|
||||
}
|
||||
id, ok := isValidId(idStr)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
// reconstruct state from event log if present
|
||||
grain := cart.NewCartGrain(id, time.Now())
|
||||
|
||||
err := fs.storage.LoadEvents(id, grain)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(fs.dataDir, fmt.Sprintf("%d.events.log", id))
|
||||
info, err := os.Stat(path)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "cart not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
lines, err := readRawLogLines(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": id,
|
||||
"cartId": cart.CartId(id).String(),
|
||||
"state": grain,
|
||||
"mutations": lines,
|
||||
"meta": map[string]any{
|
||||
"size": info.Size(),
|
||||
"modified": info.ModTime(),
|
||||
"path": path,
|
||||
},
|
||||
})
|
||||
}
|
||||
89
cmd/backoffice/fileserver_test.go
Normal file
89
cmd/backoffice/fileserver_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
||||
// repository (from a small curated list to keep the test hermetic) and verifies
|
||||
// that appendFileInfo populates Size, Modified and System without mutating the
|
||||
// identity fields (ID, CartId). The randomness is only to satisfy the requirement
|
||||
// of using "a random project file"; the test behavior is deterministic enough for
|
||||
// CI because all chosen files are expected to exist.
|
||||
func TestAppendFileInfoRandomProjectFile(t *testing.T) {
|
||||
candidates := []string{
|
||||
filepath.FromSlash("../../pkg/cart/cart_id.go"),
|
||||
filepath.FromSlash("../../pkg/actor/grain.go"),
|
||||
filepath.FromSlash("../../cmd/cart/main.go"),
|
||||
}
|
||||
// Pick one at random.
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
path := candidates[rand.Intn(len(candidates))]
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat failed for %s: %v", path, err)
|
||||
}
|
||||
|
||||
// Pre-populate a CartFileInfo with identity fields.
|
||||
origID := "test-id"
|
||||
origCartId := cart.CartId(12345)
|
||||
cf := &CartFileInfo{ID: origID, CartId: origCartId}
|
||||
|
||||
// Call function under test.
|
||||
got := appendFileInfo(info, cf)
|
||||
|
||||
if got != cf {
|
||||
t.Fatalf("appendFileInfo should return the same pointer instance")
|
||||
}
|
||||
|
||||
if cf.ID != origID {
|
||||
t.Fatalf("ID mutated: expected %q got %q", origID, cf.ID)
|
||||
}
|
||||
if cf.CartId != origCartId {
|
||||
t.Fatalf("CartId mutated: expected %v got %v", origCartId, cf.CartId)
|
||||
}
|
||||
|
||||
if cf.Size != info.Size() {
|
||||
t.Fatalf("Size mismatch: expected %d got %d", info.Size(), cf.Size)
|
||||
}
|
||||
|
||||
mod := info.ModTime()
|
||||
// Allow small clock skew / coarse timestamp truncation.
|
||||
if cf.Modified.Before(mod.Add(-2*time.Second)) || cf.Modified.After(mod.Add(2*time.Second)) {
|
||||
t.Fatalf("Modified not within expected range: want ~%v got %v", mod, cf.Modified)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestAppendFileInfoTempFile creates a temporary file to ensure Size and Modified
|
||||
// are updated for a freshly written file with known content length.
|
||||
func TestAppendFileInfoTempFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "temp.events.log")
|
||||
content := []byte("hello world\nanother line\n")
|
||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||
t.Fatalf("write temp file failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temp file failed: %v", err)
|
||||
}
|
||||
|
||||
cf := &CartFileInfo{ID: "temp", CartId: cart.CartId(0)}
|
||||
appendFileInfo(info, cf)
|
||||
|
||||
if cf.Size != int64(len(content)) {
|
||||
t.Fatalf("expected Size %d got %d", len(content), cf.Size)
|
||||
}
|
||||
if cf.Modified.IsZero() {
|
||||
t.Fatalf("Modified should be set")
|
||||
}
|
||||
}
|
||||
248
cmd/backoffice/hub.go
Normal file
248
cmd/backoffice/hub.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Hub manages websocket clients and broadcasts messages to them.
|
||||
type Hub struct {
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
clients map[*Client]bool
|
||||
}
|
||||
|
||||
// Client represents a single websocket client connection.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn net.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// NewHub constructs a new Hub instance.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 1024),
|
||||
clients: make(map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the hub event loop.
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case c := <-h.register:
|
||||
h.clients[c] = true
|
||||
case c := <-h.unregister:
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
case msg := <-h.broadcast:
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
// Client is slow or dead; drop it.
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// computeAccept computes the Sec-WebSocket-Accept header value.
|
||||
func computeAccept(key string) string {
|
||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
h := sha1.New()
|
||||
h.Write([]byte(key + magic))
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ServeWS upgrades the HTTP request to a WebSocket connection and registers a client.
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
||||
http.Error(w, "upgrade required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accept := computeAccept(key)
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "websocket not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, buf, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the upgrade response
|
||||
response := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: " + accept + "\r\n" +
|
||||
"\r\n"
|
||||
if _, err := buf.WriteString(response); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if err := buf.Flush(); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
h.register <- client
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// writeWSFrame writes a single WebSocket frame to the writer.
|
||||
func writeWSFrame(w io.Writer, opcode byte, payload []byte) error {
|
||||
// FIN set, opcode as provided
|
||||
header := []byte{0x80 | (opcode & 0x0F)}
|
||||
l := len(payload)
|
||||
switch {
|
||||
case l < 126:
|
||||
header = append(header, byte(l))
|
||||
case l <= 65535:
|
||||
ext := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(ext, uint16(l))
|
||||
header = append(header, 126)
|
||||
header = append(header, ext...)
|
||||
default:
|
||||
ext := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(ext, uint64(l))
|
||||
header = append(header, 127)
|
||||
header = append(header, ext...)
|
||||
}
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if l > 0 {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPump handles control frames from the client and discards other incoming frames.
|
||||
// This server is broadcast-only to clients.
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
}()
|
||||
reader := bufio.NewReader(c.conn)
|
||||
for {
|
||||
// Read first two bytes
|
||||
b1, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b2, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
opcode := b1 & 0x0F
|
||||
masked := (b2 & 0x80) != 0
|
||||
length := int64(b2 & 0x7F)
|
||||
if length == 126 {
|
||||
ext := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint16(ext))
|
||||
} else if length == 127 {
|
||||
ext := make([]byte, 8)
|
||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint64(ext))
|
||||
}
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ping -> Pong
|
||||
if opcode == 0x9 && length <= 125 {
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return
|
||||
}
|
||||
// Unmask if masked
|
||||
if masked {
|
||||
for i := int64(0); i < length; i++ {
|
||||
payload[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
_ = writeWSFrame(c.conn, 0xA, payload) // best-effort pong
|
||||
continue
|
||||
}
|
||||
|
||||
// Close frame
|
||||
if opcode == 0x8 {
|
||||
// Drain payload if any, then exit
|
||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For other frames, just discard payload
|
||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump sends queued messages to the client and pings periodically to keep the connection alive.
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
if !ok {
|
||||
// try to send close frame
|
||||
_ = writeWSFrame(c.conn, 0x8, nil)
|
||||
return
|
||||
}
|
||||
if err := writeWSFrame(c.conn, 0x1, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Send a ping to keep connections alive behind proxies
|
||||
_ = writeWSFrame(c.conn, 0x9, []byte("ping"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,152 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
// Your code here
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
actor "git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type CartFileInfo struct {
|
||||
ID string `json:"id"`
|
||||
CartId cart.CartId `json:"cartId"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
func envOrDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
||||
if err != nil {
|
||||
_ = ch.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m, ok := <-msgs:
|
||||
if !ok {
|
||||
log.Fatalf("connection closed")
|
||||
continue
|
||||
}
|
||||
// Log and broadcast to all websocket clients
|
||||
log.Printf("mutation event: %s", string(m.Body))
|
||||
|
||||
if hub != nil {
|
||||
select {
|
||||
case hub.broadcast <- m.Body:
|
||||
default:
|
||||
// if hub queue is full, drop to avoid blocking
|
||||
}
|
||||
}
|
||||
if err := m.Ack(false); err != nil {
|
||||
log.Printf("error acknowledging message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
dataDir := envOrDefault("DATA_DIR", "data")
|
||||
addr := envOrDefault("ADDR", ":8080")
|
||||
amqpURL := os.Getenv("AMQP_URL")
|
||||
|
||||
_ = os.MkdirAll(dataDir, 0755)
|
||||
|
||||
reg := cart.NewCartMultationRegistry()
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
||||
|
||||
fs := NewFileServer(dataDir, diskStorage)
|
||||
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
||||
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
||||
mux.HandleFunc("/promotions", fs.PromotionsHandler)
|
||||
mux.HandleFunc("/vouchers", fs.VoucherHandler)
|
||||
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
|
||||
|
||||
mux.HandleFunc("/ws", hub.ServeWS)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Global CORS middleware allowing all origins and handling preflight
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if amqpURL != "" {
|
||||
conn, err := amqp.Dial(amqpURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
if err := startMutationConsumer(ctx, conn, hub); err != nil {
|
||||
log.Printf("AMQP listener disabled: %v", err)
|
||||
} else {
|
||||
log.Printf("AMQP listener connected")
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server error: %v", err)
|
||||
}
|
||||
|
||||
// server stopped
|
||||
}
|
||||
|
||||
@@ -9,42 +9,48 @@ import (
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
Connection *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
conn *amqp.Connection
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Connect() error {
|
||||
conn, err := amqp.Dial(h.Url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
|
||||
return &AmqpOrderHandler{
|
||||
conn: conn,
|
||||
}
|
||||
h.Connection = conn
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
func (h *AmqpOrderHandler) DefineTopics() error {
|
||||
ch, err := h.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
h.Channel = ch
|
||||
defer ch.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
err = ch.ExchangeDeclare(
|
||||
"orders", // name
|
||||
"direct", // type
|
||||
true, // durable
|
||||
false, // auto-deleted
|
||||
false, // internal
|
||||
false, // no-wait
|
||||
nil, // arguments
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to declare an exchange: %w", err)
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Close() error {
|
||||
if h.Channel != nil {
|
||||
h.Channel.Close()
|
||||
}
|
||||
if h.Connection != nil {
|
||||
return h.Connection.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ch, err := h.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.Channel.PublishWithContext(ctx,
|
||||
return ch.PublishWithContext(ctx,
|
||||
"orders", // exchange
|
||||
"new", // routing key
|
||||
false, // mandatory
|
||||
@@ -53,9 +59,5 @@ func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish a message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// CheckoutMeta carries the external / URL metadata required to build a
|
||||
@@ -33,7 +35,7 @@ type CheckoutMeta struct {
|
||||
//
|
||||
// If you later need to support different tax rates per line, you can extend
|
||||
// CartItem / Delivery to expose that data and propagate it here.
|
||||
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
|
||||
243
cmd/cart/main.go
243
cmd/cart/main.go
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
"git.tornberg.me/go-cart-actor/pkg/discovery"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/proxy"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -45,7 +47,7 @@ func init() {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
pool *actor.SimpleGrainPool[CartGrain]
|
||||
pool *actor.SimpleGrainPool[cart.CartGrain]
|
||||
}
|
||||
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
@@ -97,63 +99,24 @@ type MutationContext struct {
|
||||
VoucherService voucher.Service
|
||||
}
|
||||
|
||||
type CartChangeEvent struct {
|
||||
CartId cart.CartId `json:"cartId"`
|
||||
Mutations []actor.ApplyResult `json:"mutations"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
controlPlaneConfig := actor.DefaultServerConfig()
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
reg.RegisterMutations(
|
||||
actor.NewMutation(AddItem, func() *messages.AddItem {
|
||||
return &messages.AddItem{}
|
||||
}),
|
||||
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
|
||||
return &messages.ChangeQuantity{}
|
||||
}),
|
||||
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
|
||||
return &messages.RemoveItem{}
|
||||
}),
|
||||
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
|
||||
return &messages.InitializeCheckout{}
|
||||
}),
|
||||
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
|
||||
return &messages.OrderCreated{}
|
||||
}),
|
||||
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
|
||||
return &messages.RemoveDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
|
||||
return &messages.SetDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
|
||||
return &messages.SetPickupPoint{}
|
||||
}),
|
||||
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
|
||||
return &messages.ClearCartRequest{}
|
||||
}),
|
||||
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
|
||||
return &messages.AddVoucher{}
|
||||
}),
|
||||
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
|
||||
return &messages.RemoveVoucher{}
|
||||
}),
|
||||
)
|
||||
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
|
||||
poolConfig := actor.GrainPoolConfig[CartGrain]{
|
||||
reg := cart.NewCartMultationRegistry()
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
|
||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
|
||||
Spawn: func(id uint64) (actor.Grain[cart.CartGrain], error) {
|
||||
grainSpawns.Inc()
|
||||
ret := &CartGrain{
|
||||
lastItemId: 0,
|
||||
lastDeliveryId: 0,
|
||||
Deliveries: []*CartDelivery{},
|
||||
Id: CartId(id),
|
||||
Items: []*CartItem{},
|
||||
TotalPrice: NewPrice(),
|
||||
}
|
||||
ret := cart.NewCartGrain(id, time.Now())
|
||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||
ret.lastChange = time.Now()
|
||||
ret.lastAccess = time.Now()
|
||||
|
||||
err := diskStorage.LoadEvents(id, ret)
|
||||
|
||||
@@ -175,13 +138,77 @@ func main() {
|
||||
pool: pool,
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if amqpUrl == "" {
|
||||
log.Printf("no connection to amqp defined")
|
||||
} else {
|
||||
conn, err := amqp.Dial(amqpUrl)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %w", err)
|
||||
}
|
||||
|
||||
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
|
||||
return &CartChangeEvent{
|
||||
CartId: cart.CartId(id),
|
||||
Mutations: msg,
|
||||
}, nil
|
||||
})
|
||||
amqpListener.DefineTopics()
|
||||
pool.AddListener(amqpListener)
|
||||
orderHandler := NewAmqpOrderHandler(conn)
|
||||
orderHandler.DefineTopics()
|
||||
|
||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = confirmOrder(order, orderHandler)
|
||||
if err != nil {
|
||||
log.Printf("Error confirming order: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = triggerOrderCompleted(syncedServer, order)
|
||||
if err != nil {
|
||||
log.Printf("Error processing cart message: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = klarnaClient.AcknowledgeOrder(orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
go diskStorage.SaveLoop(10 * time.Second)
|
||||
// go diskStorage.SaveLoop(10 * time.Second)
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
@@ -211,13 +238,6 @@ func main() {
|
||||
}
|
||||
}(GetDiscovery())
|
||||
|
||||
orderHandler := &AmqpOrderHandler{
|
||||
Url: amqpUrl,
|
||||
}
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||
// only for local
|
||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -256,61 +276,14 @@ func main() {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
order := &CheckoutOrder{}
|
||||
mux.HandleFunc("/checkout", syncedServer.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
return err
|
||||
}))
|
||||
|
||||
if orderId == "" {
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
if cookie.Value == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("no cart id to checkout is empty"))
|
||||
return
|
||||
}
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid cart id format"))
|
||||
return
|
||||
}
|
||||
cartId := parsed
|
||||
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
return nil
|
||||
})(cartId, w, r)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||
} else {
|
||||
order, err = klarnaClient.GetOrder(orderId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
}
|
||||
|
||||
})
|
||||
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
@@ -365,43 +338,7 @@ func main() {
|
||||
//}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := klarnaClient.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = confirmOrder(order, orderHandler)
|
||||
if err != nil {
|
||||
log.Printf("Error confirming order: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = triggerOrderCompleted(syncedServer, order)
|
||||
if err != nil {
|
||||
log.Printf("Error processing cart message: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = klarnaClient.AcknowledgeOrder(orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1.0.0"))
|
||||
@@ -433,7 +370,7 @@ func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error
|
||||
OrderId: order.ID,
|
||||
Status: order.Status,
|
||||
}
|
||||
cid, ok := ParseCartId(order.MerchantReference1)
|
||||
cid, ok := cart.ParseCartId(order.MerchantReference1)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||
}
|
||||
@@ -447,11 +384,7 @@ func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = orderHandler.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer orderHandler.Close()
|
||||
|
||||
err = orderHandler.OrderCompleted(orderToSend)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -11,18 +11,19 @@ import (
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||
"github.com/gogo/protobuf/proto"
|
||||
)
|
||||
|
||||
type PoolServer struct {
|
||||
actor.GrainPool[*CartGrain]
|
||||
actor.GrainPool[*cart.CartGrain]
|
||||
pod_name string
|
||||
klarnaClient *KlarnaClient
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
|
||||
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
|
||||
return &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
@@ -30,11 +31,11 @@ func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClie
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
|
||||
func (s *PoolServer) ApplyLocal(id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
|
||||
return s.Apply(uint64(id), mutation...)
|
||||
}
|
||||
|
||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
grain, err := s.Get(uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -43,7 +44,7 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id C
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
|
||||
if err != nil {
|
||||
@@ -73,7 +74,7 @@ func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
itemIdString := r.PathValue("itemId")
|
||||
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
|
||||
@@ -93,7 +94,7 @@ type SetDeliveryRequest struct {
|
||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
delivery := SetDeliveryRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||
@@ -111,7 +112,7 @@ func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request,
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
|
||||
@@ -138,7 +139,7 @@ func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Reques
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||
@@ -152,7 +153,7 @@ func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Reques
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
changeQuantity := messages.ChangeQuantity{}
|
||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||
if err != nil {
|
||||
@@ -197,7 +198,7 @@ func getMultipleAddMessages(items []Item, country string) []proto.Message {
|
||||
return msgs
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
@@ -215,7 +216,7 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
setCartItems := SetCartItems{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
@@ -236,7 +237,7 @@ type AddRequest struct {
|
||||
StoreId *string `json:"storeId"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
addRequest := AddRequest{Quantity: 1}
|
||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||
if err != nil {
|
||||
@@ -287,7 +288,7 @@ func getLocale(country string) string {
|
||||
return "sv-se"
|
||||
}
|
||||
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(host string, id cart.CartId) (*CheckoutOrder, error) {
|
||||
country := getCountryFromHost(host)
|
||||
meta := &CheckoutMeta{
|
||||
Terms: fmt.Sprintf("https://%s/terms", host),
|
||||
@@ -319,7 +320,7 @@ func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOr
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
|
||||
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
|
||||
// Persist initialization state via mutation (best-effort)
|
||||
return s.ApplyLocal(id, &messages.InitializeCheckout{
|
||||
OrderId: klarnaOrder.ID,
|
||||
@@ -341,12 +342,12 @@ func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId)
|
||||
// }
|
||||
//
|
||||
|
||||
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
var id cart.CartId
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
id = MustNewCartId()
|
||||
id = cart.MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
@@ -358,9 +359,9 @@ func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.R
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
id = MustNewCartId()
|
||||
id = cart.MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: id.String(),
|
||||
@@ -388,7 +389,7 @@ func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.R
|
||||
|
||||
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
|
||||
|
||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
// Clear cart cookie (breaking change: do not issue a new legacy id here)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
@@ -403,17 +404,17 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
|
||||
return nil
|
||||
}
|
||||
|
||||
func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id CartId
|
||||
var id cart.CartId
|
||||
raw := r.PathValue("id")
|
||||
// If no id supplied, generate a new one
|
||||
if raw == "" {
|
||||
id := MustNewCartId()
|
||||
id := cart.MustNewCartId()
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
} else {
|
||||
// Parse base62 cart id
|
||||
if parsedId, ok := ParseCartId(raw); !ok {
|
||||
if parsedId, ok := cart.ParseCartId(raw); !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("cart id is invalid"))
|
||||
return
|
||||
@@ -431,8 +432,8 @@ func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
return func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
||||
if err == nil && handled {
|
||||
@@ -449,12 +450,16 @@ type AddVoucherRequest struct {
|
||||
VoucherCode string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
data := &AddVoucherRequest{}
|
||||
json.NewDecoder(r.Body).Decode(data)
|
||||
v := voucher.Service{}
|
||||
msg, err := v.GetVoucher(data.VoucherCode)
|
||||
if err != nil {
|
||||
s.ApplyLocal(cartId, &messages.PreConditionFailed{
|
||||
Operation: "AddVoucher",
|
||||
Error: err.Error(),
|
||||
})
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
@@ -469,7 +474,44 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
data := &messages.UpsertSubscriptionDetails{}
|
||||
err := json.NewDecoder(r.Body).Decode(data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(cartId, data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
s.WriteResult(w, reply)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return CookieCartIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
if orderId == "" {
|
||||
order, err := s.CreateOrUpdateCheckout(r.Host, cartId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.ApplyCheckoutStarted(order, cartId)
|
||||
return fn(order, w)
|
||||
}
|
||||
order, err := s.klarnaClient.GetOrder(orderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(order, w)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
|
||||
idStr := r.PathValue("voucherId")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
@@ -510,6 +552,7 @@ func (s *PoolServer) Serve() *http.ServeMux {
|
||||
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
mux.HandleFunc("PUT /subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
||||
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
|
||||
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
"github.com/matst80/slask-finder/pkg/index"
|
||||
)
|
||||
@@ -42,31 +43,41 @@ func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*m
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ToItemAddMessage(item, storeId, qty, country), nil
|
||||
return ToItemAddMessage(item, storeId, qty, country)
|
||||
}
|
||||
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
|
||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||
|
||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stock := StockStatus(0)
|
||||
stock := cart.StockStatus(0)
|
||||
centralStockValue, ok := item.GetStringFieldValue(3)
|
||||
if storeId == nil {
|
||||
if ok {
|
||||
if !item.Buyable {
|
||||
return nil, fmt.Errorf("item not available")
|
||||
}
|
||||
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
|
||||
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
|
||||
stock = StockStatus(centralStock)
|
||||
stock = cart.StockStatus(centralStock)
|
||||
}
|
||||
if stock == cart.StockStatus(0) && item.SaleStatus == "TBD" {
|
||||
return nil, fmt.Errorf("no items available")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !item.BuyableInStore {
|
||||
return nil, fmt.Errorf("item not available in store")
|
||||
}
|
||||
storeStock, ok := item.Stock.GetStock()[*storeId]
|
||||
if ok {
|
||||
stock = StockStatus(storeStock)
|
||||
stock = cart.StockStatus(storeStock)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||
@@ -108,7 +119,8 @@ func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country st
|
||||
Country: country,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
}
|
||||
SaleStatus: item.SaleStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getTax(articleType string) int32 {
|
||||
@@ -119,3 +131,10 @@ func getTax(articleType string) int32 {
|
||||
return 2500
|
||||
}
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
}
|
||||
|
||||
BIN
data/1.prot
BIN
data/1.prot
Binary file not shown.
BIN
data/4.prot
BIN
data/4.prot
Binary file not shown.
BIN
data/5.prot
BIN
data/5.prot
Binary file not shown.
BIN
data/state.gob
BIN
data/state.gob
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,94 @@ type: Opaque
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
name: cart-backoffice-x86
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
name: cart-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
command: ["/go-cart-backoffice"]
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
# - name: BASE_URL
|
||||
# value: "https://s10n-no.tornberg.me"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
@@ -222,6 +310,17 @@ spec:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: cart-backoffice
|
||||
spec:
|
||||
selector:
|
||||
app: cart-backoffice
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
@@ -250,3 +349,27 @@ spec:
|
||||
name: cart-actor
|
||||
port:
|
||||
number: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: cart-backend-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- slask-cart.tornberg.me
|
||||
secretName: cart-backoffice-actor-tls-secret
|
||||
rules:
|
||||
- host: slask-cart.tornberg.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cart-backoffice
|
||||
port:
|
||||
number: 8080
|
||||
|
||||
@@ -26,7 +26,7 @@ type DiskStorage[V any] struct {
|
||||
|
||||
type LogStorage[V any] interface {
|
||||
LoadEvents(id uint64, grain Grain[V]) error
|
||||
AppendEvent(id uint64, msg ...proto.Message) error
|
||||
AppendMutations(id uint64, msg ...proto.Message) error
|
||||
}
|
||||
|
||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||
@@ -104,11 +104,13 @@ func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) Close() {
|
||||
s.save()
|
||||
if s.queue != nil {
|
||||
s.save()
|
||||
}
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
|
||||
func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error {
|
||||
if s.queue != nil {
|
||||
queue := make([]QueueEvent, 0)
|
||||
data, found := s.queue.Load(id)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
// It delegates to a grain pool and cluster operations to a synced pool.
|
||||
type ControlServer[V any] struct {
|
||||
messages.UnimplementedControlPlaneServer
|
||||
|
||||
pool GrainPool[V]
|
||||
}
|
||||
|
||||
|
||||
47
pkg/actor/log_listerner.go
Normal file
47
pkg/actor/log_listerner.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
transformer: transformer,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
data, err := l.transformer(id, msg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to transform mutation event: %v", err)
|
||||
return
|
||||
}
|
||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -171,11 +171,21 @@ func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyR
|
||||
if grain == nil {
|
||||
return results, fmt.Errorf("nil grain")
|
||||
}
|
||||
// Nil slice of mutations still treated as an error (call contract violation).
|
||||
if msg == nil {
|
||||
return results, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
|
||||
for _, m := range msg {
|
||||
// Ignore nil mutation elements (untyped or typed nil pointers) silently; they carry no data.
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
|
||||
rv := reflect.ValueOf(m)
|
||||
if rv.Kind() == reflect.Ptr && rv.IsNil() {
|
||||
continue
|
||||
}
|
||||
rt := indirectType(reflect.TypeOf(m))
|
||||
r.mutationRegistryMu.RLock()
|
||||
entry, ok := r.mutationRegistry[rt]
|
||||
@@ -188,10 +198,6 @@ func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyR
|
||||
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
||||
}
|
||||
|
||||
// if entry.updateTotals {
|
||||
// grain.UpdateTotals()
|
||||
// }
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ type SimpleGrainPool[V any] struct {
|
||||
mutationRegistry MutationRegistry
|
||||
spawn func(id uint64) (Grain[V], error)
|
||||
spawnHost func(host string) (Host, error)
|
||||
listeners []LogListener
|
||||
storage LogStorage[V]
|
||||
ttl time.Duration
|
||||
poolSize int
|
||||
@@ -66,6 +67,19 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) AddListener(listener LogListener) {
|
||||
p.listeners = append(p.listeners, listener)
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
||||
for i, l := range p.listeners {
|
||||
if l == listener {
|
||||
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) purge() {
|
||||
purgeLimit := time.Now().Add(-p.ttl)
|
||||
purgedIds := make([]uint64, 0, len(p.grains))
|
||||
@@ -383,11 +397,14 @@ func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*Mutat
|
||||
}
|
||||
if p.storage != nil {
|
||||
go func() {
|
||||
if err := p.storage.AppendEvent(id, mutation...); err != nil {
|
||||
if err := p.storage.AppendMutations(id, mutation...); err != nil {
|
||||
log.Printf("failed to store mutation for grain %d: %v", id, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, listener := range p.listeners {
|
||||
go listener.AppendMutations(id, mutations...)
|
||||
}
|
||||
result, err := grain.GetCurrentState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -33,7 +32,7 @@ type ItemMeta struct {
|
||||
type CartItem struct {
|
||||
Id uint32 `json:"id"`
|
||||
ItemId uint32 `json:"itemId,omitempty"`
|
||||
ParentId uint32 `json:"parentId,omitempty"`
|
||||
ParentId *uint32 `json:"parentId,omitempty"`
|
||||
Sku string `json:"sku"`
|
||||
Price Price `json:"price"`
|
||||
TotalPrice Price `json:"totalPrice"`
|
||||
@@ -45,6 +44,7 @@ type CartItem struct {
|
||||
ArticleType string `json:"type,omitempty"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
Meta *ItemMeta `json:"meta,omitempty"`
|
||||
SaleStatus string `json:"saleStatus"`
|
||||
}
|
||||
|
||||
type CartDelivery struct {
|
||||
@@ -62,32 +62,42 @@ 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 {
|
||||
Code string `json:"code"`
|
||||
Rules []*messages.VoucherRule `json:"rules"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
Code string `json:"code"`
|
||||
Applied bool `json:"applied"`
|
||||
Rules []string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
@@ -119,8 +129,8 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
}
|
||||
|
||||
// All voucher rules must pass (logical AND)
|
||||
for _, rule := range v.Rules {
|
||||
expr := rule.GetCondition()
|
||||
for _, expr := range v.Rules {
|
||||
|
||||
if expr == "" {
|
||||
// Empty condition treated as pass (acts like a comment / placeholder)
|
||||
continue
|
||||
@@ -138,6 +148,23 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
return cart.Items, true
|
||||
}
|
||||
|
||||
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(),
|
||||
SubscriptionDetails: make(map[string]*SubscriptionDetails),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() uint64 {
|
||||
return uint64(c.Id)
|
||||
}
|
||||
@@ -155,22 +182,6 @@ func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
}
|
||||
|
||||
// func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
|
||||
// cartItem, err := getItemData(sku, qty, country)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// cartItem.StoreId = storeId
|
||||
// return c.Apply(cartItem, false)
|
||||
// }
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
@@ -240,27 +251,36 @@ func (c *CartGrain) UpdateTotals() {
|
||||
for _, item := range c.Items {
|
||||
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
||||
|
||||
item.TotalPrice = *rowTotal
|
||||
|
||||
c.TotalPrice.Add(*rowTotal)
|
||||
|
||||
if item.OrgPrice != nil {
|
||||
diff := NewPrice()
|
||||
diff.Add(*item.OrgPrice)
|
||||
diff.Subtract(item.Price)
|
||||
diff.Multiply(int64(item.Quantity))
|
||||
rowTotal.Subtract(*diff)
|
||||
item.Discount = diff
|
||||
if diff.IncVat > 0 {
|
||||
c.TotalDiscount.Add(*diff)
|
||||
}
|
||||
}
|
||||
|
||||
item.TotalPrice = *rowTotal
|
||||
|
||||
c.TotalPrice.Add(*rowTotal)
|
||||
|
||||
}
|
||||
for _, delivery := range c.Deliveries {
|
||||
c.TotalPrice.Add(delivery.Price)
|
||||
}
|
||||
for _, voucher := range c.Vouchers {
|
||||
if _, ok := voucher.AppliesTo(c); ok {
|
||||
_, ok := voucher.AppliesTo(c)
|
||||
voucher.Applied = false
|
||||
if ok {
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
|
||||
if c.TotalPrice.IncVat <= value.IncVat {
|
||||
// don't apply discounts to more than the total price
|
||||
continue
|
||||
}
|
||||
voucher.Applied = true
|
||||
c.TotalDiscount.Add(*value)
|
||||
c.TotalPrice.Subtract(*value)
|
||||
}
|
||||
54
pkg/cart/cart-mutation-helper.go
Normal file
54
pkg/cart/cart-mutation-helper.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
|
||||
func NewCartMultationRegistry() actor.MutationRegistry {
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
reg.RegisterMutations(
|
||||
actor.NewMutation(AddItem, func() *messages.AddItem {
|
||||
return &messages.AddItem{}
|
||||
}),
|
||||
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
|
||||
return &messages.ChangeQuantity{}
|
||||
}),
|
||||
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
|
||||
return &messages.RemoveItem{}
|
||||
}),
|
||||
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
|
||||
return &messages.InitializeCheckout{}
|
||||
}),
|
||||
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
|
||||
return &messages.OrderCreated{}
|
||||
}),
|
||||
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
|
||||
return &messages.RemoveDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
|
||||
return &messages.SetDelivery{}
|
||||
}),
|
||||
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
|
||||
return &messages.SetPickupPoint{}
|
||||
}),
|
||||
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
|
||||
return &messages.ClearCartRequest{}
|
||||
}),
|
||||
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
|
||||
return &messages.AddVoucher{}
|
||||
}),
|
||||
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
|
||||
return &messages.RemoveVoucher{}
|
||||
}),
|
||||
actor.NewMutation(UpsertSubscriptionDetails, func() *messages.UpsertSubscriptionDetails {
|
||||
return &messages.UpsertSubscriptionDetails{}
|
||||
}),
|
||||
actor.NewMutation(PreConditionFailed, func() *messages.PreConditionFailed {
|
||||
return &messages.PreConditionFailed{}
|
||||
}),
|
||||
)
|
||||
return reg
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -28,9 +28,22 @@ 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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -63,6 +76,8 @@ func AddItem(g *CartGrain, m *messages.AddItem) error {
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
},
|
||||
SaleStatus: m.SaleStatus,
|
||||
ParentId: m.ParentId,
|
||||
|
||||
Price: *pricePerItem,
|
||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -54,10 +54,12 @@ func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
|
||||
|
||||
g.lastVoucherId++
|
||||
g.Vouchers = append(g.Vouchers, &Voucher{
|
||||
Id: g.lastVoucherId,
|
||||
Code: m.Code,
|
||||
Rules: m.VoucherRules,
|
||||
Value: m.Value,
|
||||
Id: g.lastVoucherId,
|
||||
Applied: false,
|
||||
Description: m.Description,
|
||||
Code: m.Code,
|
||||
Rules: m.VoucherRules,
|
||||
Value: m.Value,
|
||||
})
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
7
pkg/cart/mutation_precondition.go
Normal file
7
pkg/cart/mutation_precondition.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package cart
|
||||
|
||||
import messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
|
||||
func PreConditionFailed(g *CartGrain, m *messages.PreConditionFailed) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
520
pkg/cart/mutation_test.go
Normal file
520
pkg/cart/mutation_test.go
Normal 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 ...string) *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))
|
||||
}
|
||||
}
|
||||
57
pkg/cart/mutation_upsert_subscriptiondetails.go
Normal file
57
pkg/cart/mutation_upsert_subscriptiondetails.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -33,14 +33,14 @@ func TestPriceMarshalJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewPriceFromIncVat(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1000, 0.25)
|
||||
if p.IncVat != 1000 {
|
||||
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
|
||||
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() != 750 {
|
||||
if p.ValueExVat() != 1000 {
|
||||
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v5.29.3
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc v6.32.1
|
||||
// source: control_plane.proto
|
||||
|
||||
package messages
|
||||
@@ -453,65 +453,38 @@ func (x *ExpiryAnnounce) GetIds() []uint64 {
|
||||
|
||||
var File_control_plane_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_control_plane_proto_rawDesc = string([]byte{
|
||||
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22,
|
||||
0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67,
|
||||
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69,
|
||||
0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e,
|
||||
0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
|
||||
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e,
|
||||
0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
|
||||
0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e,
|
||||
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a,
|
||||
0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f,
|
||||
0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52,
|
||||
0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
|
||||
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
|
||||
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65,
|
||||
0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65,
|
||||
0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23,
|
||||
0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12,
|
||||
0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
|
||||
0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
|
||||
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
|
||||
0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36,
|
||||
0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
|
||||
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x32, 0x8d, 0x03, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72,
|
||||
0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12,
|
||||
0x0f, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
|
||||
0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67,
|
||||
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x41, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61,
|
||||
0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65,
|
||||
0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
|
||||
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
|
||||
0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x3c, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c,
|
||||
0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x0f, 0x2e, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e,
|
||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
|
||||
0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e,
|
||||
0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x1b, 0x2e, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
|
||||
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41,
|
||||
0x63, 0x6b, 0x12, 0x44, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78,
|
||||
0x70, 0x69, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||
0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18,
|
||||
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
|
||||
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x3c, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73,
|
||||
0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43,
|
||||
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61,
|
||||
0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f,
|
||||
0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e, 0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
|
||||
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
})
|
||||
const file_control_plane_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x13control_plane.proto\x12\bmessages\"\a\n" +
|
||||
"\x05Empty\"<\n" +
|
||||
"\tPingReply\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
|
||||
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
|
||||
"\x10NegotiateRequest\x12\x1f\n" +
|
||||
"\vknown_hosts\x18\x01 \x03(\tR\n" +
|
||||
"knownHosts\"&\n" +
|
||||
"\x0eNegotiateReply\x12\x14\n" +
|
||||
"\x05hosts\x18\x01 \x03(\tR\x05hosts\"!\n" +
|
||||
"\rActorIdsReply\x12\x10\n" +
|
||||
"\x03ids\x18\x01 \x03(\x04R\x03ids\"F\n" +
|
||||
"\x0eOwnerChangeAck\x12\x1a\n" +
|
||||
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
|
||||
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
|
||||
"\rClosingNotice\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\"9\n" +
|
||||
"\x11OwnershipAnnounce\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
|
||||
"\x03ids\x18\x02 \x03(\x04R\x03ids\"6\n" +
|
||||
"\x0eExpiryAnnounce\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
|
||||
"\x03ids\x18\x02 \x03(\x04R\x03ids2\x8d\x03\n" +
|
||||
"\fControlPlane\x12,\n" +
|
||||
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
|
||||
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x12<\n" +
|
||||
"\x10GetLocalActorIds\x12\x0f.messages.Empty\x1a\x17.messages.ActorIdsReply\x12J\n" +
|
||||
"\x11AnnounceOwnership\x12\x1b.messages.OwnershipAnnounce\x1a\x18.messages.OwnerChangeAck\x12D\n" +
|
||||
"\x0eAnnounceExpiry\x12\x18.messages.ExpiryAnnounce\x1a\x18.messages.OwnerChangeAck\x12<\n" +
|
||||
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||
|
||||
var (
|
||||
file_control_plane_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.3
|
||||
// - protoc v6.32.1
|
||||
// source: control_plane.proto
|
||||
|
||||
package messages
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v5.29.3
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc v6.32.1
|
||||
// source: messages.proto
|
||||
|
||||
package messages
|
||||
@@ -9,6 +9,7 @@ package messages
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
anypb "google.golang.org/protobuf/types/known/anypb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
@@ -79,6 +80,7 @@ type AddItem struct {
|
||||
SellerId string `protobuf:"bytes,19,opt,name=sellerId,proto3" json:"sellerId,omitempty"`
|
||||
SellerName string `protobuf:"bytes,20,opt,name=sellerName,proto3" json:"sellerName,omitempty"`
|
||||
Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"`
|
||||
SaleStatus string `protobuf:"bytes,24,opt,name=saleStatus,proto3" json:"saleStatus,omitempty"`
|
||||
Outlet *string `protobuf:"bytes,12,opt,name=outlet,proto3,oneof" json:"outlet,omitempty"`
|
||||
StoreId *string `protobuf:"bytes,22,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"`
|
||||
ParentId *uint32 `protobuf:"varint,23,opt,name=parentId,proto3,oneof" json:"parentId,omitempty"`
|
||||
@@ -256,6 +258,13 @@ func (x *AddItem) GetCountry() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AddItem) GetSaleStatus() string {
|
||||
if x != nil {
|
||||
return x.SaleStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AddItem) GetOutlet() string {
|
||||
if x != nil && x.Outlet != nil {
|
||||
return *x.Outlet
|
||||
@@ -917,86 +926,19 @@ func (x *InitializeCheckout) GetPaymentInProgress() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type VoucherRule struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
|
||||
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
|
||||
Condition string `protobuf:"bytes,4,opt,name=condition,proto3" json:"condition,omitempty"`
|
||||
Action string `protobuf:"bytes,5,opt,name=action,proto3" json:"action,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *VoucherRule) Reset() {
|
||||
*x = VoucherRule{}
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *VoucherRule) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*VoucherRule) ProtoMessage() {}
|
||||
|
||||
func (x *VoucherRule) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use VoucherRule.ProtoReflect.Descriptor instead.
|
||||
func (*VoucherRule) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetType() string {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetDescription() string {
|
||||
if x != nil {
|
||||
return x.Description
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetCondition() string {
|
||||
if x != nil {
|
||||
return x.Condition
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VoucherRule) GetAction() string {
|
||||
if x != nil {
|
||||
return x.Action
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type AddVoucher struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"`
|
||||
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"`
|
||||
VoucherRules []*VoucherRule `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
|
||||
VoucherRules []string `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
|
||||
Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AddVoucher) Reset() {
|
||||
*x = AddVoucher{}
|
||||
mi := &file_messages_proto_msgTypes[13]
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1008,7 +950,7 @@ func (x *AddVoucher) String() string {
|
||||
func (*AddVoucher) ProtoMessage() {}
|
||||
|
||||
func (x *AddVoucher) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[13]
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1021,7 +963,7 @@ func (x *AddVoucher) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AddVoucher.ProtoReflect.Descriptor instead.
|
||||
func (*AddVoucher) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{13}
|
||||
return file_messages_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *AddVoucher) GetCode() string {
|
||||
@@ -1038,13 +980,20 @@ func (x *AddVoucher) GetValue() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AddVoucher) GetVoucherRules() []*VoucherRule {
|
||||
func (x *AddVoucher) GetVoucherRules() []string {
|
||||
if x != nil {
|
||||
return x.VoucherRules
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AddVoucher) GetDescription() string {
|
||||
if x != nil {
|
||||
return x.Description
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type RemoveVoucher struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -1054,7 +1003,7 @@ type RemoveVoucher struct {
|
||||
|
||||
func (x *RemoveVoucher) Reset() {
|
||||
*x = RemoveVoucher{}
|
||||
mi := &file_messages_proto_msgTypes[14]
|
||||
mi := &file_messages_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1066,7 +1015,7 @@ func (x *RemoveVoucher) String() string {
|
||||
func (*RemoveVoucher) ProtoMessage() {}
|
||||
|
||||
func (x *RemoveVoucher) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[14]
|
||||
mi := &file_messages_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1079,7 +1028,7 @@ func (x *RemoveVoucher) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RemoveVoucher.ProtoReflect.Descriptor instead.
|
||||
func (*RemoveVoucher) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{14}
|
||||
return file_messages_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *RemoveVoucher) GetId() uint32 {
|
||||
@@ -1089,153 +1038,262 @@ func (x *RemoveVoucher) GetId() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type UpsertSubscriptionDetails struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id *string `protobuf:"bytes,1,opt,name=id,proto3,oneof" json:"id,omitempty"`
|
||||
OfferingCode string `protobuf:"bytes,2,opt,name=offeringCode,proto3" json:"offeringCode,omitempty"`
|
||||
SigningType string `protobuf:"bytes,3,opt,name=signingType,proto3" json:"signingType,omitempty"`
|
||||
Data *anypb.Any `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) Reset() {
|
||||
*x = UpsertSubscriptionDetails{}
|
||||
mi := &file_messages_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UpsertSubscriptionDetails) ProtoMessage() {}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UpsertSubscriptionDetails.ProtoReflect.Descriptor instead.
|
||||
func (*UpsertSubscriptionDetails) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetId() string {
|
||||
if x != nil && x.Id != nil {
|
||||
return *x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetOfferingCode() string {
|
||||
if x != nil {
|
||||
return x.OfferingCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetSigningType() string {
|
||||
if x != nil {
|
||||
return x.SigningType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertSubscriptionDetails) GetData() *anypb.Any {
|
||||
if x != nil {
|
||||
return x.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PreConditionFailed struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Operation string `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
Input *anypb.Any `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) Reset() {
|
||||
*x = PreConditionFailed{}
|
||||
mi := &file_messages_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PreConditionFailed) ProtoMessage() {}
|
||||
|
||||
func (x *PreConditionFailed) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PreConditionFailed.ProtoReflect.Descriptor instead.
|
||||
func (*PreConditionFailed) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) GetOperation() string {
|
||||
if x != nil {
|
||||
return x.Operation
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PreConditionFailed) GetInput() *anypb.Any {
|
||||
if x != nil {
|
||||
return x.Input
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_messages_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_messages_proto_rawDesc = string([]byte{
|
||||
0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c,
|
||||
0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x97,
|
||||
0x05, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74,
|
||||
0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65,
|
||||
0x6d, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12,
|
||||
0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05,
|
||||
0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
|
||||
0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
|
||||
0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x73, 0x6b, 0x75, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65,
|
||||
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a,
|
||||
0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74,
|
||||
0x6f, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05,
|
||||
0x52, 0x03, 0x74, 0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63,
|
||||
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63,
|
||||
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67,
|
||||
0x6f, 0x72, 0x79, 0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65,
|
||||
0x67, 0x6f, 0x72, 0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72,
|
||||
0x79, 0x33, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f,
|
||||
0x72, 0x79, 0x33, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34,
|
||||
0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
|
||||
0x34, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12,
|
||||
0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12,
|
||||
0x20, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70,
|
||||
0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a,
|
||||
0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a,
|
||||
0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
|
||||
0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
|
||||
0x74, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18,
|
||||
0x16, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64,
|
||||
0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18,
|
||||
0x17, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
|
||||
0x64, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42,
|
||||
0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f,
|
||||
0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
|
||||
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
||||
0x74, 0x69, 0x74, 0x79, 0x22, 0x86, 0x02, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x44, 0x65, 0x6c, 0x69,
|
||||
0x76, 0x65, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
||||
0x12, 0x14, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52,
|
||||
0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70,
|
||||
0x50, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69,
|
||||
0x6e, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e,
|
||||
0x74, 0x88, 0x01, 0x01, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18,
|
||||
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
|
||||
0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70,
|
||||
0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28,
|
||||
0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12,
|
||||
0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52,
|
||||
0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x69, 0x63,
|
||||
0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x22, 0xf9, 0x01,
|
||||
0x0a, 0x0e, 0x53, 0x65, 0x74, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74,
|
||||
0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64,
|
||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
|
||||
0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
|
||||
0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64,
|
||||
0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64,
|
||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79,
|
||||
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01,
|
||||
0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03,
|
||||
0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
|
||||
0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05,
|
||||
0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a,
|
||||
0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xd6, 0x01, 0x0a, 0x0b, 0x50, 0x69,
|
||||
0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
|
||||
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88,
|
||||
0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01,
|
||||
0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||
0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69,
|
||||
0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01,
|
||||
0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01,
|
||||
0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01,
|
||||
0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64,
|
||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06,
|
||||
0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x72, 0x79, 0x22, 0x20, 0x0a, 0x0e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x44, 0x65, 0x6c, 0x69,
|
||||
0x76, 0x65, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
|
||||
0x52, 0x02, 0x69, 0x64, 0x22, 0xb9, 0x01, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43,
|
||||
0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05,
|
||||
0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x65, 0x72,
|
||||
0x6d, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x22,
|
||||
0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x75, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x04, 0x70, 0x75, 0x73, 0x68, 0x12, 0x1e, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61,
|
||||
0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x69,
|
||||
0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79,
|
||||
0x22, 0x40, 0x0a, 0x0c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
|
||||
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
|
||||
0x75, 0x73, 0x22, 0x06, 0x0a, 0x04, 0x4e, 0x6f, 0x6f, 0x70, 0x22, 0x74, 0x0a, 0x12, 0x49, 0x6e,
|
||||
0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
|
||||
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
|
||||
0x75, 0x73, 0x12, 0x2c, 0x0a, 0x11, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50,
|
||||
0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x70,
|
||||
0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73,
|
||||
0x22, 0x79, 0x0a, 0x0b, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x12,
|
||||
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
|
||||
0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x0a, 0x41,
|
||||
0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64,
|
||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a,
|
||||
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61,
|
||||
0x6c, 0x75, 0x65, 0x12, 0x39, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75,
|
||||
0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65,
|
||||
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x1f,
|
||||
0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12,
|
||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x42,
|
||||
0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e,
|
||||
0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62,
|
||||
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
})
|
||||
const file_messages_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x0emessages.proto\x12\bmessages\x1a\x19google/protobuf/any.proto\"\x12\n" +
|
||||
"\x10ClearCartRequest\"\xb7\x05\n" +
|
||||
"\aAddItem\x12\x17\n" +
|
||||
"\aitem_id\x18\x01 \x01(\rR\x06itemId\x12\x1a\n" +
|
||||
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
|
||||
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
|
||||
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
|
||||
"\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" +
|
||||
"\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" +
|
||||
"\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" +
|
||||
"\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" +
|
||||
"\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" +
|
||||
"\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" +
|
||||
"\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" +
|
||||
"\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" +
|
||||
"\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" +
|
||||
"\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" +
|
||||
"\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" +
|
||||
"\n" +
|
||||
"disclaimer\x18\n" +
|
||||
" \x01(\tR\n" +
|
||||
"disclaimer\x12 \n" +
|
||||
"\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" +
|
||||
"\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" +
|
||||
"\n" +
|
||||
"sellerName\x18\x14 \x01(\tR\n" +
|
||||
"sellerName\x12\x18\n" +
|
||||
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1e\n" +
|
||||
"\n" +
|
||||
"saleStatus\x18\x18 \x01(\tR\n" +
|
||||
"saleStatus\x12\x1b\n" +
|
||||
"\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" +
|
||||
"\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01\x12\x1f\n" +
|
||||
"\bparentId\x18\x17 \x01(\rH\x02R\bparentId\x88\x01\x01B\t\n" +
|
||||
"\a_outletB\n" +
|
||||
"\n" +
|
||||
"\b_storeIdB\v\n" +
|
||||
"\t_parentId\"\x1c\n" +
|
||||
"\n" +
|
||||
"RemoveItem\x12\x0e\n" +
|
||||
"\x02Id\x18\x01 \x01(\rR\x02Id\"<\n" +
|
||||
"\x0eChangeQuantity\x12\x0e\n" +
|
||||
"\x02Id\x18\x01 \x01(\rR\x02Id\x12\x1a\n" +
|
||||
"\bquantity\x18\x02 \x01(\x05R\bquantity\"\x86\x02\n" +
|
||||
"\vSetDelivery\x12\x1a\n" +
|
||||
"\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" +
|
||||
"\x05items\x18\x02 \x03(\rR\x05items\x12<\n" +
|
||||
"\vpickupPoint\x18\x03 \x01(\v2\x15.messages.PickupPointH\x00R\vpickupPoint\x88\x01\x01\x12\x18\n" +
|
||||
"\acountry\x18\x04 \x01(\tR\acountry\x12\x10\n" +
|
||||
"\x03zip\x18\x05 \x01(\tR\x03zip\x12\x1d\n" +
|
||||
"\aaddress\x18\x06 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
|
||||
"\x04city\x18\a \x01(\tH\x02R\x04city\x88\x01\x01B\x0e\n" +
|
||||
"\f_pickupPointB\n" +
|
||||
"\n" +
|
||||
"\b_addressB\a\n" +
|
||||
"\x05_city\"\xf9\x01\n" +
|
||||
"\x0eSetPickupPoint\x12\x1e\n" +
|
||||
"\n" +
|
||||
"deliveryId\x18\x01 \x01(\rR\n" +
|
||||
"deliveryId\x12\x0e\n" +
|
||||
"\x02id\x18\x02 \x01(\tR\x02id\x12\x17\n" +
|
||||
"\x04name\x18\x03 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
|
||||
"\aaddress\x18\x04 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
|
||||
"\x04city\x18\x05 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
|
||||
"\x03zip\x18\x06 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
|
||||
"\acountry\x18\a \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
|
||||
"\x05_nameB\n" +
|
||||
"\n" +
|
||||
"\b_addressB\a\n" +
|
||||
"\x05_cityB\x06\n" +
|
||||
"\x04_zipB\n" +
|
||||
"\n" +
|
||||
"\b_country\"\xd6\x01\n" +
|
||||
"\vPickupPoint\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" +
|
||||
"\x04name\x18\x02 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
|
||||
"\aaddress\x18\x03 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
|
||||
"\x04city\x18\x04 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
|
||||
"\x03zip\x18\x05 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
|
||||
"\acountry\x18\x06 \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
|
||||
"\x05_nameB\n" +
|
||||
"\n" +
|
||||
"\b_addressB\a\n" +
|
||||
"\x05_cityB\x06\n" +
|
||||
"\x04_zipB\n" +
|
||||
"\n" +
|
||||
"\b_country\" \n" +
|
||||
"\x0eRemoveDelivery\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\rR\x02id\"\xb9\x01\n" +
|
||||
"\x13CreateCheckoutOrder\x12\x14\n" +
|
||||
"\x05terms\x18\x01 \x01(\tR\x05terms\x12\x1a\n" +
|
||||
"\bcheckout\x18\x02 \x01(\tR\bcheckout\x12\"\n" +
|
||||
"\fconfirmation\x18\x03 \x01(\tR\fconfirmation\x12\x12\n" +
|
||||
"\x04push\x18\x04 \x01(\tR\x04push\x12\x1e\n" +
|
||||
"\n" +
|
||||
"validation\x18\x05 \x01(\tR\n" +
|
||||
"validation\x12\x18\n" +
|
||||
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
|
||||
"\fOrderCreated\x12\x18\n" +
|
||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
|
||||
"\x04Noop\"t\n" +
|
||||
"\x12InitializeCheckout\x12\x18\n" +
|
||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
|
||||
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgress\"|\n" +
|
||||
"\n" +
|
||||
"AddVoucher\x12\x12\n" +
|
||||
"\x04code\x18\x01 \x01(\tR\x04code\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\x03R\x05value\x12\"\n" +
|
||||
"\fvoucherRules\x18\x03 \x03(\tR\fvoucherRules\x12 \n" +
|
||||
"\vdescription\x18\x04 \x01(\tR\vdescription\"\x1f\n" +
|
||||
"\rRemoveVoucher\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\rR\x02id\"\xa7\x01\n" +
|
||||
"\x19UpsertSubscriptionDetails\x12\x13\n" +
|
||||
"\x02id\x18\x01 \x01(\tH\x00R\x02id\x88\x01\x01\x12\"\n" +
|
||||
"\fofferingCode\x18\x02 \x01(\tR\fofferingCode\x12 \n" +
|
||||
"\vsigningType\x18\x03 \x01(\tR\vsigningType\x12(\n" +
|
||||
"\x04data\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\x04dataB\x05\n" +
|
||||
"\x03_id\"t\n" +
|
||||
"\x12PreConditionFailed\x12\x1c\n" +
|
||||
"\toperation\x18\x01 \x01(\tR\toperation\x12\x14\n" +
|
||||
"\x05error\x18\x02 \x01(\tR\x05error\x12*\n" +
|
||||
"\x05input\x18\x03 \x01(\v2\x14.google.protobuf.AnyR\x05inputB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||
|
||||
var (
|
||||
file_messages_proto_rawDescOnce sync.Once
|
||||
@@ -1249,32 +1307,35 @@ func file_messages_proto_rawDescGZIP() []byte {
|
||||
return file_messages_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
|
||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
|
||||
var file_messages_proto_goTypes = []any{
|
||||
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
|
||||
(*AddItem)(nil), // 1: messages.AddItem
|
||||
(*RemoveItem)(nil), // 2: messages.RemoveItem
|
||||
(*ChangeQuantity)(nil), // 3: messages.ChangeQuantity
|
||||
(*SetDelivery)(nil), // 4: messages.SetDelivery
|
||||
(*SetPickupPoint)(nil), // 5: messages.SetPickupPoint
|
||||
(*PickupPoint)(nil), // 6: messages.PickupPoint
|
||||
(*RemoveDelivery)(nil), // 7: messages.RemoveDelivery
|
||||
(*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder
|
||||
(*OrderCreated)(nil), // 9: messages.OrderCreated
|
||||
(*Noop)(nil), // 10: messages.Noop
|
||||
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
|
||||
(*VoucherRule)(nil), // 12: messages.VoucherRule
|
||||
(*AddVoucher)(nil), // 13: messages.AddVoucher
|
||||
(*RemoveVoucher)(nil), // 14: messages.RemoveVoucher
|
||||
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
|
||||
(*AddItem)(nil), // 1: messages.AddItem
|
||||
(*RemoveItem)(nil), // 2: messages.RemoveItem
|
||||
(*ChangeQuantity)(nil), // 3: messages.ChangeQuantity
|
||||
(*SetDelivery)(nil), // 4: messages.SetDelivery
|
||||
(*SetPickupPoint)(nil), // 5: messages.SetPickupPoint
|
||||
(*PickupPoint)(nil), // 6: messages.PickupPoint
|
||||
(*RemoveDelivery)(nil), // 7: messages.RemoveDelivery
|
||||
(*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder
|
||||
(*OrderCreated)(nil), // 9: messages.OrderCreated
|
||||
(*Noop)(nil), // 10: messages.Noop
|
||||
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
|
||||
(*AddVoucher)(nil), // 12: messages.AddVoucher
|
||||
(*RemoveVoucher)(nil), // 13: messages.RemoveVoucher
|
||||
(*UpsertSubscriptionDetails)(nil), // 14: messages.UpsertSubscriptionDetails
|
||||
(*PreConditionFailed)(nil), // 15: messages.PreConditionFailed
|
||||
(*anypb.Any)(nil), // 16: google.protobuf.Any
|
||||
}
|
||||
var file_messages_proto_depIdxs = []int32{
|
||||
6, // 0: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
|
||||
12, // 1: messages.AddVoucher.voucherRules:type_name -> messages.VoucherRule
|
||||
2, // [2:2] is the sub-list for method output_type
|
||||
2, // [2:2] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
16, // 1: messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
|
||||
16, // 2: messages.PreConditionFailed.input:type_name -> google.protobuf.Any
|
||||
3, // [3:3] is the sub-list for method output_type
|
||||
3, // [3:3] is the sub-list for method input_type
|
||||
3, // [3:3] is the sub-list for extension type_name
|
||||
3, // [3:3] is the sub-list for extension extendee
|
||||
0, // [0:3] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_messages_proto_init() }
|
||||
@@ -1286,13 +1347,14 @@ func file_messages_proto_init() {
|
||||
file_messages_proto_msgTypes[4].OneofWrappers = []any{}
|
||||
file_messages_proto_msgTypes[5].OneofWrappers = []any{}
|
||||
file_messages_proto_msgTypes[6].OneofWrappers = []any{}
|
||||
file_messages_proto_msgTypes[14].OneofWrappers = []any{}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 15,
|
||||
NumMessages: 16,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
443
pkg/promotions/type_test.go
Normal file
443
pkg/promotions/type_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sampleJSON mirrors the user's full example data (all three rules)
|
||||
var sampleJSON = []byte(`[
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Summer Sale 2024",
|
||||
"description": "20% off on all summer items",
|
||||
"status": "active",
|
||||
"priority": 1,
|
||||
"startDate": "2024-06-01",
|
||||
"endDate": "2024-08-31",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "group1",
|
||||
"type": "group",
|
||||
"operator": "AND",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "product_category",
|
||||
"operator": "in",
|
||||
"value": ["summer", "beachwear"],
|
||||
"label": "Product category is Summer or Beachwear"
|
||||
},
|
||||
{
|
||||
"id": "c2",
|
||||
"type": "cart_total",
|
||||
"operator": "greater_or_equal",
|
||||
"value": 50,
|
||||
"label": "Cart total is at least $50"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "a1",
|
||||
"type": "percentage_discount",
|
||||
"value": 20,
|
||||
"label": "20% discount"
|
||||
}
|
||||
],
|
||||
"usageLimit": 1000,
|
||||
"usageCount": 342,
|
||||
"createdAt": "2024-05-15T10:00:00Z",
|
||||
"updatedAt": "2024-05-20T14:30:00Z",
|
||||
"createdBy": "admin@example.com",
|
||||
"tags": ["seasonal", "summer"]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "VIP Customer Exclusive",
|
||||
"description": "Free shipping for VIP customers",
|
||||
"status": "active",
|
||||
"priority": 2,
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": null,
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c3",
|
||||
"type": "customer_segment",
|
||||
"operator": "equals",
|
||||
"value": "vip",
|
||||
"label": "Customer segment is VIP"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "a2",
|
||||
"type": "free_shipping",
|
||||
"value": 0,
|
||||
"label": "Free shipping"
|
||||
}
|
||||
],
|
||||
"usageCount": 1523,
|
||||
"createdAt": "2023-12-15T09:00:00Z",
|
||||
"updatedAt": "2024-01-05T11:20:00Z",
|
||||
"createdBy": "marketing@example.com",
|
||||
"tags": ["vip", "loyalty"]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Buy 2 Get 1 Free",
|
||||
"description": "Buy 2 items, get the cheapest one free",
|
||||
"status": "scheduled",
|
||||
"priority": 3,
|
||||
"startDate": "2024-12-01",
|
||||
"endDate": "2024-12-25",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c4",
|
||||
"type": "item_quantity",
|
||||
"operator": "greater_or_equal",
|
||||
"value": 3,
|
||||
"label": "Cart has at least 3 items"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "a3",
|
||||
"type": "buy_x_get_y",
|
||||
"value": 0,
|
||||
"config": { "buy": 2, "get": 1, "discount": 100 },
|
||||
"label": "Buy 2 Get 1 Free"
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-11-01T08:00:00Z",
|
||||
"updatedAt": "2024-11-01T08:00:00Z",
|
||||
"createdBy": "admin@example.com",
|
||||
"tags": ["holiday", "christmas"]
|
||||
}
|
||||
]`)
|
||||
|
||||
func TestDecodePromotionRulesBasic(t *testing.T) {
|
||||
rules, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||
}
|
||||
if len(rules) != 3 {
|
||||
t.Fatalf("expected 3 rules, got %d", len(rules))
|
||||
}
|
||||
|
||||
// Rule 1 checks
|
||||
r1 := rules[0]
|
||||
if r1.ID != "1" {
|
||||
t.Errorf("rule[0].ID = %s, want 1", r1.ID)
|
||||
}
|
||||
if r1.Status != StatusActive {
|
||||
t.Errorf("rule[0].Status = %s, want %s", r1.Status, StatusActive)
|
||||
}
|
||||
if r1.EndDate == nil || *r1.EndDate != "2024-08-31" {
|
||||
t.Errorf("rule[0].EndDate = %v, want 2024-08-31", r1.EndDate)
|
||||
}
|
||||
if r1.UsageLimit == nil || *r1.UsageLimit != 1000 {
|
||||
t.Errorf("rule[0].UsageLimit = %v, want 1000", r1.UsageLimit)
|
||||
}
|
||||
|
||||
// Rule 2 checks
|
||||
r2 := rules[1]
|
||||
if r2.ID != "2" {
|
||||
t.Errorf("rule[1].ID = %s, want 2", r2.ID)
|
||||
}
|
||||
if r2.EndDate != nil {
|
||||
t.Errorf("rule[1].EndDate should be nil (from null), got %v", *r2.EndDate)
|
||||
}
|
||||
if r2.UsageLimit != nil {
|
||||
t.Errorf("rule[1].UsageLimit should be nil (missing), got %v", *r2.UsageLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionDecoding(t *testing.T) {
|
||||
rules, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||
}
|
||||
r1 := rules[0]
|
||||
if len(r1.Conditions) != 1 {
|
||||
t.Fatalf("expected 1 top-level condition group, got %d", len(r1.Conditions))
|
||||
}
|
||||
|
||||
grp, ok := r1.Conditions[0].(ConditionGroup)
|
||||
if !ok {
|
||||
t.Fatalf("top-level condition is not a group, type=%T", r1.Conditions[0])
|
||||
}
|
||||
if grp.Operator != LogicAND {
|
||||
t.Errorf("group operator = %s, want AND", grp.Operator)
|
||||
}
|
||||
|
||||
if len(grp.Conditions) != 2 {
|
||||
t.Fatalf("expected 2 child conditions, got %d", len(grp.Conditions))
|
||||
}
|
||||
|
||||
// First child: product_category condition with slice value
|
||||
c0, ok := grp.Conditions[0].(BaseCondition)
|
||||
if !ok {
|
||||
t.Fatalf("first child not BaseCondition, got %T", grp.Conditions[0])
|
||||
}
|
||||
if c0.Type != CondProductCategory {
|
||||
t.Errorf("first child type = %s, want %s", c0.Type, CondProductCategory)
|
||||
}
|
||||
if c0.Operator != OpIn {
|
||||
t.Errorf("first child operator = %s, want %s", c0.Operator, OpIn)
|
||||
}
|
||||
if arr, ok := c0.Value.AsStringSlice(); !ok || len(arr) != 2 || arr[0] != "summer" {
|
||||
t.Errorf("expected string slice value [summer,...], got %v", arr)
|
||||
}
|
||||
|
||||
// Second child: cart_total numeric
|
||||
c1, ok := grp.Conditions[1].(BaseCondition)
|
||||
if !ok {
|
||||
t.Fatalf("second child not BaseCondition, got %T", grp.Conditions[1])
|
||||
}
|
||||
if c1.Type != CondCartTotal {
|
||||
t.Errorf("second child type = %s, want %s", c1.Type, CondCartTotal)
|
||||
}
|
||||
if val, ok := c1.Value.AsFloat64(); !ok || val != 50 {
|
||||
t.Errorf("expected numeric value 50, got %v (ok=%v)", val, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionValueHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonVal string
|
||||
wantStr string
|
||||
wantNum *float64
|
||||
wantSS []string
|
||||
wantFS []float64
|
||||
}{
|
||||
{
|
||||
name: "string value",
|
||||
jsonVal: `"vip"`,
|
||||
wantStr: "vip",
|
||||
},
|
||||
{
|
||||
name: "number value",
|
||||
jsonVal: `42`,
|
||||
wantNum: floatPtr(42),
|
||||
},
|
||||
{
|
||||
name: "string slice",
|
||||
jsonVal: `["a","b"]`,
|
||||
wantSS: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "number slice int",
|
||||
jsonVal: `[1,2,3]`,
|
||||
wantFS: []float64{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "number slice float",
|
||||
jsonVal: `[1.5,2.25]`,
|
||||
wantFS: []float64{1.5, 2.25},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var cv ConditionValue
|
||||
if err := json.Unmarshal([]byte(tc.jsonVal), &cv); err != nil {
|
||||
t.Fatalf("unmarshal value failed: %v", err)
|
||||
}
|
||||
if tc.wantStr != "" {
|
||||
if got, ok := cv.AsString(); !ok || got != tc.wantStr {
|
||||
t.Errorf("AsString got=%q ok=%v want=%q", got, ok, tc.wantStr)
|
||||
}
|
||||
}
|
||||
if tc.wantNum != nil {
|
||||
if got, ok := cv.AsFloat64(); !ok || got != *tc.wantNum {
|
||||
t.Errorf("AsFloat64 got=%v ok=%v want=%v", got, ok, *tc.wantNum)
|
||||
}
|
||||
}
|
||||
if tc.wantSS != nil {
|
||||
if got, ok := cv.AsStringSlice(); !ok || len(got) != len(tc.wantSS) || got[0] != tc.wantSS[0] {
|
||||
t.Errorf("AsStringSlice got=%v ok=%v want=%v", got, ok, tc.wantSS)
|
||||
}
|
||||
}
|
||||
if tc.wantFS != nil {
|
||||
if got, ok := cv.AsFloat64Slice(); !ok || len(got) != len(tc.wantFS) {
|
||||
t.Errorf("AsFloat64Slice got=%v ok=%v want=%v", got, ok, tc.wantFS)
|
||||
} else {
|
||||
for i := range got {
|
||||
if got[i] != tc.wantFS[i] {
|
||||
t.Errorf("AsFloat64Slice[%d]=%v want=%v", i, got[i], tc.wantFS[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkConditionsTraversal(t *testing.T) {
|
||||
rules, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||
}
|
||||
visited := 0
|
||||
WalkConditions(rules[0].Conditions, func(c Condition) bool {
|
||||
visited++
|
||||
return true
|
||||
})
|
||||
// group + 2 children
|
||||
if visited != 3 {
|
||||
t.Errorf("expected 3 visited conditions, got %d", visited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionBundleConfigParsing(t *testing.T) {
|
||||
jsonData := []byte(`[
|
||||
{
|
||||
"id": "bundle-1",
|
||||
"name": "Bundle Deal",
|
||||
"description": "Fixed price bundle",
|
||||
"status": "active",
|
||||
"priority": 1,
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": null,
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{
|
||||
"id": "act-bundle",
|
||||
"type": "bundle_discount",
|
||||
"value": 0,
|
||||
"bundleConfig": {
|
||||
"containers": [
|
||||
{
|
||||
"id": "cont1",
|
||||
"name": "Shoes",
|
||||
"quantity": 2,
|
||||
"selectionType": "any",
|
||||
"qualifyingRules": {
|
||||
"type": "category",
|
||||
"value": "shoes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cont2",
|
||||
"name": "Socks",
|
||||
"quantity": 3,
|
||||
"selectionType": "specific",
|
||||
"qualifyingRules": {
|
||||
"type": "product_ids",
|
||||
"value": ["sock-1", "sock-2"]
|
||||
},
|
||||
"allowedProducts": ["sock-1","sock-2"]
|
||||
}
|
||||
],
|
||||
"pricing": {
|
||||
"type": "fixed_price",
|
||||
"value": 49.99
|
||||
},
|
||||
"requireAllContainers": true
|
||||
},
|
||||
"config": { "note": "Bundle applies to footwear + socks" }
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "bundle@example.com",
|
||||
"tags": ["bundle","footwear"]
|
||||
}
|
||||
]`)
|
||||
rules, err := DecodePromotionRules(jsonData)
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||
}
|
||||
if len(rules[0].Actions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(rules[0].Actions))
|
||||
}
|
||||
act := rules[0].Actions[0]
|
||||
if act.Type != ActionBundleDiscount {
|
||||
t.Fatalf("action type = %s, want %s", act.Type, ActionBundleDiscount)
|
||||
}
|
||||
if act.BundleConfig == nil {
|
||||
t.Fatalf("bundleConfig nil")
|
||||
}
|
||||
if act.BundleConfig.Pricing.Type != "fixed_price" {
|
||||
t.Errorf("pricing.type = %s, want fixed_price", act.BundleConfig.Pricing.Type)
|
||||
}
|
||||
if act.BundleConfig.Pricing.Value != 49.99 {
|
||||
t.Errorf("pricing.value = %v, want 49.99", act.BundleConfig.Pricing.Value)
|
||||
}
|
||||
if !act.BundleConfig.RequireAllContainers {
|
||||
t.Errorf("RequireAllContainers expected true")
|
||||
}
|
||||
if len(act.BundleConfig.Containers) != 2 {
|
||||
t.Fatalf("expected 2 containers, got %d", len(act.BundleConfig.Containers))
|
||||
}
|
||||
if act.Config == nil || act.Config["note"] != "Bundle applies to footwear + socks" {
|
||||
t.Errorf("config.note mismatch: %v", act.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionValueInvalidTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"object", `{}`},
|
||||
{"booleanTrue", `true`},
|
||||
{"booleanFalse", `false`},
|
||||
{"null", `null`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var cv ConditionValue
|
||||
if err := json.Unmarshal([]byte(tc.raw), &cv); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if s, ok := cv.AsString(); ok {
|
||||
t.Errorf("AsString unexpectedly succeeded (%q) for %s", s, tc.name)
|
||||
}
|
||||
if n, ok := cv.AsFloat64(); ok {
|
||||
t.Errorf("AsFloat64 unexpectedly succeeded (%v) for %s", n, tc.name)
|
||||
}
|
||||
if ss, ok := cv.AsStringSlice(); ok {
|
||||
t.Errorf("AsStringSlice unexpectedly succeeded (%v) for %s", ss, tc.name)
|
||||
}
|
||||
if fs, ok := cv.AsFloat64Slice(); ok {
|
||||
t.Errorf("AsFloat64Slice unexpectedly succeeded (%v) for %s", fs, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotionRuleRoundTrip(t *testing.T) {
|
||||
orig, err := DecodePromotionRules(sampleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("initial decode failed: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
round, err := DecodePromotionRules(data)
|
||||
if err != nil {
|
||||
t.Fatalf("round-trip decode failed: %v", err)
|
||||
}
|
||||
if len(orig) != len(round) {
|
||||
t.Fatalf("rule count mismatch: orig=%d round=%d", len(orig), len(round))
|
||||
}
|
||||
// spot-check first rule
|
||||
if orig[0].Name != round[0].Name {
|
||||
t.Errorf("first rule name mismatch: %s vs %s", orig[0].Name, round[0].Name)
|
||||
}
|
||||
if len(orig[0].Conditions) != len(round[0].Conditions) {
|
||||
t.Errorf("first rule condition count mismatch: %d vs %d", len(orig[0].Conditions), len(round[0].Conditions))
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(f float64) *float64 { return &f }
|
||||
405
pkg/promotions/types.go
Normal file
405
pkg/promotions/types.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// -----------------------------
|
||||
// Enum-like string types
|
||||
// -----------------------------
|
||||
|
||||
type ConditionOperator string
|
||||
|
||||
const (
|
||||
OpEquals ConditionOperator = "equals"
|
||||
OpNotEquals ConditionOperator = "not_equals"
|
||||
OpGreaterThan ConditionOperator = "greater_than"
|
||||
OpLessThan ConditionOperator = "less_than"
|
||||
OpGreaterOrEqual ConditionOperator = "greater_or_equal"
|
||||
OpLessOrEqual ConditionOperator = "less_or_equal"
|
||||
OpContains ConditionOperator = "contains"
|
||||
OpNotContains ConditionOperator = "not_contains"
|
||||
OpIn ConditionOperator = "in"
|
||||
OpNotIn ConditionOperator = "not_in"
|
||||
)
|
||||
|
||||
type ConditionType string
|
||||
|
||||
const (
|
||||
CondCartTotal ConditionType = "cart_total"
|
||||
CondItemQuantity ConditionType = "item_quantity"
|
||||
CondCustomerSegment ConditionType = "customer_segment"
|
||||
CondProductCategory ConditionType = "product_category"
|
||||
CondProductID ConditionType = "product_id"
|
||||
CondCustomerLifetime ConditionType = "customer_lifetime_value"
|
||||
CondOrderCount ConditionType = "order_count"
|
||||
CondDateRange ConditionType = "date_range"
|
||||
CondDayOfWeek ConditionType = "day_of_week"
|
||||
CondTimeOfDay ConditionType = "time_of_day"
|
||||
CondGroup ConditionType = "group" // synthetic value for groups
|
||||
)
|
||||
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
ActionPercentageDiscount ActionType = "percentage_discount"
|
||||
ActionFixedDiscount ActionType = "fixed_discount"
|
||||
ActionFreeShipping ActionType = "free_shipping"
|
||||
ActionBuyXGetY ActionType = "buy_x_get_y"
|
||||
ActionTieredDiscount ActionType = "tiered_discount"
|
||||
ActionBundleDiscount ActionType = "bundle_discount"
|
||||
)
|
||||
|
||||
type LogicOperator string
|
||||
|
||||
const (
|
||||
LogicAND LogicOperator = "AND"
|
||||
LogicOR LogicOperator = "OR"
|
||||
)
|
||||
|
||||
type PromotionStatus string
|
||||
|
||||
const (
|
||||
StatusActive PromotionStatus = "active"
|
||||
StatusInactive PromotionStatus = "inactive"
|
||||
StatusScheduled PromotionStatus = "scheduled"
|
||||
StatusExpired PromotionStatus = "expired"
|
||||
)
|
||||
|
||||
// -----------------------------
|
||||
// Condition Value (union)
|
||||
// -----------------------------
|
||||
//
|
||||
// Represents: string | number | []string | []number
|
||||
// We store raw JSON and lazily interpret.
|
||||
|
||||
type ConditionValue struct {
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
func (cv *ConditionValue) UnmarshalJSON(b []byte) error {
|
||||
if len(b) == 0 {
|
||||
return errors.New("empty ConditionValue")
|
||||
}
|
||||
// Just store raw; interpretation happens via helpers.
|
||||
cv.Raw = append(cv.Raw[0:0], b...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helpers to interpret value:
|
||||
func (cv ConditionValue) AsString() (string, bool) {
|
||||
// Treat explicit JSON null as invalid
|
||||
if string(cv.Raw) == "null" {
|
||||
return "", false
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(cv.Raw, &s); err == nil {
|
||||
return s, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) AsFloat64() (float64, bool) {
|
||||
if string(cv.Raw) == "null" {
|
||||
return 0, false
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(cv.Raw, &f); err == nil {
|
||||
return f, true
|
||||
}
|
||||
// Attempt integer decode into float64
|
||||
var i int64
|
||||
if err := json.Unmarshal(cv.Raw, &i); err == nil {
|
||||
return float64(i), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) AsStringSlice() ([]string, bool) {
|
||||
if string(cv.Raw) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal(cv.Raw, &arr); err == nil {
|
||||
return arr, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) AsFloat64Slice() ([]float64, bool) {
|
||||
if string(cv.Raw) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var arrNum []float64
|
||||
if err := json.Unmarshal(cv.Raw, &arrNum); err == nil {
|
||||
return arrNum, true
|
||||
}
|
||||
// Try []int -> []float64
|
||||
var arrInt []int64
|
||||
if err := json.Unmarshal(cv.Raw, &arrInt); err == nil {
|
||||
out := make([]float64, len(arrInt))
|
||||
for i, v := range arrInt {
|
||||
out[i] = float64(v)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (cv ConditionValue) String() string {
|
||||
if s, ok := cv.AsString(); ok {
|
||||
return s
|
||||
}
|
||||
if f, ok := cv.AsFloat64(); ok {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
if ss, ok := cv.AsStringSlice(); ok {
|
||||
return fmt.Sprintf("%v", ss)
|
||||
}
|
||||
if fs, ok := cv.AsFloat64Slice(); ok {
|
||||
return fmt.Sprintf("%v", fs)
|
||||
}
|
||||
return string(cv.Raw)
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// BaseCondition
|
||||
// -----------------------------
|
||||
|
||||
type BaseCondition struct {
|
||||
ID string `json:"id"`
|
||||
Type ConditionType `json:"type"`
|
||||
Operator ConditionOperator `json:"operator"`
|
||||
Value ConditionValue `json:"value"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
func (b BaseCondition) IsGroup() bool { return false }
|
||||
|
||||
// -----------------------------
|
||||
// ConditionGroup
|
||||
// -----------------------------
|
||||
|
||||
type ConditionGroup struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // always "group"
|
||||
Operator LogicOperator `json:"operator"`
|
||||
Conditions Conditions `json:"conditions"`
|
||||
}
|
||||
|
||||
// Custom unmarshaller ensures nested polymorphic conditions are decoded
|
||||
// using the Conditions type (which applies the raw element discriminator).
|
||||
func (g *ConditionGroup) UnmarshalJSON(b []byte) error {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Operator LogicOperator `json:"operator"`
|
||||
Conditions json.RawMessage `json:"conditions"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
// Basic validation
|
||||
if a.Type != "group" {
|
||||
return fmt.Errorf("ConditionGroup expected type 'group', got %q", a.Type)
|
||||
}
|
||||
|
||||
var conds Conditions
|
||||
if len(a.Conditions) > 0 {
|
||||
if err := json.Unmarshal(a.Conditions, &conds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
g.ID = a.ID
|
||||
g.Type = a.Type
|
||||
g.Operator = a.Operator
|
||||
g.Conditions = conds
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g ConditionGroup) IsGroup() bool { return true }
|
||||
|
||||
// -----------------------------
|
||||
// Condition interface + slice
|
||||
// -----------------------------
|
||||
|
||||
type Condition interface {
|
||||
IsGroup() bool
|
||||
}
|
||||
|
||||
// Internal wrapper to help decode each element.
|
||||
type rawCond struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Custom unmarshaler for a Condition slice
|
||||
type Conditions []Condition
|
||||
|
||||
func (cs *Conditions) UnmarshalJSON(b []byte) error {
|
||||
var rawList []json.RawMessage
|
||||
if err := json.Unmarshal(b, &rawList); err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]Condition, 0, len(rawList))
|
||||
for _, elem := range rawList {
|
||||
var hdr rawCond
|
||||
if err := json.Unmarshal(elem, &hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if hdr.Type == "group" {
|
||||
var grp ConditionGroup
|
||||
if err := json.Unmarshal(elem, &grp); err != nil {
|
||||
return err
|
||||
}
|
||||
// Recursively decode grp.Conditions (already handled because field type is []Condition)
|
||||
out = append(out, grp)
|
||||
} else {
|
||||
var bc BaseCondition
|
||||
if err := json.Unmarshal(elem, &bc); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, bc)
|
||||
}
|
||||
}
|
||||
*cs = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Bundle Structures
|
||||
// -----------------------------
|
||||
|
||||
type BundleQualifyingRules struct {
|
||||
Type string `json:"type"` // "category" | "product_ids" | "tag" | "all"
|
||||
Value interface{} `json:"value"` // string or []string
|
||||
}
|
||||
|
||||
type BundleContainer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
SelectionType string `json:"selectionType"` // "any" | "specific"
|
||||
QualifyingRules BundleQualifyingRules `json:"qualifyingRules"`
|
||||
AllowedProducts []string `json:"allowedProducts,omitempty"`
|
||||
}
|
||||
|
||||
type BundlePricing struct {
|
||||
Type string `json:"type"` // "fixed_price" | "percentage_discount" | "fixed_discount"
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
type BundleConfig struct {
|
||||
Containers []BundleContainer `json:"containers"`
|
||||
Pricing BundlePricing `json:"pricing"`
|
||||
RequireAllContainers bool `json:"requireAllContainers"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Action
|
||||
// -----------------------------
|
||||
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Type ActionType `json:"type"`
|
||||
Value interface{} `json:"value"` // number or string
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
BundleConfig *BundleConfig `json:"bundleConfig,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Promotion Rule
|
||||
// -----------------------------
|
||||
|
||||
type PromotionRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status PromotionStatus `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate *string `json:"endDate"` // null -> nil
|
||||
Conditions Conditions `json:"conditions"`
|
||||
Actions []Action `json:"actions"`
|
||||
UsageLimit *int `json:"usageLimit,omitempty"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Promotion Stats
|
||||
// -----------------------------
|
||||
|
||||
type PromotionStats struct {
|
||||
TotalPromotions int `json:"totalPromotions"`
|
||||
ActivePromotions int `json:"activePromotions"`
|
||||
TotalRevenue float64 `json:"totalRevenue"`
|
||||
TotalOrders int `json:"totalOrders"`
|
||||
AverageDiscount float64 `json:"averageDiscount"`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Utility: Decode array of rules
|
||||
// -----------------------------
|
||||
|
||||
func DecodePromotionRules(data []byte) ([]PromotionRule, error) {
|
||||
var rules []PromotionRule
|
||||
if err := json.Unmarshal(data, &rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// Example helper to inspect conditions programmatically.
|
||||
func WalkConditions(conds []Condition, fn func(c Condition) bool) {
|
||||
for _, c := range conds {
|
||||
if !fn(c) {
|
||||
return
|
||||
}
|
||||
if grp, ok := c.(ConditionGroup); ok {
|
||||
WalkConditions(grp.Conditions, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PromotionState struct {
|
||||
Vouchers []PromotionRule `json:"promotions"`
|
||||
}
|
||||
|
||||
type StateFile struct {
|
||||
State PromotionState `json:"state"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (sf *StateFile) GetPromotion(id string) (*PromotionRule, bool) {
|
||||
for _, v := range sf.State.Vouchers {
|
||||
if v.ID == id {
|
||||
return &v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func LoadStateFile(fileName string) (*PromotionState, error) {
|
||||
f, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
sf := &PromotionState{}
|
||||
err = dec.Decode(sf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sf, nil
|
||||
}
|
||||
@@ -28,6 +28,32 @@ func TestParseRules_SimpleSku(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleCartTotal(t *testing.T) {
|
||||
rs, err := ParseRules("min_total>=500000")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(rs.Conditions) != 1 {
|
||||
t.Fatalf("expected 1 condition got %d", len(rs.Conditions))
|
||||
}
|
||||
c := rs.Conditions[0]
|
||||
if c.Kind != RuleMinTotal {
|
||||
t.Fatalf("expected kind cart total got %s", c.Kind)
|
||||
}
|
||||
ctx := EvalContext{
|
||||
Items: []Item{
|
||||
Item{
|
||||
Sku: "123",
|
||||
},
|
||||
},
|
||||
CartTotalInc: 400000,
|
||||
}
|
||||
applied := rs.Applies(ctx)
|
||||
if applied {
|
||||
t.Fatalf("expected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRules_CategoryAndSkuMixedSeparators(t *testing.T) {
|
||||
rs, err := ParseRules(" category=Shoes|Bags ; sku= A | B , min_total>=1000\nmin_item_price>=500")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package voucher
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||
)
|
||||
@@ -12,16 +15,14 @@ type Rule struct {
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Value int64 `json:"discount"`
|
||||
TaxValue int64 `json:"taxValue"`
|
||||
TaxRate int `json:"taxRate"`
|
||||
rules []Rule `json:"rules"`
|
||||
Code string `json:"code"`
|
||||
Value int64 `json:"value"`
|
||||
Rules string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
// Add fields here
|
||||
|
||||
}
|
||||
|
||||
var ErrInvalidCode = errors.New("invalid vouchercode")
|
||||
@@ -30,10 +31,54 @@ func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
|
||||
if code == "" {
|
||||
return nil, ErrInvalidCode
|
||||
}
|
||||
value := int64(250_00)
|
||||
sf, err := LoadStateFile("data/vouchers.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, ok := sf.GetVoucher(code)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no voucher found for code: %s", code)
|
||||
}
|
||||
|
||||
return &messages.AddVoucher{
|
||||
Code: code,
|
||||
Value: value,
|
||||
VoucherRules: make([]*messages.VoucherRule, 0),
|
||||
Code: code,
|
||||
Value: v.Value,
|
||||
Description: v.Description,
|
||||
VoucherRules: []string{
|
||||
v.Rules,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Vouchers []Voucher `json:"vouchers"`
|
||||
}
|
||||
|
||||
type StateFile struct {
|
||||
State State `json:"state"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (sf *StateFile) GetVoucher(code string) (*Voucher, bool) {
|
||||
for _, v := range sf.State.Vouchers {
|
||||
if v.Code == code {
|
||||
return &v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func LoadStateFile(fileName string) (*StateFile, error) {
|
||||
f, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
sf := &StateFile{}
|
||||
err = dec.Decode(sf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sf, nil
|
||||
}
|
||||
|
||||
@@ -2,115 +2,117 @@ syntax = "proto3";
|
||||
package messages;
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
|
||||
message ClearCartRequest {
|
||||
import "google/protobuf/any.proto";
|
||||
|
||||
}
|
||||
message ClearCartRequest {}
|
||||
|
||||
message AddItem {
|
||||
uint32 item_id = 1;
|
||||
int32 quantity = 2;
|
||||
int64 price = 3;
|
||||
int64 orgPrice = 9;
|
||||
string sku = 4;
|
||||
string name = 5;
|
||||
string image = 6;
|
||||
int32 stock = 7;
|
||||
int32 tax = 8;
|
||||
string brand = 13;
|
||||
string category = 14;
|
||||
string category2 = 15;
|
||||
string category3 = 16;
|
||||
string category4 = 17;
|
||||
string category5 = 18;
|
||||
string disclaimer = 10;
|
||||
string articleType = 11;
|
||||
string sellerId = 19;
|
||||
string sellerName = 20;
|
||||
string country = 21;
|
||||
optional string outlet = 12;
|
||||
optional string storeId = 22;
|
||||
optional uint32 parentId = 23;
|
||||
uint32 item_id = 1;
|
||||
int32 quantity = 2;
|
||||
int64 price = 3;
|
||||
int64 orgPrice = 9;
|
||||
string sku = 4;
|
||||
string name = 5;
|
||||
string image = 6;
|
||||
int32 stock = 7;
|
||||
int32 tax = 8;
|
||||
string brand = 13;
|
||||
string category = 14;
|
||||
string category2 = 15;
|
||||
string category3 = 16;
|
||||
string category4 = 17;
|
||||
string category5 = 18;
|
||||
string disclaimer = 10;
|
||||
string articleType = 11;
|
||||
string sellerId = 19;
|
||||
string sellerName = 20;
|
||||
string country = 21;
|
||||
string saleStatus = 24;
|
||||
optional string outlet = 12;
|
||||
optional string storeId = 22;
|
||||
optional uint32 parentId = 23;
|
||||
}
|
||||
|
||||
message RemoveItem {
|
||||
uint32 Id = 1;
|
||||
}
|
||||
message RemoveItem { uint32 Id = 1; }
|
||||
|
||||
message ChangeQuantity {
|
||||
uint32 Id = 1;
|
||||
int32 quantity = 2;
|
||||
uint32 Id = 1;
|
||||
int32 quantity = 2;
|
||||
}
|
||||
|
||||
message SetDelivery {
|
||||
string provider = 1;
|
||||
repeated uint32 items = 2;
|
||||
optional PickupPoint pickupPoint = 3;
|
||||
string country = 4;
|
||||
string zip = 5;
|
||||
optional string address = 6;
|
||||
optional string city = 7;
|
||||
string provider = 1;
|
||||
repeated uint32 items = 2;
|
||||
optional PickupPoint pickupPoint = 3;
|
||||
string country = 4;
|
||||
string zip = 5;
|
||||
optional string address = 6;
|
||||
optional string city = 7;
|
||||
}
|
||||
|
||||
message SetPickupPoint {
|
||||
uint32 deliveryId = 1;
|
||||
string id = 2;
|
||||
optional string name = 3;
|
||||
optional string address = 4;
|
||||
optional string city = 5;
|
||||
optional string zip = 6;
|
||||
optional string country = 7;
|
||||
uint32 deliveryId = 1;
|
||||
string id = 2;
|
||||
optional string name = 3;
|
||||
optional string address = 4;
|
||||
optional string city = 5;
|
||||
optional string zip = 6;
|
||||
optional string country = 7;
|
||||
}
|
||||
|
||||
message PickupPoint {
|
||||
string id = 1;
|
||||
optional string name = 2;
|
||||
optional string address = 3;
|
||||
optional string city = 4;
|
||||
optional string zip = 5;
|
||||
optional string country = 6;
|
||||
string id = 1;
|
||||
optional string name = 2;
|
||||
optional string address = 3;
|
||||
optional string city = 4;
|
||||
optional string zip = 5;
|
||||
optional string country = 6;
|
||||
}
|
||||
|
||||
message RemoveDelivery {
|
||||
uint32 id = 1;
|
||||
}
|
||||
message RemoveDelivery { uint32 id = 1; }
|
||||
|
||||
message CreateCheckoutOrder {
|
||||
string terms = 1;
|
||||
string checkout = 2;
|
||||
string confirmation = 3;
|
||||
string push = 4;
|
||||
string validation = 5;
|
||||
string country = 6;
|
||||
string terms = 1;
|
||||
string checkout = 2;
|
||||
string confirmation = 3;
|
||||
string push = 4;
|
||||
string validation = 5;
|
||||
string country = 6;
|
||||
}
|
||||
|
||||
message OrderCreated {
|
||||
string orderId = 1;
|
||||
string status = 2;
|
||||
string orderId = 1;
|
||||
string status = 2;
|
||||
}
|
||||
|
||||
message Noop {
|
||||
// Intentionally empty - used for ownership acquisition or health pings
|
||||
// Intentionally empty - used for ownership acquisition or health pings
|
||||
}
|
||||
|
||||
message InitializeCheckout {
|
||||
string orderId = 1;
|
||||
string status = 2;
|
||||
bool paymentInProgress = 3;
|
||||
}
|
||||
|
||||
message VoucherRule {
|
||||
string type = 2;
|
||||
string description = 3;
|
||||
string condition = 4;
|
||||
string action = 5;
|
||||
string orderId = 1;
|
||||
string status = 2;
|
||||
bool paymentInProgress = 3;
|
||||
}
|
||||
|
||||
message AddVoucher {
|
||||
string code = 1;
|
||||
int64 value = 2;
|
||||
repeated VoucherRule voucherRules = 3;
|
||||
string code = 1;
|
||||
int64 value = 2;
|
||||
repeated string voucherRules = 3;
|
||||
string description = 4;
|
||||
}
|
||||
|
||||
message RemoveVoucher {
|
||||
uint32 id = 1;
|
||||
message RemoveVoucher { uint32 id = 1; }
|
||||
|
||||
message UpsertSubscriptionDetails {
|
||||
optional string id = 1;
|
||||
string offeringCode = 2;
|
||||
string signingType = 3;
|
||||
google.protobuf.Any data = 4;
|
||||
}
|
||||
|
||||
message PreConditionFailed {
|
||||
string operation = 1;
|
||||
string error = 2;
|
||||
google.protobuf.Any input = 3;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user