Implement integration-test based on YNAB-Export #46

Merged
jacob1123 merged 24 commits from integration-test into master 2022-04-06 21:26:47 +02:00
7 changed files with 328 additions and 109 deletions

View File

@ -4,6 +4,11 @@ type: docker
name: budgeteer name: budgeteer
steps: steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --recursive --init
- name: Taskfile.dev PR - name: Taskfile.dev PR
image: hub.javil.eu/budgeteer:dev image: hub.javil.eu/budgeteer:dev
commands: commands:
@ -57,5 +62,13 @@ steps:
event: event:
- tag - tag
services:
- name: db
image: postgres:alpine
environment:
POSTGRES_USER: budgeteer
POSTGRES_PASSWORD: budgeteer
POSTGRES_DB: budgeteer_test
image_pull_secrets: image_pull_secrets:
- hub.javil.eu - hub.javil.eu

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "testdata"]
path = testdata
url = https://git.javil.eu/jacob1123/budgeteer-testdata.git

View File

@ -65,7 +65,13 @@ tasks:
desc: Run CI build desc: Run CI build
cmds: cmds:
- task: build-prod - task: build-prod
- go test ./... - task: cover
cover:
desc: Run test and analyze coverage
cmds:
- go test ./... -coverprofile=coverage.out -covermode=atomic
- go tool cover -html=coverage.out -o=coverage.html
frontend: frontend:
desc: Build vue frontend desc: Build vue frontend

View File

@ -2,7 +2,6 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@ -11,24 +10,18 @@ import (
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
txdb "github.com/DATA-DOG/go-txdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func init() { //nolint:gochecknoinits func TestRegisterUser(t *testing.T) {
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
}
func TestRegisterUser(t *testing.T) { //nolint:funlen
t.Parallel() t.Parallel()
database, err := postgres.Connect("pgtx", "example") database, err := postgres.Connect("pgtx", cfg.DatabaseConnection)
if err != nil { if err != nil {
fmt.Printf("could not connect to db: %s\n", err) t.Errorf("connect to DB: %v", err)
t.Skip()
return return
} }
tokenVerifier, _ := jwt.NewTokenVerifier("this_is_my_demo_secret_for_unit_tests") tokenVerifier, _ := jwt.NewTokenVerifier(cfg.SessionSecret)
h := Handler{ h := Handler{
Service: database, Service: database,
TokenVerifier: tokenVerifier, TokenVerifier: tokenVerifier,
@ -66,22 +59,4 @@ func TestRegisterUser(t *testing.T) { //nolint:funlen
t.Error("Did not get a token") t.Error("Did not get a token")
} }
}) })
t.Run("GetTransactions", func(t *testing.T) {
t.Parallel()
context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
}
var response TransactionsResponse
err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil {
t.Error(err.Error())
t.Error("Error retreiving list of transactions.")
}
if len(response.Transactions) == 0 {
t.Error("Did not get any transactions.")
}
})
} }

View File

@ -1,6 +1,7 @@
package server package server
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
@ -25,7 +26,6 @@ func getFirstOfMonthTime(date time.Time) time.Time {
type CategoryWithBalance struct { type CategoryWithBalance struct {
*postgres.GetCategoriesRow *postgres.GetCategoriesRow
Available numeric.Numeric Available numeric.Numeric
AvailableLastMonth numeric.Numeric
Activity numeric.Numeric Activity numeric.Numeric
Assigned numeric.Numeric Assigned numeric.Numeric
} }
@ -34,7 +34,6 @@ func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBal
return CategoryWithBalance{ return CategoryWithBalance{
GetCategoriesRow: category, GetCategoriesRow: category,
Available: numeric.Zero(), Available: numeric.Zero(),
AvailableLastMonth: numeric.Zero(),
Activity: numeric.Zero(), Activity: numeric.Zero(),
Assigned: numeric.Zero(), Assigned: numeric.Zero(),
} }
@ -56,46 +55,46 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
firstOfMonth, err := getDate(c) firstOfMonth, err := getDate(c)
if err != nil { if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String()) c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budget.ID.String())
return return
} }
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID) data, err := h.prepareBudgeting(c.Request.Context(), budget, firstOfMonth)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
c.JSON(http.StatusOK, data)
}
func (h *Handler) prepareBudgeting(ctx context.Context, budget postgres.Budget, firstOfMonth time.Time) (BudgetingForMonthResponse, error) {
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0) firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID) categories, err := h.Service.GetCategories(ctx, budget.ID)
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)}) return BudgetingForMonthResponse{}, fmt.Errorf("error loading categories: %w", err)
return
} }
categoriesWithBalance, moneyUsed := h.calculateBalances( cumultativeBalances, err := h.Service.GetCumultativeBalances(ctx, budget.ID)
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) if err != nil {
availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, firstOfNextMonth) return BudgetingForMonthResponse{}, fmt.Errorf("error loading balances: %w", err)
for i := range categoriesWithBalance {
cat := &categoriesWithBalance[i]
if cat.ID != budget.IncomeCategoryID {
continue
} }
cat.Available = availableBalance categoriesWithBalance, moneyUsed := h.calculateBalances(firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
cat.AvailableLastMonth = availableBalance availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, categoriesWithBalance, firstOfNextMonth)
}
data := struct { data := BudgetingForMonthResponse{categoriesWithBalance, availableBalance}
return data, nil
}
type BudgetingForMonthResponse struct {
Categories []CategoryWithBalance Categories []CategoryWithBalance
AvailableBalance numeric.Numeric AvailableBalance numeric.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
} }
func (*Handler) getAvailableBalance(budget postgres.Budget, func (*Handler) getAvailableBalance(budget postgres.Budget,
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow, moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
firstOfNextMonth time.Time) numeric.Numeric { categoriesWithBalance []CategoryWithBalance, firstOfNextMonth time.Time,
) numeric.Numeric {
availableBalance := moneyUsed availableBalance := moneyUsed
for _, bal := range cumultativeBalances { for _, bal := range cumultativeBalances {
@ -110,6 +109,15 @@ func (*Handler) getAvailableBalance(budget postgres.Budget,
availableBalance.AddI(bal.Transactions) availableBalance.AddI(bal.Transactions)
availableBalance.AddI(bal.Assignments) availableBalance.AddI(bal.Assignments)
} }
for i := range categoriesWithBalance {
cat := &categoriesWithBalance[i]
if cat.ID != budget.IncomeCategoryID {
continue
}
cat.Available = availableBalance
}
return availableBalance return availableBalance
} }
@ -147,28 +155,14 @@ func (h *Handler) returnBudgetingData(c *gin.Context, budgetUUID uuid.UUID) {
c.JSON(http.StatusOK, data) c.JSON(http.StatusOK, data)
} }
func (h *Handler) calculateBalances(budget postgres.Budget, func (h *Handler) calculateBalances(firstOfNextMonth time.Time, firstOfMonth time.Time,
firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) { ) ([]CategoryWithBalance, numeric.Numeric) {
categoriesWithBalance := []CategoryWithBalance{} categoriesWithBalance := []CategoryWithBalance{}
moneyUsed2 := numeric.Zero() moneyUsed := numeric.Zero()
moneyUsed := &moneyUsed2
for i := range categories { for i := range categories {
cat := &categories[i] cat := &categories[i]
// do not show hidden categories
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
firstOfNextMonth, moneyUsed, firstOfMonth, budget)
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
}
return categoriesWithBalance, *moneyUsed
}
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time,
moneyUsed *numeric.Numeric, firstOfMonth time.Time, budget postgres.Budget) CategoryWithBalance {
categoryWithBalance := NewCategoryWithBalance(cat) categoryWithBalance := NewCategoryWithBalance(cat)
for _, bal := range cumultativeBalances { for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID { if bal.CategoryID != cat.ID {
@ -188,13 +182,14 @@ func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
categoryWithBalance.Available = numeric.Zero() categoryWithBalance.Available = numeric.Zero()
} }
if bal.Date.Before(firstOfMonth) { if bal.Date.Year() == firstOfMonth.Year() && bal.Date.Month() == firstOfMonth.Month() {
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
} else if bal.Date.Before(firstOfNextMonth) {
categoryWithBalance.Activity = bal.Transactions categoryWithBalance.Activity = bal.Transactions
categoryWithBalance.Assigned = bal.Assignments categoryWithBalance.Assigned = bal.Assignments
} }
} }
return categoryWithBalance categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
}
return categoriesWithBalance, moneyUsed
} }

226
server/main_test.go Normal file
View File

@ -0,0 +1,226 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/config"
"git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/web"
txdb "github.com/DATA-DOG/go-txdb"
)
var cfg = config.Config{ //nolint:gochecknoglobals
DatabaseConnection: "postgres://budgeteer:budgeteer@db:5432/budgeteer_test",
SessionSecret: "this_is_my_demo_secret_for_unit_tests",
}
func init() { //nolint:gochecknoinits
_, err := postgres.Connect("pgx", cfg.DatabaseConnection)
if err != nil {
panic("failed connecting to DB for migrations: " + err.Error())
}
txdb.Register("pgtx", "pgx", cfg.DatabaseConnection)
}
func TestMain(t *testing.T) {
t.Parallel()
queries, err := postgres.Connect("pgtx", cfg.DatabaseConnection)
if err != nil {
t.Errorf("connect to DB: %v", err)
}
static, err := fs.Sub(web.Static, "dist")
if err != nil {
panic("couldn't open static files")
}
tokenVerifier, err := jwt.NewTokenVerifier(cfg.SessionSecret)
if err != nil {
panic(fmt.Errorf("couldn't create token verifier: %w", err))
}
handler := &Handler{
Service: queries,
TokenVerifier: tokenVerifier,
CredentialsVerifier: &bcrypt.Verifier{},
StaticFS: http.FS(static),
}
ctx := context.Background()
createUserParams := postgres.CreateUserParams{
Email: "test@example.com",
Name: "test@example.com",
Password: "this is my dumb password",
}
user, err := handler.Service.CreateUser(ctx, createUserParams)
if err != nil {
fmt.Println(err)
t.Fail()
return
}
budget, err := handler.Service.NewBudget(ctx, "My nice Budget", user.ID)
if err != nil {
fmt.Println(err)
t.Fail()
return
}
handler.DoYNABImport(ctx, t, budget)
loc := time.Now().Location()
AssertCategoriesAndAvailableEqual(ctx, t, loc, handler, budget)
// check accounts
}
func AssertCategoriesAndAvailableEqual(ctx context.Context, t *testing.T, loc *time.Location, handler *Handler, budget *postgres.Budget) {
t.Helper()
t.Run("Categories and available balance", func(t *testing.T) {
t.Parallel()
resultDir := "../testdata/production-export/results"
files, err := os.ReadDir(resultDir)
if err != nil {
t.Errorf("could not load results: %s", err)
return
}
for _, file := range files {
if file.IsDir() {
continue
}
name := file.Name()[0 : len(file.Name())-5]
parts := strings.Split(name, "-")
year, _ := strconv.Atoi(parts[0])
month, _ := strconv.Atoi(parts[1])
first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
testCaseFile := filepath.Join(resultDir, file.Name())
handler.CheckAvailableBalance(ctx, t, testCaseFile, budget, first)
}
})
}
type TestData struct {
AvailableBalance float64
Categories map[string]CategoryTestData
}
type CategoryTestData struct {
Available float64
Activity float64
Assigned float64
}
func (h Handler) CheckAvailableBalance(ctx context.Context, t *testing.T, testCaseFile string, budget *postgres.Budget, first time.Time) {
t.Helper()
t.Run(first.Format("2006-01"), func(t *testing.T) {
t.Parallel()
data, err := h.prepareBudgeting(ctx, *budget, first)
if err != nil {
t.Errorf("prepare budgeting: %s", err)
return
}
testDataFile, err := os.Open(testCaseFile)
if err != nil {
t.Errorf("could not load category test data: %s", err)
return
}
var testData TestData
dec := json.NewDecoder(testDataFile)
err = dec.Decode(&testData)
if err != nil {
t.Errorf("could not decode category test data: %s", err)
return
}
assertEqual(t, testData.AvailableBalance, data.AvailableBalance.GetFloat64(), "available balance")
for categoryName, categoryTestData := range testData.Categories {
found := false
for _, category := range data.Categories {
name := category.Group + " : " + category.Name
if name == categoryName {
assertEqual(t, categoryTestData.Available, category.Available.GetFloat64(), "available for "+categoryName)
assertEqual(t, categoryTestData.Activity, category.Activity.GetFloat64(), "activity for "+categoryName)
assertEqual(t, categoryTestData.Assigned, category.Assigned.GetFloat64(), "assigned for "+categoryName)
found = true
}
}
if !found {
t.Errorf("category " + categoryName + " was not found in result")
}
}
})
}
func assertEqual(t *testing.T, expected, actual float64, message string) {
t.Helper()
if expected == actual {
return
}
t.Errorf("%s: expected %f, got %f", message, expected, actual)
}
func (h Handler) DoYNABImport(ctx context.Context, t *testing.T, budget *postgres.Budget) {
t.Helper()
budgetID := budget.ID
ynab, err := postgres.NewYNABImport(ctx, h.Service.Queries, budgetID)
if err != nil {
fmt.Println(err)
t.Fail()
return
}
transactions, err := os.Open("../testdata/production-export/Register.tsv")
if err != nil {
fmt.Println(err)
t.Fail()
return
}
assignments, err := os.Open("../testdata/production-export/Budget.tsv")
if err != nil {
fmt.Println(err)
t.Fail()
return
}
err = ynab.ImportTransactions(ctx, transactions)
if err != nil {
fmt.Println(err)
t.Fail()
return
}
err = ynab.ImportAssignments(ctx, assignments)
if err != nil {
fmt.Println(err)
t.Fail()
return
}
}

1
testdata Submodule

@ -0,0 +1 @@
Subproject commit b6eda7681f92c2c635555c392660cb69bec2a1ed