diff --git a/.drone.yml b/.drone.yml index 9dc657e..a3877dd 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,6 +4,11 @@ type: docker name: budgeteer steps: +- name: submodules + image: alpine/git + commands: + - git submodule update --recursive --init + - name: Taskfile.dev PR image: hub.javil.eu/budgeteer:dev commands: @@ -57,5 +62,13 @@ steps: event: - tag +services: +- name: db + image: postgres:alpine + environment: + POSTGRES_USER: budgeteer + POSTGRES_PASSWORD: budgeteer + POSTGRES_DB: budgeteer_test + image_pull_secrets: - hub.javil.eu \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b1140be --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "testdata"] + path = testdata + url = https://git.javil.eu/jacob1123/budgeteer-testdata.git diff --git a/Taskfile.yml b/Taskfile.yml index 08508ee..92e2fdc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -65,7 +65,13 @@ tasks: desc: Run CI build cmds: - 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: desc: Build vue frontend diff --git a/server/account_test.go b/server/account_test.go index 061b1bd..e233b41 100644 --- a/server/account_test.go +++ b/server/account_test.go @@ -2,7 +2,6 @@ package server import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" "strings" @@ -11,24 +10,18 @@ import ( "git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/postgres" - txdb "github.com/DATA-DOG/go-txdb" "github.com/gin-gonic/gin" ) -func init() { //nolint:gochecknoinits - txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test") -} - -func TestRegisterUser(t *testing.T) { //nolint:funlen +func TestRegisterUser(t *testing.T) { t.Parallel() - database, err := postgres.Connect("pgtx", "example") + database, err := postgres.Connect("pgtx", cfg.DatabaseConnection) if err != nil { - fmt.Printf("could not connect to db: %s\n", err) - t.Skip() + t.Errorf("connect to DB: %v", err) return } - tokenVerifier, _ := jwt.NewTokenVerifier("this_is_my_demo_secret_for_unit_tests") + tokenVerifier, _ := jwt.NewTokenVerifier(cfg.SessionSecret) h := Handler{ Service: database, TokenVerifier: tokenVerifier, @@ -66,22 +59,4 @@ func TestRegisterUser(t *testing.T) { //nolint:funlen 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.") - } - }) } diff --git a/server/budgeting.go b/server/budgeting.go index 9c65d85..b8341b7 100644 --- a/server/budgeting.go +++ b/server/budgeting.go @@ -1,6 +1,7 @@ package server import ( + "context" "fmt" "net/http" "time" @@ -24,19 +25,17 @@ func getFirstOfMonthTime(date time.Time) time.Time { type CategoryWithBalance struct { *postgres.GetCategoriesRow - Available numeric.Numeric - AvailableLastMonth numeric.Numeric - Activity numeric.Numeric - Assigned numeric.Numeric + Available numeric.Numeric + Activity numeric.Numeric + Assigned numeric.Numeric } func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBalance { return CategoryWithBalance{ - GetCategoriesRow: category, - Available: numeric.Zero(), - AvailableLastMonth: numeric.Zero(), - Activity: numeric.Zero(), - Assigned: numeric.Zero(), + GetCategoriesRow: category, + Available: numeric.Zero(), + Activity: numeric.Zero(), + Assigned: numeric.Zero(), } } @@ -56,46 +55,46 @@ func (h *Handler) budgetingForMonth(c *gin.Context) { firstOfMonth, err := getDate(c) if err != nil { - c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String()) + c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budget.ID.String()) return } - categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID) + data, err := h.prepareBudgeting(c.Request.Context(), budget, firstOfMonth) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } - - firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0) - cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)}) - return - } - - categoriesWithBalance, moneyUsed := h.calculateBalances( - budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) - availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, firstOfNextMonth) - for i := range categoriesWithBalance { - cat := &categoriesWithBalance[i] - if cat.ID != budget.IncomeCategoryID { - continue - } - - cat.Available = availableBalance - cat.AvailableLastMonth = availableBalance - } - - data := struct { - Categories []CategoryWithBalance - AvailableBalance numeric.Numeric - }{categoriesWithBalance, availableBalance} 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) + categories, err := h.Service.GetCategories(ctx, budget.ID) + if err != nil { + return BudgetingForMonthResponse{}, fmt.Errorf("error loading categories: %w", err) + } + + cumultativeBalances, err := h.Service.GetCumultativeBalances(ctx, budget.ID) + if err != nil { + return BudgetingForMonthResponse{}, fmt.Errorf("error loading balances: %w", err) + } + + categoriesWithBalance, moneyUsed := h.calculateBalances(firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) + availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, categoriesWithBalance, firstOfNextMonth) + + data := BudgetingForMonthResponse{categoriesWithBalance, availableBalance} + return data, nil +} + +type BudgetingForMonthResponse struct { + Categories []CategoryWithBalance + AvailableBalance numeric.Numeric +} + func (*Handler) getAvailableBalance(budget postgres.Budget, moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow, - firstOfNextMonth time.Time) numeric.Numeric { + categoriesWithBalance []CategoryWithBalance, firstOfNextMonth time.Time, +) numeric.Numeric { availableBalance := moneyUsed for _, bal := range cumultativeBalances { @@ -110,6 +109,15 @@ func (*Handler) getAvailableBalance(budget postgres.Budget, availableBalance.AddI(bal.Transactions) availableBalance.AddI(bal.Assignments) } + + for i := range categoriesWithBalance { + cat := &categoriesWithBalance[i] + if cat.ID != budget.IncomeCategoryID { + continue + } + + cat.Available = availableBalance + } return availableBalance } @@ -147,54 +155,41 @@ func (h *Handler) returnBudgetingData(c *gin.Context, budgetUUID uuid.UUID) { c.JSON(http.StatusOK, data) } -func (h *Handler) calculateBalances(budget postgres.Budget, - firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, - cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) { +func (h *Handler) calculateBalances(firstOfNextMonth time.Time, firstOfMonth time.Time, + categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow, +) ([]CategoryWithBalance, numeric.Numeric) { categoriesWithBalance := []CategoryWithBalance{} - moneyUsed2 := numeric.Zero() - moneyUsed := &moneyUsed2 + moneyUsed := numeric.Zero() for i := range categories { cat := &categories[i] - // do not show hidden categories - categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances, - firstOfNextMonth, moneyUsed, firstOfMonth, budget) + categoryWithBalance := NewCategoryWithBalance(cat) + for _, bal := range cumultativeBalances { + if bal.CategoryID != cat.ID { + continue + } + + // skip everything in the future + if !bal.Date.Before(firstOfNextMonth) { + continue + } + + moneyUsed.SubI(bal.Assignments) + categoryWithBalance.Available.AddI(bal.Assignments) + categoryWithBalance.Available.AddI(bal.Transactions) + if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) { + moneyUsed.AddI(categoryWithBalance.Available) + categoryWithBalance.Available = numeric.Zero() + } + + if bal.Date.Year() == firstOfMonth.Year() && bal.Date.Month() == firstOfMonth.Month() { + categoryWithBalance.Activity = bal.Transactions + categoryWithBalance.Assigned = bal.Assignments + } + } 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) - for _, bal := range cumultativeBalances { - if bal.CategoryID != cat.ID { - continue - } - - // skip everything in the future - if !bal.Date.Before(firstOfNextMonth) { - continue - } - - moneyUsed.SubI(bal.Assignments) - categoryWithBalance.Available.AddI(bal.Assignments) - categoryWithBalance.Available.AddI(bal.Transactions) - if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) { - moneyUsed.AddI(categoryWithBalance.Available) - categoryWithBalance.Available = numeric.Zero() - } - - if bal.Date.Before(firstOfMonth) { - categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available - } else if bal.Date.Before(firstOfNextMonth) { - categoryWithBalance.Activity = bal.Transactions - categoryWithBalance.Assigned = bal.Assignments - } - } - - return categoryWithBalance + return categoriesWithBalance, moneyUsed } diff --git a/server/main_test.go b/server/main_test.go new file mode 100644 index 0000000..cface7b --- /dev/null +++ b/server/main_test.go @@ -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 + } +} diff --git a/testdata b/testdata new file mode 160000 index 0000000..b6eda76 --- /dev/null +++ b/testdata @@ -0,0 +1 @@ +Subproject commit b6eda7681f92c2c635555c392660cb69bec2a1ed