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

1885 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Итерация 0: Фундамент
**Цель:** развернуть скелет проекта, базу данных, авторизацию и каркас мобильного приложения. После итерации можно зарегистрироваться, войти и увидеть пустые экраны.
**Зависимости:** нет.
**Ориентир:** [Summary.md](./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 Конфигурация
```go
// 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 Логирование
```go
// 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
```go
// 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
```go
// 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:
```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
```sql
-- 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-роутер
```go
// 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
```go
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
```go
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
```go
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
```go
// 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-генерация
```go
// 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
```go
// 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 Доменная модель
```go
// 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
```go
// 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
```go
// 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
```yaml
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
```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
```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
```bash
# 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 Основные пакеты
```yaml
# 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 Тема и дизайн-токены
```dart
// 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 дней / просрочено
}
```
```dart
// 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
```dart
// 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
```dart
// 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
```dart
// 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)
```dart
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 Экраны-заглушки
Каждый экран-заглушка:
```dart
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-клиент
```dart
// 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
```dart
// 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
```dart
// 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)
```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)
}
```
```go
// 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)
```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)
}
```
```go
// 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`).
```dart
// 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-зависимости для тестов:
```yaml
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 | `FindByRefreshToken``token_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`. Миграции применяются перед тестами.
```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_url``avatarUrl`, `daily_calories``dailyCalories` |
| 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
```yaml
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
```