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) } }