generated from VLADIMIR/template
add zip
This commit is contained in:
@@ -2,6 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
@@ -9,6 +11,9 @@ import (
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/modules/roles"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
@@ -17,6 +22,7 @@ import (
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
@@ -571,6 +577,82 @@ func (s *server) DeleteScenarioPlace(ctx context.Context, req *proto.DeleteScena
|
||||
return &proto.DeleteScenarioPlaceRsp{}, nil
|
||||
}
|
||||
|
||||
// DownloadScenarioArchive отдаёт ZIP-архив сценария; ошибки — gRPC-статусами.
|
||||
func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
data, name, err := s.scenarioService.DownloadArchive(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, scenarios_repo.ErrScenarioNotFound):
|
||||
return nil, status.Errorf(codes.NotFound, "%v", err)
|
||||
case errors.Is(err, scenarios_service.ErrScenarioNotOwner):
|
||||
return nil, status.Errorf(codes.PermissionDenied, "%v", err)
|
||||
default:
|
||||
return nil, status.Errorf(codes.Internal, "%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
|
||||
// санитайзится от header injection; заголовок пробрасывается gateway.
|
||||
transliterated := sanitizeFilename(string_tools.Transliterate(name))
|
||||
if transliterated == "" {
|
||||
transliterated = "scenario"
|
||||
}
|
||||
filename := transliterated + ".zip"
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err)
|
||||
}
|
||||
|
||||
return &httpbody.HttpBody{
|
||||
Data: data,
|
||||
ContentType: "application/zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadScenarioArchive создаёт сценарий из ZIP-архива. Тело — сырые байты;
|
||||
// JSON base64 тоже поддерживается.
|
||||
func (s *server) UploadScenarioArchive(ctx context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if req == nil || len(req.Data) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "пустое тело запроса")
|
||||
}
|
||||
if len(req.Data) > scenario_archive.MaxArchiveSize() {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "архив больше максимального размера (%d байт)", scenario_archive.MaxArchiveSize())
|
||||
}
|
||||
|
||||
id, err := s.scenarioService.UploadArchive(ctx, req.Data, claims.UserID)
|
||||
if err != nil {
|
||||
return &proto.UploadScenarioArchiveRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
return &proto.UploadScenarioArchiveRsp{
|
||||
Id: int32(id),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sanitizeFilename — защита от инъекции заголовков в Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`"`, "_",
|
||||
"\r", "_",
|
||||
"\n", "_",
|
||||
)
|
||||
return replacer.Replace(name)
|
||||
}
|
||||
|
||||
func (s *server) AddGame(ctx context.Context, req *proto.AddGameReq) (*proto.AddGameRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
|
||||
@@ -11,4 +11,6 @@ type File struct {
|
||||
type IFileStorage interface {
|
||||
Put(ctx context.Context, file *File) error
|
||||
Get(ctx context.Context, filename string) (*File, error)
|
||||
Delete(ctx context.Context, filename string) error
|
||||
MimeType(filename string) string
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func NewRustFSStorage(
|
||||
}
|
||||
|
||||
func (s *storage) Put(ctx context.Context, file *File) error {
|
||||
mime := s.getMimeType(file.Name)
|
||||
mime := s.MimeType(file.Name)
|
||||
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime)
|
||||
}
|
||||
|
||||
@@ -59,11 +59,15 @@ func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
||||
return &File{
|
||||
Name: filename,
|
||||
Data: data,
|
||||
Mime: s.getMimeType(filename),
|
||||
Mime: s.MimeType(filename),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *storage) getMimeType(filename string) string {
|
||||
func (s *storage) Delete(ctx context.Context, filename string) error {
|
||||
return s.client.DeleteObject(ctx, s.bucket, filename)
|
||||
}
|
||||
|
||||
func (s *storage) MimeType(filename string) string {
|
||||
if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// Пакет scenario_archive — сборка и разбор ZIP-архива сценария.
|
||||
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
|
||||
// Все пути внутри архива относительные.
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// Имя файла с описанием сценария внутри архива.
|
||||
const FileName = "scenario.json"
|
||||
|
||||
// Текущая версия формата scenario.json.
|
||||
const Version = 1
|
||||
|
||||
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
||||
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
||||
var (
|
||||
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
|
||||
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
|
||||
maxEntries = 500 // число записей
|
||||
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
|
||||
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
||||
)
|
||||
|
||||
// MaxArchiveSize — максимальный размер входящего архива (для проверок
|
||||
// в хендлере и на HTTP-слое).
|
||||
func MaxArchiveSize() int {
|
||||
return maxArchiveSize
|
||||
}
|
||||
|
||||
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
|
||||
// scenarios.scenario в БД.
|
||||
type ScenarioJSON struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Story *storytelling.Story `json:"story"`
|
||||
}
|
||||
|
||||
// Bundle — результат разбора входящего архива.
|
||||
type Bundle struct {
|
||||
Scenario *ScenarioJSON
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
|
||||
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
|
||||
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
|
||||
func ImagePath(ref, domain string) (string, bool) {
|
||||
ref = strings.TrimPrefix(ref, domain)
|
||||
if ref == "" {
|
||||
return "", false
|
||||
}
|
||||
low := strings.ToLower(ref)
|
||||
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
|
||||
return "", false
|
||||
}
|
||||
return "images/" + path.Base(ref), true
|
||||
}
|
||||
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
||||
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
||||
func Pack(
|
||||
ctx context.Context,
|
||||
scenario *repos.Scenario,
|
||||
domain string,
|
||||
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||
) ([]byte, error) {
|
||||
if scenario == nil {
|
||||
return nil, errors.New("сценарий не задан")
|
||||
}
|
||||
|
||||
story := &storytelling.Story{}
|
||||
if scenario.Scenario != "" {
|
||||
if err := json.Unmarshal([]byte(scenario.Scenario), story); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать историю сценария: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
|
||||
archivePathOf := map[string]string{}
|
||||
addRef := func(ref string) error {
|
||||
if ref == "" {
|
||||
return nil
|
||||
}
|
||||
archivePath, ok := ImagePath(ref, domain)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, exists := archivePathOf[ref]; exists {
|
||||
return nil
|
||||
}
|
||||
for storageName, existingPath := range archivePathOf {
|
||||
if existingPath == archivePath {
|
||||
return fmt.Errorf("конфликт имён изображений в архиве: %q и %q", storageName, ref)
|
||||
}
|
||||
}
|
||||
archivePathOf[ref] = archivePath
|
||||
return nil
|
||||
}
|
||||
if scenario.Image != nil {
|
||||
if err := addRef(*scenario.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, place := range story.Places {
|
||||
if err := addRef(place.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
if err := addRef(application.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
|
||||
images := make(map[string][]byte, len(archivePathOf))
|
||||
var totalPacked int64
|
||||
for ref, archivePath := range archivePathOf {
|
||||
storageName := strings.TrimPrefix(ref, domain)
|
||||
data, err := getFile(ctx, storageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось получить изображение %q: %w", storageName, err)
|
||||
}
|
||||
totalPacked += int64(len(data))
|
||||
if totalPacked > int64(maxPackSize) {
|
||||
return nil, fmt.Errorf("суммарный объём изображений больше %d байт", maxPackSize)
|
||||
}
|
||||
images[archivePath] = data
|
||||
}
|
||||
|
||||
for _, place := range story.Places {
|
||||
place.Image = rewriteRef(place.Image, archivePathOf)
|
||||
for _, application := range place.Applications {
|
||||
application.Image = rewriteRef(application.Image, archivePathOf)
|
||||
}
|
||||
}
|
||||
|
||||
doc := ScenarioJSON{
|
||||
Version: Version,
|
||||
Name: scenario.Name,
|
||||
Description: strPtrValue(scenario.Description),
|
||||
Story: story,
|
||||
}
|
||||
if scenario.Image != nil {
|
||||
doc.Image = rewriteRef(*scenario.Image, archivePathOf)
|
||||
}
|
||||
docJSON, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось собрать %s: %w", FileName, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
if err := writeZipEntry(zw, FileName, docJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(images))
|
||||
for name := range images {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if err := writeZipEntry(zw, name, images[name]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("не удалось закрыть архив: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
|
||||
header := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
// Нулевое время — детерминизм архива между запусками.
|
||||
header.Modified = time.Time{}
|
||||
w, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось создать запись %q: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("не удалось записать %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rewriteRef(ref string, archivePathOf map[string]string) string {
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
if archivePath, ok := archivePathOf[ref]; ok {
|
||||
return archivePath
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func strPtrValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
func Unpack(data []byte) (*Bundle, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("архив пуст")
|
||||
}
|
||||
if len(data) > maxArchiveSize {
|
||||
return nil, fmt.Errorf("архив больше максимального размера (%d байт)", maxArchiveSize)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось открыть архив: %w", err)
|
||||
}
|
||||
if len(zr.File) > maxEntries {
|
||||
return nil, fmt.Errorf("в архиве больше %d записей", maxEntries)
|
||||
}
|
||||
|
||||
bundle := &Bundle{Files: make(map[string][]byte, len(zr.File))}
|
||||
var totalUnpacked int64
|
||||
var scenarioRaw []byte
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := validateEntryPath(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось открыть запись %q: %w", name, err)
|
||||
}
|
||||
content, err := readEntry(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("запись %q: %w", name, err)
|
||||
}
|
||||
|
||||
totalUnpacked += int64(len(content))
|
||||
if totalUnpacked > int64(maxUnpackedSize) {
|
||||
return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
|
||||
}
|
||||
|
||||
// Дубли записей отвергаем — «последний победил» портит данные.
|
||||
if name == FileName {
|
||||
if scenarioRaw != nil {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
|
||||
}
|
||||
scenarioRaw = content
|
||||
continue
|
||||
}
|
||||
if _, exists := bundle.Files[name]; exists {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %q", name)
|
||||
}
|
||||
bundle.Files[name] = content
|
||||
}
|
||||
|
||||
if scenarioRaw == nil {
|
||||
return nil, fmt.Errorf("в архиве нет файла %s", FileName)
|
||||
}
|
||||
|
||||
scenario := &ScenarioJSON{}
|
||||
if err := json.Unmarshal(scenarioRaw, scenario); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать %s: %w", FileName, err)
|
||||
}
|
||||
if scenario.Version != Version {
|
||||
return nil, fmt.Errorf("неподдерживаемая версия архива: %d", scenario.Version)
|
||||
}
|
||||
if scenario.Name == "" {
|
||||
return nil, errors.New("в сценарии не указано название")
|
||||
}
|
||||
if scenario.Story == nil {
|
||||
return nil, errors.New("в сценарии не указана история")
|
||||
}
|
||||
|
||||
bundle.Scenario = scenario
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
|
||||
// подделываются тривиально).
|
||||
func readEntry(r io.Reader) ([]byte, error) {
|
||||
lr := io.LimitReader(r, int64(maxEntrySize)+1)
|
||||
content, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось прочитать: %w", err)
|
||||
}
|
||||
if int64(len(content)) > int64(maxEntrySize) {
|
||||
return nil, fmt.Errorf("размер больше максимального (%d байт)", maxEntrySize)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// validateEntryPath отклоняет небезопасные пути записей.
|
||||
func validateEntryPath(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("пустое имя записи в архиве")
|
||||
}
|
||||
if strings.Contains(name, "\\") {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
}
|
||||
if clean := path.Clean(name); clean != name {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
// buildScenario собирает сценарий с историей и ссылками на изображения.
|
||||
func buildScenario() *repos.Scenario {
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
return &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Scenario: `{"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
|
||||
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
}
|
||||
|
||||
// memoryFiles — замена хранилища для Pack: имя → содержимое.
|
||||
type memoryFiles map[string][]byte
|
||||
|
||||
func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
|
||||
data, ok := m[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func newMemoryFiles() memoryFiles {
|
||||
return memoryFiles{
|
||||
"cover.png": []byte("cover-bytes"),
|
||||
"club.png": []byte("club-bytes"),
|
||||
"ticket.jpg": []byte("ticket-bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpackRoundTrip(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
data, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
bundle, err := Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
|
||||
if bundle.Scenario.Version != Version {
|
||||
t.Errorf("Version = %d, want %d", bundle.Scenario.Version, Version)
|
||||
}
|
||||
if bundle.Scenario.Name != "Ночной клуб" {
|
||||
t.Errorf("Name = %q, want %q", bundle.Scenario.Name, "Ночной клуб")
|
||||
}
|
||||
if bundle.Scenario.Description != "Детективная история" {
|
||||
t.Errorf("Description = %q", bundle.Scenario.Description)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
|
||||
}
|
||||
if bundle.Scenario.Story == nil || len(bundle.Scenario.Story.Places) != 2 {
|
||||
t.Fatalf("Story.Places = %+v, want 2 places", bundle.Scenario.Story)
|
||||
}
|
||||
|
||||
club := bundle.Scenario.Story.Places[0]
|
||||
if club.Image != "images/club.png" {
|
||||
t.Errorf("place image = %q, want %q", club.Image, "images/club.png")
|
||||
}
|
||||
if len(club.Applications) != 1 || club.Applications[0].Image != "images/ticket.jpg" {
|
||||
t.Errorf("application image = %+v, want images/ticket.jpg", club.Applications)
|
||||
}
|
||||
|
||||
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
|
||||
if _, ok := bundle.Files[want]; !ok {
|
||||
t.Errorf("в архиве нет файла %q", want)
|
||||
}
|
||||
}
|
||||
// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
|
||||
if len(bundle.Files) != 3 {
|
||||
t.Errorf("Files = %v, want 3 files", bundle.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Внешний",
|
||||
Description: &description,
|
||||
Image: ptr("https://example.com/cover.png"),
|
||||
Scenario: `{"places":[
|
||||
{"code":"p","name":"P","text":"t","image":"http://other.example/x.png"}
|
||||
]}`,
|
||||
}
|
||||
|
||||
files := newMemoryFiles()
|
||||
var fetched []string
|
||||
getFile := func(ctx context.Context, name string) ([]byte, error) {
|
||||
fetched = append(fetched, name)
|
||||
return files.get(ctx, name)
|
||||
}
|
||||
|
||||
data, err := Pack(context.Background(), scenario, testDomain, getFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
if len(fetched) != 0 {
|
||||
t.Errorf("getFile вызван для внешних URL: %v", fetched)
|
||||
}
|
||||
|
||||
bundle, err := Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Image != "https://example.com/cover.png" {
|
||||
t.Errorf("Image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Image)
|
||||
}
|
||||
if bundle.Scenario.Story.Places[0].Image != "http://other.example/x.png" {
|
||||
t.Errorf("place image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Story.Places[0].Image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackLegacyFullURLImage(t *testing.T) {
|
||||
// Legacy-данные: полный URL хранилища срезается до базового имени файла.
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Legacy",
|
||||
Description: &description,
|
||||
Image: ptr(testDomain + "cover.png"),
|
||||
Scenario: `{"places":[]}`,
|
||||
}
|
||||
data, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
bundle, err := Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackMissingImage(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
delete(files, "ticket.jpg")
|
||||
|
||||
_, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ticket.jpg") {
|
||||
t.Errorf("ошибка должна содержать имя файла: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackDeterministic(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
first, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Error("архив недетерминирован: повторный Pack дал другие байты")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackNilScenario(t *testing.T) {
|
||||
_, err := Pack(context.Background(), nil, testDomain, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Pack(nil) должен вернуть ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackImageNameCollision(t *testing.T) {
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Конфликт",
|
||||
Description: &description,
|
||||
Scenario: `{"places":[
|
||||
{"code":"a","name":"A","text":"t","image":"a.png"},
|
||||
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
|
||||
]}`,
|
||||
}
|
||||
_, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
|
||||
}
|
||||
}
|
||||
|
||||
// writeZip собирает архив с заданными записями (для негативных тестов Unpack).
|
||||
func writeZip(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q): %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// validScenarioJSON — минимальный корректный scenario.json.
|
||||
func validScenarioJSON() []byte {
|
||||
b, _ := json.Marshal(ScenarioJSON{
|
||||
Version: Version,
|
||||
Name: "Тест",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
func TestUnpackRejectsBadPaths(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"../evil",
|
||||
"a/../../evil",
|
||||
"/abs",
|
||||
"dir\\evil",
|
||||
"a/../b",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/x.png": []byte("x"),
|
||||
name: []byte("evil"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatalf("Unpack должен отклонить путь %q", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEmpty(t *testing.T) {
|
||||
if _, err := Unpack(nil); err == nil {
|
||||
t.Fatal("Unpack(nil) должен вернуть ошибку")
|
||||
}
|
||||
if _, err := Unpack([]byte{}); err == nil {
|
||||
t.Fatal("Unpack(пусто) должен вернуть ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackNotAZip(t *testing.T) {
|
||||
if _, err := Unpack([]byte("this is not a zip")); err == nil {
|
||||
t.Fatal("Unpack должен отклонить не-zip данные")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackMissingScenarioJSON(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackInvalidScenarioJSON(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить битый scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackBadVersion(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить неизвестную версию")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEmptyName(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать название")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackNilStory(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать story")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEntrySizeLimit(t *testing.T) {
|
||||
prev := maxEntrySize
|
||||
maxEntrySize = 4
|
||||
defer func() { maxEntrySize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/big.png": bytes.Repeat([]byte("x"), 8),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackTotalSizeLimit(t *testing.T) {
|
||||
prev := maxUnpackedSize
|
||||
maxUnpackedSize = 6
|
||||
defer func() { maxUnpackedSize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/a.png": []byte("aaa"),
|
||||
"images/b.png": []byte("bbb"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackArchiveSizeLimit(t *testing.T) {
|
||||
prev := maxArchiveSize
|
||||
maxArchiveSize = 4
|
||||
defer func() { maxArchiveSize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEntriesLimit(t *testing.T) {
|
||||
prev := maxEntries
|
||||
maxEntries = 2
|
||||
defer func() { maxEntries = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/a.png": []byte("a"),
|
||||
"images/b.png": []byte("b"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := 0; i < 2; i++ {
|
||||
w, err := zw.Create(FileName)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(validScenarioJSON()); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackRejectsDuplicateFiles(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := 0; i < 3; i++ {
|
||||
name := FileName
|
||||
content := validScenarioJSON()
|
||||
switch i {
|
||||
case 1:
|
||||
name = "images/x.png"
|
||||
content = []byte("x")
|
||||
case 2:
|
||||
// Дубль записи images/x.png.
|
||||
name = "images/x.png"
|
||||
content = []byte("y")
|
||||
}
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли файлов")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackTotalSizeLimit(t *testing.T) {
|
||||
prev := maxPackSize
|
||||
maxPackSize = 8
|
||||
defer func() { maxPackSize = prev }()
|
||||
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Большой",
|
||||
Description: &description,
|
||||
Scenario: `{"places":[
|
||||
{"code":"a","name":"A","text":"t","image":"a.png"},
|
||||
{"code":"b","name":"B","text":"t","image":"b.png"}
|
||||
]}`,
|
||||
}
|
||||
files := memoryFiles{
|
||||
"a.png": bytes.Repeat([]byte("x"), 6),
|
||||
"b.png": bytes.Repeat([]byte("y"), 6),
|
||||
}
|
||||
if _, err := Pack(context.Background(), scenario, testDomain, files.get); err == nil {
|
||||
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
ref string
|
||||
domain string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"cover.png", "", "images/cover.png", true},
|
||||
{"dir/cover.png", "", "images/cover.png", true},
|
||||
{testDomain + "cover.png", testDomain, "images/cover.png", true},
|
||||
{"", "", "", false},
|
||||
{"http://example.com/x.png", "", "", false},
|
||||
{"https://example.com/x.png", testDomain, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := ImagePath(tc.ref, tc.domain)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Errorf("ImagePath(%q, %q) = (%q, %v), want (%q, %v)",
|
||||
tc.ref, tc.domain, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
@@ -2,28 +2,51 @@ package scenarios_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scenariosRepository — граница доступа к данным сценариев, реализуемая
|
||||
// *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД).
|
||||
type scenariosRepository interface {
|
||||
AddScenario(ctx context.Context, name string, authorId int) (int, error)
|
||||
GetScenariosByAuthorID(ctx context.Context, authorId int) ([]*repos.Scenario, error)
|
||||
GetScenariosByStatus(ctx context.Context, status string) ([]*repos.Scenario, error)
|
||||
GetScenarioByID(ctx context.Context, id int) (*repos.Scenario, error)
|
||||
UpdateScenarioByID(ctx context.Context, id int, name string, description string, image string) error
|
||||
UpdateScenarioStatusByID(ctx context.Context, id int, status string, allowPublished bool) (bool, error)
|
||||
DeleteScenarioByID(ctx context.Context, id int, allowPublished bool) (bool, error)
|
||||
GetStoryByScenarioID(ctx context.Context, id int) (string, error)
|
||||
UpdateStoryByScenarioID(ctx context.Context, id int, story string) error
|
||||
}
|
||||
|
||||
type ScenarioService struct {
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo
|
||||
scenariosRepo scenariosRepository
|
||||
cleaner cleaner.ICleaner
|
||||
fileStorage file_storage.IFileStorage
|
||||
domain string
|
||||
}
|
||||
|
||||
func NewScenarioService(
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo,
|
||||
scenariosRepo scenariosRepository,
|
||||
cleaner cleaner.ICleaner,
|
||||
domain string,
|
||||
fileStorage file_storage.IFileStorage,
|
||||
) *ScenarioService {
|
||||
return &ScenarioService{
|
||||
scenariosRepo: scenariosRepo,
|
||||
cleaner: cleaner,
|
||||
fileStorage: fileStorage,
|
||||
domain: domain,
|
||||
}
|
||||
}
|
||||
@@ -260,14 +283,160 @@ func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.S
|
||||
return mapStory(storyString, s.domain)
|
||||
}
|
||||
|
||||
// DownloadArchive собирает ZIP-архив сценария
|
||||
func (s *ScenarioService) DownloadArchive(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) ([]byte, string, error) {
|
||||
scenario, err := s.getScenarioForChange(ctx, id, actorId, isAdmin)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := scenario_archive.Pack(
|
||||
ctx,
|
||||
scenario,
|
||||
s.domain,
|
||||
func(ctx context.Context, name string) ([]byte, error) {
|
||||
file, err := s.fileStorage.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Data, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, scenario.Name, nil
|
||||
}
|
||||
|
||||
// UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются
|
||||
// под новыми случайными именами (не перетирают чужие файлы), ссылки
|
||||
// переписываются. Новый сценарий всегда draft — статус из архива игнорируется.
|
||||
func (s *ScenarioService) UploadArchive(
|
||||
ctx context.Context,
|
||||
data []byte,
|
||||
authorId int,
|
||||
) (int, error) {
|
||||
bundle, err := scenario_archive.Unpack(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
scenario := bundle.Scenario
|
||||
|
||||
// При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов.
|
||||
uploaded := map[string]string{}
|
||||
ok := false
|
||||
defer func() {
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
for _, name := range uploaded {
|
||||
_ = s.fileStorage.Delete(ctx, name)
|
||||
}
|
||||
}()
|
||||
|
||||
rewrite := func(ref string) (string, error) {
|
||||
// Берём только ссылки images/... из архива; остальное — как есть.
|
||||
archiveRef := strings.TrimPrefix(ref, s.domain)
|
||||
if !strings.HasPrefix(archiveRef, "images/") {
|
||||
return ref, nil
|
||||
}
|
||||
if name, exists := uploaded[archiveRef]; exists {
|
||||
return name, nil
|
||||
}
|
||||
content, exists := bundle.Files[archiveRef]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
|
||||
}
|
||||
name, err := newStorageName(archiveRef)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.fileStorage.Put(ctx, &file_storage.File{
|
||||
Name: name,
|
||||
Data: content,
|
||||
Mime: s.fileStorage.MimeType(name),
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("не удалось сохранить изображение %q: %w", archiveRef, err)
|
||||
}
|
||||
uploaded[archiveRef] = name
|
||||
return name, nil
|
||||
}
|
||||
|
||||
image, err := rewrite(scenario.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, place := range scenario.Story.Places {
|
||||
place.Image, err = rewrite(place.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
application.Image, err = rewrite(application.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
storyJSON, err := normalizeStory(scenario.Story)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
id, err := s.scenariosRepo.AddScenario(ctx, scenario.Name, authorId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.scenariosRepo.UpdateScenarioByID(ctx, id, scenario.Name, scenario.Description, image); err != nil {
|
||||
// Зачищаем строку сценария (best-effort), как и файлы.
|
||||
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
|
||||
return 0, err
|
||||
}
|
||||
if err := s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyJSON); err != nil {
|
||||
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
|
||||
return 0, err
|
||||
}
|
||||
ok = true
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// newStorageName — случайное имя в хранилище (hex + расширение): исключает
|
||||
// коллизии с файлами других сценариев в общем бакете.
|
||||
func newStorageName(archivePath string) (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err)
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(archivePath))
|
||||
return hex.EncodeToString(b[:]) + ext, nil
|
||||
}
|
||||
|
||||
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
|
||||
mapCodes := map[string]struct{}{}
|
||||
for _, place := range story.Places {
|
||||
mapCodes[place.Code] = struct{}{}
|
||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
||||
}
|
||||
if len(mapCodes) != len(story.Places) {
|
||||
return errors.New("Такой код точки уже существует")
|
||||
storyString, err := normalizeStory(story)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
}
|
||||
|
||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды
|
||||
// и возвращает JSON истории. Общая для редактирования и импорта.
|
||||
func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
codes := map[string]struct{}{}
|
||||
for _, place := range story.Places {
|
||||
codes[place.Code] = struct{}{}
|
||||
}
|
||||
if len(codes) != len(story.Places) {
|
||||
return "", errors.New("Такой код точки уже существует")
|
||||
}
|
||||
|
||||
cleanPlaces := make([]*storytelling.Place, 0, len(story.Places))
|
||||
@@ -279,10 +448,5 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
|
||||
}
|
||||
story.Places = cleanPlaces
|
||||
|
||||
storyString, err := convertStory(story)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
return convertStory(story)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
package scenarios_service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage.
|
||||
type fakeStorage struct {
|
||||
mu sync.Mutex
|
||||
files map[string]*file_storage.File
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeStorage() *fakeStorage {
|
||||
return &fakeStorage{files: map[string]*file_storage.File{}}
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
s.files[f.Name] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
f, ok := s.files[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Delete(_ context.Context, name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.files, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) MimeType(filename string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *fakeStorage) names() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res := make([]string, 0, len(s.files))
|
||||
for name := range s.files {
|
||||
res = append(res, name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// fakeScenariosRepo — минимальная in-memory реализация scenariosRepository.
|
||||
type fakeScenariosRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[int]*repos.Scenario
|
||||
nextID int
|
||||
addErr error
|
||||
updateErr error
|
||||
storyErr error
|
||||
}
|
||||
|
||||
func newFakeScenariosRepo() *fakeScenariosRepo {
|
||||
return &fakeScenariosRepo{
|
||||
byID: map[int]*repos.Scenario{},
|
||||
nextID: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) AddScenario(_ context.Context, name string, authorId int) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.addErr != nil {
|
||||
return 0, r.addErr
|
||||
}
|
||||
r.nextID++
|
||||
description := ""
|
||||
r.byID[r.nextID] = &repos.Scenario{
|
||||
ID: r.nextID,
|
||||
Name: name,
|
||||
Description: &description,
|
||||
Author: &repos.User{ID: authorId},
|
||||
Status: "draft",
|
||||
}
|
||||
return r.nextID, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenariosByAuthorID(_ context.Context, authorId int) ([]*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var res []*repos.Scenario
|
||||
for _, s := range r.byID {
|
||||
if s.Author != nil && s.Author.ID == authorId {
|
||||
res = append(res, s)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenariosByStatus(_ context.Context, status string) ([]*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var res []*repos.Scenario
|
||||
for _, s := range r.byID {
|
||||
if s.Status == status {
|
||||
res = append(res, s)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenarioByID(_ context.Context, id int) (*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateScenarioByID(_ context.Context, id int, name string, description string, image string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.updateErr != nil {
|
||||
return r.updateErr
|
||||
}
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
s.Name = name
|
||||
s.Description = &description
|
||||
s.Image = &image
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateScenarioStatusByID(_ context.Context, id int, status string, allowPublished bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return false, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
if !allowPublished && s.Status == "public" {
|
||||
return false, nil
|
||||
}
|
||||
s.Status = status
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) DeleteScenarioByID(_ context.Context, id int, allowPublished bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return false, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
if !allowPublished && s.Status == "public" {
|
||||
return false, nil
|
||||
}
|
||||
s.IsDeleted = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetStoryByScenarioID(_ context.Context, id int) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.storyErr != nil {
|
||||
return "", r.storyErr
|
||||
}
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return "", scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
return s.Scenario, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateStoryByScenarioID(_ context.Context, id int, story string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
s.Scenario = story
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) get(id int) *repos.Scenario {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.byID[id]
|
||||
}
|
||||
|
||||
func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioService {
|
||||
if repo == nil {
|
||||
repo = newFakeScenariosRepo()
|
||||
}
|
||||
if storage == nil {
|
||||
storage = newFakeStorage()
|
||||
}
|
||||
return NewScenarioService(repo, nil, testDomain, storage)
|
||||
}
|
||||
|
||||
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
|
||||
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
|
||||
t.Helper()
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Author: &repos.User{ID: 7},
|
||||
Status: "draft",
|
||||
Scenario: `{"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
|
||||
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
repo.byID[scenario.ID] = scenario
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "ticket.jpg", Data: []byte("ticket-bytes")})
|
||||
return scenario
|
||||
}
|
||||
|
||||
func TestUploadArchive(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
srcStorage := newFakeStorage()
|
||||
seedScenario(t, repo, srcStorage)
|
||||
|
||||
// Собираем архив как при скачивании (Pack через storage).
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
|
||||
// Импортируем в «чистые» repo и storage.
|
||||
importRepo := newFakeScenariosRepo()
|
||||
importStorage := newFakeStorage()
|
||||
importSvc := newTestService(importRepo, importStorage)
|
||||
|
||||
id, err := importSvc.UploadArchive(context.Background(), archive, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
if id != 1 {
|
||||
t.Errorf("id = %d, want 1", id)
|
||||
}
|
||||
|
||||
saved := importRepo.get(id)
|
||||
if saved == nil {
|
||||
t.Fatal("сценарий не создан")
|
||||
}
|
||||
if saved.Name != "Ночной клуб" {
|
||||
t.Errorf("Name = %q", saved.Name)
|
||||
}
|
||||
if saved.Description == nil || *saved.Description != "Детективная история" {
|
||||
t.Errorf("Description = %v", saved.Description)
|
||||
}
|
||||
if saved.Status != "draft" {
|
||||
t.Errorf("Status = %q, want draft", saved.Status)
|
||||
}
|
||||
if saved.Author == nil || saved.Author.ID != 42 {
|
||||
t.Errorf("Author = %+v, want id 42", saved.Author)
|
||||
}
|
||||
|
||||
// Ссылки переписаны на новые имена без префикса images/.
|
||||
if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" {
|
||||
t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image)
|
||||
}
|
||||
story := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
|
||||
t.Fatalf("story не разобрался: %v", err)
|
||||
}
|
||||
if len(story.Places) != 2 {
|
||||
t.Fatalf("places = %d, want 2", len(story.Places))
|
||||
}
|
||||
club := story.Places[0]
|
||||
if club.Image == "" || strings.HasPrefix(club.Image, "images/") || club.Image == "club.png" {
|
||||
t.Errorf("place image = %q, want новое имя", club.Image)
|
||||
}
|
||||
if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") {
|
||||
t.Errorf("application image = %+v, want новое имя", club.Applications)
|
||||
}
|
||||
// Одна и та же картинка в двух точках — одно новое имя.
|
||||
if story.Places[1].Image != club.Image {
|
||||
t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image)
|
||||
}
|
||||
|
||||
// Файлы загружены в storage под новыми именами с правильным содержимым.
|
||||
files := importStorage.names()
|
||||
if len(files) != 3 {
|
||||
t.Fatalf("storage files = %v, want 3", files)
|
||||
}
|
||||
contents := map[string]bool{}
|
||||
for _, name := range files {
|
||||
file, err := importStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
contents[string(file.Data)] = true
|
||||
}
|
||||
for _, want := range []string{"cover-bytes", "club-bytes", "ticket-bytes"} {
|
||||
if !contents[want] {
|
||||
t.Errorf("в storage нет содержимого %q, есть: %v", want, contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveMissingImage(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Архив ссылается на images/missing.png, файла в архиве нет.
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/missing.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку при отсутствующем файле")
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("storage не должен содержать файлов: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveExternalURLKept(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "https://example.com/cover.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P", Image: "http://other.example/x.png"}}},
|
||||
}),
|
||||
})
|
||||
|
||||
id, err := svc.UploadArchive(context.Background(), archive, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
saved := repo.get(id)
|
||||
if saved.Image == nil || *saved.Image != "https://example.com/cover.png" {
|
||||
t.Errorf("Image = %v, внешний URL должен остаться без изменений", saved.Image)
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("storage не должен содержать файлов: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
repo.addErr = errors.New("db is down")
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/x.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
"images/x.png": []byte("x"),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Загруженные файлы удалены (best-effort), orphan-объектов не осталось.
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
repo.updateErr = errors.New("db is down")
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/x.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
"images/x.png": []byte("x"),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены.
|
||||
if got := repo.get(1); got == nil || !got.IsDeleted {
|
||||
t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got)
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("после частичного сбоя storage должен быть пуст: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchive(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
data, name, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
if name != "Ночной клуб" {
|
||||
t.Errorf("name = %q", name)
|
||||
}
|
||||
|
||||
bundle, err := scenario_archive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Name != "Ночной клуб" {
|
||||
t.Errorf("scenario name = %q", bundle.Scenario.Name)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("scenario image = %q", bundle.Scenario.Image)
|
||||
}
|
||||
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
|
||||
if _, ok := bundle.Files[want]; !ok {
|
||||
t.Errorf("в архиве нет %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveNotOwner(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
// Админ может скачать чужой сценарий.
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil {
|
||||
t.Errorf("админ: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveNotFound(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 404, 1, true); !errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveMissingImage(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
_ = storage.Delete(context.Background(), "ticket.jpg")
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 7, false); err == nil {
|
||||
t.Fatal("DownloadArchive должен вернуть ошибку при недоступном изображении")
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
|
||||
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q): %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user