axe/internal/repository/photo_test.go
2026-10-18 22:07:25 +03:00

293 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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