generated from VLADIMIR/template
add load applications
This commit is contained in:
@@ -2,6 +2,9 @@ package scenarios_service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
@@ -73,16 +76,34 @@ func mapStory(o string, domain string) (*storytelling.Story, error) {
|
||||
return nil, err
|
||||
}
|
||||
for _, place := range res.Places {
|
||||
if place.Image != "" {
|
||||
place.Image = domain + place.Image
|
||||
place.Image = prefixDomain(place.Image, domain)
|
||||
for _, application := range place.Applications {
|
||||
application.Image = prefixDomain(application.Image, domain)
|
||||
if application.FileType == "" {
|
||||
application.FileType = file_storage.FileType(application.Image)
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.Introduction != nil && res.Introduction.Audio != "" {
|
||||
res.Introduction.Audio = domain + res.Introduction.Audio
|
||||
if res.Introduction != nil {
|
||||
res.Introduction.Audio = prefixDomain(res.Introduction.Audio, domain)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища:
|
||||
// пустые ссылки и внешние http/https URL не трогаются, повторный префикс
|
||||
// исключён. Единое правило для картинок точек, аудио введения и улик.
|
||||
func prefixDomain(ref, domain string) string {
|
||||
if ref == "" || strings.HasPrefix(ref, domain) {
|
||||
return ref
|
||||
}
|
||||
low := strings.ToLower(ref)
|
||||
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
|
||||
return ref
|
||||
}
|
||||
return domain + ref
|
||||
}
|
||||
|
||||
func convertStory(o *storytelling.Story) (string, error) {
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package scenarios_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// TestMapStoryPrefixesEvidence — улики получают полный URL (домен + имя),
|
||||
// как картинки точек и аудио введения; префиксация идемпотентна, внешние
|
||||
// URL не трогаются; file_type деривируется при пустом значении.
|
||||
func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
storyJSON := `{
|
||||
"introduction": {"text": "Вступление", "audio": "intro.mp3"},
|
||||
"places": [{
|
||||
"code": "p1",
|
||||
"name": "Место",
|
||||
"text": "Текст",
|
||||
"image": "place.png",
|
||||
"applications": [
|
||||
{"name": "Договор", "image": "contract.pdf"},
|
||||
{"name": "Фото", "image": "https://external.example.com/pic.jpg", "file_type": "image"}
|
||||
]
|
||||
}]
|
||||
}`
|
||||
|
||||
story, err := mapStory(storyJSON, testDomain)
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory: %v", err)
|
||||
}
|
||||
|
||||
if story.Introduction.Audio != testDomain+"intro.mp3" {
|
||||
t.Errorf("Audio = %q, want %q", story.Introduction.Audio, testDomain+"intro.mp3")
|
||||
}
|
||||
place := story.Places[0]
|
||||
if place.Image != testDomain+"place.png" {
|
||||
t.Errorf("place.Image = %q, want %q", place.Image, testDomain+"place.png")
|
||||
}
|
||||
|
||||
// Улика из хранилища: полный URL + деривированный file_type.
|
||||
app := place.Applications[0]
|
||||
if app.Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("application.Image = %q, want %q", app.Image, testDomain+"contract.pdf")
|
||||
}
|
||||
if app.FileType != "pdf" {
|
||||
t.Errorf("application.FileType = %q, want pdf", app.FileType)
|
||||
}
|
||||
|
||||
// Внешний URL не префиксуется, file_type сохраняется.
|
||||
external := place.Applications[1]
|
||||
if external.Image != "https://external.example.com/pic.jpg" {
|
||||
t.Errorf("external.Image = %q, want без изменений", external.Image)
|
||||
}
|
||||
if external.FileType != "image" {
|
||||
t.Errorf("external.FileType = %q, want image", external.FileType)
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный прогон не даёт двойного префикса.
|
||||
jsonOut, err := convertStory(story)
|
||||
if err != nil {
|
||||
t.Fatalf("convertStory: %v", err)
|
||||
}
|
||||
again, err := mapStory(jsonOut, testDomain)
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory повторно: %v", err)
|
||||
}
|
||||
if again.Places[0].Applications[0].Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("повторный mapStory: Image = %q, want %q", again.Places[0].Applications[0].Image, testDomain+"contract.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeFileType — невалидный file_type заменяется деривацией из
|
||||
// расширения файла, известные значения сохраняются.
|
||||
func TestNormalizeFileType(t *testing.T) {
|
||||
cases := []struct {
|
||||
fileType string
|
||||
image string
|
||||
want string
|
||||
}{
|
||||
{"banana", "http://domain/api/files/clue.pdf", "pdf"},
|
||||
{"banana", "clue", ""},
|
||||
{"", "clue.png", "image"},
|
||||
{"pdf", "clue.pdf", "pdf"},
|
||||
{"image", "clue.gif", "image"},
|
||||
{"audio", "clue.ogg", "audio"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeFileType(c.fileType, c.image); got != c.want {
|
||||
t.Errorf("normalizeFileType(%q, %q) = %q, want %q", c.fileType, c.image, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeStoryBanana — файл-улика с невалидным file_type проходит
|
||||
// normalizeStory и получает деривированный тип.
|
||||
func TestNormalizeStoryBanana(t *testing.T) {
|
||||
story := &storytelling.Story{
|
||||
Places: []*storytelling.Place{
|
||||
{
|
||||
Code: "p1",
|
||||
Name: "Место",
|
||||
Applications: []*storytelling.Application{
|
||||
{Name: "Договор", Image: "clue.pdf", FileType: "banana"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonOut, err := normalizeStory(story)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeStory: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := mapStory(jsonOut, "")
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory: %v", err)
|
||||
}
|
||||
if got := parsed.Places[0].Applications[0].FileType; got != "pdf" {
|
||||
t.Errorf("FileType = %q, want pdf", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateStoryTrimsEvidenceDomain — сохранение истории снимает доменный
|
||||
// префикс с улик (в БД хранятся относительные имена).
|
||||
func TestUpdateStoryTrimsEvidenceDomain(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Сценарий с историей, где улика уже с доменным префиксом.
|
||||
scenario := seedScenario(t, repo, storage)
|
||||
scenario.Scenario = `{"places":[{"code":"p1","name":"Место","text":"","image":"place.png","applications":[{"name":"Договор","image":"http://storage.test/api/files/contract.pdf","file_type":"pdf"}]}]}`
|
||||
|
||||
story := &storytelling.Story{
|
||||
Places: []*storytelling.Place{
|
||||
{
|
||||
Code: "p1",
|
||||
Name: "Место",
|
||||
Image: testDomain + "place.png",
|
||||
Text: "Текст",
|
||||
Applications: []*storytelling.Application{
|
||||
{Name: "Договор", Image: testDomain + "contract.pdf", FileType: "pdf"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.updateStory(context.Background(), scenario.ID, story); err != nil {
|
||||
t.Fatalf("updateStory: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := mapStory(scenario.Scenario, testDomain)
|
||||
if err != nil {
|
||||
t.Fatalf("mapStory: %v", err)
|
||||
}
|
||||
app := parsed.Places[0].Applications[0]
|
||||
if app.Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("после trim+prefix Image = %q, want %q (без двойного префикса)", app.Image, testDomain+"contract.pdf")
|
||||
}
|
||||
if app.FileType != "pdf" {
|
||||
t.Errorf("FileType = %q, want pdf", app.FileType)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package scenarios_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
@@ -373,10 +371,11 @@ func (s *ScenarioService) UploadArchive(
|
||||
if !exists {
|
||||
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
|
||||
}
|
||||
name, err := newStorageName(archiveRef)
|
||||
name, err := file_storage.NewName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name += strings.ToLower(filepath.Ext(archiveRef))
|
||||
if err := s.fileStorage.Put(ctx, &file_storage.File{
|
||||
Name: name,
|
||||
Data: content,
|
||||
@@ -433,20 +432,13 @@ func (s *ScenarioService) UploadArchive(
|
||||
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 {
|
||||
for _, place := range story.Places {
|
||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
||||
for _, application := range place.Applications {
|
||||
application.Image = strings.TrimPrefix(application.Image, s.domain)
|
||||
}
|
||||
}
|
||||
if story.Introduction != nil {
|
||||
story.Introduction.Audio = strings.TrimPrefix(story.Introduction.Audio, s.domain)
|
||||
@@ -459,8 +451,9 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
}
|
||||
|
||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды
|
||||
// и возвращает JSON истории. Общая для редактирования и импорта.
|
||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды,
|
||||
// нормализует file_type улик и возвращает JSON истории.
|
||||
// Общая для редактирования и импорта.
|
||||
func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
codes := map[string]struct{}{}
|
||||
for _, place := range story.Places {
|
||||
@@ -475,9 +468,28 @@ func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
if place.Code == "" {
|
||||
continue
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
// file_type принимает только известные значения; невалидные
|
||||
// (включая значения из импортированных архивов) заменяются
|
||||
// деривацией из расширения файла.
|
||||
application.FileType = normalizeFileType(application.FileType, application.Image)
|
||||
}
|
||||
cleanPlaces = append(cleanPlaces, place)
|
||||
}
|
||||
story.Places = cleanPlaces
|
||||
|
||||
return convertStory(story)
|
||||
}
|
||||
|
||||
// normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}.
|
||||
// Пустое/невалидное значение при сохранении заменяется деривацией из
|
||||
// расширения файла; пустое значение в legacy-строках деривируется при
|
||||
// чтении (mapStory).
|
||||
func normalizeFileType(fileType, image string) string {
|
||||
switch fileType {
|
||||
case "pdf", "image", "audio":
|
||||
return fileType
|
||||
default:
|
||||
return file_storage.FileType(image)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user