generated from VLADIMIR/template
clear
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package formatter_utils
|
||||
|
||||
// IFormatter — форматирование текста сценария для отображения.
|
||||
type IFormatter interface {
|
||||
FormatText(text string) string
|
||||
FormatString(text string) string
|
||||
}
|
||||
@@ -5,7 +5,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatText(text string) string {
|
||||
type formatter struct{}
|
||||
|
||||
// NewFormatter создаёт реализацию IFormatter.
|
||||
func NewFormatter() IFormatter {
|
||||
return &formatter{}
|
||||
}
|
||||
|
||||
// FormatText форматирует многострочный текст: обрезает пробелы по краям
|
||||
// строк, превращает "--" в тире и добавляет отступ абзаца.
|
||||
func (f *formatter) FormatText(text string) string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(text))
|
||||
|
||||
scanner.Split(bufio.ScanLines)
|
||||
@@ -35,7 +44,9 @@ func FormatText(text string) string {
|
||||
return res.String()
|
||||
}
|
||||
|
||||
func FormatString(text string) string {
|
||||
// FormatString форматирует одиночную строку: обрезает пробелы и заменяет
|
||||
// "--" на тире.
|
||||
func (f *formatter) FormatString(text string) string {
|
||||
l := strings.TrimSpace(text)
|
||||
if strings.HasPrefix(l, "--") {
|
||||
l = strings.Replace(l, "--", "—", 1)
|
||||
|
||||
@@ -29,9 +29,10 @@ func Test_service_FormatText(t *testing.T) {
|
||||
want: " — Привет",
|
||||
},
|
||||
}
|
||||
formatter := NewFormatter()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatText(tt.text)
|
||||
got := formatter.FormatText(tt.text)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatText() = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package roles
|
||||
|
||||
// Роли пользователей системы.
|
||||
const (
|
||||
Admin = "admin"
|
||||
Author = "author"
|
||||
Organizer = "organizer"
|
||||
User = "user"
|
||||
)
|
||||
|
||||
// AllRoles — список всех ролей.
|
||||
var AllRoles = []string{
|
||||
Admin,
|
||||
Author,
|
||||
Organizer,
|
||||
User,
|
||||
}
|
||||
|
||||
// IHasRole — контракт субъекта, у которого можно проверить наличие роли.
|
||||
type IHasRole interface {
|
||||
HasRole(role string) bool
|
||||
}
|
||||
@@ -1,23 +1,7 @@
|
||||
package roles
|
||||
|
||||
const (
|
||||
Admin = "admin"
|
||||
Author = "author"
|
||||
Organizer = "organizer"
|
||||
User = "user"
|
||||
)
|
||||
|
||||
var AllRoles = []string{
|
||||
Admin,
|
||||
Author,
|
||||
Organizer,
|
||||
User,
|
||||
}
|
||||
|
||||
type IHasRole interface {
|
||||
HasRole(role string) bool
|
||||
}
|
||||
|
||||
// HasRole возвращает true, если у субъекта есть роль; администратор
|
||||
// проходит проверку любой роли.
|
||||
func HasRole(claims IHasRole, role string) bool {
|
||||
if claims.HasRole(Admin) {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package roles
|
||||
|
||||
import "slices"
|
||||
|
||||
// subject — реализация IHasRole: субъект с фиксированным набором ролей.
|
||||
type subject struct {
|
||||
roles []string
|
||||
}
|
||||
|
||||
// NewSubject создаёт субъект с заданными ролями (например, для тестов
|
||||
// авторизации или in-memory проверок прав).
|
||||
func NewSubject(roles ...string) IHasRole {
|
||||
return &subject{roles: roles}
|
||||
}
|
||||
|
||||
// HasRole возвращает true, если роль есть в наборе ролей субъекта.
|
||||
func (s *subject) HasRole(role string) bool {
|
||||
return slices.Contains(s.roles, role)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package roles
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_subject_HasRole(t *testing.T) {
|
||||
subject := NewSubject(Author, Organizer)
|
||||
|
||||
if !subject.HasRole(Author) {
|
||||
t.Errorf("HasRole(%q) = false, want true", Author)
|
||||
}
|
||||
if !subject.HasRole(Organizer) {
|
||||
t.Errorf("HasRole(%q) = false, want true", Organizer)
|
||||
}
|
||||
if subject.HasRole(Admin) {
|
||||
t.Errorf("HasRole(%q) = true, want false", Admin)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_subject_HasRoleEmpty(t *testing.T) {
|
||||
subject := NewSubject()
|
||||
|
||||
if subject.HasRole(User) {
|
||||
t.Errorf("HasRole(%q) = true, want false для субъекта без ролей", User)
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,6 @@ import (
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// Имя файла с описанием сценария внутри архива.
|
||||
const FileName = "scenario.json"
|
||||
|
||||
// Текущая версия формата scenario.json.
|
||||
const Version = 1
|
||||
|
||||
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
||||
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
||||
var (
|
||||
@@ -36,28 +30,19 @@ var (
|
||||
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
||||
)
|
||||
|
||||
type scenarioArchive struct{}
|
||||
|
||||
// NewScenarioArchive создаёт реализацию IScenarioArchive.
|
||||
func NewScenarioArchive() IScenarioArchive {
|
||||
return &scenarioArchive{}
|
||||
}
|
||||
|
||||
// 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): в архив не кладутся.
|
||||
@@ -76,7 +61,7 @@ func ImagePath(ref, domain string) (string, bool) {
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
||||
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
||||
func Pack(
|
||||
func (a *scenarioArchive) Pack(
|
||||
ctx context.Context,
|
||||
scenario *repos.Scenario,
|
||||
domain string,
|
||||
@@ -223,7 +208,7 @@ func strPtrValue(p *string) string {
|
||||
}
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
func Unpack(data []byte) (*Bundle, error) {
|
||||
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("архив пуст")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
// testArchive — экземпляр модуля для тестов.
|
||||
var testArchive = NewScenarioArchive()
|
||||
|
||||
// buildScenario собирает сценарий с историей и ссылками на изображения.
|
||||
func buildScenario() *repos.Scenario {
|
||||
description := "Детективная история"
|
||||
@@ -53,12 +56,12 @@ func newMemoryFiles() memoryFiles {
|
||||
|
||||
func TestPackUnpackRoundTrip(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
data, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
data, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
bundle, err := Unpack(data)
|
||||
bundle, err := testArchive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
@@ -117,7 +120,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
||||
return files.get(ctx, name)
|
||||
}
|
||||
|
||||
data, err := Pack(context.Background(), scenario, testDomain, getFile)
|
||||
data, err := testArchive.Pack(context.Background(), scenario, testDomain, getFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
@@ -125,7 +128,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
||||
t.Errorf("getFile вызван для внешних URL: %v", fetched)
|
||||
}
|
||||
|
||||
bundle, err := Unpack(data)
|
||||
bundle, err := testArchive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
@@ -147,11 +150,11 @@ func TestPackLegacyFullURLImage(t *testing.T) {
|
||||
Image: ptr(testDomain + "cover.png"),
|
||||
Scenario: `{"places":[]}`,
|
||||
}
|
||||
data, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
data, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
bundle, err := Unpack(data)
|
||||
bundle, err := testArchive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
@@ -164,7 +167,7 @@ func TestPackMissingImage(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
delete(files, "ticket.jpg")
|
||||
|
||||
_, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
_, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
|
||||
}
|
||||
@@ -175,12 +178,12 @@ func TestPackMissingImage(t *testing.T) {
|
||||
|
||||
func TestPackDeterministic(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
first, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
first, err := testArchive.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)
|
||||
second, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
@@ -190,7 +193,7 @@ func TestPackDeterministic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPackNilScenario(t *testing.T) {
|
||||
_, err := Pack(context.Background(), nil, testDomain, nil)
|
||||
_, err := testArchive.Pack(context.Background(), nil, testDomain, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Pack(nil) должен вернуть ошибку")
|
||||
}
|
||||
@@ -207,7 +210,7 @@ func TestPackImageNameCollision(t *testing.T) {
|
||||
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
|
||||
]}`,
|
||||
}
|
||||
_, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
_, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
|
||||
}
|
||||
@@ -257,7 +260,7 @@ func TestUnpackRejectsBadPaths(t *testing.T) {
|
||||
"images/x.png": []byte("x"),
|
||||
name: []byte("evil"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatalf("Unpack должен отклонить путь %q", name)
|
||||
}
|
||||
})
|
||||
@@ -265,30 +268,30 @@ func TestUnpackRejectsBadPaths(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnpackEmpty(t *testing.T) {
|
||||
if _, err := Unpack(nil); err == nil {
|
||||
if _, err := testArchive.Unpack(nil); err == nil {
|
||||
t.Fatal("Unpack(nil) должен вернуть ошибку")
|
||||
}
|
||||
if _, err := Unpack([]byte{}); err == nil {
|
||||
t.Fatal("Unpack(пусто) должен вернуть ошибку")
|
||||
if _, err := testArchive.Unpack([]byte{}); err == nil {
|
||||
t.Fatal("testArchive.Unpack(пусто) должен вернуть ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackNotAZip(t *testing.T) {
|
||||
if _, err := Unpack([]byte("this is not a zip")); err == nil {
|
||||
if _, err := testArchive.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 {
|
||||
if _, err := testArchive.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 {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить битый scenario.json")
|
||||
}
|
||||
}
|
||||
@@ -296,7 +299,7 @@ func TestUnpackInvalidScenarioJSON(t *testing.T) {
|
||||
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 {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить неизвестную версию")
|
||||
}
|
||||
}
|
||||
@@ -304,7 +307,7 @@ func TestUnpackBadVersion(t *testing.T) {
|
||||
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 {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать название")
|
||||
}
|
||||
}
|
||||
@@ -312,7 +315,7 @@ func TestUnpackEmptyName(t *testing.T) {
|
||||
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 {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать story")
|
||||
}
|
||||
}
|
||||
@@ -326,7 +329,7 @@ func TestUnpackEntrySizeLimit(t *testing.T) {
|
||||
FileName: validScenarioJSON(),
|
||||
"images/big.png": bytes.Repeat([]byte("x"), 8),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
|
||||
}
|
||||
}
|
||||
@@ -341,7 +344,7 @@ func TestUnpackTotalSizeLimit(t *testing.T) {
|
||||
"images/a.png": []byte("aaa"),
|
||||
"images/b.png": []byte("bbb"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
|
||||
}
|
||||
}
|
||||
@@ -352,7 +355,7 @@ func TestUnpackArchiveSizeLimit(t *testing.T) {
|
||||
defer func() { maxArchiveSize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
|
||||
}
|
||||
}
|
||||
@@ -367,7 +370,7 @@ func TestUnpackEntriesLimit(t *testing.T) {
|
||||
"images/a.png": []byte("a"),
|
||||
"images/b.png": []byte("b"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
if _, err := testArchive.Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
|
||||
}
|
||||
}
|
||||
@@ -388,7 +391,7 @@ func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
||||
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли scenario.json")
|
||||
}
|
||||
}
|
||||
@@ -420,7 +423,7 @@ func TestUnpackRejectsDuplicateFiles(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
||||
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли файлов")
|
||||
}
|
||||
}
|
||||
@@ -444,7 +447,7 @@ func TestPackTotalSizeLimit(t *testing.T) {
|
||||
"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 {
|
||||
if _, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get); err == nil {
|
||||
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// Имя файла с описанием сценария внутри архива.
|
||||
const FileName = "scenario.json"
|
||||
|
||||
// Текущая версия формата scenario.json.
|
||||
const Version = 1
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор
|
||||
// ZIP-архива сценария.
|
||||
type IScenarioArchive interface {
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile;
|
||||
// внешние URL остаются без изменений.
|
||||
Pack(
|
||||
ctx context.Context,
|
||||
scenario *repos.Scenario,
|
||||
domain string,
|
||||
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||
) ([]byte, error)
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
Unpack(data []byte) (*Bundle, error)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package string_tools
|
||||
|
||||
// IStringTools — утилиты работы со строками.
|
||||
type IStringTools interface {
|
||||
// Transliterate выполняет транслитерацию русского текста в латиницу.
|
||||
Transliterate(text string) string
|
||||
}
|
||||
@@ -44,9 +44,16 @@ var (
|
||||
)
|
||||
)
|
||||
|
||||
type stringTools struct{}
|
||||
|
||||
// NewStringTools создаёт реализацию IStringTools.
|
||||
func NewStringTools() IStringTools {
|
||||
return &stringTools{}
|
||||
}
|
||||
|
||||
// Transliterate выполняет транслитерацию русского текста в латиницу,
|
||||
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
||||
func Transliterate(text string) string {
|
||||
func (s *stringTools) Transliterate(text string) string {
|
||||
|
||||
// Приводим к нижнему регистру и транслитерируем
|
||||
result := replacer.Replace(strings.ToLower(text))
|
||||
|
||||
Reference in New Issue
Block a user