From 74889f9d59e3f95b744cf3d4cd35e6094fe09dda Mon Sep 17 00:00:00 2001
From: meskio <meskio@sindominio.net>
Date: Wed, 23 Sep 2020 13:26:52 +0200
Subject: [PATCH] Add purchases to API

---
 api/api.go           |   9 ++-
 api/api_test.go      |   2 +-
 api/auth.go          |  33 +++++++---
 api/member_test.go   |  53 ++++++---------
 api/product_test.go  |  61 +++++++-----------
 api/purchase.go      | 149 +++++++++++++++++++++++++++++++++++++++++++
 api/purchase_test.go |  54 ++++++++++++++++
 go.mod               |   1 +
 go.sum               |   2 +
 9 files changed, 283 insertions(+), 81 deletions(-)
 create mode 100644 api/purchase.go
 create mode 100644 api/purchase_test.go

diff --git a/api/api.go b/api/api.go
index 211a457..21006aa 100644
--- a/api/api.go
+++ b/api/api.go
@@ -19,8 +19,7 @@ func initDB(dbPath string) (*gorm.DB, error) {
 		return nil, err
 	}
 
-	db.AutoMigrate(&Member{})
-	db.AutoMigrate(&Product{})
+	db.AutoMigrate(&Member{}, &Product{}, &PurchasedProduct{}, &Purchase{})
 	return db, err
 }
 
@@ -42,11 +41,17 @@ func Init(dbPath string, signKey string, r *mux.Router) error {
 	r.HandleFunc("/member/{num:[0-9]+}", a.auth(a.GetMember)).Methods("GET")
 	r.HandleFunc("/member/{num:[0-9]+}", a.auth(a.UpdateMember)).Methods("PUT")
 	r.HandleFunc("/member/{num:[0-9]+}", a.auth(a.DeleteMember)).Methods("DELETE")
+	r.HandleFunc("/member/{num:[0-9]+}/purchase", a.auth(a.GetMemberPurchases)).Methods("GET")
 
 	r.HandleFunc("/product", a.auth(a.ListProducts)).Methods("GET")
 	r.HandleFunc("/product", a.auth(a.AddProduct)).Methods("POST")
 	r.HandleFunc("/product/{code:[0-9]+}", a.auth(a.GetProduct)).Methods("GET")
 	r.HandleFunc("/product/{code:[0-9]+}", a.auth(a.UpdateProduct)).Methods("PUT")
 	r.HandleFunc("/product/{code:[0-9]+}", a.auth(a.DeleteProduct)).Methods("DELETE")
+
+	r.HandleFunc("/purchase", a.auth(a.ListPurchases)).Methods("GET")
+	r.HandleFunc("/purchase", a.authNum(a.AddPurchase)).Methods("POST")
+	r.HandleFunc("/purchase/{id:[0-9]+}", a.auth(a.GetPurchase)).Methods("GET")
+	r.HandleFunc("/purchase/mine", a.authNum(a.getPurchasesByMember)).Methods("GET")
 	return nil
 }
diff --git a/api/api_test.go b/api/api_test.go
index 83e4599..97378a6 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -53,7 +53,7 @@ func newTestAPI(t *testing.T) *testAPI {
 	server := httptest.NewServer(r)
 
 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
-		"num":  0,
+		"num":  testMember.Num,
 		"role": "admin",
 		"exp":  time.Now().Add(time.Hour * 24).Unix(),
 	})
diff --git a/api/auth.go b/api/auth.go
index 11cdf58..7f8e996 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -62,7 +62,7 @@ func (a *api) SignIn(w http.ResponseWriter, req *http.Request) {
 func (a *api) auth(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
 	return func(w http.ResponseWriter, req *http.Request) {
 		token := req.Header.Get("x-authentication")
-		if !a.validToken(token) {
+		if ok, _ := a.validateToken(token); !ok {
 			w.WriteHeader(http.StatusUnauthorized)
 			return
 		}
@@ -70,6 +70,24 @@ func (a *api) auth(fn func(http.ResponseWriter, *http.Request)) func(http.Respon
 	}
 }
 
+func (a *api) authNum(fn func(int, http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, req *http.Request) {
+		token := req.Header.Get("x-authentication")
+		ok, claims := a.validateToken(token)
+		if !ok {
+			log.Print("foo")
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		num, ok := claims["num"].(float64)
+		if !ok {
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		fn(int(num), w, req)
+	}
+}
+
 func (a *api) newToken(num int, role string) (string, error) {
 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
 		"num":  num,
@@ -79,26 +97,25 @@ func (a *api) newToken(num int, role string) (string, error) {
 	return token.SignedString(a.signKey)
 }
 
-func (a *api) validToken(token string) bool {
+func (a *api) validateToken(token string) (bool, jwt.MapClaims) {
 	t, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
 		return a.signKey, nil
 	})
 	if err != nil {
-		return false
+		return false, nil
 	}
 	if !t.Valid {
-		return false
+		return false, nil
 	}
 	claims, ok := t.Claims.(jwt.MapClaims)
 	if !ok {
-		return false
+		return false, nil
 	}
 	exp, ok := claims["exp"].(float64)
 	if !ok {
-		return false
+		return false, claims
 	}
-	// TODO: num, role
-	return time.Unix(int64(exp), 0).After(time.Now())
+	return time.Unix(int64(exp), 0).After(time.Now()), claims
 }
 
 func newHashPass(password string) (hash []byte, salt []byte, err error) {
diff --git a/api/member_test.go b/api/member_test.go
index 330d373..c000462 100644
--- a/api/member_test.go
+++ b/api/member_test.go
@@ -5,22 +5,20 @@ import (
 	"testing"
 )
 
+var testMember = Member{
+	Num:     10,
+	Name:    "foo",
+	Email:   "foo@example.com",
+	Balance: 2000,
+}
+
 func TestMemberAddList(t *testing.T) {
 	tapi := newTestAPI(t)
 	defer tapi.close()
 
-	member := Member{
-		Num:     10,
-		Name:    "foo",
-		Email:   "foo@example.com",
-		Balance: 2000,
-	}
-	resp := tapi.do("POST", "/member", member, nil)
-	if resp.StatusCode != http.StatusCreated {
-		t.Fatal("Can't create member:", resp.Status)
-	}
+	tapi.addTestMember()
 	var members []Member
-	resp = tapi.do("GET", "/member", nil, &members)
+	resp := tapi.do("GET", "/member", nil, &members)
 	if resp.StatusCode != http.StatusOK {
 		t.Fatal("Can't get members:", resp.Status)
 	}
@@ -44,16 +42,7 @@ func TestMemberGetDelete(t *testing.T) {
 	if resp.StatusCode != http.StatusNotFound {
 		t.Error("Expected not found:", resp.Status, resp.Body)
 	}
-	member := Member{
-		Num:     10,
-		Name:    "foo",
-		Email:   "foo@example.com",
-		Balance: 2000,
-	}
-	resp = tapi.do("POST", "/member", member, nil)
-	if resp.StatusCode != http.StatusCreated {
-		t.Fatal("Can't create member:", resp.Status)
-	}
+	tapi.addTestMember()
 
 	var gotMember Member
 	resp = tapi.do("GET", "/member/10", nil, &gotMember)
@@ -78,19 +67,10 @@ func TestMemberUpdate(t *testing.T) {
 	tapi := newTestAPI(t)
 	defer tapi.close()
 
-	member := Member{
-		Num:     10,
-		Name:    "foo",
-		Email:   "foo@example.com",
-		Balance: 2000,
-	}
-	resp := tapi.do("POST", "/member", member, nil)
-	if resp.StatusCode != http.StatusCreated {
-		t.Fatal("Can't create member:", resp.Status)
-	}
-
+	tapi.addTestMember()
+	member := testMember
 	member.Balance = 1000
-	resp = tapi.do("PUT", "/member/10", member, nil)
+	resp := tapi.do("PUT", "/member/10", member, nil)
 	if resp.StatusCode != http.StatusAccepted {
 		t.Fatal("Can't update member:", resp.Status)
 	}
@@ -104,3 +84,10 @@ func TestMemberUpdate(t *testing.T) {
 		t.Error("Wrong balance:", gotMember)
 	}
 }
+
+func (tapi *testAPI) addTestMember() {
+	resp := tapi.do("POST", "/member", testMember, nil)
+	if resp.StatusCode != http.StatusCreated {
+		tapi.t.Fatal("Can't create member:", resp.Status)
+	}
+}
diff --git a/api/product_test.go b/api/product_test.go
index 66b7ddc..dbf7411 100644
--- a/api/product_test.go
+++ b/api/product_test.go
@@ -5,22 +5,20 @@ import (
 	"testing"
 )
 
+var testProduct = Product{
+	Code:  234,
+	Name:  "Aceite",
+	Price: 1700,
+	Stock: 10,
+}
+
 func TestProductAddList(t *testing.T) {
 	tapi := newTestAPI(t)
 	defer tapi.close()
+	tapi.addTestProducts()
 
-	product := Product{
-		Code:  234,
-		Name:  "Aceite",
-		Price: 1700,
-		Stock: 10,
-	}
-	resp := tapi.do("POST", "/product", product, nil)
-	if resp.StatusCode != http.StatusCreated {
-		t.Fatal("Can't create product:", resp.Status)
-	}
 	var products []Product
-	resp = tapi.do("GET", "/product", nil, &products)
+	resp := tapi.do("GET", "/product", nil, &products)
 	if resp.StatusCode != http.StatusOK {
 		t.Fatal("Can't get products:", resp.Status)
 	}
@@ -28,10 +26,10 @@ func TestProductAddList(t *testing.T) {
 	if len(products) != 1 {
 		t.Fatal("Wrong number of products", len(products), products)
 	}
-	if products[0].Name != "Aceite" {
+	if products[0].Name != testProduct.Name {
 		t.Error("Wrong name:", products[0].Name)
 	}
-	if products[0].Price != 1700 {
+	if products[0].Price != testProduct.Price {
 		t.Error("Wrong price:", products[0].Price)
 	}
 }
@@ -44,23 +42,14 @@ func TestProductGetDelete(t *testing.T) {
 	if resp.StatusCode != http.StatusNotFound {
 		t.Error("Expected not found:", resp.Status, resp.Body)
 	}
-	product := Product{
-		Code:  234,
-		Name:  "Aceite",
-		Price: 1700,
-		Stock: 10,
-	}
-	resp = tapi.do("POST", "/product", product, nil)
-	if resp.StatusCode != http.StatusCreated {
-		t.Fatal("Can't create product:", resp.Status)
-	}
+	tapi.addTestProducts()
 
 	var gotProduct Product
 	resp = tapi.do("GET", "/product/234", nil, &gotProduct)
 	if resp.StatusCode != http.StatusOK {
 		t.Error("Can't find the product:", resp.Status)
 	}
-	if gotProduct.Code != 234 {
+	if gotProduct.Code != testProduct.Code {
 		t.Error("Wrong product:", gotProduct.Code)
 	}
 	resp = tapi.do("DELETE", "/product/234", nil, nil)
@@ -77,20 +66,11 @@ func TestProductGetDelete(t *testing.T) {
 func TestProductUpdate(t *testing.T) {
 	tapi := newTestAPI(t)
 	defer tapi.close()
+	tapi.addTestProducts()
 
-	product := Product{
-		Code:  234,
-		Name:  "Aceite",
-		Price: 1700,
-		Stock: 10,
-	}
-	resp := tapi.do("POST", "/product", product, nil)
-	if resp.StatusCode != http.StatusCreated {
-		t.Fatal("Can't create product:", resp.Status)
-	}
-
-	product.Stock = product.Stock - 5
-	resp = tapi.do("PUT", "/product/234", product, nil)
+	product := testProduct
+	product.Stock = testProduct.Stock - 5
+	resp := tapi.do("PUT", "/product/234", product, nil)
 	if resp.StatusCode != http.StatusAccepted {
 		t.Fatal("Can't update product:", resp.Status)
 	}
@@ -104,3 +84,10 @@ func TestProductUpdate(t *testing.T) {
 		t.Error("Wrong sotck:", gotProduct)
 	}
 }
+
+func (tapi *testAPI) addTestProducts() {
+	resp := tapi.do("POST", "/product", testProduct, nil)
+	if resp.StatusCode != http.StatusCreated {
+		tapi.t.Fatal("Can't create product:", resp.Status)
+	}
+}
diff --git a/api/purchase.go b/api/purchase.go
new file mode 100644
index 0000000..5b982bb
--- /dev/null
+++ b/api/purchase.go
@@ -0,0 +1,149 @@
+package api
+
+import (
+	"encoding/json"
+	"log"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/gorilla/mux"
+	"gorm.io/gorm"
+)
+
+type Purchase struct {
+	gorm.Model `json:"-"`
+	MemberNum  int                `json:"member" gorm:"column:member"`
+	Member     Member             `json:"-" gorm:"foreignKey:MemberNum;references:Num"`
+	Date       time.Time          `json:"date"`
+	Total      int                `json:"total"`
+	Products   []PurchasedProduct `json:"products"`
+}
+
+type PurchasedProduct struct {
+	gorm.Model  `json:"-"`
+	PurchaseID  int     `json:"-" gorm:"column:purchase"`
+	ProductCode int     `json:"product" gorm:"column:product"`
+	Product     Product `gorm:"foreignKey:ProductCode;references:Code"`
+	Price       int     `json:"price"`
+	Ammount     int     `json:"ammount"`
+}
+
+func (a *api) ListPurchases(w http.ResponseWriter, req *http.Request) {
+	var purchases []Purchase
+	err := a.db.Preload("Products.Product").Find(&purchases).Error
+	if err != nil {
+		log.Printf("Can't list purchases: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	err = json.NewEncoder(w).Encode(purchases)
+	if err != nil {
+		log.Printf("Can't encode purchases: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+	}
+}
+
+func (a *api) AddPurchase(num int, w http.ResponseWriter, req *http.Request) {
+	var products []PurchasedProduct
+	err := json.NewDecoder(req.Body).Decode(&products)
+	if err != nil {
+		log.Printf("Can't create purchase: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	total := 0
+	for i, p := range products {
+		var product Product
+		err := a.db.Where("code = ?", p.ProductCode).First(&product).Error
+		if err != nil {
+			log.Printf("Can't get product %d: %v", p.ProductCode, err)
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+
+		total += product.Price * p.Ammount
+		products[i].Price = product.Price
+	}
+
+	purchase := Purchase{
+		MemberNum: num,
+		Date:      time.Now(),
+		Products:  products,
+		Total:     total,
+	}
+	err = a.db.Create(&purchase).Error
+	if err != nil {
+		log.Printf("Can't create purchase: %v\n%v", err, purchase)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	for _, p := range products {
+		err := a.db.Model(&Product{}).
+			Where("code = ?", p.ProductCode).
+			Update("stock", gorm.Expr("stock - ?", p.Ammount)).Error
+		if err != nil {
+			log.Printf("Can't update product stock %d: %v", p.ProductCode, err)
+		}
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusCreated)
+	err = json.NewEncoder(w).Encode(purchase)
+	if err != nil {
+		log.Printf("Can't encode added purchase: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+	}
+
+}
+
+func (a *api) GetPurchase(w http.ResponseWriter, req *http.Request) {
+	vars := mux.Vars(req)
+	var purchase Purchase
+	err := a.db.Where("id = ?", vars["id"]).First(&purchase).Error
+	if err != nil {
+		if err.Error() == "record not found" {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		log.Printf("Can't get purchase %s: %v", vars["code"], err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	err = json.NewEncoder(w).Encode(purchase)
+	if err != nil {
+		log.Printf("Can't encode purchase: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+}
+
+func (a *api) GetMemberPurchases(w http.ResponseWriter, req *http.Request) {
+	vars := mux.Vars(req)
+	num, _ := strconv.Atoi(vars["num"])
+	a.getPurchasesByMember(num, w, req)
+}
+
+func (a *api) getPurchasesByMember(num int, w http.ResponseWriter, req *http.Request) {
+	var purchases []Purchase
+	err := a.db.Where("member = ?", num).
+		Preload("Products.Product").
+		Find(&purchases).Error
+	if err != nil {
+		log.Printf("Can't list purchases: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	err = json.NewEncoder(w).Encode(purchases)
+	if err != nil {
+		log.Printf("Can't encode purchases: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+	}
+}
diff --git a/api/purchase_test.go b/api/purchase_test.go
new file mode 100644
index 0000000..622a0d0
--- /dev/null
+++ b/api/purchase_test.go
@@ -0,0 +1,54 @@
+package api
+
+import (
+	"net/http"
+	"testing"
+)
+
+func TestPurchaseAddListMine(t *testing.T) {
+	tapi := newTestAPI(t)
+	defer tapi.close()
+	tapi.addTestMember()
+	tapi.addTestProducts()
+
+	products := []PurchasedProduct{
+		{
+			ProductCode: testProduct.Code,
+			Ammount:     5,
+		},
+	}
+	resp := tapi.do("POST", "/purchase", products, nil)
+	if resp.StatusCode != http.StatusCreated {
+		t.Fatal("Can't create purchase:", resp.Status)
+	}
+	var purchases []Purchase
+	resp = tapi.do("GET", "/purchase/mine", nil, &purchases)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatal("Can't get purchases:", resp.Status)
+	}
+
+	if len(purchases) != 1 {
+		t.Fatal("Wrong number of purchases", len(purchases), purchases)
+	}
+	if purchases[0].Total != testProduct.Price*products[0].Ammount {
+		t.Error("Wrong total:", purchases[0].Total)
+	}
+	if len(purchases[0].Products) != 1 {
+		t.Fatal("Wrong number of products", len(purchases[0].Products), purchases[0].Products)
+	}
+	if purchases[0].Products[0].ProductCode != testProduct.Code {
+		t.Error("Wrong product code:", purchases[0].Products[0].ProductCode)
+	}
+	if purchases[0].Products[0].Price != testProduct.Price {
+		t.Error("Wrong product price:", purchases[0].Products[0].Price)
+	}
+
+	var product Product
+	resp = tapi.do("GET", "/product/234", nil, &product)
+	if resp.StatusCode != http.StatusOK {
+		t.Error("Can't find the product:", resp.Status)
+	}
+	if product.Stock != testProduct.Stock-products[0].Ammount {
+		t.Error("Wrong product stock:", product)
+	}
+}
diff --git a/go.mod b/go.mod
index a2d2f81..5bc0cfc 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.14
 require (
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/gorilla/mux v1.8.0
+	github.com/jstemmer/gotags v1.4.1 // indirect
 	github.com/olivere/env v1.1.0
 	golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
 	gorm.io/driver/sqlite v1.1.2
diff --git a/go.sum b/go.sum
index 2708a9b..98aa088 100644
--- a/go.sum
+++ b/go.sum
@@ -9,6 +9,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
 github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jstemmer/gotags v1.4.1 h1:aWIyXsU3lTDqhsEC49MP85p2cUUWr2ptvdGNqqGA3r4=
+github.com/jstemmer/gotags v1.4.1/go.mod h1:b6J3X0bsLbR4C5SgSx3V3KjuWTtmRzcmWPbTkWZ49PA=
 github.com/mattn/go-sqlite3 v1.14.2 h1:A2EQLwjYf/hfYaM20FVjs1UewCTTFR7RmjEHkLjldIA=
 github.com/mattn/go-sqlite3 v1.14.2/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
 github.com/olivere/env v1.1.0 h1:owp/uwMwhru5668JjMDp8UTG3JGT27GTCk4ufYQfaTw=
-- 
GitLab