1 Commits

Author SHA1 Message Date
Георгий
1e1008d506 refactor: разнести пакеты и обновить валидацию 2026-07-14 22:00:34 +03:00
20 changed files with 409 additions and 128 deletions

View File

@@ -22,6 +22,12 @@ Mila намеренно не знает ничего про снапшоты, г
go get gitea.unprism.ru/KRBL/mila
```
Библиотека собирается под актуальную стабильную ветку Go:
```text
go 1.26.5
```
## Зона Ответственности
Mila — библиотека протокола. Ее нужно встраивать в прикладной сервис, например
@@ -48,6 +54,36 @@ Mila не делает:
- не общается с фронтендом напрямую;
- не решает, как именно должен отображаться контент.
## Структура Пакетов
Корневой пакет `mila` оставлен фасадом для удобной интеграции. Прикладному коду
достаточно импортировать только его:
```go
import "gitea.unprism.ru/KRBL/mila"
```
Внутри реализация разнесена по подпакетам:
| Пакет | Ответственность |
| --- | --- |
| `protocol` | модели, конфиг, ошибки, валидация, интерфейс хранилища |
| `storage` | `MemoryStore` и `FileStore` |
| `httpserver` | HTTP-handler, options, коды ответов |
Фасад в корне переэкспортирует основные типы и функции:
```go
mila.ContentState
mila.Config
mila.Store
mila.NewHandler
mila.NewMemoryStore
mila.NewFileStore
mila.WithLogger
mila.WithOnSet
```
## Протокол
Медиакомплекс выступает HTTP-сервером. БВК выступает HTTP-клиентом.
@@ -141,6 +177,20 @@ mila.Config{
Лимиты для `str` и `tpl_name` нужно подтвердить у интегратора. В переданном PDF
текстовый слой неоднозначно распознает исходное значение.
Валидация выполняется через `github.com/go-playground/validator/v10`. Ошибки
валидации заворачиваются в `mila.ErrInvalidData`, поэтому их можно проверять
стандартно:
```go
if errors.Is(err, mila.ErrInvalidData) {
// обработать невалидные данные от БВК
}
```
Для проверки лимитов строка временно конвертируется в `[]rune`, потому что
ограничение задано в символах протокола, а хранить и отдавать данные удобнее как
обычную UTF-8 строку JSON/HTTP.
## Хранилища
`MemoryStore` подходит, когда состояние не должно переживать рестарт процесса:
@@ -214,7 +264,7 @@ type ContentState struct {
```json
{
"error": "route: must be at most 3 characters"
"error": "mila: invalid content state: route must be at most 3 characters"
}
```
@@ -229,6 +279,6 @@ go test ./...
- успешный `set-content` + `get-content`;
- `get-content` до готовности состояния;
- невалидный `route`;
- UTF-8 и лимиты для текста/шаблона;
- лимиты для текста/шаблона;
- сохранение и чтение через `FileStore`;
- отсутствие файла состояния в `FileStore`.

14
go.mod
View File

@@ -1,3 +1,15 @@
module gitea.unprism.ru/KRBL/mila
go 1.24
go 1.26.5
require github.com/go-playground/validator/v10 v10.30.3
require (
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
)

26
go.sum Normal file
View File

@@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,4 +1,4 @@
package mila
package httpserver
import (
"context"
@@ -8,22 +8,24 @@ import (
"net/http"
"strings"
"time"
"gitea.unprism.ru/KRBL/mila/protocol"
)
type Handler struct {
cfg Config
store Store
cfg protocol.Config
store protocol.Store
logger *slog.Logger
onSet func(ContentState)
onSet func(protocol.ContentState)
}
func NewHandler(cfg Config, store Store, opts ...Option) *Handler {
func NewHandler(cfg protocol.Config, store protocol.Store, opts ...Option) *Handler {
if store == nil {
store = NewMemoryStore()
store = nilStore{}
}
h := &Handler{
cfg: normalizeConfig(cfg),
cfg: protocol.NormalizeConfig(cfg),
store: store,
logger: slog.Default(),
}
@@ -66,17 +68,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleSetContent(w http.ResponseWriter, r *http.Request) int {
q := r.URL.Query()
state := ContentState{
state := protocol.ContentState{
Route: strings.TrimSpace(q.Get("route")),
Text: q.Get("str"),
Template: strings.TrimSpace(q.Get("tpl_name")),
UpdatedAt: time.Now().UTC(),
}
if err := Validate(h.cfg, state); err != nil {
if err := protocol.Validate(h.cfg, state); err != nil {
if errors.Is(err, protocol.ErrInvalidData) {
writeError(w, http.StatusBadRequest, err.Error())
return http.StatusBadRequest
}
writeError(w, http.StatusInternalServerError, err.Error())
return http.StatusInternalServerError
}
if err := h.store.Set(r.Context(), state); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -94,7 +100,7 @@ func (h *Handler) handleSetContent(w http.ResponseWriter, r *http.Request) int {
func (h *Handler) handleGetContent(w http.ResponseWriter, r *http.Request) int {
state, err := h.store.Get(r.Context())
if err != nil {
if errors.Is(err, ErrNotReady) {
if errors.Is(err, protocol.ErrNotReady) {
writeError(w, http.StatusServiceUnavailable, "content state is not ready")
return http.StatusServiceUnavailable
}
@@ -120,6 +126,16 @@ type protocolContentResponse struct {
Template string `json:"tpl_name"`
}
type nilStore struct{}
func (nilStore) Get(context.Context) (protocol.ContentState, error) {
return protocol.ContentState{}, protocol.ErrNotReady
}
func (nilStore) Set(context.Context, protocol.ContentState) error {
return errors.New("mila: store is not configured")
}
func cleanPath(path string) string {
if path == "" {
return "/"

View File

@@ -0,0 +1,57 @@
package httpserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gitea.unprism.ru/KRBL/mila/protocol"
"gitea.unprism.ru/KRBL/mila/storage"
)
func TestHandlerSetAndGetContent(t *testing.T) {
handler := NewHandler(protocol.DefaultConfig(), storage.NewMemoryStore())
params := url.Values{}
params.Set("route", "3")
params.Set("str", "Гостиный двор - Gostiny dvor")
params.Set("tpl_name", "Гостиный двор")
req := httptest.NewRequest(http.MethodGet, "/set-content?"+params.Encode(), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("set-content status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/get-content", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get-content status=%d body=%s", rec.Code, rec.Body.String())
}
var state protocol.ContentState
if err := json.NewDecoder(rec.Body).Decode(&state); err != nil {
t.Fatalf("decode response: %v", err)
}
if state.Route != "3" || state.Text != "Гостиный двор - Gostiny dvor" || state.Template != "Гостиный двор" {
t.Fatalf("unexpected state: %+v", state)
}
}
func TestHandlerGetBeforeSetReturns503(t *testing.T) {
handler := NewHandler(protocol.DefaultConfig(), storage.NewMemoryStore())
req := httptest.NewRequest(http.MethodGet, "/get-content", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -1,6 +1,10 @@
package mila
package httpserver
import "log/slog"
import (
"log/slog"
"gitea.unprism.ru/KRBL/mila/protocol"
)
type Option func(*Handler)
@@ -12,7 +16,7 @@ func WithLogger(logger *slog.Logger) Option {
}
}
func WithOnSet(fn func(ContentState)) Option {
func WithOnSet(fn func(protocol.ContentState)) Option {
return func(h *Handler) {
h.onSet = fn
}

50
mila.go Normal file
View File

@@ -0,0 +1,50 @@
package mila
import (
"log/slog"
"gitea.unprism.ru/KRBL/mila/httpserver"
"gitea.unprism.ru/KRBL/mila/protocol"
"gitea.unprism.ru/KRBL/mila/storage"
)
type ContentState = protocol.ContentState
type Config = protocol.Config
type Store = protocol.Store
type Handler = httpserver.Handler
type Option = httpserver.Option
type MemoryStore = storage.MemoryStore
type FileStore = storage.FileStore
var (
ErrNotReady = protocol.ErrNotReady
ErrInvalidData = protocol.ErrInvalidData
)
func DefaultConfig() Config {
return protocol.DefaultConfig()
}
func Validate(cfg Config, state ContentState) error {
return protocol.Validate(cfg, state)
}
func NewHandler(cfg Config, store Store, opts ...Option) *Handler {
return httpserver.NewHandler(cfg, store, opts...)
}
func WithLogger(logger *slog.Logger) Option {
return httpserver.WithLogger(logger)
}
func WithOnSet(fn func(ContentState)) Option {
return httpserver.WithOnSet(fn)
}
func NewMemoryStore() *MemoryStore {
return storage.NewMemoryStore()
}
func NewFileStore(path string) *FileStore {
return storage.NewFileStore(path)
}

View File

@@ -34,6 +34,10 @@ GET /set-content?route=<R>&str=<S>&tpl_name=<T>
Точные лимиты для `str` и `tpl_name` нужно подтвердить у интегратора, потому что
текстовый слой PDF неоднозначно распознает исходное значение.
Валидация реализована через `go-playground/validator`. Ошибки невалидных данных
заворачиваются в `mila.ErrInvalidData`, поэтому прикладной код может использовать
обычный `errors.Is`.
## Запрос Контента
```text

29
protocol/config.go Normal file
View File

@@ -0,0 +1,29 @@
package protocol
type Config struct {
MaxRouteLen int
MaxTextLen int
MaxTemplateLen int
}
func DefaultConfig() Config {
return Config{
MaxRouteLen: 3,
MaxTextLen: 200,
MaxTemplateLen: 200,
}
}
func NormalizeConfig(cfg Config) Config {
def := DefaultConfig()
if cfg.MaxRouteLen <= 0 {
cfg.MaxRouteLen = def.MaxRouteLen
}
if cfg.MaxTextLen <= 0 {
cfg.MaxTextLen = def.MaxTextLen
}
if cfg.MaxTemplateLen <= 0 {
cfg.MaxTemplateLen = def.MaxTemplateLen
}
return cfg
}

View File

@@ -1,4 +1,4 @@
package mila
package protocol
import "errors"

View File

@@ -1,4 +1,4 @@
package mila
package protocol
import "time"

View File

@@ -1,4 +1,4 @@
package mila
package protocol
import "context"

35
protocol/validation.go Normal file
View File

@@ -0,0 +1,35 @@
package protocol
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
var validate = validator.New(validator.WithRequiredStructEnabled())
func Validate(cfg Config, state ContentState) error {
cfg = NormalizeConfig(cfg)
route := strings.TrimSpace(state.Route)
template := strings.TrimSpace(state.Template)
if err := validate.Var(route, "required"); err != nil {
return fmt.Errorf("%w: route is required", ErrInvalidData)
}
if err := validate.Var([]rune(route), fmt.Sprintf("max=%d", cfg.MaxRouteLen)); err != nil {
return fmt.Errorf("%w: route must be at most %d characters", ErrInvalidData, cfg.MaxRouteLen)
}
if err := validate.Var([]rune(state.Text), fmt.Sprintf("max=%d", cfg.MaxTextLen)); err != nil {
return fmt.Errorf("%w: str must be at most %d characters", ErrInvalidData, cfg.MaxTextLen)
}
if err := validate.Var(template, "required"); err != nil {
return fmt.Errorf("%w: tpl_name is required", ErrInvalidData)
}
if err := validate.Var([]rune(template), fmt.Sprintf("max=%d", cfg.MaxTemplateLen)); err != nil {
return fmt.Errorf("%w: tpl_name must be at most %d characters", ErrInvalidData, cfg.MaxTemplateLen)
}
return nil
}

View File

@@ -0,0 +1,31 @@
package protocol
import (
"errors"
"testing"
)
func TestValidateAcceptsValidState(t *testing.T) {
err := Validate(DefaultConfig(), ContentState{
Route: "3",
Text: "Гостиный двор - Gostiny dvor",
Template: "Гостиный двор",
})
if err != nil {
t.Fatalf("Validate returned error: %v", err)
}
}
func TestValidateWrapsInvalidData(t *testing.T) {
err := Validate(DefaultConfig(), ContentState{
Route: "1234",
Text: "ok",
Template: "tpl",
})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrInvalidData) {
t.Fatalf("expected ErrInvalidData, got %v", err)
}
}

View File

@@ -1,4 +1,4 @@
package mila
package storage
import (
"context"
@@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
"sync"
"gitea.unprism.ru/KRBL/mila/protocol"
)
type FileStore struct {
@@ -22,7 +24,7 @@ func NewFileStore(path string) *FileStore {
}
}
func (s *FileStore) Get(ctx context.Context) (ContentState, error) {
func (s *FileStore) Get(ctx context.Context) (protocol.ContentState, error) {
if state, err := s.mem.Get(ctx); err == nil {
return state, nil
}
@@ -33,26 +35,26 @@ func (s *FileStore) Get(ctx context.Context) (ContentState, error) {
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ContentState{}, ErrNotReady
return protocol.ContentState{}, protocol.ErrNotReady
}
return ContentState{}, err
return protocol.ContentState{}, err
}
var state ContentState
var state protocol.ContentState
if err := json.Unmarshal(data, &state); err != nil {
return ContentState{}, err
return protocol.ContentState{}, err
}
if state.IsZero() {
return ContentState{}, ErrNotReady
return protocol.ContentState{}, protocol.ErrNotReady
}
if err := s.mem.Set(ctx, state); err != nil {
return ContentState{}, err
return protocol.ContentState{}, err
}
return state, nil
}
func (s *FileStore) Set(ctx context.Context, state ContentState) error {
func (s *FileStore) Set(ctx context.Context, state protocol.ContentState) error {
select {
case <-ctx.Done():
return ctx.Err()

47
storage/file_test.go Normal file
View File

@@ -0,0 +1,47 @@
package storage
import (
"context"
"errors"
"path/filepath"
"testing"
"gitea.unprism.ru/KRBL/mila/protocol"
)
func TestFileStorePersistsAndLoadsState(t *testing.T) {
path := filepath.Join(t.TempDir(), "bvk_content.json")
ctx := context.Background()
first := NewFileStore(path)
state := protocol.ContentState{
Route: "3",
Text: "Гостиный двор",
Template: "main",
}
if err := first.Set(ctx, state); err != nil {
t.Fatalf("set state: %v", err)
}
second := NewFileStore(path)
got, err := second.Get(ctx)
if err != nil {
t.Fatalf("get state: %v", err)
}
if got.Route != state.Route || got.Text != state.Text || got.Template != state.Template {
t.Fatalf("unexpected state: %+v", got)
}
}
func TestFileStoreMissingFileReturnsNotReady(t *testing.T) {
store := NewFileStore(filepath.Join(t.TempDir(), "missing.json"))
_, err := store.Get(context.Background())
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, protocol.ErrNotReady) {
t.Fatalf("expected ErrNotReady, got %v", err)
}
}

View File

@@ -1,13 +1,15 @@
package mila
package storage
import (
"context"
"sync"
"gitea.unprism.ru/KRBL/mila/protocol"
)
type MemoryStore struct {
mu sync.RWMutex
state ContentState
state protocol.ContentState
ready bool
}
@@ -15,10 +17,10 @@ func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) {
func (s *MemoryStore) Get(ctx context.Context) (protocol.ContentState, error) {
select {
case <-ctx.Done():
return ContentState{}, ctx.Err()
return protocol.ContentState{}, ctx.Err()
default:
}
@@ -26,12 +28,12 @@ func (s *MemoryStore) Get(ctx context.Context) (ContentState, error) {
defer s.mu.RUnlock()
if !s.ready {
return ContentState{}, ErrNotReady
return protocol.ContentState{}, protocol.ErrNotReady
}
return s.state, nil
}
func (s *MemoryStore) Set(ctx context.Context, state ContentState) error {
func (s *MemoryStore) Set(ctx context.Context, state protocol.ContentState) error {
select {
case <-ctx.Done():
return ctx.Err()

View File

@@ -2,6 +2,7 @@ package mila
import (
"context"
"errors"
"path/filepath"
"testing"
)
@@ -41,7 +42,7 @@ func TestFileStoreMissingFileReturnsNotReady(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
if err != ErrNotReady {
if !errors.Is(err, ErrNotReady) {
t.Fatalf("expected ErrNotReady, got %v", err)
}
}

View File

@@ -1,88 +0,0 @@
package mila
import (
"errors"
"fmt"
"strings"
"unicode/utf8"
)
type Config struct {
MaxRouteLen int
MaxTextLen int
MaxTemplateLen int
}
func DefaultConfig() Config {
return Config{
MaxRouteLen: 3,
MaxTextLen: 200,
MaxTemplateLen: 200,
}
}
type ValidationError struct {
Field string `json:"field"`
Msg string `json:"message"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func Validate(cfg Config, state ContentState) error {
cfg = normalizeConfig(cfg)
state.Route = strings.TrimSpace(state.Route)
state.Template = strings.TrimSpace(state.Template)
if state.Route == "" {
return ValidationError{Field: "route", Msg: "is required"}
}
if runeLen(state.Route) > cfg.MaxRouteLen {
return ValidationError{Field: "route", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxRouteLen)}
}
if !utf8.ValidString(state.Route) {
return ValidationError{Field: "route", Msg: "must be valid UTF-8"}
}
if !utf8.ValidString(state.Text) {
return ValidationError{Field: "str", Msg: "must be valid UTF-8"}
}
if runeLen(state.Text) > cfg.MaxTextLen {
return ValidationError{Field: "str", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxTextLen)}
}
if state.Template == "" {
return ValidationError{Field: "tpl_name", Msg: "is required"}
}
if !utf8.ValidString(state.Template) {
return ValidationError{Field: "tpl_name", Msg: "must be valid UTF-8"}
}
if runeLen(state.Template) > cfg.MaxTemplateLen {
return ValidationError{Field: "tpl_name", Msg: fmt.Sprintf("must be at most %d characters", cfg.MaxTemplateLen)}
}
return nil
}
func IsValidationError(err error) bool {
var target ValidationError
return errors.As(err, &target)
}
func normalizeConfig(cfg Config) Config {
def := DefaultConfig()
if cfg.MaxRouteLen <= 0 {
cfg.MaxRouteLen = def.MaxRouteLen
}
if cfg.MaxTextLen <= 0 {
cfg.MaxTextLen = def.MaxTextLen
}
if cfg.MaxTemplateLen <= 0 {
cfg.MaxTemplateLen = def.MaxTemplateLen
}
return cfg
}
func runeLen(s string) int {
return utf8.RuneCountInString(s)
}

View File

@@ -1,6 +1,9 @@
package mila
import "testing"
import (
"errors"
"testing"
)
func TestValidateAcceptsValidUTF8State(t *testing.T) {
err := Validate(DefaultConfig(), ContentState{
@@ -22,8 +25,8 @@ func TestValidateRejectsTooLongRoute(t *testing.T) {
if err == nil {
t.Fatal("expected validation error")
}
if !IsValidationError(err) {
t.Fatalf("expected ValidationError, got %T", err)
if !errors.Is(err, ErrInvalidData) {
t.Fatalf("expected ErrInvalidData, got %v", err)
}
}