48/add-complete (#53)

Closes #48

Reviewed-on: #53
This commit is contained in:
Sebastiaan de Schaetzen 2025-05-18 08:00:29 +02:00
parent 238aedb5c9
commit b5aae3be3d
7 changed files with 224 additions and 24 deletions

View File

@ -1,2 +1,9 @@
# Allowance Planner 2000 # Allowance Planner 2000
An improved Allowance Planner app. An improved Allowance Planner app.
## Running backend
In order to run the backend, go to the `backend directory and run:
```bash
$ go run .
```

View File

@ -52,7 +52,9 @@ func TestGetUserBadId(t *testing.T) {
func TestGetUserAllowanceWhenNoAllowancePresent(t *testing.T) { func TestGetUserAllowanceWhenNoAllowancePresent(t *testing.T) {
e := startServer(t) e := startServer(t)
result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
result.Length().IsEqual(0) result.Length().IsEqual(1)
item := result.Value(0).Object()
item.Value("id").IsEqual(0)
} }
func TestGetUserAllowance(t *testing.T) { func TestGetUserAllowance(t *testing.T) {
@ -68,8 +70,8 @@ func TestGetUserAllowance(t *testing.T) {
// Validate allowance // Validate allowance
result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
result.Length().IsEqual(1) result.Length().IsEqual(2)
item := result.Value(0).Object() item := result.Value(1).Object()
item.Value("id").IsEqual(1) item.Value("id").IsEqual(1)
item.Value("name").IsEqual(TestAllowanceName) item.Value("name").IsEqual(TestAllowanceName)
item.Value("target").IsEqual(5000) item.Value("target").IsEqual(5000)
@ -114,9 +116,9 @@ func TestCreateUserAllowance(t *testing.T) {
Status(200). Status(200).
JSON().Array() JSON().Array()
allowances.Length().IsEqual(1) allowances.Length().IsEqual(2)
allowance := allowances.Value(0).Object() allowance := allowances.Value(1).Object()
allowance.Value("id").IsEqual(allowanceId) allowance.Value("id").IsEqual(allowanceId)
allowance.Value("name").IsEqual(TestAllowanceName) allowance.Value("name").IsEqual(TestAllowanceName)
allowance.Value("target").IsEqual(5000) allowance.Value("target").IsEqual(5000)
@ -208,7 +210,12 @@ func TestDeleteUserAllowance(t *testing.T) {
Expect(). Expect().
Status(200). Status(200).
JSON().Array() JSON().Array()
allowances.Length().IsEqual(0) allowances.Length().IsEqual(1)
}
func TestDeleteUserRestAllowance(t *testing.T) {
e := startServer(t)
e.DELETE("/user/1/allowance/0").Expect().Status(400)
} }
func TestDeleteUserAllowanceNotFound(t *testing.T) { func TestDeleteUserAllowanceNotFound(t *testing.T) {
@ -248,7 +255,16 @@ func TestCreateTask(t *testing.T) {
// Verify the response has an ID // Verify the response has an ID
response.ContainsKey("id") response.ContainsKey("id")
taskId := response.Value("id").Number().Raw() response.Value("id").Number().IsEqual(1)
e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1)
// Get task
result := e.GET("/task/1").Expect().Status(200).JSON().Object()
result.Value("id").IsEqual(1)
result.Value("name").IsEqual("Test Task")
result.Value("reward").IsEqual(100)
result.Value("assigned").IsNull()
// Create a new task with assigned user // Create a new task with assigned user
assignedUserId := 1 assignedUserId := 1
@ -265,7 +281,7 @@ func TestCreateTask(t *testing.T) {
JSON().Object() JSON().Object()
responseWithUser.ContainsKey("id") responseWithUser.ContainsKey("id")
responseWithUser.Value("id").Number().NotEqual(taskId) // Ensure different ID responseWithUser.Value("id").Number().IsEqual(2)
} }
func TestDeleteTask(t *testing.T) { func TestDeleteTask(t *testing.T) {
@ -345,12 +361,16 @@ func TestGetTaskWhenNoTasks(t *testing.T) {
result.Length().IsEqual(0) result.Length().IsEqual(0)
} }
func createTestTask(e *httpexpect.Expect) { func createTestTask(e *httpexpect.Expect) int {
return createTestTaskWithAmount(e, 100)
}
func createTestTaskWithAmount(e *httpexpect.Expect, amount int) int {
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"name": "Test Task", "name": "Test Task",
"reward": 100, "reward": amount,
} }
e.POST("/tasks").WithJSON(requestBody).Expect().Status(201) return int(e.POST("/tasks").WithJSON(requestBody).Expect().Status(201).JSON().Object().Value("id").Number().Raw())
} }
func TestGetTasksWhenTasks(t *testing.T) { func TestGetTasksWhenTasks(t *testing.T) {
@ -520,6 +540,44 @@ func TestPutAllowanceById(t *testing.T) {
result.Value("weight").IsEqual(15) result.Value("weight").IsEqual(15)
} }
func TestCompleteTask(t *testing.T) {
e := startServer(t)
taskId := createTestTaskWithAmount(e, 101)
e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1)
// Update rest allowance
e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{
Weight: 25,
}).Expect().Status(200)
// Create two allowance goals
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: "Test Allowance 1",
Target: 1000,
Weight: 50,
}).Expect().Status(201)
e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{
Name: "Test Allowance 1",
Target: 1000,
Weight: 25,
}).Expect().Status(201)
// Complete the task
e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200)
// Verify the task is marked as completed
e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404)
// Verify the allowances are updated
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Length().IsEqual(3)
allowances.Value(0).Object().Value("id").Number().IsEqual(0)
allowances.Value(0).Object().Value("progress").Number().IsEqual(26)
allowances.Value(1).Object().Value("id").Number().IsEqual(1)
allowances.Value(1).Object().Value("progress").Number().IsEqual(50)
allowances.Value(2).Object().Value("id").Number().IsEqual(2)
allowances.Value(2).Object().Value("progress").Number().IsEqual(25)
}
func getDelta(base time.Time, delta float64) (time.Time, time.Time) { func getDelta(base time.Time, delta float64) (time.Time, time.Time) {
start := base.Add(-time.Duration(delta) * time.Second) start := base.Add(-time.Duration(delta) * time.Second)
end := base.Add(time.Duration(delta) * time.Second) end := base.Add(time.Duration(delta) * time.Second)

View File

@ -71,7 +71,14 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) {
allowances := make([]Allowance, 0) allowances := make([]Allowance, 0)
var err error var err error
for row := range db.db.Query("select id, name, target, progress, weight from allowances where user_id = ?"). totalAllowance := Allowance{}
err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&totalAllowance.Progress, &totalAllowance.Weight)
if err != nil {
return nil, err
}
allowances = append(allowances, totalAllowance)
for row := range db.db.Query("select id, name, target, balance, weight from allowances where user_id = ?").
Bind(userId).Range(&err) { Bind(userId).Range(&err) {
allowance := Allowance{} allowance := Allowance{}
err = row.Scan(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) err = row.Scan(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight)
@ -88,11 +95,19 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) {
func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) { func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) {
allowance := &Allowance{} allowance := &Allowance{}
err := db.db.Query("select id, name, target, progress, weight from allowances where user_id = ? and id = ?"). if allowanceId == 0 {
Bind(userId, allowanceId). err := db.db.Query("select balance, weight from users where id = ?").
ScanSingle(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) Bind(userId).ScanSingle(&allowance.Progress, &allowance.Weight)
if err != nil { if err != nil {
return nil, err return nil, err
}
} else {
err := db.db.Query("select id, name, target, balance, weight from allowances where user_id = ? and id = ?").
Bind(userId, allowanceId).
ScanSingle(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight)
if err != nil {
return nil, err
}
} }
return allowance, nil return allowance, nil
} }
@ -114,7 +129,7 @@ func (db *Db) CreateAllowance(userId int, allowance *CreateAllowanceRequest) (in
defer tx.MustRollback() defer tx.MustRollback()
// Insert the new allowance // Insert the new allowance
err = tx.Query("insert into allowances (user_id, name, target, progress, weight) values (?, ?, ?, 0, ?)"). err = tx.Query("insert into allowances (user_id, name, target, weight) values (?, ?, ?, ?)").
Bind(userId, allowance.Name, allowance.Target, allowance.Weight). Bind(userId, allowance.Name, allowance.Target, allowance.Weight).
Exec() Exec()
@ -160,6 +175,22 @@ func (db *Db) DeleteAllowance(userId int, allowanceId int) error {
return nil return nil
} }
func (db *Db) UpdateUserAllowance(userId int, allowance *UpdateAllowanceRequest) error {
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
err = tx.Query("update users set weight=? where id = ?").
Bind(allowance.Weight, userId).
Exec()
if err != nil {
return err
}
return tx.Commit()
}
func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllowanceRequest) error { func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllowanceRequest) error {
// Check if the allowance exists for the user // Check if the allowance exists for the user
count := 0 count := 0
@ -289,6 +320,67 @@ func (db *Db) UpdateTask(id int, task *CreateTaskRequest) error {
return tx.Commit() return tx.Commit()
} }
func (db *Db) CompleteTask(taskId int) error {
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
var reward int
err = tx.Query("select reward from tasks where id = ?").Bind(taskId).ScanSingle(&reward)
if err != nil {
return err
}
for userRow := range tx.Query("select id, weight from users").Range(&err) {
var userId int
var userWeight float64
err = userRow.Scan(&userId, &userWeight)
if err != nil {
return err
}
var sumOfWeights float64
err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights)
sumOfWeights += userWeight
for allowanceRow := range tx.Query("select id, weight from allowances where user_id = ? and weight > 0").Bind(userId).Range(&err) {
var allowanceId int
var allowanceWeight float64
err = allowanceRow.Scan(&allowanceId, &allowanceWeight)
if err != nil {
return err
}
// Calculate the amount to add to the allowance
amount := int((allowanceWeight / sumOfWeights) * float64(reward))
sumOfWeights -= allowanceWeight
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").
Bind(amount, allowanceId, userId).Exec()
if err != nil {
return err
}
reward -= amount
}
// Add the remaining reward to the user
err = tx.Query("update users set balance = balance + ? where id = ?").
Bind(reward, userId).Exec()
if err != nil {
return err
}
}
if err != nil {
return err
}
// Remove the task
err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec()
return tx.Commit()
}
func (db *Db) AddHistory(userId int, allowance *PostHistory) error { func (db *Db) AddHistory(userId int, allowance *PostHistory) error {
tx, err := db.db.Begin() tx, err := db.db.Begin()
if err != nil { if err != nil {

View File

@ -3,7 +3,7 @@ module allowance_planner
go 1.24.2 go 1.24.2
require ( require (
gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0
github.com/gavv/httpexpect/v2 v2.17.0 github.com/gavv/httpexpect/v2 v2.17.0
github.com/gin-contrib/cors v1.7.5 github.com/gin-contrib/cors v1.7.5
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
@ -68,9 +68,9 @@ require (
gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.6 // indirect modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.0 // indirect modernc.org/sqlite v1.37.0 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect moul.io/http2curl/v2 v2.3.0 // indirect
zombiezen.com/go/sqlite v1.4.0 // indirect zombiezen.com/go/sqlite v1.4.0 // indirect

View File

@ -1,5 +1,9 @@
gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 h1:kl0VFgvm52UKxJhZpf1hvucxZdOoXY50g/VmzsWH+/8= gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 h1:kl0VFgvm52UKxJhZpf1hvucxZdOoXY50g/VmzsWH+/8=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0 h1:nqSXu5i5fHB1rrx/kfi8Phn/J6eFa2yh02FiGc9U1yg=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
@ -216,10 +220,14 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE= modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA= modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=

View File

@ -207,6 +207,11 @@ func deleteUserAllowance(c *gin.Context) {
return return
} }
if allowanceId == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Allowance id zero cannot be deleted"})
return
}
exists, err := db.UserExists(userId) exists, err := db.UserExists(userId)
if err != nil { if err != nil {
log.Printf(ErrCheckingUserExist, err) log.Printf(ErrCheckingUserExist, err)
@ -268,7 +273,11 @@ func putUserAllowance(c *gin.Context) {
return return
} }
err = db.UpdateAllowance(userId, allowanceId, &allowanceRequest) if allowanceId == 0 {
err = db.UpdateUserAllowance(userId, &allowanceRequest)
} else {
err = db.UpdateAllowance(userId, allowanceId, &allowanceRequest)
}
if err != nil { if err != nil {
log.Printf("Error updating allowance: %v", err) log.Printf("Error updating allowance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
@ -410,6 +419,29 @@ func deleteTask(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Task deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Task deleted successfully"})
} }
func completeTask(c *gin.Context) {
taskIdStr := c.Param("taskId")
taskId, err := strconv.Atoi(taskIdStr)
if err != nil {
log.Printf("Invalid task ID: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
}
err = db.CompleteTask(taskId)
if errors.Is(err, mysqlite.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
}
if err != nil {
log.Printf("Error completing task: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Task completed successfully"})
}
func postHistory(c *gin.Context) { func postHistory(c *gin.Context) {
userIdStr := c.Param("userId") userIdStr := c.Param("userId")
userId, err := strconv.Atoi(userIdStr) userId, err := strconv.Atoi(userIdStr)
@ -491,6 +523,7 @@ func start(ctx context.Context, config *ServerConfig) {
router.GET("/api/task/:taskId", getTask) router.GET("/api/task/:taskId", getTask)
router.PUT("/api/task/:taskId", putTask) router.PUT("/api/task/:taskId", putTask)
router.DELETE("/api/task/:taskId", deleteTask) router.DELETE("/api/task/:taskId", deleteTask)
router.POST("/api/task/:taskId/complete", completeTask)
srv := &http.Server{ srv := &http.Server{
Addr: config.Addr, Addr: config.Addr,

View File

@ -1,7 +1,9 @@
create table users create table users
( (
id integer primary key, id integer primary key,
name text not null name text not null,
weight real not null default 1.0,
balance integer not null default 0
) strict; ) strict;
create table history create table history
@ -18,7 +20,7 @@ create table allowances
user_id integer not null, user_id integer not null,
name text not null, name text not null,
target integer not null, target integer not null,
progress integer not null, balance integer not null default 0,
weight real not null weight real not null
); );