# Итерация 0: Фундамент **Цель:** развернуть скелет проекта, базу данных, авторизацию и каркас мобильного приложения. После итерации можно зарегистрироваться, войти и увидеть пустые экраны. **Зависимости:** нет. **Ориентир:** [Summary.md](./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//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": "" } Логика: 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 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 ```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 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 signInWithApple() async { final appleProvider = AppleAuthProvider() ..addScope('email') ..addScope('name'); final userCredential = await _firebaseAuth.signInWithProvider(appleProvider); return _authenticateWithBackend(userCredential); } /// Вход через email/password Future signInWithEmail(String email, String password) async { final userCredential = await _firebaseAuth.signInWithEmailAndPassword( email: email, password: password, ); return _authenticateWithBackend(userCredential); } /// Регистрация через email/password Future 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 _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 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 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 getAccessToken() => _storage.read(key: 'access_token'); Future getRefreshToken() => _storage.read(key: 'refresh_token'); Future 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> get(String path, {Map? params}) async { final response = await _dio.get(path, queryParameters: params); return response.data; } Future> post(String path, {dynamic data}) async { final response = await _dio.post(path, data: data); return response.data; } Future> 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 preferences, }) = _User; factory User.fromJson(Map 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 ` → handler вызван, user_id в контексте | | 2 | Нет заголовка Authorization | → 401, handler не вызван | | 3 | Заголовок без Bearer | `Authorization: ` → 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 ` | `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 ```