Updated transaction routers, removed receipts router

This commit is contained in:
2026-05-09 12:04:20 +03:00
parent 2dc8ff01b7
commit a57f918d23
22 changed files with 1376 additions and 752 deletions
+2 -1
View File
@@ -37,7 +37,7 @@ func (router *ActivitiesRouter) RegisterRoutes(r *gin.RouterGroup) {
// @Success 200 {object} dto.ActivityListResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /activities [get]
// @Router /api/v1/activities [get]
func (router *ActivitiesRouter) List(c *gin.Context) {
var query dto.ActivityListQuery
if err := c.ShouldBindQuery(&query); err != nil {
@@ -52,6 +52,7 @@ func (router *ActivitiesRouter) List(c *gin.Context) {
Offset: query.Offset,
})
if err != nil {
logInternalError(c, "activity request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
return
}
+62
View File
@@ -0,0 +1,62 @@
package routers
import (
"FamilyHub/src/api/dto"
receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider"
"errors"
"log"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
func logError(c *gin.Context, scope string, err error) {
log.Printf(
"%s failed: method=%s path=%s route=%s error=%v",
scope,
c.Request.Method,
c.Request.URL.Path,
c.FullPath(),
err,
)
}
func logInternalError(c *gin.Context, scope string, err error) {
log.Printf(
"%s failed: method=%s path=%s route=%s error=%v\n%s",
scope,
c.Request.Method,
c.Request.URL.Path,
c.FullPath(),
err,
debug.Stack(),
)
}
func handleReceiptError(c *gin.Context, err error) {
var externalErr *receiptServiceIntegration.ExternalServiceError
switch {
case errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.As(err, &externalErr):
log.Printf(
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
c.Request.Method,
c.Request.URL.Path,
externalErr.StatusCode,
externalErr.Body,
)
logError(c, "receipt external service", err)
switch externalErr.StatusCode {
case http.StatusForbidden, http.StatusTooManyRequests:
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service temporarily unavailable"})
default:
c.JSON(http.StatusBadGateway, dto.ErrorResponse{Message: "receipt service error"})
}
default:
logInternalError(c, "receipt request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
+5 -14
View File
@@ -5,9 +5,7 @@ import (
"FamilyHub/src/domain"
"database/sql"
"errors"
"log"
"net/http"
"runtime/debug"
"strconv"
"github.com/gin-gonic/gin"
@@ -41,7 +39,7 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
// @Success 201 {object} domain.FamilyResponse
// @Failure 400 {object} map[string]string "invalid body"
// @Failure 500 {object} map[string]string "internal server error"
// @Router /families [post]
// @Router /api/v1/families [post]
func (router *FamiliesRouter) Create(c *gin.Context) {
var req domain.CreateFamilyRequest
var resp domain.FamilyResponse
@@ -71,7 +69,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
// @Failure 400 {object} map[string]string "invalid id"
// @Failure 404 {object} map[string]string "family not found"
// @Failure 500 {object} map[string]string "internal server error"
// @Router /families/{id} [get]
// @Router /api/v1/families/{id} [get]
func (router *FamiliesRouter) Read(c *gin.Context) {
var resp domain.FamilyResponse
@@ -103,7 +101,7 @@ func (router *FamiliesRouter) Read(c *gin.Context) {
// @Failure 400 {object} map[string]string "name is required"
// @Failure 404 {object} map[string]string "family not found"
// @Failure 500 {object} map[string]string "internal server error"
// @Router /families/{id} [patch]
// @Router /api/v1/families/{id} [patch]
func (router *FamiliesRouter) Update(c *gin.Context) {
var resp domain.FamilyResponse
@@ -143,7 +141,7 @@ func (router *FamiliesRouter) Update(c *gin.Context) {
// @Failure 400 {object} map[string]string "invalid id"
// @Failure 404 {object} map[string]string "family not found"
// @Failure 500 {object} map[string]string "internal server error"
// @Router /families/{id} [delete]
// @Router /api/v1/families/{id} [delete]
func (router *FamiliesRouter) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -166,14 +164,7 @@ func handleFamilyError(c *gin.Context, err error) {
case errors.Is(err, sql.ErrNoRows):
c.JSON(http.StatusNotFound, gin.H{"error": "family not found"})
default:
log.Printf(
"family request failed: method=%s path=%s route=%s error=%v\n%s",
c.Request.Method,
c.Request.URL.Path,
c.FullPath(),
err,
debug.Stack(),
)
logInternalError(c, "family request", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}
-215
View File
@@ -1,215 +0,0 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/domain"
"FamilyHub/src/integrations/ocr"
"FamilyHub/src/utils"
"context"
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type receiptService interface {
GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
type ReceiptRouter struct {
service receiptService
ocr ocr.OCR
}
func NewReceiptRouter(s receiptService, ocrSvc ocr.OCR) *ReceiptRouter {
return &ReceiptRouter{service: s, ocr: ocrSvc}
}
func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
receipts := r.Group("/receipts")
{
receipts.POST("", router.AddReceipt)
receipts.POST("/photo", router.AddReceiptByPhoto)
}
}
// AddReceipt GoDoc
// @Summary Загрузить чек
// @Description Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию
// @Tags Receipts
// @Accept json
// @Produce json
// @Param receipt body domain.AddReceiptRequest true "Receipt payload"
// @Success 200 {object} domain.AddReceiptResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /receipts [post]
func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
var req domain.AddReceiptRequest
if err := context_.ShouldBindJSON(&req); err != nil {
log.Println("bind error:", err)
context_.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
isoDate, err := utils.NormalizeDateToISO(req.Date)
if err != nil {
context_.JSON(400, gin.H{"error": "invalid date format"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
req.Date = isoDate
receipt, err := router.service.GetReceipt(ctx, req)
if err != nil {
context_.JSON(400, gin.H{"error": err.Error()})
log.Printf("API error, %s", err.Error())
return
}
resp := domain.AddReceiptResponse{
ID: int32(receipt.ID),
Number: receipt.ReceiptNumber,
Date: receipt.IssuedAt,
TransactionID: receipt.TransactionID,
}
context_.JSON(http.StatusOK, resp)
}
// AddReceiptByPhoto GoDoc
// @Summary Загрузить чек по фото
// @Description Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию
// @Tags Receipts
// @Accept multipart/form-data
// @Produce json
// @Param photo formData file true "Receipt photo"
// @Param family_id formData int false "Family ID for auto-created transaction"
// @Param created_by formData int false "User ID for auto-created transaction"
// @Param type formData string false "Transaction type, default expense"
// @Param category formData string false "Transaction category, default receipt"
// @Param description formData string false "Transaction description override"
// @Success 200 {object} domain.AddReceiptResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /receipts/photo [post]
func (router *ReceiptRouter) AddReceiptByPhoto(c *gin.Context) {
if router.ocr == nil {
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "ocr is not configured"})
return
}
fileHeader, err := c.FormFile("photo")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "failed to open uploaded photo"})
return
}
defer file.Close()
imageBytes, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "failed to read uploaded photo"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
text, err := router.ocr.Recognize(ctx, imageBytes)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "ocr failed"})
return
}
if strings.TrimSpace(text) == "" {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "text not found"})
return
}
receiptMeta := utils.ExtractReceiptMeta(text)
if receiptMeta.ReceiptID == "" {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "receipt number not found"})
return
}
if receiptMeta.Date == "" {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "receipt date not found"})
return
}
req, err := buildPhotoReceiptRequest(c, receiptMeta)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
receipt, err := router.service.GetReceipt(ctx, req)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
log.Printf("photo receipt API error, %s", err.Error())
return
}
c.JSON(http.StatusOK, domain.AddReceiptResponse{
ID: int32(receipt.ID),
Number: receipt.ReceiptNumber,
Date: receipt.IssuedAt,
TransactionID: receipt.TransactionID,
})
}
func buildPhotoReceiptRequest(c *gin.Context, meta utils.ReceiptMeta) (domain.AddReceiptRequest, error) {
req := domain.AddReceiptRequest{
Number: meta.ReceiptID,
Date: meta.Date,
}
familyID, err := parseOptionalInt64Form(c, "family_id")
if err != nil {
return domain.AddReceiptRequest{}, err
}
createdBy, err := parseOptionalInt64Form(c, "created_by")
if err != nil {
return domain.AddReceiptRequest{}, err
}
req.FamilyID = familyID
req.CreatedBy = createdBy
req.Type = parseOptionalStringForm(c, "type")
req.Category = parseOptionalStringForm(c, "category")
req.Description = parseOptionalStringForm(c, "description")
return req, nil
}
func parseOptionalInt64Form(c *gin.Context, key string) (*int64, error) {
value := strings.TrimSpace(c.PostForm(key))
if value == "" {
return nil, nil
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, errors.New(key + " must be int64")
}
return &parsed, nil
}
func parseOptionalStringForm(c *gin.Context, key string) *string {
value := strings.TrimSpace(c.PostForm(key))
if value == "" {
return nil
}
return &value
}
-217
View File
@@ -1,217 +0,0 @@
package routers
import (
"FamilyHub/src/domain"
"FamilyHub/src/integrations/ocr"
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type receiptServiceMock struct {
getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
func (m *receiptServiceMock) GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
if m.getReceiptFn != nil {
return m.getReceiptFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
type ocrMock struct {
recognizeFn func(ctx context.Context, image []byte) (string, error)
}
func (m *ocrMock) Recognize(ctx context.Context, image []byte) (string, error) {
if m.recognizeFn != nil {
return m.recognizeFn(ctx, image)
}
return "", errors.New("mock is not configured")
}
func (m *ocrMock) Close() error {
return nil
}
func TestReceiptRouter_AddReceipt(t *testing.T) {
gin.SetMode(gin.TestMode)
validNumber := strings.Repeat("1", 24)
validDate := "21.01.2026"
expectedDate := "2026-01-21"
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
tests := []struct {
name string
body string
mock *receiptServiceMock
expectedStatus int
expectedContains string
}{
{
name: "bad request on invalid body",
body: `{"date":"21.01.2026"}`,
mock: &receiptServiceMock{},
expectedStatus: http.StatusBadRequest,
expectedContains: "Number",
},
{
name: "bad request on invalid date format",
body: `{"number":"` + validNumber + `","date":"2026-01-21"}`,
mock: &receiptServiceMock{},
expectedStatus: http.StatusBadRequest,
expectedContains: "invalid date format",
},
{
name: "bad request on service error",
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, req.Date)
assert.Equal(t, validNumber, req.Number)
return nil, errors.New("receipt not found")
}},
expectedStatus: http.StatusBadRequest,
expectedContains: "receipt not found",
},
{
name: "ok",
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, req.Date)
assert.Equal(t, validNumber, req.Number)
return &domain.Receipt{ID: 7, ReceiptNumber: validNumber, IssuedAt: now}, nil
}},
expectedStatus: http.StatusOK,
expectedContains: validNumber,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
r := gin.New()
apiV1 := r.Group("/api/v1")
router := NewReceiptRouter(tc.mock, nil)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts", bytes.NewBufferString(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, tc.expectedStatus, w.Code)
assert.Contains(t, w.Body.String(), tc.expectedContains)
if tc.expectedStatus == http.StatusOK {
var resp struct {
ID int32 `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, int32(7), resp.ID)
assert.Equal(t, validNumber, resp.Number)
assert.Equal(t, now, resp.Date)
}
})
}
}
func TestReceiptRouter_AddReceiptByPhoto(t *testing.T) {
gin.SetMode(gin.TestMode)
validNumber := strings.Repeat("1", 24)
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
newRequest := func(t *testing.T, extraFields map[string]string) (*http.Request, string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("photo", "receipt.jpg")
require.NoError(t, err)
_, err = part.Write([]byte("fake-image"))
require.NoError(t, err)
for key, value := range extraFields {
require.NoError(t, writer.WriteField(key, value))
}
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts/photo", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req, writer.FormDataContentType()
}
t.Run("bad request when photo missing", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
router := NewReceiptRouter(&receiptServiceMock{}, &ocrMock{})
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts/photo", bytes.NewBufferString(""))
req.Header.Set("Content-Type", "multipart/form-data")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "photo is required")
})
t.Run("ok", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
assert.Equal(t, []byte("fake-image"), image)
return "21.01.2026 " + validNumber, nil
}}
service := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, validNumber, req.Number)
assert.Equal(t, "21.01.2026", req.Date)
require.NotNil(t, req.FamilyID)
require.NotNil(t, req.CreatedBy)
assert.Equal(t, int64(1), *req.FamilyID)
assert.Equal(t, int64(2), *req.CreatedBy)
require.NotNil(t, req.Category)
assert.Equal(t, "groceries", *req.Category)
return &domain.Receipt{ID: 9, ReceiptNumber: validNumber, IssuedAt: now}, nil
}}
router := NewReceiptRouter(service, ocrSvc)
router.RegisterRoutes(apiV1)
req, _ := newRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
"category": "groceries",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), validNumber)
})
}
var _ ocr.OCR = (*ocrMock)(nil)
+73 -22
View File
@@ -2,22 +2,29 @@ package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type TransactionsRouter struct {
service services.TransactionService
service services.TransactionService
creationService services.TransactionCreationService
}
func NewTransactionsRouter(s services.TransactionService) *TransactionsRouter {
return &TransactionsRouter{service: s}
func NewTransactionsRouter(
s services.TransactionService,
creationService services.TransactionCreationService,
) *TransactionsRouter {
return &TransactionsRouter{service: s, creationService: creationService}
}
func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
@@ -37,36 +44,70 @@ func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
// @Description Создает новую транзакцию и при необходимости привязывает к ней чек
// @Tags Transactions
// @Accept json
// @Accept multipart/form-data
// @Produce json
// @Param transaction body dto.CreateTransactionRequest true "Transaction payload"
// @Success 201 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions [post]
// @Router /api/v1/transactions [post]
func (router *TransactionsRouter) Create(c *gin.Context) {
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
router.createFromMultipart(c)
return
}
var req dto.CreateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
dateTime, err := time.Parse(time.RFC3339, req.DateTime)
input, err := requests.BuildCreateTransactionInput(req)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"})
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
transaction, err := router.service.Create(c.Request.Context(), domain.CreateTransactionRequest{
FamilyID: req.FamilyID,
Description: req.Description,
Type: req.Type,
DateTime: dateTime,
Category: req.Category,
Amount: req.Amount,
CreatedBy: req.CreatedBy,
ReceiptID: req.ReceiptID,
})
transaction, err := router.creationService.Create(c.Request.Context(), input)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
}
func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
fileHeader, err := c.FormFile("photo")
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"})
return
}
file, err := fileHeader.Open()
if err != nil {
logInternalError(c, "transaction upload", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
return
}
defer file.Close()
imageBytes, err := io.ReadAll(file)
if err != nil {
logInternalError(c, "transaction upload", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
return
}
input, err := requests.BuildPhotoCreateTransactionInput(c, imageBytes)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
transaction, err := router.creationService.Create(c.Request.Context(), input)
if err != nil {
handleTransactionError(c, err)
return
@@ -92,7 +133,7 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
// @Success 200 {object} dto.TransactionListResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions [get]
// @Router /api/v1/transactions [get]
func (router *TransactionsRouter) List(c *gin.Context) {
var query dto.ListTransactionsQuery
if err := c.ShouldBindQuery(&query); err != nil {
@@ -128,7 +169,7 @@ func (router *TransactionsRouter) List(c *gin.Context) {
// @Success 200 {object} dto.TransactionAnalyticsResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/analytics [get]
// @Router /api/v1/transactions/analytics [get]
func (router *TransactionsRouter) Analytics(c *gin.Context) {
var query dto.TransactionAnalyticsQuery
if err := c.ShouldBindQuery(&query); err != nil {
@@ -173,7 +214,7 @@ func (router *TransactionsRouter) Analytics(c *gin.Context) {
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [get]
// @Router /api/v1/transactions/{id} [get]
func (router *TransactionsRouter) Read(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -202,7 +243,7 @@ func (router *TransactionsRouter) Read(c *gin.Context) {
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [patch]
// @Router /api/v1/transactions/{id} [patch]
func (router *TransactionsRouter) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -254,7 +295,7 @@ func (router *TransactionsRouter) Update(c *gin.Context) {
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [delete]
// @Router /api/v1/transactions/{id} [delete]
func (router *TransactionsRouter) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -277,11 +318,21 @@ func handleTransactionError(c *gin.Context, err error) {
case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction),
errors.Is(err, services.ErrInvalidAnalytics):
errors.Is(err, services.ErrInvalidAnalytics),
errors.Is(err, services.ErrInvalidTransactionCreateInput),
errors.Is(err, services.ErrReceiptTransactionActorsMissing),
errors.Is(err, services.ErrOCRTextNotFound),
errors.Is(err, services.ErrReceiptNumberNotFound),
errors.Is(err, services.ErrReceiptDateNotFound):
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptServiceNotConfigured),
errors.Is(err, services.ErrOCRNotConfigured),
errors.Is(err, services.ErrReceiptTransactionNotCreated):
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
default:
logInternalError(c, "transaction request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
@@ -0,0 +1,317 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"bytes"
"context"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type transactionServiceMock struct {
createFn func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error)
getByIDFn func(ctx context.Context, id int64) (*domain.Transaction, error)
}
type receiptServiceMock struct {
getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
func (m *receiptServiceMock) AddReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
if m.getReceiptFn != nil {
return m.getReceiptFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
type ocrMock struct {
recognizeFn func(ctx context.Context, image []byte) (string, error)
}
func (m *ocrMock) Recognize(ctx context.Context, image []byte) (string, error) {
if m.recognizeFn != nil {
return m.recognizeFn(ctx, image)
}
return "", errors.New("mock is not configured")
}
func (m *ocrMock) Close() error {
return nil
}
func (m *transactionServiceMock) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
if m.createFn != nil {
return m.createFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
func (m *transactionServiceMock) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) {
if m.getByIDFn != nil {
return m.getByIDFn(ctx, id)
}
return nil, errors.New("mock is not configured")
}
func (m *transactionServiceMock) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) {
return nil, errors.New("not implemented")
}
func (m *transactionServiceMock) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) {
return domain.TransactionAnalytics{}, errors.New("not implemented")
}
func (m *transactionServiceMock) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) {
return nil, errors.New("not implemented")
}
func (m *transactionServiceMock) Delete(ctx context.Context, id int64) error {
return errors.New("not implemented")
}
func TestTransactionsRouter_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
validNumber := strings.Repeat("1", 24)
newMultipartRequest := func(t *testing.T, fields map[string]string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("photo", "receipt.jpg")
require.NoError(t, err)
_, err = part.Write([]byte("fake-image"))
require.NoError(t, err)
for key, value := range fields {
require.NoError(t, writer.WriteField(key, value))
}
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
t.Run("creates manual transaction from json", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
service := &transactionServiceMock{createFn: func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
assert.Equal(t, int64(1), req.FamilyID)
assert.Equal(t, int64(2), req.CreatedBy)
assert.Equal(t, "expense", req.Type)
assert.Equal(t, "groceries", req.Category)
assert.Equal(t, 150.5, req.Amount)
assert.Equal(t, now, req.DateTime)
return &domain.Transaction{
ID: 11,
FamilyID: req.FamilyID,
Type: req.Type,
DateTime: req.DateTime,
Category: req.Category,
Amount: req.Amount,
CreatedBy: req.CreatedBy,
CreatedAt: now,
}, nil
}}
creationService := services.NewTransactionCreationService(service, nil, nil)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":2,
"type":"expense",
"category":"groceries",
"amount":150.5,
"datetime":"2026-01-21T10:11:12Z"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":11`)
})
t.Run("creates transaction from receipt number and date", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, validNumber, req.Number)
assert.Equal(t, "2026-01-21", req.Date)
require.NotNil(t, req.FamilyID)
require.NotNil(t, req.CreatedBy)
assert.Equal(t, int64(1), *req.FamilyID)
assert.Equal(t, int64(2), *req.CreatedBy)
return &domain.Receipt{ID: 7, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(21)}, nil
}}
service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(21), id)
return &domain.Transaction{
ID: 21,
FamilyID: 1,
Type: "expense",
DateTime: now,
Category: "receipt",
Amount: 99.9,
CreatedBy: 2,
CreatedAt: now,
ReceiptID: ptrInt64(7),
}, nil
}}
creationService := services.NewTransactionCreationService(service, receiptSvc, nil)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":2,
"receipt_number":"`+validNumber+`",
"receipt_date":"21.01.2026"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":21`)
})
t.Run("creates transaction from photo upload", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
assert.Equal(t, []byte("fake-image"), image)
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, validNumber, req.Number)
assert.Equal(t, "21.01.2026", req.Date)
require.NotNil(t, req.FamilyID)
require.NotNil(t, req.CreatedBy)
assert.Equal(t, int64(1), *req.FamilyID)
assert.Equal(t, int64(2), *req.CreatedBy)
return &domain.Receipt{ID: 8, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(22)}, nil
}}
service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(22), id)
return &domain.Transaction{
ID: 22,
FamilyID: 1,
Type: "expense",
DateTime: now,
Category: "receipt",
Amount: 123.4,
CreatedBy: 2,
CreatedAt: now,
ReceiptID: ptrInt64(8),
}, nil
}}
creationService := services.NewTransactionCreationService(service, receiptSvc, ocrSvc)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":22`)
})
t.Run("rejects mixed manual and receipt payload", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, &receiptServiceMock{}, nil)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":2,
"receipt_number":"`+validNumber+`",
"receipt_date":"21.01.2026",
"amount":10
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "manual transaction fields cannot be combined with receipt input")
})
t.Run("returns validation error when photo flow misses family data", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, &receiptServiceMock{}, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction")
})
}
func ptrInt64(v int64) *int64 {
return &v
}
func TestBuildManualTransactionRequest(t *testing.T) {
dateTime := "2026-01-21T10:11:12Z"
typeValue := "expense"
category := "groceries"
amount := 12.5
familyID := int64(1)
createdBy := int64(2)
req, err := requests.BuildManualTransactionRequest(dto.CreateTransactionRequest{
FamilyID: &familyID,
CreatedBy: &createdBy,
Type: &typeValue,
Category: &category,
Amount: &amount,
DateTime: &dateTime,
})
require.NoError(t, err)
assert.Equal(t, familyID, req.FamilyID)
}
+6 -5
View File
@@ -38,7 +38,7 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
// @Success 201 {object} domain.UserResponse
// @Failure 400 {object} domain.UserErrorResponse
// @Failure 500 {object} domain.UserErrorResponse
// @Router /users [post]
// @Router /api/v1/users [post]
func (router *UsersRouter) Create(c *gin.Context) {
var req domain.CreateUserRequest
var resp domain.UserResponse
@@ -68,7 +68,7 @@ func (router *UsersRouter) Create(c *gin.Context) {
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
// @Failure 404 {object} domain.UserErrorResponse "user not found"
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
// @Router /users/{id} [get]
// @Router /api/v1/users/{id} [get]
func (router *UsersRouter) Read(c *gin.Context) {
var resp domain.UserResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -97,7 +97,7 @@ func (router *UsersRouter) Read(c *gin.Context) {
// @Failure 400 {object} domain.UserErrorResponse "invalid telegram id"
// @Failure 404 {object} domain.UserErrorResponse "user not found"
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
// @Router /users/by-telegram/{telegramId} [get]
// @Router /api/v1/users/by-telegram/{telegramId} [get]
func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
var resp domain.UserResponse
telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64)
@@ -127,7 +127,7 @@ func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
// @Failure 400 {object} domain.UserErrorResponse "invalid id or invalid body"
// @Failure 404 {object} domain.UserErrorResponse "user not found"
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
// @Router /users/{id} [patch]
// @Router /api/v1/users/{id} [patch]
func (router *UsersRouter) Update(c *gin.Context) {
var resp domain.UserResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -162,7 +162,7 @@ func (router *UsersRouter) Update(c *gin.Context) {
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
// @Failure 404 {object} domain.UserErrorResponse "user not found"
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
// @Router /users/{id} [delete]
// @Router /api/v1/users/{id} [delete]
func (router *UsersRouter) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -187,6 +187,7 @@ func handleError(c *gin.Context, err error) {
case errors.Is(err, services.ErrTelegramIDMissing):
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
default:
logInternalError(c, "user request", err)
c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{Error: "internal server error"})
}
}