axe/internal/repository/photo_test.go

293 lines
11 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
package repository
import (
"errors"
"fmt"
"testing"
"github.com/google/uuid"
"building-mgmt/internal/models"
)
// photoMeta — каркас метаданных для теста: каждый кейс меняет
// только то, что проверяет.
func photoMeta(ticketID int64, uploaderID string, n int, maxPer int) PhotoMeta {
return PhotoMeta{
TicketID: ticketID,
UploaderID: uploaderID,
ObjectKey: fmt.Sprintf("tickets/%d/%s.jpg", ticketID, uuid.NewString()),
ContentType: "image/jpeg",
SizeBytes: int64(100 + n),
OriginalName: fmt.Sprintf("photo-%d.jpg", n),
MaxPerTicket: maxPer,
}
}
// TestPhotoReportLifecycle — сквозной сценарий фотоотчёта: запрет до
// старта работы, загрузка мастером, лимит, журнал, удаление и финальная
// стадия заявки. Все правила — SQL, поэтому проверяем именно их.
func TestPhotoReportLifecycle(t *testing.T) {
q, ctx := newTestQuerier(t)
tickets := NewTicketRepo(q)
photos := NewPhotoRepo(q)
client := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
otherWorker := createProfile(t, ctx, q, "worker")
admin := createProfile(t, ctx, q, "admin")
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
ticket := &models.Ticket{
ClientID: client.ID,
ProjectID: projectID,
ObjectID: objectID,
Address: address,
Category: "electric",
Description: "не горит лампочка",
Priority: "normal",
}
if err := tickets.Create(ctx, ticket); err != nil {
t.Fatalf("create ticket: %v", err)
}
// --- 1. Заявка в new: загрузка запрещена даже админу ---
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, admin.ID, 0, 5), "admin"); !errors.Is(err, ErrConflict) {
t.Errorf("upload in status new: err = %v, want ErrConflict", err)
}
if err := tickets.Assign(ctx, ticket.ID, worker.ID, client.ID); err != nil {
t.Fatalf("assign: %v", err)
}
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
t.Fatalf("start: %v", err)
}
// --- 2. Несуществующая заявка — 404, а не конфликт ---
if _, err := photos.AddPhoto(ctx, photoMeta(999999999, worker.ID, 0, 5), "worker"); !errors.Is(err, ErrNotFound) {
t.Errorf("upload to missing ticket: err = %v, want ErrNotFound", err)
}
// --- 3. Мастер проекта загружает в работу: строка + событие ---
m1 := photoMeta(ticket.ID, worker.ID, 1, 3)
p1, err := photos.AddPhoto(ctx, m1, "worker")
if err != nil {
t.Fatalf("upload by worker: %v", err)
}
if p1.ID == 0 || p1.TicketID != ticket.ID {
t.Errorf("photo = %+v, want id>0 and ticket %d", p1, ticket.ID)
}
if p1.ObjectKey != m1.ObjectKey || p1.ContentType != "image/jpeg" {
t.Errorf("photo meta mismatch: %+v vs %+v", p1, m1)
}
if p1.UploaderID != worker.ID || p1.UploaderName != worker.Name {
t.Errorf("uploader = %q/%q, want %q/%q", p1.UploaderID, p1.UploaderName, worker.ID, worker.Name)
}
events, _, err := tickets.ListEvents(ctx, ticket.ID, Page{})
if err != nil {
t.Fatalf("list events: %v", err)
}
var added *models.TicketEvent
for i := range events {
if events[i].Action == "photo_added" {
added = &events[i]
}
}
if added == nil {
t.Fatal("photo_added event not found")
}
if added.ActorID != worker.ID || added.FromStatus != "in_progress" || added.ToStatus != "in_progress" {
t.Errorf("event = %+v, want actor=%s in_progress→in_progress", added, worker.ID)
}
if added.Comment != m1.OriginalName {
t.Errorf("event comment = %q, want %q", added.Comment, m1.OriginalName)
}
// --- 4. Чужой мастер (не в проекте заявки) — конфликт ---
mo := photoMeta(ticket.ID, otherWorker.ID, 90, 3)
if _, err := photos.AddPhoto(ctx, mo, "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
t.Errorf("upload by stranger: err = %v, want plain ErrConflict", err)
}
// --- 5. Лимит файлов: до потолка — ок, дальше — ErrTooManyPhotos ---
m2 := photoMeta(ticket.ID, worker.ID, 2, 3)
p2, err := photos.AddPhoto(ctx, m2, "worker")
if err != nil {
t.Fatalf("second upload: %v", err)
}
ma := photoMeta(ticket.ID, admin.ID, 3, 3)
pa, err := photos.AddPhoto(ctx, ma, "admin")
if err != nil {
t.Fatalf("admin upload: %v", err)
}
// Четвёртый упирается в лимит: и у мастера, и у админа.
mw := photoMeta(ticket.ID, worker.ID, 4, 3)
if _, err := photos.AddPhoto(ctx, mw, "worker"); !errors.Is(err, ErrTooManyPhotos) {
t.Errorf("upload over limit: err = %v, want ErrTooManyPhotos", err)
}
ml := photoMeta(ticket.ID, admin.ID, 5, 3)
if _, err := photos.AddPhoto(ctx, ml, "admin"); !errors.Is(err, ErrTooManyPhotos) {
t.Errorf("admin upload over limit: err = %v, want ErrTooManyPhotos", err)
}
if n, err := photos.CountPhotos(ctx, ticket.ID); err != nil || n != 3 {
t.Errorf("count = %d, %v; want 3", n, err)
}
// --- 6. Список: хронология и имена ---
list, total, err := photos.ListPhotos(ctx, ticket.ID, Page{})
if err != nil {
t.Fatalf("list photos: %v", err)
}
if total != 3 || len(list) != 3 {
t.Fatalf("list = %d photos, total %d; want 3/3", len(list), total)
}
for i := 1; i < len(list); i++ {
if list[i-1].ID >= list[i].ID {
t.Errorf("photos not in id ASC order: %d then %d", list[i-1].ID, list[i].ID)
}
}
if list[0].UploaderName == "" {
t.Error("uploader name is empty")
}
// --- 7. Удаление: своё — можно, повторно — 404, чужая заявка — 404 ---
key, err := photos.DeletePhoto(ctx, ticket.ID, p1.ID, worker.ID, "worker")
if err != nil {
t.Fatalf("delete own photo: %v", err)
}
if key != m1.ObjectKey {
t.Errorf("deleted key = %q, want %q", key, m1.ObjectKey)
}
if _, err := photos.DeletePhoto(ctx, ticket.ID, p1.ID, worker.ID, "worker"); !errors.Is(err, ErrNotFound) {
t.Errorf("second delete: err = %v, want ErrNotFound", err)
}
if _, err := photos.DeletePhoto(ctx, 999999999, pa.ID, admin.ID, "admin"); !errors.Is(err, ErrNotFound) {
t.Errorf("cross-ticket delete: err = %v, want ErrNotFound", err)
}
// Чужое фото (мастер чужое не удаляет): осталось фото админа.
if _, err := photos.DeletePhoto(ctx, ticket.ID, pa.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
t.Errorf("delete admin photo by worker: err = %v, want ErrPhotoNotRemovable", err)
}
// --- 8. Работа выполнена (done): отчёт заморожен ---
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
t.Fatalf("done: %v", err)
}
// Ни мастер, ни админ больше не загружают: лимит тут не причина
// (maxPer 10 > 3 уже загруженных) — именно статус решает.
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 6, 10), "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
t.Errorf("worker upload in done: err = %v, want plain ErrConflict", err)
}
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, admin.ID, 7, 10), "admin"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
t.Errorf("admin upload in done: err = %v, want plain ErrConflict", err)
}
// Мастер в done не удаляет даже своё — админ может (роль выше статуса).
if _, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
t.Errorf("worker delete own photo in done: err = %v, want ErrPhotoNotRemovable", err)
}
if _, err := photos.DeletePhoto(ctx, ticket.ID, pa.ID, admin.ID, "admin"); err != nil {
t.Errorf("admin delete in done: %v", err)
}
// --- 9. Заявка закрыта: мастеру нельзя, админу можно ---
if err := tickets.Confirm(ctx, ticket.ID, client.ID); err != nil {
t.Fatalf("confirm: %v", err)
}
if _, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
t.Errorf("delete on closed ticket: err = %v, want ErrPhotoNotRemovable", err)
}
closedKey, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, admin.ID, "admin")
if err != nil {
t.Fatalf("admin delete on closed ticket: %v", err)
}
if closedKey == "" {
t.Error("admin delete returned empty key")
}
// Журнал: у фото статус не менялся — from = to у каждого
// photo_added/photo_removed, на какой бы статус ни попало событие.
events, _, err = tickets.ListEvents(ctx, ticket.ID, Page{})
if err != nil {
t.Fatalf("list events: %v", err)
}
addedN, removedN := 0, 0
for i := range events {
switch events[i].Action {
case "photo_added":
addedN++
case "photo_removed":
removedN++
default:
continue
}
if events[i].FromStatus != events[i].ToStatus {
t.Errorf("%s status = %q→%q, want equal",
events[i].Action, events[i].FromStatus, events[i].ToStatus)
}
}
if addedN != 3 {
t.Errorf("photo_added events = %d, want 3", addedN)
}
if removedN != 3 {
t.Errorf("photo_removed events = %d, want 3", removedN)
}
}
// TestPhotoReportAfterReject — отклонённая заявка (rejected) фото не
// принимает, но повторный старт открывает отчёт снова: заморозка
// действует ровно в статусах вне in_progress.
func TestPhotoReportAfterReject(t *testing.T) {
q, ctx := newTestQuerier(t)
tickets := NewTicketRepo(q)
photos := NewPhotoRepo(q)
client := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
admin := createProfile(t, ctx, q, "admin")
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
ticket := &models.Ticket{
ClientID: client.ID,
ProjectID: projectID,
ObjectID: objectID,
Address: address,
Category: "electric",
Description: "течёт кран",
Priority: "normal",
}
if err := tickets.Create(ctx, ticket); err != nil {
t.Fatalf("create ticket: %v", err)
}
if err := tickets.Assign(ctx, ticket.ID, worker.ID, client.ID); err != nil {
t.Fatalf("assign: %v", err)
}
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
t.Fatalf("start: %v", err)
}
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
t.Fatalf("done: %v", err)
}
if err := tickets.Reject(ctx, ticket.ID, client.ID, "не тот цвет"); err != nil {
t.Fatalf("reject: %v", err)
}
// В rejected загрузка закрыта (maxPer 10 — причина именно статус).
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 1, 10), "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
t.Errorf("upload in rejected: err = %v, want plain ErrConflict", err)
}
// Повторный старт (rejected → in_progress) открывает отчёт.
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
t.Fatalf("restart: %v", err)
}
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 2, 10), "worker"); err != nil {
t.Errorf("upload after rework start: %v", err)
}
}