Files
food-ai/docs/plans/Iteration_0.md
dbastrikin 24219b611e feat: implement Iteration 0 foundation (backend + Flutter client)
Backend (Go):
- Project structure with chi router, pgxpool, goose migrations
- JWT auth (access/refresh tokens) with Firebase token verification
- NoopTokenVerifier for local dev without Firebase credentials
- PostgreSQL user repository with atomic profile updates (transactions)
- Mifflin-St Jeor calorie calculation based on profile data
- REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health
- Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id
- Unit tests (51 passing) and integration tests (testcontainers)
- Docker Compose setup with postgres healthcheck and graceful shutdown

Flutter client:
- Riverpod state management with GoRouter navigation
- Firebase Auth (email/password + Google sign-in with web popup support)
- Platform-aware API URLs (web/Android/iOS)
- Dio HTTP client with JWT auth interceptor and concurrent refresh handling
- Secure token storage
- Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile)
- Unit tests (17 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 13:14:58 +02:00

68 KiB
Raw Permalink Blame History

Итерация 0: Фундамент

Цель: развернуть скелет проекта, базу данных, авторизацию и каркас мобильного приложения. После итерации можно зарегистрироваться, войти и увидеть пустые экраны.

Зависимости: нет.

Ориентир: Summary.md → stories 0.10.10


Структура задач

0.1 Go-проект
 ├── 0.1.1 Инициализация модуля и структуры каталогов
 ├── 0.1.2 Конфигурация (envconfig)
 ├── 0.1.3 Логирование (slog)
 └── 0.1.4 Graceful shutdown

0.2 PostgreSQL + миграции
 ├── 0.2.1 Подключение к PostgreSQL (pgxpool)
 ├── 0.2.2 Система миграций (goose)
 └── 0.2.3 Начальная миграция: таблица users

0.3 HTTP-сервер
 ├── 0.3.1 chi-роутер + базовый сервер
 ├── 0.3.2 Middleware: CORS, request ID, logging, recovery
 └── 0.3.3 Healthcheck endpoint

0.4 Firebase Auth
 ├── 0.4.1 Firebase Admin SDK — верификация idToken
 ├── 0.4.2 JWT-генерация (собственный токен)
 ├── 0.4.3 Auth middleware (проверка JWT на защищённых роутах)
 ├── 0.4.4 POST /auth/login
 ├── 0.4.5 POST /auth/refresh
 └── 0.4.6 POST /auth/logout

0.5 Users сервис
 ├── 0.5.1 Миграция: полная таблица users
 ├── 0.5.2 Repository (CRUD)
 ├── 0.5.3 Service layer
 ├── 0.5.4 GET /profile, PUT /profile

0.6 Docker Compose + Makefile

0.7 Flutter-проект
 ├── 0.7.1 Инициализация и структура каталогов
 ├── 0.7.2 Подключение пакетов
 └── 0.7.3 Тема и дизайн-токены

0.8 Firebase Auth (Flutter)
 ├── 0.8.1 Firebase-проект + конфигурация
 ├── 0.8.2 Экран входа (email + Google + Apple)
 ├── 0.8.3 Экран регистрации
 └── 0.8.4 Хранение JWT в secure storage

0.9 Навигация и каркас
 ├── 0.9.1 go_router + Bottom Tab Bar
 ├── 0.9.2 5 экранов-заглушек
 └── 0.9.3 Auth guard (редирект неавторизованных)

0.10 API-клиент (Flutter)
 ├── 0.10.1 Dio-клиент + interceptors
 ├── 0.10.2 Auth interceptor (JWT + refresh)
 └── 0.10.3 Модель User + сериализация

0.1 Инициализация Go-проекта

0.1.1 Структура каталогов

backend/
├── cmd/
│   └── server/
│       └── main.go              # Точка входа
├── internal/
│   ├── config/
│   │   └── config.go            # Структура конфигурации
│   ├── server/
│   │   └── server.go            # HTTP-сервер, роутер, graceful shutdown
│   ├── middleware/
│   │   ├── cors.go
│   │   ├── logging.go
│   │   ├── recovery.go
│   │   ├── request_id.go
│   │   └── auth.go              # JWT-проверка
│   ├── auth/
│   │   ├── handler.go           # HTTP-хэндлеры /auth/*
│   │   ├── service.go           # Бизнес-логика авторизации
│   │   ├── firebase.go          # Firebase Admin SDK обёртка
│   │   └── jwt.go               # Генерация/валидация JWT
│   ├── user/
│   │   ├── handler.go           # HTTP-хэндлеры /profile
│   │   ├── service.go           # Бизнес-логика
│   │   ├── repository.go        # PostgreSQL-запросы
│   │   └── model.go             # Доменная модель User
│   └── database/
│       └── postgres.go          # Подключение pgxpool
├── migrations/
│   └── 001_create_users.sql     # Первая миграция
├── docker-compose.yml
├── Makefile
├── .env.example
└── go.mod

Принципы

  • Модуль: github.com/<owner>/food_ai (или внутренний путь)
  • Зависимости подключаются по мере необходимости, не заранее
  • Каждый домен (auth, user) — отдельный пакет с handler → service → repository
  • Нет ORM — чистый pgx с SQL-запросами
  • Нет глобальных переменных — всё через DI (конструкторы)

0.1.2 Конфигурация

// internal/config/config.go

type Config struct {
    Port        int    `envconfig:"PORT" default:"8080"`
    DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`

    // Firebase
    FirebaseCredentialsFile string `envconfig:"FIREBASE_CREDENTIALS_FILE" required:"true"`

    // JWT
    JWTSecret          string        `envconfig:"JWT_SECRET" required:"true"`
    JWTAccessDuration  time.Duration `envconfig:"JWT_ACCESS_DURATION" default:"15m"`
    JWTRefreshDuration time.Duration `envconfig:"JWT_REFRESH_DURATION" default:"720h"` // 30 дней

    // CORS
    AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"`
}
  • Пакет: github.com/kelseyhightower/envconfig
  • .env.example с описанием всех переменных
  • Никаких YAML/JSON-конфигов — только переменные окружения

0.1.3 Логирование

// cmd/server/main.go — инициализация

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
  • Используем log/slog из стандартной библиотеки (Go 1.21+)
  • JSON-формат для структурированного вывода
  • Логгер передаётся через контекст или как зависимость в сервисы
  • В middleware: логируем method, path, status, duration, request_id

0.1.4 Graceful shutdown

// cmd/server/main.go

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// Запуск сервера
go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        slog.Error("server error", "err", err)
    }
}()

<-ctx.Done()
slog.Info("shutting down...")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

srv.Shutdown(shutdownCtx)
pool.Close() // pgxpool

0.2 PostgreSQL + миграции

0.2.1 Подключение к PostgreSQL

// internal/database/postgres.go

func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
    config, err := pgxpool.ParseConfig(databaseURL)
    if err != nil {
        return nil, fmt.Errorf("parse config: %w", err)
    }

    config.MaxConns = 20
    config.MinConns = 5
    config.MaxConnLifetime = 30 * time.Minute
    config.MaxConnIdleTime = 5 * time.Minute

    pool, err := pgxpool.NewWithConfig(ctx, config)
    if err != nil {
        return nil, fmt.Errorf("create pool: %w", err)
    }

    if err := pool.Ping(ctx); err != nil {
        return nil, fmt.Errorf("ping: %w", err)
    }

    return pool, nil
}
  • Пакет: github.com/jackc/pgx/v5/pgxpool
  • Пул соединений, не единичное подключение
  • MaxConns = 20 — достаточно для начала, настраивается через конфиг

0.2.2 Система миграций

  • Пакет: github.com/pressly/goose/v3
  • Миграции в migrations/.sql файлы
  • Команды в Makefile:
migrate-up:
	goose -dir migrations postgres "$(DATABASE_URL)" up

migrate-down:
	goose -dir migrations postgres "$(DATABASE_URL)" down

migrate-create:
	goose -dir migrations create $(name) sql
  • Миграции запускаются вручную через make migrate-up, НЕ при старте приложения
  • Это даёт контроль над моментом применения миграций

0.2.3 Начальная миграция: таблица users

-- migrations/001_create_users.sql

-- +goose Up
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TYPE user_plan AS ENUM ('free', 'paid');
CREATE TYPE user_gender AS ENUM ('male', 'female');
CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain');
CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high');

CREATE TABLE users (
    id            UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    firebase_uid  VARCHAR(128) NOT NULL UNIQUE,
    email         VARCHAR(255) NOT NULL,
    name          VARCHAR(255) NOT NULL DEFAULT '',
    avatar_url    TEXT,

    -- Параметры тела
    height_cm     SMALLINT,
    weight_kg     DECIMAL(5,2),
    age           SMALLINT,
    gender        user_gender,
    activity      activity_level,

    -- Цель и рассчитанная норма
    goal          user_goal,
    daily_calories SMALLINT,     -- Рассчитывается по формуле Миффлина-Сан Жеора

    -- Тариф
    plan          user_plan NOT NULL DEFAULT 'free',

    -- Предпочтения (JSONB для гибкости)
    preferences   JSONB NOT NULL DEFAULT '{}'::jsonb,
    -- Пример: {
    --   "cuisines": ["russian", "asian"],
    --   "restrictions": ["nuts"],
    --   "storage_defaults": { "dairy": 5, "meat": 3, "vegetables": 7 },
    --   "water_goal": 8,
    --   "notifications": { "expiring": true, "meals": true, "water": true }
    -- }

    -- Refresh token
    refresh_token TEXT,
    token_expires_at TIMESTAMPTZ,

    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_users_firebase_uid ON users (firebase_uid);
CREATE INDEX idx_users_email ON users (email);

-- +goose Down
DROP TABLE IF EXISTS users;
DROP TYPE IF EXISTS activity_level;
DROP TYPE IF EXISTS user_goal;
DROP TYPE IF EXISTS user_gender;
DROP TYPE IF EXISTS user_plan;

Формула калорий (Mifflin-St Jeor)

При заполнении профиля backend рассчитывает daily_calories:

Мужчины: BMR = 10 × вес(кг) + 6.25 × рост(см)  5 × возраст  161 + 166
Женщины: BMR = 10 × вес(кг) + 6.25 × рост(см)  5 × возраст  161

Коэффициент активности:
  low:      1.375
  moderate: 1.55
  high:     1.725

TDEE = BMR × коэффициент

Цель:
  lose:     TDEE  500
  maintain: TDEE
  gain:     TDEE + 300

0.3 HTTP-сервер

0.3.1 chi-роутер

// internal/server/server.go

func NewRouter(
    authHandler *auth.Handler,
    userHandler *user.Handler,
    authMiddleware func(http.Handler) http.Handler,
) *chi.Mux {
    r := chi.NewRouter()

    // Global middleware
    r.Use(middleware.RequestID)
    r.Use(middleware.Logging)
    r.Use(middleware.Recovery)
    r.Use(middleware.CORS(allowedOrigins))

    // Public
    r.Get("/health", healthCheck)
    r.Route("/auth", func(r chi.Router) {
        r.Post("/login", authHandler.Login)
        r.Post("/refresh", authHandler.Refresh)
        r.Post("/logout", authHandler.Logout)
    })

    // Protected
    r.Group(func(r chi.Router) {
        r.Use(authMiddleware)
        r.Get("/profile", userHandler.Get)
        r.Put("/profile", userHandler.Update)
    })

    return r
}
  • Пакет: github.com/go-chi/chi/v5
  • Группировка роутов: public (auth, health) и protected (всё остальное)
  • chi выбран за: совместимость с net/http, middleware-цепочки, вложенные роутеры

0.3.2 Middleware

Request ID

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Logging

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        ww := &responseWriter{ResponseWriter: w, statusCode: 200}

        next.ServeHTTP(ww, r)

        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", ww.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
            "request_id", RequestIDFromCtx(r.Context()),
        )
    })
}

CORS

func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
    return cors.Handler(cors.Options{
        AllowedOrigins:   allowedOrigins,
        AllowedMethods:   []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"Authorization", "Content-Type", "X-Request-ID"},
        ExposedHeaders:   []string{"X-Request-ID"},
        AllowCredentials: true,
        MaxAge:           300,
    })
}
  • Пакет: github.com/go-chi/cors

Recovery

  • Ловит panic в хэндлерах → логирует stack trace → возвращает 500
  • Не даёт серверу упасть из-за одного запроса

0.3.3 Healthcheck

GET /health → 200 OK
{
    "status": "ok",
    "version": "0.1.0",
    "db": "connected"       // Проверка pool.Ping()
}

0.4 Firebase Auth + JWT

0.4.1 Firebase Admin SDK

// internal/auth/firebase.go

type FirebaseAuth struct {
    client *firebaseAuth.Client
}

func NewFirebaseAuth(credentialsFile string) (*FirebaseAuth, error) {
    opt := option.WithCredentialsFile(credentialsFile)
    app, err := firebase.NewApp(context.Background(), nil, opt)
    if err != nil {
        return nil, fmt.Errorf("init firebase app: %w", err)
    }

    client, err := app.Auth(context.Background())
    if err != nil {
        return nil, fmt.Errorf("init auth client: %w", err)
    }

    return &FirebaseAuth{client: client}, nil
}

func (f *FirebaseAuth) VerifyToken(ctx context.Context, idToken string) (*firebaseAuth.Token, error) {
    return f.client.VerifyIDToken(ctx, idToken)
}
  • Пакет: firebase.google.com/go/v4
  • Файл firebase-credentials.jsonНЕ коммитится, путь через env

0.4.2 JWT-генерация

// internal/auth/jwt.go

type JWTManager struct {
    secret          []byte
    accessDuration  time.Duration
    refreshDuration time.Duration
}

type Claims struct {
    UserID string `json:"user_id"`
    Plan   string `json:"plan"`
    jwt.RegisteredClaims
}

func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) {
    claims := Claims{
        UserID: userID,
        Plan:   plan,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(j.secret)
}

func (j *JWTManager) GenerateRefreshToken() (string, time.Time) {
    token := uuid.NewString()
    expiresAt := time.Now().Add(j.refreshDuration)
    return token, expiresAt
}

func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
        return j.secret, nil
    })
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}
  • Пакет: github.com/golang-jwt/jwt/v5
  • Access token: 15 минут (short-lived)
  • Refresh token: 30 дней, хранится в БД (users.refresh_token), ротируется при каждом refresh

0.4.3 Auth middleware

// internal/middleware/auth.go

func Auth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            header := r.Header.Get("Authorization")
            if !strings.HasPrefix(header, "Bearer ") {
                http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
                return
            }

            tokenStr := strings.TrimPrefix(header, "Bearer ")
            claims, err := jwtManager.ValidateAccessToken(tokenStr)
            if err != nil {
                http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
                return
            }

            ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
            ctx = context.WithValue(ctx, userPlanKey, claims.Plan)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

0.4.4 Эндпоинты авторизации

POST /auth/login

Request:
{
    "firebase_token": "<Firebase idToken>"
}

Логика:
1. firebase.VerifyToken(firebase_token) → uid, email, name, photo
2. Upsert user в БД (по firebase_uid)
3. Генерация access JWT + refresh token
4. Сохранение refresh token в БД
5. Возвращение токенов + профиля

Response (200):
{
    "access_token": "eyJ...",
    "refresh_token": "550e8400-...",
    "expires_in": 900,
    "user": {
        "id": "...",
        "email": "user@gmail.com",
        "name": "Иван",
        "avatar_url": "...",
        "plan": "free",
        "has_completed_onboarding": false
    }
}

POST /auth/refresh

Request:
{
    "refresh_token": "550e8400-..."
}

Логика:
1. Найти user по refresh_token в БД
2. Проверить token_expires_at > now()
3. Сгенерировать новые access + refresh tokens
4. Сохранить новый refresh token (ротация)
5. Старый refresh token перестаёт работать

Response (200):
{
    "access_token": "eyJ...",
    "refresh_token": "новый-uuid",
    "expires_in": 900
}

Errors:
- 401 — невалидный или истёкший refresh token

POST /auth/logout

Логика:
1. Получить user_id из JWT (Auth middleware)
2. Обнулить refresh_token и token_expires_at в БД
3. Клиент удаляет токены из secure storage

Response (200):
{ "status": "ok" }

0.5 Users сервис

0.5.1 Доменная модель

// internal/user/model.go

type User struct {
    ID             string          `json:"id"`
    FirebaseUID    string          `json:"-"`
    Email          string          `json:"email"`
    Name           string          `json:"name"`
    AvatarURL      *string         `json:"avatar_url"`
    HeightCM       *int            `json:"height_cm"`
    WeightKG       *float64        `json:"weight_kg"`
    Age            *int            `json:"age"`
    Gender         *string         `json:"gender"`
    Activity       *string         `json:"activity"`
    Goal           *string         `json:"goal"`
    DailyCalories  *int            `json:"daily_calories"`
    Plan           string          `json:"plan"`
    Preferences    json.RawMessage `json:"preferences"`
    CreatedAt      time.Time       `json:"created_at"`
    UpdatedAt      time.Time       `json:"updated_at"`
}

type UpdateProfileRequest struct {
    Name      *string          `json:"name"`
    HeightCM  *int             `json:"height_cm"`
    WeightKG  *float64         `json:"weight_kg"`
    Age       *int             `json:"age"`
    Gender    *string          `json:"gender"`
    Activity  *string          `json:"activity"`
    Goal      *string          `json:"goal"`
    Preferences *json.RawMessage `json:"preferences"`
}

0.5.2 Repository

// internal/user/repository.go

type Repository struct {
    pool *pgxpool.Pool
}

func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error)
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error)
func (r *Repository) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error)
func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error
func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error)
func (r *Repository) ClearRefreshToken(ctx context.Context, id string) error
  • Upsert: INSERT ... ON CONFLICT (firebase_uid) DO UPDATE SET email = ..., updated_at = now()
  • Update: динамический SQL — обновляются только переданные поля (не NULL)
  • При обновлении параметров тела → пересчёт daily_calories

0.5.3 Service layer

// internal/user/service.go

type Service struct {
    repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, userID string) (*User, error)
func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*User, error)
  • В UpdateProfile: если изменился height/weight/age/gender/activity/goal → пересчёт daily_calories
  • Валидация: height 100250, weight 30300, age 10120

0.5.4 HTTP-хэндлеры

GET /profile
→ Берёт user_id из контекста (Auth middleware)
→ 200 + User JSON

PUT /profile
→ Парсит UpdateProfileRequest из body
→ Валидация
→ service.UpdateProfile()
→ 200 + обновлённый User JSON
→ 400 при невалидных данных

0.6 Docker Compose + Makefile

docker-compose.yml

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: food_ai
      POSTGRES_USER: food_ai
      POSTGRES_PASSWORD: food_ai_local
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://food_ai:food_ai_local@postgres:5432/food_ai?sslmode=disable
      FIREBASE_CREDENTIALS_FILE: /app/firebase-credentials.json
      JWT_SECRET: local-dev-secret-change-in-prod
      ALLOWED_ORIGINS: http://localhost:3000,http://localhost:8080
    depends_on:
      - postgres
    volumes:
      - ./firebase-credentials.json:/app/firebase-credentials.json:ro

volumes:
  pgdata:

Dockerfile

# Build
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server

# Run
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/server .
COPY migrations ./migrations
EXPOSE 8080
CMD ["./server"]

Makefile

.PHONY: run test lint migrate-up migrate-down migrate-create docker-up docker-down

# Запуск для разработки
run:
	go run ./cmd/server

# Тесты
test:
	go test ./... -v -race -count=1

# Линтер
lint:
	golangci-lint run ./...

# Миграции
migrate-up:
	goose -dir migrations postgres "$(DATABASE_URL)" up

migrate-down:
	goose -dir migrations postgres "$(DATABASE_URL)" down

migrate-create:
	goose -dir migrations create $(name) sql

migrate-status:
	goose -dir migrations postgres "$(DATABASE_URL)" status

# Docker
docker-up:
	docker compose up -d

docker-down:
	docker compose down

docker-logs:
	docker compose logs -f app

.env.example

# Database
DATABASE_URL=postgres://food_ai:food_ai_local@localhost:5432/food_ai?sslmode=disable

# Firebase
FIREBASE_CREDENTIALS_FILE=./firebase-credentials.json

# JWT
JWT_SECRET=change-me-in-production
JWT_ACCESS_DURATION=15m
JWT_REFRESH_DURATION=720h

# Server
PORT=8080
ALLOWED_ORIGINS=http://localhost:3000

0.7 Инициализация Flutter-проекта

0.7.1 Структура каталогов

client/
├── lib/
│   ├── main.dart
│   ├── app.dart                      # MaterialApp, тема, роутер
│   ├── core/
│   │   ├── api/
│   │   │   ├── api_client.dart       # Dio + interceptors
│   │   │   ├── auth_interceptor.dart  # JWT attach + refresh
│   │   │   └── api_exceptions.dart    # Типизированные ошибки
│   │   ├── auth/
│   │   │   ├── auth_provider.dart     # Riverpod provider состояния авторизации
│   │   │   ├── auth_service.dart      # Firebase + backend auth
│   │   │   └── secure_storage.dart    # Обёртка flutter_secure_storage
│   │   ├── router/
│   │   │   └── app_router.dart        # go_router конфигурация
│   │   └── theme/
│   │       ├── app_theme.dart         # ThemeData
│   │       └── app_colors.dart        # Палитра
│   ├── features/
│   │   ├── auth/
│   │   │   ├── login_screen.dart
│   │   │   └── register_screen.dart
│   │   ├── home/
│   │   │   └── home_screen.dart       # Заглушка
│   │   ├── products/
│   │   │   └── products_screen.dart   # Заглушка
│   │   ├── menu/
│   │   │   └── menu_screen.dart       # Заглушка
│   │   ├── recipes/
│   │   │   └── recipes_screen.dart    # Заглушка
│   │   └── profile/
│   │       └── profile_screen.dart    # Заглушка
│   └── shared/
│       ├── models/
│       │   └── user.dart              # Модель User + fromJson/toJson
│       └── widgets/
│           └── ...                    # Общие виджеты (позже)
├── pubspec.yaml
├── analysis_options.yaml
└── firebase.json

0.7.2 Основные пакеты

# pubspec.yaml

dependencies:
  flutter:
    sdk: flutter

  # Навигация
  go_router: ^14.0.0

  # Состояние
  flutter_riverpod: ^2.5.0
  riverpod_annotation: ^2.3.0

  # Сеть
  dio: ^5.4.0

  # Firebase
  firebase_core: ^3.0.0
  firebase_auth: ^5.0.0
  google_sign_in: ^6.2.0
  sign_in_with_apple: ^6.1.0

  # Хранение
  flutter_secure_storage: ^9.2.0

  # Сериализация
  json_annotation: ^4.9.0
  freezed_annotation: ^2.4.0

  # UI
  cached_network_image: ^3.3.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.0
  json_serializable: ^6.8.0
  freezed: ^2.5.0
  riverpod_generator: ^2.4.0
  flutter_lints: ^4.0.0

0.7.3 Тема и дизайн-токены

// lib/core/theme/app_colors.dart

abstract class AppColors {
  // Primary
  static const primary = Color(0xFF4CAF50);      // Зелёный — еда, здоровье
  static const primaryLight = Color(0xFF81C784);
  static const primaryDark = Color(0xFF388E3C);

  // Accent
  static const accent = Color(0xFFFF9800);        // Оранжевый — CTA, важное

  // Background
  static const background = Color(0xFFF5F5F5);
  static const surface = Color(0xFFFFFFFF);

  // Text
  static const textPrimary = Color(0xFF212121);
  static const textSecondary = Color(0xFF757575);

  // Status
  static const error = Color(0xFFE53935);
  static const warning = Color(0xFFFFA726);
  static const success = Color(0xFF66BB6A);

  // Shelf life indicators
  static const freshGreen = Color(0xFF4CAF50);    // > 3 дня
  static const warningYellow = Color(0xFFFFC107);  // 1-3 дня
  static const expiredRed = Color(0xFFE53935);     // 0 дней / просрочено
}
// lib/core/theme/app_theme.dart

ThemeData appTheme() {
  return ThemeData(
    useMaterial3: true,
    colorSchemeSeed: AppColors.primary,
    scaffoldBackgroundColor: AppColors.background,
    appBarTheme: const AppBarTheme(
      centerTitle: true,
      elevation: 0,
      backgroundColor: AppColors.surface,
      foregroundColor: AppColors.textPrimary,
    ),
    bottomNavigationBarTheme: const BottomNavigationBarThemeData(
      selectedItemColor: AppColors.primary,
      unselectedItemColor: AppColors.textSecondary,
      type: BottomNavigationBarType.fixed,
    ),
    inputDecorationTheme: InputDecorationTheme(
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
      contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
    ),
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        minimumSize: const Size(double.infinity, 48),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      ),
    ),
  );
}

0.8 Firebase Auth (Flutter)

0.8.1 Firebase-проект

Необходимые действия (вне кода):

  1. Создать проект в Firebase Console
  2. Добавить Android-приложение (package name, SHA-1)
  3. Добавить iOS-приложение (bundle ID)
  4. Добавить Web-приложение
  5. Включить провайдеры: Email/Password, Google, Apple
  6. Скачать google-services.json (Android), GoogleService-Info.plist (iOS)
  7. Сгенерировать firebase-credentials.json (Service Account) для Go backend

0.8.2 Auth Service

// lib/core/auth/auth_service.dart

class AuthService {
  final FirebaseAuth _firebaseAuth;
  final ApiClient _apiClient;
  final SecureStorageService _storage;

  /// Вход через Google
  Future<User> signInWithGoogle() async {
    final googleUser = await GoogleSignIn().signIn();
    final googleAuth = await googleUser!.authentication;
    final credential = GoogleAuthProvider.credential(
      accessToken: googleAuth.accessToken,
      idToken: googleAuth.idToken,
    );
    final userCredential = await _firebaseAuth.signInWithCredential(credential);
    return _authenticateWithBackend(userCredential);
  }

  /// Вход через Apple
  Future<User> signInWithApple() async {
    final appleProvider = AppleAuthProvider()
      ..addScope('email')
      ..addScope('name');
    final userCredential = await _firebaseAuth.signInWithProvider(appleProvider);
    return _authenticateWithBackend(userCredential);
  }

  /// Вход через email/password
  Future<User> signInWithEmail(String email, String password) async {
    final userCredential = await _firebaseAuth.signInWithEmailAndPassword(
      email: email, password: password,
    );
    return _authenticateWithBackend(userCredential);
  }

  /// Регистрация через email/password
  Future<User> registerWithEmail(String email, String password, String name) async {
    final userCredential = await _firebaseAuth.createUserWithEmailAndPassword(
      email: email, password: password,
    );
    await userCredential.user!.updateDisplayName(name);
    return _authenticateWithBackend(userCredential);
  }

  /// Общая логика: Firebase → Backend → JWT
  Future<User> _authenticateWithBackend(UserCredential credential) async {
    final idToken = await credential.user!.getIdToken();

    final response = await _apiClient.post('/auth/login', data: {
      'firebase_token': idToken,
    });

    await _storage.saveTokens(
      accessToken: response['access_token'],
      refreshToken: response['refresh_token'],
    );

    return User.fromJson(response['user']);
  }

  Future<void> signOut() async {
    await _apiClient.post('/auth/logout');
    await _firebaseAuth.signOut();
    await _storage.clearTokens();
  }
}

0.8.3 Экран входа

┌─────────────────────────────────────┐
│                                     │
│            [Лого FoodAI]            │
│                                     │
│     Управляйте питанием             │
│     с помощью AI                    │
│                                     │
│  ┌───────────────────────────────┐  │
│  │ 📧  Email                     │  │
│  └───────────────────────────────┘  │
│  ┌───────────────────────────────┐  │
│  │ 🔒  Пароль                    │  │
│  └───────────────────────────────┘  │
│                                     │
│  ┌───────────────────────────────┐  │
│  │          Войти                │  │
│  └───────────────────────────────┘  │
│                                     │
│         ── или ──                   │
│                                     │
│  ┌───────────────────────────────┐  │
│  │  [G]  Войти через Google     │  │
│  └───────────────────────────────┘  │
│  ┌───────────────────────────────┐  │
│  │  []  Войти через Apple      │  │
│  └───────────────────────────────┘  │
│                                     │
│     Нет аккаунта? Регистрация       │
│                                     │
└─────────────────────────────────────┘
  • Кнопка Apple отображается только на iOS
  • Валидация: email-формат, пароль минимум 6 символов
  • Ошибки: «Неверный email или пароль», «Email уже зарегистрирован», «Нет соединения»

0.8.4 Secure Storage

// lib/core/auth/secure_storage.dart

class SecureStorageService {
  final FlutterSecureStorage _storage;

  Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
  }) async {
    await _storage.write(key: 'access_token', value: accessToken);
    await _storage.write(key: 'refresh_token', value: refreshToken);
  }

  Future<String?> getAccessToken() => _storage.read(key: 'access_token');
  Future<String?> getRefreshToken() => _storage.read(key: 'refresh_token');

  Future<void> clearTokens() async {
    await _storage.delete(key: 'access_token');
    await _storage.delete(key: 'refresh_token');
  }
}

0.9 Навигация и каркас

0.9.1 go_router + Shell Route

// lib/core/router/app_router.dart

final appRouter = GoRouter(
  initialLocation: '/home',
  redirect: (context, state) {
    final isLoggedIn = /* check auth state */;
    final isAuthRoute = state.matchedLocation.startsWith('/auth');

    if (!isLoggedIn && !isAuthRoute) return '/auth/login';
    if (isLoggedIn && isAuthRoute) return '/home';
    return null;
  },
  routes: [
    // Auth routes (без bottom bar)
    GoRoute(path: '/auth/login', builder: (_, __) => const LoginScreen()),
    GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),

    // Main shell с Bottom Tab Bar
    ShellRoute(
      builder: (context, state, child) => MainShell(child: child),
      routes: [
        GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
        GoRoute(path: '/products', builder: (_, __) => const ProductsScreen()),
        GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()),
        GoRoute(path: '/recipes', builder: (_, __) => const RecipesScreen()),
        GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
      ],
    ),
  ],
);

0.9.2 Main Shell (Bottom Tab Bar)

class MainShell extends StatelessWidget {
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _calculateIndex(GoRouterState.of(context).matchedLocation),
        onTap: (index) => _onTabTap(context, index),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Главная'),
          BottomNavigationBarItem(icon: Icon(Icons.kitchen), label: 'Продукты'),
          BottomNavigationBarItem(icon: Icon(Icons.calendar_month), label: 'Меню'),
          BottomNavigationBarItem(icon: Icon(Icons.menu_book), label: 'Рецепты'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Профиль'),
        ],
      ),
    );
  }
}

0.9.3 Экраны-заглушки

Каждый экран-заглушка:

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Главная')),
      body: const Center(
        child: Text('Раздел в разработке'),
      ),
    );
  }
}

0.10 API-клиент (Flutter)

0.10.1 Dio-клиент

// lib/core/api/api_client.dart

class ApiClient {
  late final Dio _dio;

  ApiClient({required String baseUrl, required SecureStorageService storage}) {
    _dio = Dio(BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 30),
      headers: {'Content-Type': 'application/json'},
    ));

    _dio.interceptors.addAll([
      AuthInterceptor(storage: storage, dio: _dio),
      LogInterceptor(requestBody: true, responseBody: true),
    ]);
  }

  Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? params}) async {
    final response = await _dio.get(path, queryParameters: params);
    return response.data;
  }

  Future<Map<String, dynamic>> post(String path, {dynamic data}) async {
    final response = await _dio.post(path, data: data);
    return response.data;
  }

  Future<Map<String, dynamic>> put(String path, {dynamic data}) async {
    final response = await _dio.put(path, data: data);
    return response.data;
  }
}

0.10.2 Auth Interceptor

// lib/core/api/auth_interceptor.dart

class AuthInterceptor extends Interceptor {
  final SecureStorageService _storage;
  final Dio _dio;

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    // Не добавлять токен к auth-эндпоинтам
    if (options.path.startsWith('/auth/')) {
      return handler.next(options);
    }

    final token = await _storage.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      // Попытка refresh
      final refreshToken = await _storage.getRefreshToken();
      if (refreshToken == null) return handler.next(err);

      try {
        final response = await _dio.post('/auth/refresh', data: {
          'refresh_token': refreshToken,
        });

        await _storage.saveTokens(
          accessToken: response.data['access_token'],
          refreshToken: response.data['refresh_token'],
        );

        // Повторить оригинальный запрос с новым токеном
        final retryOptions = err.requestOptions;
        retryOptions.headers['Authorization'] = 'Bearer ${response.data['access_token']}';
        final retryResponse = await _dio.fetch(retryOptions);
        return handler.resolve(retryResponse);
      } catch (_) {
        // Refresh не удался → выход
        await _storage.clearTokens();
        return handler.next(err);
      }
    }
    handler.next(err);
  }
}

0.10.3 Модель User

// lib/shared/models/user.dart

@freezed
class User with _$User {
  const factory User({
    required String id,
    required String email,
    required String name,
    String? avatarUrl,
    int? heightCm,
    double? weightKg,
    int? age,
    String? gender,
    String? activity,
    String? goal,
    int? dailyCalories,
    required String plan,
    @Default({}) Map<String, dynamic> preferences,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Порядок выполнения

Фаза 1 — Backend core (0.1 → 0.2 → 0.3)
  0.1.1 Инициализация Go-модуля и каталогов
  0.1.2 Конфигурация
  0.1.3 Логирование
  0.1.4 Graceful shutdown
  0.2.1 Подключение pgxpool
  0.2.2 Goose миграции
  0.2.3 Миграция users
  0.3.1 chi-роутер
  0.3.2 Middleware
  0.3.3 Healthcheck
  ─── Контрольная точка: make run → GET /health → 200 ───

Фаза 2 — Auth (0.4 → 0.5)
  0.4.1 Firebase Admin SDK
  0.4.2 JWT-генерация
  0.4.3 Auth middleware
  0.4.4 POST /auth/login
  0.4.5 POST /auth/refresh
  0.4.6 POST /auth/logout
  0.5.1 Модель User
  0.5.2 Repository
  0.5.3 Service
  0.5.4 GET/PUT /profile
  ─── Контрольная точка: curl POST /auth/login → JWT → GET /profile → 200 ───

Фаза 3 — DevOps (0.6)
  docker-compose.yml
  Dockerfile
  Makefile
  .env.example
  ─── Контрольная точка: docker compose up → app + postgres работают ───

Фаза 4 — Flutter (0.7 → 0.8 → 0.9 → 0.10)
  0.7.1 Инициализация проекта
  0.7.2 Пакеты
  0.7.3 Тема
  0.8.1 Firebase-конфигурация
  0.8.2 Экран входа
  0.8.3 Экран регистрации
  0.8.4 Secure storage
  0.9.1 go_router + auth guard
  0.9.2 Main Shell + заглушки
  0.10.1 Dio-клиент
  0.10.2 Auth interceptor
  0.10.3 Модель User
  ─── Контрольная точка: вход через Google → 5 вкладок ───

Критерии готовности

Критерий Проверка
Backend запускается и отвечает на /health curl localhost:8080/health → 200
Миграция users применяется без ошибок make migrate-up → success
Регистрация через email работает POST /auth/login с Firebase token → JWT
Refresh token ротируется POST /auth/refresh → новая пара токенов
Профиль сохраняется PUT /profile → GET /profile → данные совпадают
Docker Compose поднимает всё docker compose up → app + postgres
Flutter собирается (Android) flutter run → приложение запускается
Вход через Google работает Тап → Google → вход → 5 вкладок
Auth guard работает Без токена → редирект на экран входа
Token refresh работает Через 15 мин → запрос → auto-refresh → 200

Тестирование

Моки и интерфейсы

Для unit-тестирования сервисов и хэндлеров необходимо вынести зависимости за интерфейсы. Моки генерируются через github.com/stretchr/testify/mock или go.uber.org/mock (gomock).

Интерфейсы для моков (Go)

// internal/auth/interfaces.go

// TokenVerifier — верификация Firebase idToken
type TokenVerifier interface {
    VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
}

// JWTGenerator — генерация и валидация JWT
type JWTGenerator interface {
    GenerateAccessToken(userID, plan string) (string, error)
    GenerateRefreshToken() (token string, expiresAt time.Time)
    ValidateAccessToken(tokenStr string) (*Claims, error)
}
// internal/user/interfaces.go

// UserRepository — доступ к данным пользователей
type UserRepository interface {
    UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error)
    GetByID(ctx context.Context, id string) (*User, error)
    Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error)
    SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error
    FindByRefreshToken(ctx context.Context, token string) (*User, error)
    ClearRefreshToken(ctx context.Context, id string) error
}

Моки (Go)

// internal/auth/mocks/token_verifier.go

type MockTokenVerifier struct {
    mock.Mock
}

func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (string, string, string, string, error) {
    args := m.Called(ctx, idToken)
    return args.String(0), args.String(1), args.String(2), args.String(3), args.Error(4)
}
// internal/user/mocks/repository.go

type MockUserRepository struct {
    mock.Mock
}

func (m *MockUserRepository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
    args := m.Called(ctx, uid, email, name, avatarURL)
    return args.Get(0).(*user.User), args.Error(1)
}

func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
    args := m.Called(ctx, id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*user.User), args.Error(1)
}

// ... остальные методы аналогично

Моки (Flutter)

Пакет mockito + code generation (@GenerateMocks).

// test/mocks.dart

@GenerateMocks([
  FirebaseAuth,
  GoogleSignIn,
  ApiClient,
  SecureStorageService,
])
import 'mocks.mocks.dart';

Для каждого зависимого класса создаётся Mock* через build_runner:

Класс Мок Используется в тестах
FirebaseAuth MockFirebaseAuth AuthService
GoogleSignIn MockGoogleSignIn AuthService (Google flow)
ApiClient MockApiClient AuthService, AuthInterceptor
SecureStorageService MockSecureStorageService AuthInterceptor, AuthService

Flutter dev-зависимости для тестов:

dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0   # уже есть
  network_image_mock: ^2.1.0

Unit-тесты (Go)

JWT Manager — internal/auth/jwt_test.go

# Тест-кейс Что проверяем
1 Генерация access token GenerateAccessToken("user-1", "free") → непустая строка, парсится без ошибки
2 Claims содержат правильные данные Парсим сгенерированный токен → claims.UserID == "user-1", claims.Plan == "free"
3 Токен истекает через заданное время Генерируем с duration=1s → ждём 2s → ValidateAccessToken → ошибка expired
4 Невалидная подпись Токен подписан другим секретом → ValidateAccessToken → ошибка
5 Мусор вместо токена ValidateAccessToken("not-a-token") → ошибка
6 Пустая строка ValidateAccessToken("") → ошибка
7 Генерация refresh token GenerateRefreshToken() → непустая строка UUID, expiresAt > now

Моки: нет (чистая логика, без зависимостей).

Auth Service — internal/auth/service_test.go

# Тест-кейс Что проверяем Моки
1 Login: новый пользователь Firebase token валиден → upsert создаёт юзера → возвращает JWT + refresh + user MockTokenVerifier, MockUserRepository
2 Login: существующий пользователь Firebase token валиден → upsert обновляет юзера → возвращает JWT MockTokenVerifier, MockUserRepository
3 Login: невалидный Firebase token VerifyToken возвращает ошибку → 401 MockTokenVerifier (returns error)
4 Login: пустой firebase_token Валидация на входе → 400
5 Login: ошибка БД при upsert UpsertByFirebaseUID возвращает ошибку → 500 MockTokenVerifier, MockUserRepository (returns error)
6 Refresh: валидный token Находим юзера по refresh → не истёк → генерируем новую пару → ротация MockUserRepository
7 Refresh: истёкший token FindByRefreshTokentoken_expires_at < now → 401 MockUserRepository
8 Refresh: несуществующий token FindByRefreshToken → nil → 401 MockUserRepository (returns nil)
9 Refresh: ротация — старый перестаёт работать После refresh → SetRefreshToken вызван с новым token MockUserRepository
10 Logout: успешный ClearRefreshToken вызван с правильным user_id MockUserRepository

User Service — internal/user/service_test.go

# Тест-кейс Что проверяем Моки
1 GetProfile: существующий пользователь GetByID("user-1") → User с правильными полями MockUserRepository
2 GetProfile: несуществующий пользователь GetByID("unknown") → not found error MockUserRepository (returns nil)
3 UpdateProfile: обновление имени Update вызван с Name: "Новое имя", остальные поля не изменены MockUserRepository
4 UpdateProfile: пересчёт калорий Изменили height + weight + age + gender + activity + goal → daily_calories пересчитан по Mifflin-St Jeor MockUserRepository
5 UpdateProfile: пересчёт калорий при частичном обновлении Изменили только weight → нужно дочитать остальные параметры из existing user → пересчитать MockUserRepository
6 UpdateProfile: без параметров тела — калории не пересчитываются Обновили только имя → daily_calories не изменился MockUserRepository
7 Валидация: height < 100 UpdateProfile с height=50 → ошибка валидации
8 Валидация: height > 250 UpdateProfile с height=300 → ошибка валидации
9 Валидация: weight < 30 → ошибка валидации
10 Валидация: weight > 300 → ошибка валидации
11 Валидация: age < 10 → ошибка валидации
12 Валидация: age > 120 → ошибка валидации
13 Валидация: невалидный gender → ошибка валидации
14 Валидация: невалидный goal → ошибка валидации

Расчёт калорий — internal/user/calories_test.go

# Тест-кейс Вход Ожидаемый результат
1 Мужчина, похудение male, 178cm, 75kg, 28лет, moderate, lose BMR=1728 → TDEE=2678 → 2178
2 Женщина, поддержание female, 165cm, 60kg, 30лет, low, maintain BMR=1322 → TDEE=1818 → 1818
3 Мужчина, набор массы male, 185cm, 90kg, 25лет, high, gain BMR=1923 → TDEE=3317 → 3617
4 Минимальные параметры female, 100cm, 30kg, 10лет, low, maintain Расчёт без ошибки, результат > 0
5 Максимальные параметры male, 250cm, 300kg, 120лет, high, gain Расчёт без ошибки
6 Неполные данные (нет goal) все параметры кроме goal → nil Возвращаем nil (нельзя рассчитать)
7 Неполные данные (нет weight) → nil nil

Моки: нет (чистая функция).

Middleware — internal/middleware/auth_test.go

# Тест-кейс Что проверяем
1 Валидный токен Authorization: Bearer <valid> → handler вызван, user_id в контексте
2 Нет заголовка Authorization → 401, handler не вызван
3 Заголовок без Bearer Authorization: <token> → 401
4 Истёкший токен → 401
5 Невалидный токен → 401
6 User ID в контексте После успешной проверки — UserIDFromCtx(ctx) возвращает правильный ID
7 User Plan в контексте После успешной проверки — UserPlanFromCtx(ctx) возвращает "free" или "paid"

Моки: используется настоящий JWTManager с тестовым секретом (не мок — тестируем интеграцию middleware + JWT).

Middleware — internal/middleware/request_id_test.go

# Тест-кейс Что проверяем
1 Запрос без X-Request-ID Response содержит X-Request-ID, значение — валидный UUID
2 Запрос с X-Request-ID Response содержит тот же X-Request-ID, что и запрос
3 Request ID в контексте RequestIDFromCtx(ctx) в handler возвращает правильный ID

Моки: нет (httptest.NewRecorder + httptest.NewRequest).

Middleware — internal/middleware/recovery_test.go

# Тест-кейс Что проверяем
1 Handler вызывает panic(string) Сервер возвращает 500, не падает
2 Handler вызывает panic(error) Сервер возвращает 500, не падает
3 Handler не паникует Обычный ответ проходит без изменений

Моки: нет.


Integration-тесты (Go) — с реальной БД

Для integration-тестов поднимается PostgreSQL через testcontainers-go. Миграции применяются перед тестами.

// internal/testutil/postgres.go

func SetupTestDB(t *testing.T) *pgxpool.Pool {
    ctx := context.Background()

    container, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("food_ai_test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    require.NoError(t, err)
    t.Cleanup(func() { container.Terminate(ctx) })

    connStr, err := container.ConnectionString(ctx, "sslmode=disable")
    require.NoError(t, err)

    pool, err := pgxpool.New(ctx, connStr)
    require.NoError(t, err)

    // Применяем миграции
    goose.SetDialect("postgres")
    db, _ := sql.Open("pgx", connStr)
    goose.Up(db, "../../migrations")

    return pool
}

Пакет: github.com/testcontainers/testcontainers-go/modules/postgres

User Repository — internal/user/repository_integration_test.go

# Тест-кейс Что проверяем
1 UpsertByFirebaseUID: новый пользователь INSERT → user создан, id != nil, email/name совпадают
2 UpsertByFirebaseUID: повторный вызов с тем же uid UPDATE → email обновлён, id тот же
3 GetByID: существующий Создаём → получаем → поля совпадают
4 GetByID: несуществующий → nil, без ошибки (или ErrNotFound)
5 Update: обновление всех полей Создаём → обновляем height, weight, name → GetByID → проверяем
6 Update: partial update (только name) height/weight не изменились
7 Update: preferences JSONB Сохраняем {"cuisines":["russian"]} → читаем → совпадает
8 SetRefreshToken + FindByRefreshToken Сохраняем → ищем → находим правильного юзера
9 FindByRefreshToken: несуществующий → nil
10 ClearRefreshToken Сохраняем → очищаем → FindByRefreshToken → nil
11 Уникальность firebase_uid Два INSERT с одним uid → conflict, upsert корректен
12 updated_at обновляется Создаём → обновляем → updated_at > created_at

Handler (E2E) — internal/auth/handler_integration_test.go

# Тест-кейс Что проверяем
1 POST /auth/login → 200 Полный flow: mock Firebase → upsert → JWT + refresh в response
2 POST /auth/login → 401 Невалидный Firebase token → 401
3 POST /auth/refresh → 200 Используем refresh из login → получаем новую пару
4 POST /auth/refresh → 401 Невалидный refresh → 401
5 POST /auth/logout → 200 После logout → refresh с тем же токеном → 401
6 GET /profile → 200 JWT из login → GET /profile → user data
7 GET /profile → 401 Без токена → 401
8 PUT /profile → 200 Обновляем параметры → GET /profile → данные обновлены
9 PUT /profile → 400 Невалидные данные (age=5) → 400 с описанием
10 GET /health → 200 status: ok, db: connected

Для handler-тестов используется httptest.NewServer с реальным роутером, реальной БД (testcontainers) и моком Firebase (MockTokenVerifier).


Unit-тесты (Flutter)

User model — test/shared/models/user_test.dart

# Тест-кейс Что проверяем
1 fromJson: полный объект Все поля десериализуются корректно
2 fromJson: минимальный объект Только required-поля (id, email, name, plan), nullable = null
3 fromJson: camelCase ↔ snake_case avatar_urlavatarUrl, daily_caloriesdailyCalories
4 toJson: roundtrip fromJson(toJson(user)) == user
5 fromJson: неизвестные поля игнорируются {"id": "1", "email": "...", "unknown_field": 42} → без ошибки

Моки: нет.

AuthService — test/core/auth/auth_service_test.dart

# Тест-кейс Что проверяем Моки
1 signInWithEmail: успех Firebase → backend /auth/login → токены сохранены → User возвращён MockFirebaseAuth, MockApiClient, MockSecureStorageService
2 signInWithEmail: неверный пароль FirebaseAuth бросает FirebaseAuthException → пробрасывается наверх MockFirebaseAuth (throws)
3 signInWithEmail: ошибка backend Firebase OK, но /auth/login → DioException → ошибка MockFirebaseAuth, MockApiClient (throws)
4 signInWithGoogle: успех GoogleSignIn → credential → Firebase → backend → токены MockGoogleSignIn, MockFirebaseAuth, MockApiClient, MockSecureStorageService
5 signInWithGoogle: отмена GoogleSignIn().signIn() → null (юзер закрыл окно) → graceful error MockGoogleSignIn (returns null)
6 signOut: успех /auth/logout вызван → Firebase signOut → токены удалены MockApiClient, MockFirebaseAuth, MockSecureStorageService
7 signOut: ошибка backend /auth/logout fail → всё равно Firebase signOut + clear tokens (не блокируем) MockApiClient (throws)

AuthInterceptor — test/core/api/auth_interceptor_test.dart

# Тест-кейс Что проверяем Моки
1 onRequest: добавляет Bearer token Запрос к /profile → заголовок Authorization: Bearer <token> MockSecureStorageService
2 onRequest: пропускает /auth/ пути Запрос к /auth/login → без Authorization MockSecureStorageService
3 onRequest: нет токена Storage возвращает null → запрос без Authorization MockSecureStorageService (returns null)
4 onError 401: refresh и retry 401 → refresh → новые токены сохранены → оригинальный запрос повторён MockSecureStorageService, mock Dio adapter
5 onError 401: refresh провалился 401 → refresh → DioException → токены очищены → ошибка пробрасывается MockSecureStorageService, mock Dio adapter
6 onError 401: нет refresh token 401 → storage null → ошибка пробрасывается без retry MockSecureStorageService (returns null)
7 onError не-401 500 → interceptor не вмешивается, ошибка пробрасывается

SecureStorageService — test/core/auth/secure_storage_test.dart

# Тест-кейс Что проверяем Моки
1 saveTokens + getAccessToken Сохраняем → читаем → совпадает MockFlutterSecureStorage
2 saveTokens + getRefreshToken Сохраняем → читаем → совпадает MockFlutterSecureStorage
3 clearTokens Сохраняем → очищаем → get → null MockFlutterSecureStorage
4 getAccessToken: пусто Ничего не сохраняли → null MockFlutterSecureStorage

Widget-тесты (Flutter)

LoginScreen — test/features/auth/login_screen_test.dart

# Тест-кейс Что проверяем
1 Рендеринг Email + пароль поля, кнопки «Войти», «Google», «Регистрация» видны
2 Валидация: пустой email Тап «Войти» → «Введите email»
3 Валидация: невалидный email Вводим "abc" → «Некорректный email»
4 Валидация: короткий пароль < 6 символов → «Минимум 6 символов»
5 Успешный вход Заполняем → тап «Войти» → навигация на /home
6 Ошибка входа AuthService throws → показывает snackbar с ошибкой
7 Загрузка Тап «Войти» → кнопка показывает индикатор → после ответа скрывает
8 Переход на регистрацию Тап «Регистрация» → навигация на /auth/register
9 Кнопка Apple: только iOS На Android — не отображается, на iOS — отображается

Моки: MockAuthService (возвращает User или бросает ошибку).

RegisterScreen — test/features/auth/register_screen_test.dart

# Тест-кейс Что проверяем
1 Рендеринг Поля: имя, email, пароль, подтверждение. Кнопка «Зарегистрироваться»
2 Валидация: пустое имя → «Введите имя»
3 Валидация: пароли не совпадают → «Пароли не совпадают»
4 Успешная регистрация → навигация на /home
5 Ошибка: email уже занят AuthService throws → snackbar «Email уже зарегистрирован»

Моки: MockAuthService.

MainShell — test/core/navigation/main_shell_test.dart

# Тест-кейс Что проверяем
1 Отображает 5 вкладок Иконки: home, kitchen, calendar, menu_book, person
2 Подсветка активной вкладки При location=/products → вкладка «Продукты» активна
3 Навигация при тапе Тап «Рецепты» → GoRouter переходит на /recipes

Моки: нет (widget test с GoRouter).

Auth Guard — test/core/router/auth_guard_test.dart

# Тест-кейс Что проверяем
1 Не авторизован → /auth/login Открываем /home → redirect на /auth/login
2 Авторизован → нет redirect Открываем /home → остаёмся на /home
3 Авторизован + /auth/login → /home Открываем /auth/login → redirect на /home

Моки: MockAuthProvider (isLoggedIn: true/false).


Сводная таблица

Модуль Unit Integration Widget Всего
Go: JWT Manager 7 7
Go: Auth Service 10 10
Go: User Service 14 14
Go: Calories 7 7
Go: Auth Middleware 7 7
Go: Request ID MW 3 3
Go: Recovery MW 3 3
Go: User Repository 12 12
Go: Handlers (E2E) 10 10
Flutter: User model 5 5
Flutter: AuthService 7 7
Flutter: AuthInterceptor 7 7
Flutter: SecureStorage 4 4
Flutter: LoginScreen 9 9
Flutter: RegisterScreen 5 5
Flutter: MainShell 3 3
Flutter: Auth Guard 3 3
Итого 74 22 20 116

Необходимые тестовые зависимости

Go

go get github.com/stretchr/testify                         # assert, require, mock
go get github.com/testcontainers/testcontainers-go          # PostgreSQL в Docker для integration-тестов
go get github.com/testcontainers/testcontainers-go/modules/postgres

Flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.0
  build_runner: ^2.4.0
  network_image_mock: ^2.1.0

Зависимости Go-пакетов

go get github.com/go-chi/chi/v5
go get github.com/go-chi/cors
go get github.com/jackc/pgx/v5
go get github.com/pressly/goose/v3
go get github.com/kelseyhightower/envconfig
go get github.com/golang-jwt/jwt/v5
go get github.com/google/uuid
go get firebase.google.com/go/v4
go get google.golang.org/api/option