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>
1885 lines
68 KiB
Markdown
1885 lines
68 KiB
Markdown
# Итерация 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/<owner>/food_ai` (или внутренний путь)
|
||
- **Зависимости** подключаются по мере необходимости, не заранее
|
||
- Каждый домен (`auth`, `user`) — отдельный пакет с handler → service → repository
|
||
- Нет ORM — чистый pgx с SQL-запросами
|
||
- Нет глобальных переменных — всё через DI (конструкторы)
|
||
|
||
### 0.1.2 Конфигурация
|
||
|
||
```go
|
||
// internal/config/config.go
|
||
|
||
type Config struct {
|
||
Port int `envconfig:"PORT" default:"8080"`
|
||
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
|
||
|
||
// Firebase
|
||
FirebaseCredentialsFile string `envconfig:"FIREBASE_CREDENTIALS_FILE" required:"true"`
|
||
|
||
// JWT
|
||
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||
JWTAccessDuration time.Duration `envconfig:"JWT_ACCESS_DURATION" default:"15m"`
|
||
JWTRefreshDuration time.Duration `envconfig:"JWT_REFRESH_DURATION" default:"720h"` // 30 дней
|
||
|
||
// CORS
|
||
AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"`
|
||
}
|
||
```
|
||
|
||
- Пакет: `github.com/kelseyhightower/envconfig`
|
||
- `.env.example` с описанием всех переменных
|
||
- Никаких YAML/JSON-конфигов — только переменные окружения
|
||
|
||
### 0.1.3 Логирование
|
||
|
||
```go
|
||
// cmd/server/main.go — инициализация
|
||
|
||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||
Level: slog.LevelInfo,
|
||
}))
|
||
slog.SetDefault(logger)
|
||
```
|
||
|
||
- Используем `log/slog` из стандартной библиотеки (Go 1.21+)
|
||
- JSON-формат для структурированного вывода
|
||
- Логгер передаётся через контекст или как зависимость в сервисы
|
||
- В middleware: логируем method, path, status, duration, request_id
|
||
|
||
### 0.1.4 Graceful shutdown
|
||
|
||
```go
|
||
// cmd/server/main.go
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
// Запуск сервера
|
||
go func() {
|
||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
slog.Error("server error", "err", err)
|
||
}
|
||
}()
|
||
|
||
<-ctx.Done()
|
||
slog.Info("shutting down...")
|
||
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
srv.Shutdown(shutdownCtx)
|
||
pool.Close() // pgxpool
|
||
```
|
||
|
||
---
|
||
|
||
## 0.2 PostgreSQL + миграции
|
||
|
||
### 0.2.1 Подключение к PostgreSQL
|
||
|
||
```go
|
||
// internal/database/postgres.go
|
||
|
||
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||
config, err := pgxpool.ParseConfig(databaseURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse config: %w", err)
|
||
}
|
||
|
||
config.MaxConns = 20
|
||
config.MinConns = 5
|
||
config.MaxConnLifetime = 30 * time.Minute
|
||
config.MaxConnIdleTime = 5 * time.Minute
|
||
|
||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create pool: %w", err)
|
||
}
|
||
|
||
if err := pool.Ping(ctx); err != nil {
|
||
return nil, fmt.Errorf("ping: %w", err)
|
||
}
|
||
|
||
return pool, nil
|
||
}
|
||
```
|
||
|
||
- Пакет: `github.com/jackc/pgx/v5/pgxpool`
|
||
- Пул соединений, не единичное подключение
|
||
- MaxConns = 20 — достаточно для начала, настраивается через конфиг
|
||
|
||
### 0.2.2 Система миграций
|
||
|
||
- Пакет: `github.com/pressly/goose/v3`
|
||
- Миграции в `migrations/` — `.sql` файлы
|
||
- Команды в Makefile:
|
||
|
||
```makefile
|
||
migrate-up:
|
||
goose -dir migrations postgres "$(DATABASE_URL)" up
|
||
|
||
migrate-down:
|
||
goose -dir migrations postgres "$(DATABASE_URL)" down
|
||
|
||
migrate-create:
|
||
goose -dir migrations create $(name) sql
|
||
```
|
||
|
||
- Миграции запускаются вручную через `make migrate-up`, НЕ при старте приложения
|
||
- Это даёт контроль над моментом применения миграций
|
||
|
||
### 0.2.3 Начальная миграция: таблица users
|
||
|
||
```sql
|
||
-- migrations/001_create_users.sql
|
||
|
||
-- +goose Up
|
||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||
|
||
CREATE TYPE user_plan AS ENUM ('free', 'paid');
|
||
CREATE TYPE user_gender AS ENUM ('male', 'female');
|
||
CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain');
|
||
CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high');
|
||
|
||
CREATE TABLE users (
|
||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||
email VARCHAR(255) NOT NULL,
|
||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||
avatar_url TEXT,
|
||
|
||
-- Параметры тела
|
||
height_cm SMALLINT,
|
||
weight_kg DECIMAL(5,2),
|
||
age SMALLINT,
|
||
gender user_gender,
|
||
activity activity_level,
|
||
|
||
-- Цель и рассчитанная норма
|
||
goal user_goal,
|
||
daily_calories SMALLINT, -- Рассчитывается по формуле Миффлина-Сан Жеора
|
||
|
||
-- Тариф
|
||
plan user_plan NOT NULL DEFAULT 'free',
|
||
|
||
-- Предпочтения (JSONB для гибкости)
|
||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
-- Пример: {
|
||
-- "cuisines": ["russian", "asian"],
|
||
-- "restrictions": ["nuts"],
|
||
-- "storage_defaults": { "dairy": 5, "meat": 3, "vegetables": 7 },
|
||
-- "water_goal": 8,
|
||
-- "notifications": { "expiring": true, "meals": true, "water": true }
|
||
-- }
|
||
|
||
-- Refresh token
|
||
refresh_token TEXT,
|
||
token_expires_at TIMESTAMPTZ,
|
||
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_users_firebase_uid ON users (firebase_uid);
|
||
CREATE INDEX idx_users_email ON users (email);
|
||
|
||
-- +goose Down
|
||
DROP TABLE IF EXISTS users;
|
||
DROP TYPE IF EXISTS activity_level;
|
||
DROP TYPE IF EXISTS user_goal;
|
||
DROP TYPE IF EXISTS user_gender;
|
||
DROP TYPE IF EXISTS user_plan;
|
||
```
|
||
|
||
### Формула калорий (Mifflin-St Jeor)
|
||
|
||
При заполнении профиля backend рассчитывает `daily_calories`:
|
||
|
||
```
|
||
Мужчины: BMR = 10 × вес(кг) + 6.25 × рост(см) − 5 × возраст − 161 + 166
|
||
Женщины: BMR = 10 × вес(кг) + 6.25 × рост(см) − 5 × возраст − 161
|
||
|
||
Коэффициент активности:
|
||
low: 1.375
|
||
moderate: 1.55
|
||
high: 1.725
|
||
|
||
TDEE = BMR × коэффициент
|
||
|
||
Цель:
|
||
lose: TDEE − 500
|
||
maintain: TDEE
|
||
gain: TDEE + 300
|
||
```
|
||
|
||
---
|
||
|
||
## 0.3 HTTP-сервер
|
||
|
||
### 0.3.1 chi-роутер
|
||
|
||
```go
|
||
// internal/server/server.go
|
||
|
||
func NewRouter(
|
||
authHandler *auth.Handler,
|
||
userHandler *user.Handler,
|
||
authMiddleware func(http.Handler) http.Handler,
|
||
) *chi.Mux {
|
||
r := chi.NewRouter()
|
||
|
||
// Global middleware
|
||
r.Use(middleware.RequestID)
|
||
r.Use(middleware.Logging)
|
||
r.Use(middleware.Recovery)
|
||
r.Use(middleware.CORS(allowedOrigins))
|
||
|
||
// Public
|
||
r.Get("/health", healthCheck)
|
||
r.Route("/auth", func(r chi.Router) {
|
||
r.Post("/login", authHandler.Login)
|
||
r.Post("/refresh", authHandler.Refresh)
|
||
r.Post("/logout", authHandler.Logout)
|
||
})
|
||
|
||
// Protected
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(authMiddleware)
|
||
r.Get("/profile", userHandler.Get)
|
||
r.Put("/profile", userHandler.Update)
|
||
})
|
||
|
||
return r
|
||
}
|
||
```
|
||
|
||
- Пакет: `github.com/go-chi/chi/v5`
|
||
- Группировка роутов: public (auth, health) и protected (всё остальное)
|
||
- chi выбран за: совместимость с `net/http`, middleware-цепочки, вложенные роутеры
|
||
|
||
### 0.3.2 Middleware
|
||
|
||
#### Request ID
|
||
|
||
```go
|
||
func RequestID(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
id := r.Header.Get("X-Request-ID")
|
||
if id == "" {
|
||
id = uuid.NewString()
|
||
}
|
||
ctx := context.WithValue(r.Context(), requestIDKey, id)
|
||
w.Header().Set("X-Request-ID", id)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
```
|
||
|
||
#### Logging
|
||
|
||
```go
|
||
func Logging(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
ww := &responseWriter{ResponseWriter: w, statusCode: 200}
|
||
|
||
next.ServeHTTP(ww, r)
|
||
|
||
slog.Info("request",
|
||
"method", r.Method,
|
||
"path", r.URL.Path,
|
||
"status", ww.statusCode,
|
||
"duration_ms", time.Since(start).Milliseconds(),
|
||
"request_id", RequestIDFromCtx(r.Context()),
|
||
)
|
||
})
|
||
}
|
||
```
|
||
|
||
#### CORS
|
||
|
||
```go
|
||
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
||
return cors.Handler(cors.Options{
|
||
AllowedOrigins: allowedOrigins,
|
||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
|
||
ExposedHeaders: []string{"X-Request-ID"},
|
||
AllowCredentials: true,
|
||
MaxAge: 300,
|
||
})
|
||
}
|
||
```
|
||
|
||
- Пакет: `github.com/go-chi/cors`
|
||
|
||
#### Recovery
|
||
|
||
- Ловит panic в хэндлерах → логирует stack trace → возвращает 500
|
||
- Не даёт серверу упасть из-за одного запроса
|
||
|
||
### 0.3.3 Healthcheck
|
||
|
||
```
|
||
GET /health → 200 OK
|
||
{
|
||
"status": "ok",
|
||
"version": "0.1.0",
|
||
"db": "connected" // Проверка pool.Ping()
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 0.4 Firebase Auth + JWT
|
||
|
||
### 0.4.1 Firebase Admin SDK
|
||
|
||
```go
|
||
// internal/auth/firebase.go
|
||
|
||
type FirebaseAuth struct {
|
||
client *firebaseAuth.Client
|
||
}
|
||
|
||
func NewFirebaseAuth(credentialsFile string) (*FirebaseAuth, error) {
|
||
opt := option.WithCredentialsFile(credentialsFile)
|
||
app, err := firebase.NewApp(context.Background(), nil, opt)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("init firebase app: %w", err)
|
||
}
|
||
|
||
client, err := app.Auth(context.Background())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("init auth client: %w", err)
|
||
}
|
||
|
||
return &FirebaseAuth{client: client}, nil
|
||
}
|
||
|
||
func (f *FirebaseAuth) VerifyToken(ctx context.Context, idToken string) (*firebaseAuth.Token, error) {
|
||
return f.client.VerifyIDToken(ctx, idToken)
|
||
}
|
||
```
|
||
|
||
- Пакет: `firebase.google.com/go/v4`
|
||
- Файл `firebase-credentials.json` — НЕ коммитится, путь через env
|
||
|
||
### 0.4.2 JWT-генерация
|
||
|
||
```go
|
||
// internal/auth/jwt.go
|
||
|
||
type JWTManager struct {
|
||
secret []byte
|
||
accessDuration time.Duration
|
||
refreshDuration time.Duration
|
||
}
|
||
|
||
type Claims struct {
|
||
UserID string `json:"user_id"`
|
||
Plan string `json:"plan"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) {
|
||
claims := Claims{
|
||
UserID: userID,
|
||
Plan: plan,
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)),
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
},
|
||
}
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||
return token.SignedString(j.secret)
|
||
}
|
||
|
||
func (j *JWTManager) GenerateRefreshToken() (string, time.Time) {
|
||
token := uuid.NewString()
|
||
expiresAt := time.Now().Add(j.refreshDuration)
|
||
return token, expiresAt
|
||
}
|
||
|
||
func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
|
||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||
return j.secret, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
claims, ok := token.Claims.(*Claims)
|
||
if !ok || !token.Valid {
|
||
return nil, fmt.Errorf("invalid token")
|
||
}
|
||
return claims, nil
|
||
}
|
||
```
|
||
|
||
- Пакет: `github.com/golang-jwt/jwt/v5`
|
||
- Access token: 15 минут (short-lived)
|
||
- Refresh token: 30 дней, хранится в БД (users.refresh_token), ротируется при каждом refresh
|
||
|
||
### 0.4.3 Auth middleware
|
||
|
||
```go
|
||
// internal/middleware/auth.go
|
||
|
||
func Auth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
header := r.Header.Get("Authorization")
|
||
if !strings.HasPrefix(header, "Bearer ") {
|
||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||
claims, err := jwtManager.ValidateAccessToken(tokenStr)
|
||
if err != nil {
|
||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||
ctx = context.WithValue(ctx, userPlanKey, claims.Plan)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
### 0.4.4 Эндпоинты авторизации
|
||
|
||
#### POST /auth/login
|
||
|
||
```
|
||
Request:
|
||
{
|
||
"firebase_token": "<Firebase idToken>"
|
||
}
|
||
|
||
Логика:
|
||
1. firebase.VerifyToken(firebase_token) → uid, email, name, photo
|
||
2. Upsert user в БД (по firebase_uid)
|
||
3. Генерация access JWT + refresh token
|
||
4. Сохранение refresh token в БД
|
||
5. Возвращение токенов + профиля
|
||
|
||
Response (200):
|
||
{
|
||
"access_token": "eyJ...",
|
||
"refresh_token": "550e8400-...",
|
||
"expires_in": 900,
|
||
"user": {
|
||
"id": "...",
|
||
"email": "user@gmail.com",
|
||
"name": "Иван",
|
||
"avatar_url": "...",
|
||
"plan": "free",
|
||
"has_completed_onboarding": false
|
||
}
|
||
}
|
||
```
|
||
|
||
#### POST /auth/refresh
|
||
|
||
```
|
||
Request:
|
||
{
|
||
"refresh_token": "550e8400-..."
|
||
}
|
||
|
||
Логика:
|
||
1. Найти user по refresh_token в БД
|
||
2. Проверить token_expires_at > now()
|
||
3. Сгенерировать новые access + refresh tokens
|
||
4. Сохранить новый refresh token (ротация)
|
||
5. Старый refresh token перестаёт работать
|
||
|
||
Response (200):
|
||
{
|
||
"access_token": "eyJ...",
|
||
"refresh_token": "новый-uuid",
|
||
"expires_in": 900
|
||
}
|
||
|
||
Errors:
|
||
- 401 — невалидный или истёкший refresh token
|
||
```
|
||
|
||
#### POST /auth/logout
|
||
|
||
```
|
||
Логика:
|
||
1. Получить user_id из JWT (Auth middleware)
|
||
2. Обнулить refresh_token и token_expires_at в БД
|
||
3. Клиент удаляет токены из secure storage
|
||
|
||
Response (200):
|
||
{ "status": "ok" }
|
||
```
|
||
|
||
---
|
||
|
||
## 0.5 Users сервис
|
||
|
||
### 0.5.1 Доменная модель
|
||
|
||
```go
|
||
// internal/user/model.go
|
||
|
||
type User struct {
|
||
ID string `json:"id"`
|
||
FirebaseUID string `json:"-"`
|
||
Email string `json:"email"`
|
||
Name string `json:"name"`
|
||
AvatarURL *string `json:"avatar_url"`
|
||
HeightCM *int `json:"height_cm"`
|
||
WeightKG *float64 `json:"weight_kg"`
|
||
Age *int `json:"age"`
|
||
Gender *string `json:"gender"`
|
||
Activity *string `json:"activity"`
|
||
Goal *string `json:"goal"`
|
||
DailyCalories *int `json:"daily_calories"`
|
||
Plan string `json:"plan"`
|
||
Preferences json.RawMessage `json:"preferences"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
type UpdateProfileRequest struct {
|
||
Name *string `json:"name"`
|
||
HeightCM *int `json:"height_cm"`
|
||
WeightKG *float64 `json:"weight_kg"`
|
||
Age *int `json:"age"`
|
||
Gender *string `json:"gender"`
|
||
Activity *string `json:"activity"`
|
||
Goal *string `json:"goal"`
|
||
Preferences *json.RawMessage `json:"preferences"`
|
||
}
|
||
```
|
||
|
||
### 0.5.2 Repository
|
||
|
||
```go
|
||
// internal/user/repository.go
|
||
|
||
type Repository struct {
|
||
pool *pgxpool.Pool
|
||
}
|
||
|
||
func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error)
|
||
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error)
|
||
func (r *Repository) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error)
|
||
func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error
|
||
func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error)
|
||
func (r *Repository) ClearRefreshToken(ctx context.Context, id string) error
|
||
```
|
||
|
||
- Upsert: `INSERT ... ON CONFLICT (firebase_uid) DO UPDATE SET email = ..., updated_at = now()`
|
||
- Update: динамический SQL — обновляются только переданные поля (не NULL)
|
||
- При обновлении параметров тела → пересчёт daily_calories
|
||
|
||
### 0.5.3 Service layer
|
||
|
||
```go
|
||
// internal/user/service.go
|
||
|
||
type Service struct {
|
||
repo *Repository
|
||
}
|
||
|
||
func (s *Service) GetProfile(ctx context.Context, userID string) (*User, error)
|
||
func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*User, error)
|
||
```
|
||
|
||
- В `UpdateProfile`: если изменился height/weight/age/gender/activity/goal → пересчёт daily_calories
|
||
- Валидация: height 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<User> signInWithGoogle() async {
|
||
final googleUser = await GoogleSignIn().signIn();
|
||
final googleAuth = await googleUser!.authentication;
|
||
final credential = GoogleAuthProvider.credential(
|
||
accessToken: googleAuth.accessToken,
|
||
idToken: googleAuth.idToken,
|
||
);
|
||
final userCredential = await _firebaseAuth.signInWithCredential(credential);
|
||
return _authenticateWithBackend(userCredential);
|
||
}
|
||
|
||
/// Вход через Apple
|
||
Future<User> signInWithApple() async {
|
||
final appleProvider = AppleAuthProvider()
|
||
..addScope('email')
|
||
..addScope('name');
|
||
final userCredential = await _firebaseAuth.signInWithProvider(appleProvider);
|
||
return _authenticateWithBackend(userCredential);
|
||
}
|
||
|
||
/// Вход через email/password
|
||
Future<User> signInWithEmail(String email, String password) async {
|
||
final userCredential = await _firebaseAuth.signInWithEmailAndPassword(
|
||
email: email, password: password,
|
||
);
|
||
return _authenticateWithBackend(userCredential);
|
||
}
|
||
|
||
/// Регистрация через email/password
|
||
Future<User> registerWithEmail(String email, String password, String name) async {
|
||
final userCredential = await _firebaseAuth.createUserWithEmailAndPassword(
|
||
email: email, password: password,
|
||
);
|
||
await userCredential.user!.updateDisplayName(name);
|
||
return _authenticateWithBackend(userCredential);
|
||
}
|
||
|
||
/// Общая логика: Firebase → Backend → JWT
|
||
Future<User> _authenticateWithBackend(UserCredential credential) async {
|
||
final idToken = await credential.user!.getIdToken();
|
||
|
||
final response = await _apiClient.post('/auth/login', data: {
|
||
'firebase_token': idToken,
|
||
});
|
||
|
||
await _storage.saveTokens(
|
||
accessToken: response['access_token'],
|
||
refreshToken: response['refresh_token'],
|
||
);
|
||
|
||
return User.fromJson(response['user']);
|
||
}
|
||
|
||
Future<void> signOut() async {
|
||
await _apiClient.post('/auth/logout');
|
||
await _firebaseAuth.signOut();
|
||
await _storage.clearTokens();
|
||
}
|
||
}
|
||
```
|
||
|
||
### 0.8.3 Экран входа
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ │
|
||
│ [Лого FoodAI] │
|
||
│ │
|
||
│ Управляйте питанием │
|
||
│ с помощью AI │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ 📧 Email │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ 🔒 Пароль │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ Войти │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
│ ── или ── │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ [G] Войти через Google │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ [] Войти через Apple │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
│ Нет аккаунта? Регистрация │
|
||
│ │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
- Кнопка Apple отображается только на iOS
|
||
- Валидация: email-формат, пароль минимум 6 символов
|
||
- Ошибки: «Неверный email или пароль», «Email уже зарегистрирован», «Нет соединения»
|
||
|
||
### 0.8.4 Secure Storage
|
||
|
||
```dart
|
||
// lib/core/auth/secure_storage.dart
|
||
|
||
class SecureStorageService {
|
||
final FlutterSecureStorage _storage;
|
||
|
||
Future<void> saveTokens({
|
||
required String accessToken,
|
||
required String refreshToken,
|
||
}) async {
|
||
await _storage.write(key: 'access_token', value: accessToken);
|
||
await _storage.write(key: 'refresh_token', value: refreshToken);
|
||
}
|
||
|
||
Future<String?> getAccessToken() => _storage.read(key: 'access_token');
|
||
Future<String?> getRefreshToken() => _storage.read(key: 'refresh_token');
|
||
|
||
Future<void> clearTokens() async {
|
||
await _storage.delete(key: 'access_token');
|
||
await _storage.delete(key: 'refresh_token');
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 0.9 Навигация и каркас
|
||
|
||
### 0.9.1 go_router + Shell Route
|
||
|
||
```dart
|
||
// lib/core/router/app_router.dart
|
||
|
||
final appRouter = GoRouter(
|
||
initialLocation: '/home',
|
||
redirect: (context, state) {
|
||
final isLoggedIn = /* check auth state */;
|
||
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
||
|
||
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
|
||
if (isLoggedIn && isAuthRoute) return '/home';
|
||
return null;
|
||
},
|
||
routes: [
|
||
// Auth routes (без bottom bar)
|
||
GoRoute(path: '/auth/login', builder: (_, __) => const LoginScreen()),
|
||
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
|
||
|
||
// Main shell с Bottom Tab Bar
|
||
ShellRoute(
|
||
builder: (context, state, child) => MainShell(child: child),
|
||
routes: [
|
||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||
GoRoute(path: '/products', builder: (_, __) => const ProductsScreen()),
|
||
GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()),
|
||
GoRoute(path: '/recipes', builder: (_, __) => const RecipesScreen()),
|
||
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
```
|
||
|
||
### 0.9.2 Main Shell (Bottom Tab Bar)
|
||
|
||
```dart
|
||
class MainShell extends StatelessWidget {
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
body: child,
|
||
bottomNavigationBar: BottomNavigationBar(
|
||
currentIndex: _calculateIndex(GoRouterState.of(context).matchedLocation),
|
||
onTap: (index) => _onTabTap(context, index),
|
||
items: const [
|
||
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Главная'),
|
||
BottomNavigationBarItem(icon: Icon(Icons.kitchen), label: 'Продукты'),
|
||
BottomNavigationBarItem(icon: Icon(Icons.calendar_month), label: 'Меню'),
|
||
BottomNavigationBarItem(icon: Icon(Icons.menu_book), label: 'Рецепты'),
|
||
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Профиль'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 0.9.3 Экраны-заглушки
|
||
|
||
Каждый экран-заглушка:
|
||
|
||
```dart
|
||
class HomeScreen extends StatelessWidget {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Главная')),
|
||
body: const Center(
|
||
child: Text('Раздел в разработке'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 0.10 API-клиент (Flutter)
|
||
|
||
### 0.10.1 Dio-клиент
|
||
|
||
```dart
|
||
// lib/core/api/api_client.dart
|
||
|
||
class ApiClient {
|
||
late final Dio _dio;
|
||
|
||
ApiClient({required String baseUrl, required SecureStorageService storage}) {
|
||
_dio = Dio(BaseOptions(
|
||
baseUrl: baseUrl,
|
||
connectTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 30),
|
||
headers: {'Content-Type': 'application/json'},
|
||
));
|
||
|
||
_dio.interceptors.addAll([
|
||
AuthInterceptor(storage: storage, dio: _dio),
|
||
LogInterceptor(requestBody: true, responseBody: true),
|
||
]);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? params}) async {
|
||
final response = await _dio.get(path, queryParameters: params);
|
||
return response.data;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> post(String path, {dynamic data}) async {
|
||
final response = await _dio.post(path, data: data);
|
||
return response.data;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> put(String path, {dynamic data}) async {
|
||
final response = await _dio.put(path, data: data);
|
||
return response.data;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 0.10.2 Auth Interceptor
|
||
|
||
```dart
|
||
// lib/core/api/auth_interceptor.dart
|
||
|
||
class AuthInterceptor extends Interceptor {
|
||
final SecureStorageService _storage;
|
||
final Dio _dio;
|
||
|
||
@override
|
||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||
// Не добавлять токен к auth-эндпоинтам
|
||
if (options.path.startsWith('/auth/')) {
|
||
return handler.next(options);
|
||
}
|
||
|
||
final token = await _storage.getAccessToken();
|
||
if (token != null) {
|
||
options.headers['Authorization'] = 'Bearer $token';
|
||
}
|
||
handler.next(options);
|
||
}
|
||
|
||
@override
|
||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||
if (err.response?.statusCode == 401) {
|
||
// Попытка refresh
|
||
final refreshToken = await _storage.getRefreshToken();
|
||
if (refreshToken == null) return handler.next(err);
|
||
|
||
try {
|
||
final response = await _dio.post('/auth/refresh', data: {
|
||
'refresh_token': refreshToken,
|
||
});
|
||
|
||
await _storage.saveTokens(
|
||
accessToken: response.data['access_token'],
|
||
refreshToken: response.data['refresh_token'],
|
||
);
|
||
|
||
// Повторить оригинальный запрос с новым токеном
|
||
final retryOptions = err.requestOptions;
|
||
retryOptions.headers['Authorization'] = 'Bearer ${response.data['access_token']}';
|
||
final retryResponse = await _dio.fetch(retryOptions);
|
||
return handler.resolve(retryResponse);
|
||
} catch (_) {
|
||
// Refresh не удался → выход
|
||
await _storage.clearTokens();
|
||
return handler.next(err);
|
||
}
|
||
}
|
||
handler.next(err);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 0.10.3 Модель User
|
||
|
||
```dart
|
||
// lib/shared/models/user.dart
|
||
|
||
@freezed
|
||
class User with _$User {
|
||
const factory User({
|
||
required String id,
|
||
required String email,
|
||
required String name,
|
||
String? avatarUrl,
|
||
int? heightCm,
|
||
double? weightKg,
|
||
int? age,
|
||
String? gender,
|
||
String? activity,
|
||
String? goal,
|
||
int? dailyCalories,
|
||
required String plan,
|
||
@Default({}) Map<String, dynamic> preferences,
|
||
}) = _User;
|
||
|
||
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Порядок выполнения
|
||
|
||
```
|
||
Фаза 1 — Backend core (0.1 → 0.2 → 0.3)
|
||
0.1.1 Инициализация Go-модуля и каталогов
|
||
0.1.2 Конфигурация
|
||
0.1.3 Логирование
|
||
0.1.4 Graceful shutdown
|
||
0.2.1 Подключение pgxpool
|
||
0.2.2 Goose миграции
|
||
0.2.3 Миграция users
|
||
0.3.1 chi-роутер
|
||
0.3.2 Middleware
|
||
0.3.3 Healthcheck
|
||
─── Контрольная точка: make run → GET /health → 200 ───
|
||
|
||
Фаза 2 — Auth (0.4 → 0.5)
|
||
0.4.1 Firebase Admin SDK
|
||
0.4.2 JWT-генерация
|
||
0.4.3 Auth middleware
|
||
0.4.4 POST /auth/login
|
||
0.4.5 POST /auth/refresh
|
||
0.4.6 POST /auth/logout
|
||
0.5.1 Модель User
|
||
0.5.2 Repository
|
||
0.5.3 Service
|
||
0.5.4 GET/PUT /profile
|
||
─── Контрольная точка: curl POST /auth/login → JWT → GET /profile → 200 ───
|
||
|
||
Фаза 3 — DevOps (0.6)
|
||
docker-compose.yml
|
||
Dockerfile
|
||
Makefile
|
||
.env.example
|
||
─── Контрольная точка: docker compose up → app + postgres работают ───
|
||
|
||
Фаза 4 — Flutter (0.7 → 0.8 → 0.9 → 0.10)
|
||
0.7.1 Инициализация проекта
|
||
0.7.2 Пакеты
|
||
0.7.3 Тема
|
||
0.8.1 Firebase-конфигурация
|
||
0.8.2 Экран входа
|
||
0.8.3 Экран регистрации
|
||
0.8.4 Secure storage
|
||
0.9.1 go_router + auth guard
|
||
0.9.2 Main Shell + заглушки
|
||
0.10.1 Dio-клиент
|
||
0.10.2 Auth interceptor
|
||
0.10.3 Модель User
|
||
─── Контрольная точка: вход через Google → 5 вкладок ───
|
||
```
|
||
|
||
---
|
||
|
||
## Критерии готовности
|
||
|
||
| Критерий | Проверка |
|
||
|----------|----------|
|
||
| Backend запускается и отвечает на /health | `curl localhost:8080/health` → 200 |
|
||
| Миграция users применяется без ошибок | `make migrate-up` → success |
|
||
| Регистрация через email работает | POST /auth/login с Firebase token → JWT |
|
||
| Refresh token ротируется | POST /auth/refresh → новая пара токенов |
|
||
| Профиль сохраняется | PUT /profile → GET /profile → данные совпадают |
|
||
| Docker Compose поднимает всё | `docker compose up` → app + postgres |
|
||
| Flutter собирается (Android) | `flutter run` → приложение запускается |
|
||
| Вход через Google работает | Тап → Google → вход → 5 вкладок |
|
||
| Auth guard работает | Без токена → редирект на экран входа |
|
||
| Token refresh работает | Через 15 мин → запрос → auto-refresh → 200 |
|
||
|
||
---
|
||
|
||
## Тестирование
|
||
|
||
### Моки и интерфейсы
|
||
|
||
Для unit-тестирования сервисов и хэндлеров необходимо вынести зависимости за интерфейсы. Моки генерируются через `github.com/stretchr/testify/mock` или `go.uber.org/mock` (gomock).
|
||
|
||
#### Интерфейсы для моков (Go)
|
||
|
||
```go
|
||
// internal/auth/interfaces.go
|
||
|
||
// TokenVerifier — верификация Firebase idToken
|
||
type TokenVerifier interface {
|
||
VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
|
||
}
|
||
|
||
// JWTGenerator — генерация и валидация JWT
|
||
type JWTGenerator interface {
|
||
GenerateAccessToken(userID, plan string) (string, error)
|
||
GenerateRefreshToken() (token string, expiresAt time.Time)
|
||
ValidateAccessToken(tokenStr string) (*Claims, error)
|
||
}
|
||
```
|
||
|
||
```go
|
||
// internal/user/interfaces.go
|
||
|
||
// UserRepository — доступ к данным пользователей
|
||
type UserRepository interface {
|
||
UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error)
|
||
GetByID(ctx context.Context, id string) (*User, error)
|
||
Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error)
|
||
SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error
|
||
FindByRefreshToken(ctx context.Context, token string) (*User, error)
|
||
ClearRefreshToken(ctx context.Context, id string) error
|
||
}
|
||
```
|
||
|
||
#### Моки (Go)
|
||
|
||
```go
|
||
// internal/auth/mocks/token_verifier.go
|
||
|
||
type MockTokenVerifier struct {
|
||
mock.Mock
|
||
}
|
||
|
||
func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||
args := m.Called(ctx, idToken)
|
||
return args.String(0), args.String(1), args.String(2), args.String(3), args.Error(4)
|
||
}
|
||
```
|
||
|
||
```go
|
||
// internal/user/mocks/repository.go
|
||
|
||
type MockUserRepository struct {
|
||
mock.Mock
|
||
}
|
||
|
||
func (m *MockUserRepository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||
args := m.Called(ctx, uid, email, name, avatarURL)
|
||
return args.Get(0).(*user.User), args.Error(1)
|
||
}
|
||
|
||
func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
|
||
args := m.Called(ctx, id)
|
||
if args.Get(0) == nil {
|
||
return nil, args.Error(1)
|
||
}
|
||
return args.Get(0).(*user.User), args.Error(1)
|
||
}
|
||
|
||
// ... остальные методы аналогично
|
||
```
|
||
|
||
#### Моки (Flutter)
|
||
|
||
Пакет `mockito` + code generation (`@GenerateMocks`).
|
||
|
||
```dart
|
||
// test/mocks.dart
|
||
|
||
@GenerateMocks([
|
||
FirebaseAuth,
|
||
GoogleSignIn,
|
||
ApiClient,
|
||
SecureStorageService,
|
||
])
|
||
import 'mocks.mocks.dart';
|
||
```
|
||
|
||
Для каждого зависимого класса создаётся `Mock*` через `build_runner`:
|
||
|
||
| Класс | Мок | Используется в тестах |
|
||
|-------|-----|----------------------|
|
||
| `FirebaseAuth` | `MockFirebaseAuth` | AuthService |
|
||
| `GoogleSignIn` | `MockGoogleSignIn` | AuthService (Google flow) |
|
||
| `ApiClient` | `MockApiClient` | AuthService, AuthInterceptor |
|
||
| `SecureStorageService` | `MockSecureStorageService` | AuthInterceptor, AuthService |
|
||
|
||
Flutter dev-зависимости для тестов:
|
||
|
||
```yaml
|
||
dev_dependencies:
|
||
mockito: ^5.4.0
|
||
build_runner: ^2.4.0 # уже есть
|
||
network_image_mock: ^2.1.0
|
||
```
|
||
|
||
---
|
||
|
||
### Unit-тесты (Go)
|
||
|
||
#### JWT Manager — `internal/auth/jwt_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Генерация access token | `GenerateAccessToken("user-1", "free")` → непустая строка, парсится без ошибки |
|
||
| 2 | Claims содержат правильные данные | Парсим сгенерированный токен → `claims.UserID == "user-1"`, `claims.Plan == "free"` |
|
||
| 3 | Токен истекает через заданное время | Генерируем с duration=1s → ждём 2s → `ValidateAccessToken` → ошибка expired |
|
||
| 4 | Невалидная подпись | Токен подписан другим секретом → `ValidateAccessToken` → ошибка |
|
||
| 5 | Мусор вместо токена | `ValidateAccessToken("not-a-token")` → ошибка |
|
||
| 6 | Пустая строка | `ValidateAccessToken("")` → ошибка |
|
||
| 7 | Генерация refresh token | `GenerateRefreshToken()` → непустая строка UUID, `expiresAt` > now |
|
||
|
||
**Моки:** нет (чистая логика, без зависимостей).
|
||
|
||
#### Auth Service — `internal/auth/service_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем | Моки |
|
||
|---|-----------|---------------|------|
|
||
| 1 | Login: новый пользователь | Firebase token валиден → upsert создаёт юзера → возвращает JWT + refresh + user | `MockTokenVerifier`, `MockUserRepository` |
|
||
| 2 | Login: существующий пользователь | Firebase token валиден → upsert обновляет юзера → возвращает JWT | `MockTokenVerifier`, `MockUserRepository` |
|
||
| 3 | Login: невалидный Firebase token | `VerifyToken` возвращает ошибку → 401 | `MockTokenVerifier` (returns error) |
|
||
| 4 | Login: пустой firebase_token | Валидация на входе → 400 | — |
|
||
| 5 | Login: ошибка БД при upsert | `UpsertByFirebaseUID` возвращает ошибку → 500 | `MockTokenVerifier`, `MockUserRepository` (returns error) |
|
||
| 6 | Refresh: валидный token | Находим юзера по refresh → не истёк → генерируем новую пару → ротация | `MockUserRepository` |
|
||
| 7 | Refresh: истёкший token | `FindByRefreshToken` → `token_expires_at` < now → 401 | `MockUserRepository` |
|
||
| 8 | Refresh: несуществующий token | `FindByRefreshToken` → nil → 401 | `MockUserRepository` (returns nil) |
|
||
| 9 | Refresh: ротация — старый перестаёт работать | После refresh → `SetRefreshToken` вызван с новым token | `MockUserRepository` |
|
||
| 10 | Logout: успешный | `ClearRefreshToken` вызван с правильным user_id | `MockUserRepository` |
|
||
|
||
#### User Service — `internal/user/service_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем | Моки |
|
||
|---|-----------|---------------|------|
|
||
| 1 | GetProfile: существующий пользователь | `GetByID("user-1")` → User с правильными полями | `MockUserRepository` |
|
||
| 2 | GetProfile: несуществующий пользователь | `GetByID("unknown")` → not found error | `MockUserRepository` (returns nil) |
|
||
| 3 | UpdateProfile: обновление имени | `Update` вызван с `Name: "Новое имя"`, остальные поля не изменены | `MockUserRepository` |
|
||
| 4 | UpdateProfile: пересчёт калорий | Изменили height + weight + age + gender + activity + goal → `daily_calories` пересчитан по Mifflin-St Jeor | `MockUserRepository` |
|
||
| 5 | UpdateProfile: пересчёт калорий при частичном обновлении | Изменили только weight → нужно дочитать остальные параметры из existing user → пересчитать | `MockUserRepository` |
|
||
| 6 | UpdateProfile: без параметров тела — калории не пересчитываются | Обновили только имя → `daily_calories` не изменился | `MockUserRepository` |
|
||
| 7 | Валидация: height < 100 | `UpdateProfile` с height=50 → ошибка валидации | — |
|
||
| 8 | Валидация: height > 250 | `UpdateProfile` с height=300 → ошибка валидации | — |
|
||
| 9 | Валидация: weight < 30 | → ошибка валидации | — |
|
||
| 10 | Валидация: weight > 300 | → ошибка валидации | — |
|
||
| 11 | Валидация: age < 10 | → ошибка валидации | — |
|
||
| 12 | Валидация: age > 120 | → ошибка валидации | — |
|
||
| 13 | Валидация: невалидный gender | → ошибка валидации | — |
|
||
| 14 | Валидация: невалидный goal | → ошибка валидации | — |
|
||
|
||
#### Расчёт калорий — `internal/user/calories_test.go`
|
||
|
||
| # | Тест-кейс | Вход | Ожидаемый результат |
|
||
|---|-----------|------|---------------------|
|
||
| 1 | Мужчина, похудение | male, 178cm, 75kg, 28лет, moderate, lose | BMR=1728 → TDEE=2678 → 2178 |
|
||
| 2 | Женщина, поддержание | female, 165cm, 60kg, 30лет, low, maintain | BMR=1322 → TDEE=1818 → 1818 |
|
||
| 3 | Мужчина, набор массы | male, 185cm, 90kg, 25лет, high, gain | BMR=1923 → TDEE=3317 → 3617 |
|
||
| 4 | Минимальные параметры | female, 100cm, 30kg, 10лет, low, maintain | Расчёт без ошибки, результат > 0 |
|
||
| 5 | Максимальные параметры | male, 250cm, 300kg, 120лет, high, gain | Расчёт без ошибки |
|
||
| 6 | Неполные данные (нет goal) | все параметры кроме goal → nil | Возвращаем nil (нельзя рассчитать) |
|
||
| 7 | Неполные данные (нет weight) | → nil | nil |
|
||
|
||
**Моки:** нет (чистая функция).
|
||
|
||
#### Middleware — `internal/middleware/auth_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Валидный токен | `Authorization: Bearer <valid>` → handler вызван, user_id в контексте |
|
||
| 2 | Нет заголовка Authorization | → 401, handler не вызван |
|
||
| 3 | Заголовок без Bearer | `Authorization: <token>` → 401 |
|
||
| 4 | Истёкший токен | → 401 |
|
||
| 5 | Невалидный токен | → 401 |
|
||
| 6 | User ID в контексте | После успешной проверки — `UserIDFromCtx(ctx)` возвращает правильный ID |
|
||
| 7 | User Plan в контексте | После успешной проверки — `UserPlanFromCtx(ctx)` возвращает "free" или "paid" |
|
||
|
||
**Моки:** используется настоящий `JWTManager` с тестовым секретом (не мок — тестируем интеграцию middleware + JWT).
|
||
|
||
#### Middleware — `internal/middleware/request_id_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Запрос без X-Request-ID | Response содержит `X-Request-ID`, значение — валидный UUID |
|
||
| 2 | Запрос с X-Request-ID | Response содержит тот же `X-Request-ID`, что и запрос |
|
||
| 3 | Request ID в контексте | `RequestIDFromCtx(ctx)` в handler возвращает правильный ID |
|
||
|
||
**Моки:** нет (`httptest.NewRecorder` + `httptest.NewRequest`).
|
||
|
||
#### Middleware — `internal/middleware/recovery_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Handler вызывает panic(string) | Сервер возвращает 500, не падает |
|
||
| 2 | Handler вызывает panic(error) | Сервер возвращает 500, не падает |
|
||
| 3 | Handler не паникует | Обычный ответ проходит без изменений |
|
||
|
||
**Моки:** нет.
|
||
|
||
---
|
||
|
||
### Integration-тесты (Go) — с реальной БД
|
||
|
||
Для integration-тестов поднимается PostgreSQL через `testcontainers-go`. Миграции применяются перед тестами.
|
||
|
||
```go
|
||
// internal/testutil/postgres.go
|
||
|
||
func SetupTestDB(t *testing.T) *pgxpool.Pool {
|
||
ctx := context.Background()
|
||
|
||
container, err := postgres.RunContainer(ctx,
|
||
testcontainers.WithImage("postgres:16-alpine"),
|
||
postgres.WithDatabase("food_ai_test"),
|
||
postgres.WithUsername("test"),
|
||
postgres.WithPassword("test"),
|
||
)
|
||
require.NoError(t, err)
|
||
t.Cleanup(func() { container.Terminate(ctx) })
|
||
|
||
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
|
||
require.NoError(t, err)
|
||
|
||
pool, err := pgxpool.New(ctx, connStr)
|
||
require.NoError(t, err)
|
||
|
||
// Применяем миграции
|
||
goose.SetDialect("postgres")
|
||
db, _ := sql.Open("pgx", connStr)
|
||
goose.Up(db, "../../migrations")
|
||
|
||
return pool
|
||
}
|
||
```
|
||
|
||
Пакет: `github.com/testcontainers/testcontainers-go/modules/postgres`
|
||
|
||
#### User Repository — `internal/user/repository_integration_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | UpsertByFirebaseUID: новый пользователь | INSERT → user создан, id != nil, email/name совпадают |
|
||
| 2 | UpsertByFirebaseUID: повторный вызов с тем же uid | UPDATE → email обновлён, id тот же |
|
||
| 3 | GetByID: существующий | Создаём → получаем → поля совпадают |
|
||
| 4 | GetByID: несуществующий | → nil, без ошибки (или ErrNotFound) |
|
||
| 5 | Update: обновление всех полей | Создаём → обновляем height, weight, name → GetByID → проверяем |
|
||
| 6 | Update: partial update (только name) | height/weight не изменились |
|
||
| 7 | Update: preferences JSONB | Сохраняем `{"cuisines":["russian"]}` → читаем → совпадает |
|
||
| 8 | SetRefreshToken + FindByRefreshToken | Сохраняем → ищем → находим правильного юзера |
|
||
| 9 | FindByRefreshToken: несуществующий | → nil |
|
||
| 10 | ClearRefreshToken | Сохраняем → очищаем → FindByRefreshToken → nil |
|
||
| 11 | Уникальность firebase_uid | Два INSERT с одним uid → conflict, upsert корректен |
|
||
| 12 | updated_at обновляется | Создаём → обновляем → updated_at > created_at |
|
||
|
||
#### Handler (E2E) — `internal/auth/handler_integration_test.go`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | POST /auth/login → 200 | Полный flow: mock Firebase → upsert → JWT + refresh в response |
|
||
| 2 | POST /auth/login → 401 | Невалидный Firebase token → 401 |
|
||
| 3 | POST /auth/refresh → 200 | Используем refresh из login → получаем новую пару |
|
||
| 4 | POST /auth/refresh → 401 | Невалидный refresh → 401 |
|
||
| 5 | POST /auth/logout → 200 | После logout → refresh с тем же токеном → 401 |
|
||
| 6 | GET /profile → 200 | JWT из login → GET /profile → user data |
|
||
| 7 | GET /profile → 401 | Без токена → 401 |
|
||
| 8 | PUT /profile → 200 | Обновляем параметры → GET /profile → данные обновлены |
|
||
| 9 | PUT /profile → 400 | Невалидные данные (age=5) → 400 с описанием |
|
||
| 10 | GET /health → 200 | `status: ok`, `db: connected` |
|
||
|
||
Для handler-тестов используется `httptest.NewServer` с реальным роутером, реальной БД (testcontainers) и **моком Firebase** (`MockTokenVerifier`).
|
||
|
||
---
|
||
|
||
### Unit-тесты (Flutter)
|
||
|
||
#### User model — `test/shared/models/user_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | fromJson: полный объект | Все поля десериализуются корректно |
|
||
| 2 | fromJson: минимальный объект | Только required-поля (id, email, name, plan), nullable = null |
|
||
| 3 | fromJson: camelCase ↔ snake_case | `avatar_url` → `avatarUrl`, `daily_calories` → `dailyCalories` |
|
||
| 4 | toJson: roundtrip | `fromJson(toJson(user)) == user` |
|
||
| 5 | fromJson: неизвестные поля игнорируются | `{"id": "1", "email": "...", "unknown_field": 42}` → без ошибки |
|
||
|
||
**Моки:** нет.
|
||
|
||
#### AuthService — `test/core/auth/auth_service_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем | Моки |
|
||
|---|-----------|---------------|------|
|
||
| 1 | signInWithEmail: успех | Firebase → backend /auth/login → токены сохранены → User возвращён | `MockFirebaseAuth`, `MockApiClient`, `MockSecureStorageService` |
|
||
| 2 | signInWithEmail: неверный пароль | FirebaseAuth бросает `FirebaseAuthException` → пробрасывается наверх | `MockFirebaseAuth` (throws) |
|
||
| 3 | signInWithEmail: ошибка backend | Firebase OK, но /auth/login → DioException → ошибка | `MockFirebaseAuth`, `MockApiClient` (throws) |
|
||
| 4 | signInWithGoogle: успех | GoogleSignIn → credential → Firebase → backend → токены | `MockGoogleSignIn`, `MockFirebaseAuth`, `MockApiClient`, `MockSecureStorageService` |
|
||
| 5 | signInWithGoogle: отмена | `GoogleSignIn().signIn()` → null (юзер закрыл окно) → graceful error | `MockGoogleSignIn` (returns null) |
|
||
| 6 | signOut: успех | /auth/logout вызван → Firebase signOut → токены удалены | `MockApiClient`, `MockFirebaseAuth`, `MockSecureStorageService` |
|
||
| 7 | signOut: ошибка backend | /auth/logout fail → всё равно Firebase signOut + clear tokens (не блокируем) | `MockApiClient` (throws) |
|
||
|
||
#### AuthInterceptor — `test/core/api/auth_interceptor_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем | Моки |
|
||
|---|-----------|---------------|------|
|
||
| 1 | onRequest: добавляет Bearer token | Запрос к /profile → заголовок `Authorization: Bearer <token>` | `MockSecureStorageService` |
|
||
| 2 | onRequest: пропускает /auth/ пути | Запрос к /auth/login → без Authorization | `MockSecureStorageService` |
|
||
| 3 | onRequest: нет токена | Storage возвращает null → запрос без Authorization | `MockSecureStorageService` (returns null) |
|
||
| 4 | onError 401: refresh и retry | 401 → refresh → новые токены сохранены → оригинальный запрос повторён | `MockSecureStorageService`, mock Dio adapter |
|
||
| 5 | onError 401: refresh провалился | 401 → refresh → DioException → токены очищены → ошибка пробрасывается | `MockSecureStorageService`, mock Dio adapter |
|
||
| 6 | onError 401: нет refresh token | 401 → storage null → ошибка пробрасывается без retry | `MockSecureStorageService` (returns null) |
|
||
| 7 | onError не-401 | 500 → interceptor не вмешивается, ошибка пробрасывается | — |
|
||
|
||
#### SecureStorageService — `test/core/auth/secure_storage_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем | Моки |
|
||
|---|-----------|---------------|------|
|
||
| 1 | saveTokens + getAccessToken | Сохраняем → читаем → совпадает | `MockFlutterSecureStorage` |
|
||
| 2 | saveTokens + getRefreshToken | Сохраняем → читаем → совпадает | `MockFlutterSecureStorage` |
|
||
| 3 | clearTokens | Сохраняем → очищаем → get → null | `MockFlutterSecureStorage` |
|
||
| 4 | getAccessToken: пусто | Ничего не сохраняли → null | `MockFlutterSecureStorage` |
|
||
|
||
---
|
||
|
||
### Widget-тесты (Flutter)
|
||
|
||
#### LoginScreen — `test/features/auth/login_screen_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Рендеринг | Email + пароль поля, кнопки «Войти», «Google», «Регистрация» видны |
|
||
| 2 | Валидация: пустой email | Тап «Войти» → «Введите email» |
|
||
| 3 | Валидация: невалидный email | Вводим "abc" → «Некорректный email» |
|
||
| 4 | Валидация: короткий пароль | < 6 символов → «Минимум 6 символов» |
|
||
| 5 | Успешный вход | Заполняем → тап «Войти» → навигация на /home |
|
||
| 6 | Ошибка входа | AuthService throws → показывает snackbar с ошибкой |
|
||
| 7 | Загрузка | Тап «Войти» → кнопка показывает индикатор → после ответа скрывает |
|
||
| 8 | Переход на регистрацию | Тап «Регистрация» → навигация на /auth/register |
|
||
| 9 | Кнопка Apple: только iOS | На Android — не отображается, на iOS — отображается |
|
||
|
||
**Моки:** `MockAuthService` (возвращает User или бросает ошибку).
|
||
|
||
#### RegisterScreen — `test/features/auth/register_screen_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Рендеринг | Поля: имя, email, пароль, подтверждение. Кнопка «Зарегистрироваться» |
|
||
| 2 | Валидация: пустое имя | → «Введите имя» |
|
||
| 3 | Валидация: пароли не совпадают | → «Пароли не совпадают» |
|
||
| 4 | Успешная регистрация | → навигация на /home |
|
||
| 5 | Ошибка: email уже занят | AuthService throws → snackbar «Email уже зарегистрирован» |
|
||
|
||
**Моки:** `MockAuthService`.
|
||
|
||
#### MainShell — `test/core/navigation/main_shell_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Отображает 5 вкладок | Иконки: home, kitchen, calendar, menu_book, person |
|
||
| 2 | Подсветка активной вкладки | При location=/products → вкладка «Продукты» активна |
|
||
| 3 | Навигация при тапе | Тап «Рецепты» → GoRouter переходит на /recipes |
|
||
|
||
**Моки:** нет (widget test с GoRouter).
|
||
|
||
#### Auth Guard — `test/core/router/auth_guard_test.dart`
|
||
|
||
| # | Тест-кейс | Что проверяем |
|
||
|---|-----------|---------------|
|
||
| 1 | Не авторизован → /auth/login | Открываем /home → redirect на /auth/login |
|
||
| 2 | Авторизован → нет redirect | Открываем /home → остаёмся на /home |
|
||
| 3 | Авторизован + /auth/login → /home | Открываем /auth/login → redirect на /home |
|
||
|
||
**Моки:** `MockAuthProvider` (isLoggedIn: true/false).
|
||
|
||
---
|
||
|
||
### Сводная таблица
|
||
|
||
| Модуль | Unit | Integration | Widget | Всего |
|
||
|--------|------|-------------|--------|-------|
|
||
| **Go: JWT Manager** | 7 | — | — | 7 |
|
||
| **Go: Auth Service** | 10 | — | — | 10 |
|
||
| **Go: User Service** | 14 | — | — | 14 |
|
||
| **Go: Calories** | 7 | — | — | 7 |
|
||
| **Go: Auth Middleware** | 7 | — | — | 7 |
|
||
| **Go: Request ID MW** | 3 | — | — | 3 |
|
||
| **Go: Recovery MW** | 3 | — | — | 3 |
|
||
| **Go: User Repository** | — | 12 | — | 12 |
|
||
| **Go: Handlers (E2E)** | — | 10 | — | 10 |
|
||
| **Flutter: User model** | 5 | — | — | 5 |
|
||
| **Flutter: AuthService** | 7 | — | — | 7 |
|
||
| **Flutter: AuthInterceptor** | 7 | — | — | 7 |
|
||
| **Flutter: SecureStorage** | 4 | — | — | 4 |
|
||
| **Flutter: LoginScreen** | — | — | 9 | 9 |
|
||
| **Flutter: RegisterScreen** | — | — | 5 | 5 |
|
||
| **Flutter: MainShell** | — | — | 3 | 3 |
|
||
| **Flutter: Auth Guard** | — | — | 3 | 3 |
|
||
| **Итого** | **74** | **22** | **20** | **116** |
|
||
|
||
### Необходимые тестовые зависимости
|
||
|
||
#### Go
|
||
|
||
```
|
||
go get github.com/stretchr/testify # assert, require, mock
|
||
go get github.com/testcontainers/testcontainers-go # PostgreSQL в Docker для integration-тестов
|
||
go get github.com/testcontainers/testcontainers-go/modules/postgres
|
||
```
|
||
|
||
#### Flutter
|
||
|
||
```yaml
|
||
dev_dependencies:
|
||
flutter_test:
|
||
sdk: flutter
|
||
mockito: ^5.4.0
|
||
build_runner: ^2.4.0
|
||
network_image_mock: ^2.1.0
|
||
```
|
||
|
||
---
|
||
|
||
## Зависимости Go-пакетов
|
||
|
||
```
|
||
go get github.com/go-chi/chi/v5
|
||
go get github.com/go-chi/cors
|
||
go get github.com/jackc/pgx/v5
|
||
go get github.com/pressly/goose/v3
|
||
go get github.com/kelseyhightower/envconfig
|
||
go get github.com/golang-jwt/jwt/v5
|
||
go get github.com/google/uuid
|
||
go get firebase.google.com/go/v4
|
||
go get google.golang.org/api/option
|
||
```
|