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>
68 KiB
Итерация 0: Фундамент
Цель: развернуть скелет проекта, базу данных, авторизацию и каркас мобильного приложения. После итерации можно зарегистрироваться, войти и увидеть пустые экраны.
Зависимости: нет.
Ориентир: Summary.md → stories 0.1–0.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 100–250, weight 30–300, age 10–120
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-проект
Необходимые действия (вне кода):
- Создать проект в Firebase Console
- Добавить Android-приложение (package name, SHA-1)
- Добавить iOS-приложение (bundle ID)
- Добавить Web-приложение
- Включить провайдеры: Email/Password, Google, Apple
- Скачать
google-services.json(Android),GoogleService-Info.plist(iOS) - Сгенерировать
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 | 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. Миграции применяются перед тестами.
// 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
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