Added uploading receipt photo to API
This commit is contained in:
@@ -295,6 +295,80 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/receipts/photo": {
|
||||||
|
"post": {
|
||||||
|
"description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию",
|
||||||
|
"consumes": [
|
||||||
|
"multipart/form-data"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Receipts"
|
||||||
|
],
|
||||||
|
"summary": "Загрузить чек по фото",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"description": "Receipt photo",
|
||||||
|
"name": "photo",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Family ID for auto-created transaction",
|
||||||
|
"name": "family_id",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "User ID for auto-created transaction",
|
||||||
|
"name": "created_by",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Transaction type, default expense",
|
||||||
|
"name": "type",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Transaction category, default receipt",
|
||||||
|
"name": "category",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Transaction description override",
|
||||||
|
"name": "description",
|
||||||
|
"in": "formData"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/domain.AddReceiptResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/transactions": {
|
"/transactions": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
||||||
|
|||||||
@@ -284,6 +284,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/receipts/photo": {
|
||||||
|
"post": {
|
||||||
|
"description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию",
|
||||||
|
"consumes": [
|
||||||
|
"multipart/form-data"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Receipts"
|
||||||
|
],
|
||||||
|
"summary": "Загрузить чек по фото",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"description": "Receipt photo",
|
||||||
|
"name": "photo",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Family ID for auto-created transaction",
|
||||||
|
"name": "family_id",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "User ID for auto-created transaction",
|
||||||
|
"name": "created_by",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Transaction type, default expense",
|
||||||
|
"name": "type",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Transaction category, default receipt",
|
||||||
|
"name": "category",
|
||||||
|
"in": "formData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Transaction description override",
|
||||||
|
"name": "description",
|
||||||
|
"in": "formData"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/domain.AddReceiptResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.ErrorResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/dto.ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/transactions": {
|
"/transactions": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
||||||
|
|||||||
@@ -387,6 +387,56 @@ paths:
|
|||||||
summary: Загрузить чек
|
summary: Загрузить чек
|
||||||
tags:
|
tags:
|
||||||
- Receipts
|
- Receipts
|
||||||
|
/receipts/photo:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- multipart/form-data
|
||||||
|
description: Принимает фото, распознает текст через Google OCR и создает чек
|
||||||
|
с позициями; опционально создает связанную транзакцию
|
||||||
|
parameters:
|
||||||
|
- description: Receipt photo
|
||||||
|
in: formData
|
||||||
|
name: photo
|
||||||
|
required: true
|
||||||
|
type: file
|
||||||
|
- description: Family ID for auto-created transaction
|
||||||
|
in: formData
|
||||||
|
name: family_id
|
||||||
|
type: integer
|
||||||
|
- description: User ID for auto-created transaction
|
||||||
|
in: formData
|
||||||
|
name: created_by
|
||||||
|
type: integer
|
||||||
|
- description: Transaction type, default expense
|
||||||
|
in: formData
|
||||||
|
name: type
|
||||||
|
type: string
|
||||||
|
- description: Transaction category, default receipt
|
||||||
|
in: formData
|
||||||
|
name: category
|
||||||
|
type: string
|
||||||
|
- description: Transaction description override
|
||||||
|
in: formData
|
||||||
|
name: description
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/domain.AddReceiptResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/dto.ErrorResponse'
|
||||||
|
summary: Загрузить чек по фото
|
||||||
|
tags:
|
||||||
|
- Receipts
|
||||||
/transactions:
|
/transactions:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ package routers
|
|||||||
import (
|
import (
|
||||||
"FamilyHub/src/api/dto"
|
"FamilyHub/src/api/dto"
|
||||||
"FamilyHub/src/domain"
|
"FamilyHub/src/domain"
|
||||||
|
"FamilyHub/src/integrations/ocr"
|
||||||
"FamilyHub/src/utils"
|
"FamilyHub/src/utils"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -18,15 +23,17 @@ type receiptService interface {
|
|||||||
|
|
||||||
type ReceiptRouter struct {
|
type ReceiptRouter struct {
|
||||||
service receiptService
|
service receiptService
|
||||||
|
ocr ocr.OCR
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReceiptRouter(s receiptService) *ReceiptRouter {
|
func NewReceiptRouter(s receiptService, ocrSvc ocr.OCR) *ReceiptRouter {
|
||||||
return &ReceiptRouter{service: s}
|
return &ReceiptRouter{service: s, ocr: ocrSvc}
|
||||||
}
|
}
|
||||||
func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
|
func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||||
receipts := r.Group("/receipts")
|
receipts := r.Group("/receipts")
|
||||||
{
|
{
|
||||||
receipts.POST("", router.AddReceipt)
|
receipts.POST("", router.AddReceipt)
|
||||||
|
receipts.POST("/photo", router.AddReceiptByPhoto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,3 +81,135 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
|
|||||||
}
|
}
|
||||||
context_.JSON(http.StatusOK, resp)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package routers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"FamilyHub/src/domain"
|
"FamilyHub/src/domain"
|
||||||
|
"FamilyHub/src/integrations/ocr"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,6 +30,21 @@ func (m *receiptServiceMock) GetReceipt(ctx context.Context, req domain.AddRecei
|
|||||||
return nil, errors.New("mock is not configured")
|
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) {
|
func TestReceiptRouter_AddReceipt(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
@@ -88,7 +105,7 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
|
|||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
apiV1 := r.Group("/api/v1")
|
apiV1 := r.Group("/api/v1")
|
||||||
router := NewReceiptRouter(tc.mock)
|
router := NewReceiptRouter(tc.mock, nil)
|
||||||
router.RegisterRoutes(apiV1)
|
router.RegisterRoutes(apiV1)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts", bytes.NewBufferString(tc.body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts", bytes.NewBufferString(tc.body))
|
||||||
@@ -115,3 +132,86 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"FamilyHub/src/api/services"
|
"FamilyHub/src/api/services"
|
||||||
"FamilyHub/src/config"
|
"FamilyHub/src/config"
|
||||||
"FamilyHub/src/database"
|
"FamilyHub/src/database"
|
||||||
|
"FamilyHub/src/integrations/ocr"
|
||||||
"FamilyHub/src/integrations/receiptService"
|
"FamilyHub/src/integrations/receiptService"
|
||||||
"FamilyHub/src/repositories"
|
"FamilyHub/src/repositories"
|
||||||
"context"
|
"context"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
|
ocr ocr.OCR
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg config.Config) *Server {
|
func NewServer(cfg config.Config) *Server {
|
||||||
@@ -89,9 +91,14 @@ func NewServer(cfg config.Config) *Server {
|
|||||||
|
|
||||||
transactionRepo := repositories.NewTransactionsSQLRepository(dbConn)
|
transactionRepo := repositories.NewTransactionsSQLRepository(dbConn)
|
||||||
|
|
||||||
|
ocrSvc, err := ocr.NewGoogleOCR(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
receiptRepo := repositories.NewReceiptsSQLRepository(dbConn)
|
receiptRepo := repositories.NewReceiptsSQLRepository(dbConn)
|
||||||
receiptService_ := receiptService.NewReceiptService(receiptRepo, transactionRepo)
|
receiptService_ := receiptService.NewReceiptService(receiptRepo, transactionRepo)
|
||||||
receiptRouter := routers.NewReceiptRouter(receiptService_)
|
receiptRouter := routers.NewReceiptRouter(receiptService_, ocrSvc)
|
||||||
receiptRouter.RegisterRoutes(apiV1)
|
receiptRouter.RegisterRoutes(apiV1)
|
||||||
|
|
||||||
transactionService := services.NewTransactionService(transactionRepo)
|
transactionService := services.NewTransactionService(transactionRepo)
|
||||||
@@ -118,6 +125,7 @@ func NewServer(cfg config.Config) *Server {
|
|||||||
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
},
|
},
|
||||||
|
ocr: ocrSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,5 +134,8 @@ func (s *Server) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Shutdown(ctx context.Context) error {
|
func (s *Server) Shutdown(ctx context.Context) error {
|
||||||
|
if s.ocr != nil {
|
||||||
|
_ = s.ocr.Close()
|
||||||
|
}
|
||||||
return s.httpServer.Shutdown(ctx)
|
return s.httpServer.Shutdown(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if runMode == API || runMode == Standalone {
|
if runMode == API || runMode == Standalone {
|
||||||
|
if ocrTokenPath == "" {
|
||||||
|
warnings = append(warnings, "Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS")
|
||||||
|
}
|
||||||
if apiSecret == "" {
|
if apiSecret == "" {
|
||||||
warnings = append(warnings, "Missing required environment variable: API_SECRET")
|
warnings = append(warnings, "Missing required environment variable: API_SECRET")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user