ソースを参照

test: integration tests for booking and place repositories

- integration_test.go: TestMain with DATABASE_URL_TEST, fixtures
  (insertTestUser, insertTestPlace, insertTestTag, insertTestPlaceTag,
  cleanTables helper)
- booking_repo_test.go: Create with price calculation, place not found,
  exclusion constraint overlap, same slot different place
- place_repo_test.go: List with status/type filters, cursor pagination,
  GetTagsBatch with batch and empty input

Run with: DATABASE_URL_TEST=postgres://... go test ./internal/repository/ -v
neyrogovnarik 1 ヶ月 前
コミット
31cafe429e

+ 4 - 0
FINDINGS.md

@@ -25,6 +25,7 @@
 ### ✅ P1 — Исправлено
 11. **Теги и фичи в листинге мест** — `PlaceFilter.IncludeTagsFeatures` + batch-загрузка (`GetTagsBatch`/`GetFeaturesBatch`), запрос `?include=tags,features`. N+1 проблема решена.
 12. **Cursor-based пагинация** — `PlaceFilter.CursorRating`, `PaginatedPlaces` с json-тегами, сервис возвращает `*PaginatedPlaces`, репозиторий поддерживает курсор для `created_at` и `rating` сортировок. API: `?cursor=<base64>&limit=N`. Ответ: `{"data":[...], "next_cursor":"...", "has_more":true}`.
+13. **Интеграционные тесты** — репозитории: `TestMain` с подключением по `DATABASE_URL_TEST`, фикстуры (user, place, tag), автономные тесты с `cleanTables`. Booking: цена, overlap, place not found, same slot different place. Place: status filter, cursor pagination, batch tags.
 
 ### ✅ P1 — Исправлено
 6. **Rate limiter** — Redis-based имплементация подключена в main.go, in-memory как fallback при недоступности Redis (реализован корректный fallback: при недоступности Redis автоматически используется in-memory limiter; в production `failOpen=false` — возвращается 503 только если оба лимитера недоступны).
@@ -80,6 +81,9 @@
 | `backend/internal/handlers/places.go` | List: парсинг ?include=tags,features и ?cursor= |
 | `backend/internal/repository/bookings.go` | Create: транзакция + SELECT ... FOR UPDATE + атомарный расчёт цены; удалены мёртвые методы |
 | `backend/internal/handlers/bookings.go` | Убран placeRepo; ErrPlaceNotFound в хендлере; цена в репозитории |
+| `backend/internal/repository/integration_test.go` | **Новый**: TestMain, тестовые фикстуры (insertTestUser, insertTestPlace, insertTestTag, cleanTables) |
+| `backend/internal/repository/booking_repo_test.go` | **Новый**: booking create (price, overlap, place not found, same slot different place) |
+| `backend/internal/repository/place_repo_test.go` | **Новый**: list with filters, cursor pagination, GetTagsBatch |
 | `docker-compose.yml` | Все пароли через ${VAR:-default} |
 | `deploy/env.prod` | Очищен Yandex-ключ |
 | `deploy/Caddyfile` | CSP + Permissions-Policy |

+ 172 - 0
backend/internal/repository/booking_repo_test.go

@@ -0,0 +1,172 @@
+package repository
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/jackc/pgx/v5/pgconn"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+)
+
+func TestBookingRepo_Create_CalculatesTotalPrice(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "bookings", "places", "users")
+
+	user := insertTestUser(t)
+	rate := 1000
+	place := insertTestPlace(t, user.ID, &rate)
+
+	repo := NewBookingRepo(testPool)
+	start := time.Now().Truncate(time.Hour).Add(2 * time.Hour)
+	end := start.Add(3 * time.Hour)
+
+	booking := &models.Booking{
+		PlaceID:   place.ID,
+		UserID:    user.ID,
+		StartTime: start,
+		EndTime:   end,
+	}
+
+	if err := repo.Create(context.Background(), booking); err != nil {
+		t.Fatalf("Create: %v", err)
+	}
+
+	if booking.ID == "" {
+		t.Fatal("expected non-empty booking ID")
+	}
+	if booking.TotalPrice == nil {
+		t.Fatal("expected TotalPrice to be calculated")
+	}
+	expected := 1000 * 3 // 1000 RUB/h * 3h
+	if *booking.TotalPrice != expected {
+		t.Errorf("TotalPrice = %d, want %d", *booking.TotalPrice, expected)
+	}
+	if booking.Currency != "RUB" {
+		t.Errorf("Currency = %s, want RUB", booking.Currency)
+	}
+	if booking.Status != "pending" {
+		t.Errorf("Status = %s, want pending", booking.Status)
+	}
+}
+
+func TestBookingRepo_Create_NoPriceForNonStudio(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "bookings", "places", "users")
+
+	user := insertTestUser(t)
+	place := insertTestPlace(t, user.ID, nil)
+	place.Type = "place"
+
+	repo := NewBookingRepo(testPool)
+	start := time.Now().Truncate(time.Hour).Add(2 * time.Hour)
+	end := start.Add(3 * time.Hour)
+
+	booking := &models.Booking{
+		PlaceID:   place.ID,
+		UserID:    user.ID,
+		StartTime: start,
+		EndTime:   end,
+	}
+
+	if err := repo.Create(context.Background(), booking); err != nil {
+		t.Fatalf("Create: %v", err)
+	}
+
+	if booking.TotalPrice != nil {
+		t.Errorf("TotalPrice = %d, want nil (place without hourly_rate)", *booking.TotalPrice)
+	}
+}
+
+func TestBookingRepo_Create_PlaceNotFound(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "bookings")
+
+	repo := NewBookingRepo(testPool)
+	booking := &models.Booking{
+		PlaceID:   "00000000-0000-0000-0000-000000000000",
+		UserID:    "00000000-0000-0000-0000-000000000000",
+		StartTime: time.Now(),
+		EndTime:   time.Now().Add(1 * time.Hour),
+	}
+
+	err := repo.Create(context.Background(), booking)
+	if !errors.Is(err, ErrPlaceNotFound) {
+		t.Errorf("expected ErrPlaceNotFound, got %v", err)
+	}
+}
+
+func TestBookingRepo_Create_OverlapRejected(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "bookings", "places", "users")
+
+	user := insertTestUser(t)
+	rate := 500
+	place := insertTestPlace(t, user.ID, &rate)
+
+	repo := NewBookingRepo(testPool)
+	start := time.Now().Truncate(time.Hour).Add(5 * time.Hour)
+	end := start.Add(2 * time.Hour)
+
+	// First booking succeeds
+	b1 := &models.Booking{
+		PlaceID:   place.ID,
+		UserID:    user.ID,
+		StartTime: start,
+		EndTime:   end,
+	}
+	if err := repo.Create(context.Background(), b1); err != nil {
+		t.Fatalf("first booking: %v", err)
+	}
+
+	// Overlapping booking fails with 23P01 (exclusion constraint)
+	b2 := &models.Booking{
+		PlaceID:   place.ID,
+		UserID:    user.ID,
+		StartTime: start.Add(30 * time.Minute),
+		EndTime:   end.Add(30 * time.Minute),
+	}
+	err := repo.Create(context.Background(), b2)
+	if err == nil {
+		t.Fatal("expected error for overlapping booking, got nil")
+	}
+	var pgErr *pgconn.PgError
+	if !errors.As(err, &pgErr) || pgErr.Code != "23P01" {
+		t.Errorf("expected 23P01 exclusion violation, got: %v", err)
+	}
+}
+
+func TestBookingRepo_Create_SameSlotDifferentPlace(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "bookings", "places", "users")
+
+	user := insertTestUser(t)
+	rate := 500
+	p1 := insertTestPlace(t, user.ID, &rate)
+	p2 := insertTestPlace(t, user.ID, &rate)
+
+	repo := NewBookingRepo(testPool)
+	start := time.Now().Truncate(time.Hour).Add(10 * time.Hour)
+	end := start.Add(2 * time.Hour)
+
+	b1 := &models.Booking{PlaceID: p1.ID, UserID: user.ID, StartTime: start, EndTime: end}
+	if err := repo.Create(context.Background(), b1); err != nil {
+		t.Fatalf("first booking: %v", err)
+	}
+
+	b2 := &models.Booking{PlaceID: p2.ID, UserID: user.ID, StartTime: start, EndTime: end}
+	if err := repo.Create(context.Background(), b2); err != nil {
+		t.Fatalf("same slot different place: %v", err)
+	}
+}

+ 120 - 0
backend/internal/repository/integration_test.go

@@ -0,0 +1,120 @@
+package repository
+
+import (
+	"context"
+	"log"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+)
+
+var testPool *pgxpool.Pool
+
+func TestMain(m *testing.M) {
+	databaseURL := os.Getenv("DATABASE_URL_TEST")
+	if databaseURL == "" {
+		databaseURL = os.Getenv("DATABASE_URL")
+	}
+	if databaseURL == "" {
+		log.Println("SKIP: DATABASE_URL_TEST not set")
+		return
+	}
+
+	ctx := context.Background()
+	pool, err := pgxpool.New(ctx, databaseURL)
+	if err != nil {
+		log.Printf("SKIP: cannot connect to test database: %v", err)
+		return
+	}
+	defer pool.Close()
+
+	testPool = pool
+	os.Exit(m.Run())
+}
+
+func cleanTables(t *testing.T, tables ...string) {
+	t.Helper()
+	ctx := context.Background()
+	for _, table := range tables {
+		if _, err := testPool.Exec(ctx, "DELETE FROM "+table); err != nil {
+			t.Fatalf("clean table %s: %v", table, err)
+		}
+	}
+}
+
+func insertTestUser(t *testing.T) *models.User {
+	t.Helper()
+	ctx := context.Background()
+	user := &models.User{
+		ID:           uuid.New().String(),
+		Email:        uuid.New().String() + "@test.test",
+		PasswordHash: "hash",
+		Role:         "customer",
+		Status:       "active",
+		CreatedAt:    time.Now(),
+	}
+	_, err := testPool.Exec(ctx,
+		`INSERT INTO users (id, email, password_hash, role, status, created_at)
+		 VALUES ($1, $2, $3, $4, $5, $6)`,
+		user.ID, user.Email, user.PasswordHash, user.Role, user.Status, user.CreatedAt)
+	if err != nil {
+		t.Fatalf("insert test user: %v", err)
+	}
+	return user
+}
+
+func insertTestPlace(t *testing.T, ownerID string, hourlyRate *int) *models.Place {
+	t.Helper()
+	ctx := context.Background()
+	p := &models.Place{
+		ID:         uuid.New().String(),
+		Type:       "studio",
+		OwnerID:    ownerID,
+		Title:      "Test Place " + uuid.New().String()[:8],
+		Lat:        55.75,
+		Lng:        37.61,
+		Status:     "published",
+		HourlyRate: hourlyRate,
+		Currency:   "RUB",
+		Rating:     0,
+		CreatedAt:  time.Now(),
+		UpdatedAt:  time.Now(),
+	}
+	_, err := testPool.Exec(ctx,
+		`INSERT INTO places (id, type, owner_id, title, coordinates, status, hourly_rate, currency, rating, created_at, updated_at)
+		 VALUES ($1, $2, $3, $4, ST_SetSRID(ST_MakePoint($5, $6), 4326), $7, $8, $9, $10, $11, $12)`,
+		p.ID, p.Type, p.OwnerID, p.Title, p.Lng, p.Lat, p.Status, p.HourlyRate, p.Currency, p.Rating, p.CreatedAt, p.UpdatedAt)
+	if err != nil {
+		t.Fatalf("insert test place: %v", err)
+	}
+	return p
+}
+
+func insertTestTag(t *testing.T) *models.Tag {
+	t.Helper()
+	ctx := context.Background()
+	tag := &models.Tag{
+		ID:       uuid.New().String(),
+		Name:     "Test Tag " + uuid.New().String()[:8],
+		Category: "style",
+	}
+	_, err := testPool.Exec(ctx,
+		`INSERT INTO tags (id, name, category) VALUES ($1, $2, $3)`,
+		tag.ID, tag.Name, tag.Category)
+	if err != nil {
+		t.Fatalf("insert test tag: %v", err)
+	}
+	return tag
+}
+
+func insertTestPlaceTag(t *testing.T, placeID, tagID string) {
+	t.Helper()
+	if _, err := testPool.Exec(context.Background(),
+		`INSERT INTO place_tags (place_id, tag_id) VALUES ($1, $2)`, placeID, tagID); err != nil {
+		t.Fatalf("insert place_tag: %v", err)
+	}
+}

+ 177 - 0
backend/internal/repository/place_repo_test.go

@@ -0,0 +1,177 @@
+package repository
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+)
+
+func TestPlaceRepo_List_WithStatusFilter(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "places", "users")
+
+	user := insertTestUser(t)
+	ctx := context.Background()
+	repo := NewPlaceRepo(testPool)
+
+	insertTestPlace(t, user.ID, nil)
+	insertTestPlace(t, user.ID, nil)
+
+	pending := insertTestPlace(t, user.ID, nil)
+	if _, err := testPool.Exec(ctx,
+		`UPDATE places SET status = $1 WHERE id = $2`, "pending_moderation", pending.ID); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Run("filter by status published", func(t *testing.T) {
+		results, err := repo.List(ctx, models.PlaceFilter{Status: "published"})
+		if err != nil {
+			t.Fatalf("List: %v", err)
+		}
+		if len(results) < 2 {
+			t.Errorf("expected at least 2 published places, got %d", len(results))
+		}
+	})
+
+	t.Run("filter by status pending_moderation", func(t *testing.T) {
+		results, err := repo.List(ctx, models.PlaceFilter{Status: "pending_moderation"})
+		if err != nil {
+			t.Fatalf("List: %v", err)
+		}
+		if len(results) != 1 {
+			t.Errorf("expected 1 pending_moderation place, got %d", len(results))
+		}
+	})
+
+	t.Run("filter by both status and type", func(t *testing.T) {
+		results, err := repo.List(ctx, models.PlaceFilter{Type: "studio", Status: "published"})
+		if err != nil {
+			t.Fatalf("List: %v", err)
+		}
+		if len(results) < 2 {
+			t.Errorf("expected at least 2 published studios, got %d", len(results))
+		}
+	})
+}
+
+func TestPlaceRepo_List_CursorPagination(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "places", "users")
+
+	user := insertTestUser(t)
+	repo := NewPlaceRepo(testPool)
+
+	// Create 5 places with slightly different created_at times
+	for i := 0; i < 5; i++ {
+		insertTestPlace(t, user.ID, nil)
+		time.Sleep(5 * time.Millisecond) // ensure different created_at
+	}
+
+	t.Run("first page returns limit items with hasMore", func(t *testing.T) {
+		results, err := repo.List(context.Background(), models.PlaceFilter{
+			Limit_:  2,
+			Status:  "published",
+		})
+		if err != nil {
+			t.Fatalf("List: %v", err)
+		}
+		if len(results) != 3 { // LIMIT 2 + 1 (extra for hasMore detection)
+			t.Errorf("expected 3 results (2 + 1 extra), got %d", len(results))
+		}
+	})
+
+	t.Run("cursor returns correct page", func(t *testing.T) {
+		// Get first page
+		page1, err := repo.List(context.Background(), models.PlaceFilter{
+			Limit_: 2,
+			Status: "published",
+		})
+		if err != nil {
+			t.Fatalf("List page1: %v", err)
+		}
+		if len(page1) < 2 {
+			t.Skip("not enough places for cursor test")
+		}
+
+		// Get second page using cursor from last item of page1
+		last := page1[1]
+		cursorCreatedAt := last.CreatedAt
+		page2, err := repo.List(context.Background(), models.PlaceFilter{
+			Cursor:          last.ID,
+			CursorCreatedAt: &cursorCreatedAt,
+			Limit_:          2,
+			Status:          "published",
+		})
+		if err != nil {
+			t.Fatalf("List page2 with cursor: %v", err)
+		}
+		if len(page2) == 0 {
+			t.Error("expected at least 1 result after cursor, got 0")
+		}
+	})
+}
+
+func TestPlaceRepo_GetTagsBatch(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+	cleanTables(t, "place_tags", "tags", "places", "users")
+
+	user := insertTestUser(t)
+	ctx := context.Background()
+	repo := NewPlaceRepo(testPool)
+
+	place1 := insertTestPlace(t, user.ID, nil)
+	place2 := insertTestPlace(t, user.ID, nil)
+
+	tag1 := insertTestTag(t)
+	tag2 := insertTestTag(t)
+
+	insertTestPlaceTag(t, place1.ID, tag1.ID)
+	insertTestPlaceTag(t, place1.ID, tag2.ID)
+	insertTestPlaceTag(t, place2.ID, tag1.ID)
+
+	tagsMap, err := repo.GetTagsBatch(ctx, []string{place1.ID, place2.ID})
+	if err != nil {
+		t.Fatalf("GetTagsBatch: %v", err)
+	}
+
+	if len(tagsMap[place1.ID]) != 2 {
+		t.Errorf("place1 expected 2 tags, got %d", len(tagsMap[place1.ID]))
+	}
+	if len(tagsMap[place2.ID]) != 1 {
+		t.Errorf("place2 expected 1 tag, got %d", len(tagsMap[place2.ID]))
+	}
+	if len(tagsMap) != 2 {
+		t.Errorf("expected 2 place entries in map, got %d", len(tagsMap))
+	}
+}
+
+func TestPlaceRepo_GetTagsBatch_EmptyInput(t *testing.T) {
+	if testPool == nil {
+		t.Skip("DATABASE_URL_TEST not set")
+	}
+
+	repo := NewPlaceRepo(testPool)
+	result, err := repo.GetTagsBatch(context.Background(), nil)
+	if err != nil {
+		t.Fatalf("GetTagsBatch with nil: %v", err)
+	}
+	if len(result) != 0 {
+		t.Errorf("expected empty map, got %d entries", len(result))
+	}
+
+	result, err = repo.GetTagsBatch(context.Background(), []string{})
+	if err != nil {
+		t.Fatalf("GetTagsBatch with empty: %v", err)
+	}
+	if len(result) != 0 {
+		t.Errorf("expected empty map, got %d entries", len(result))
+	}
+}