Updated transaction routers, removed receipts router
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user