alpha
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
main
|
||||
.db
|
||||
.json
|
||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
||||
module git.gm6.ru/icewind/seadoc
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/evanphx/json-patch/v5 v5.9.11
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
)
|
||||
31
go.sum
Normal file
31
go.sum
Normal file
@@ -0,0 +1,31 @@
|
||||
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/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 h1:hQWBtNqRYrI7CWIaUSXXtNKR90KzcUA5uiuxFVWw7sU=
|
||||
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
28
models/models.go
Normal file
28
models/models.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Document — логическая сущность (например, пользователь, настройки и т.п.)
|
||||
type Document struct {
|
||||
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LatestVersion uuid.UUID `gorm:"type:char(36);"`
|
||||
}
|
||||
|
||||
// Version — конкретная версия документа
|
||||
type Version struct {
|
||||
ID uuid.UUID `gorm:"type:char(36);primaryKey"`
|
||||
DocumentID uuid.UUID `gorm:"type:char(36);index"`
|
||||
ParentID *uuid.UUID `gorm:"type:char(36);"`
|
||||
IsSnapshot bool
|
||||
Patch json.RawMessage `gorm:"type:json"` // JSON Patch или nil, если Snapshot
|
||||
Snapshot json.RawMessage `gorm:"type:json"` // Полный JSON, если IsSnapshot = true
|
||||
CreatedAt time.Time
|
||||
}
|
||||
231
service/version.go
Normal file
231
service/version.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gm6.ru/icewind/seadoc/models"
|
||||
"git.gm6.ru/icewind/seadoc/store"
|
||||
jsonpatch "github.com/evanphx/json-patch/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VersionService — слой логики версионирования, использующий интерфейс VersionStorage.
|
||||
type VersionService struct {
|
||||
storage store.IVersionStorage
|
||||
// snapshotInterval (опционально) — через сколько версий делать full snapshot
|
||||
snapshotInterval int
|
||||
}
|
||||
|
||||
// NewVersionService создаёт новый сервис
|
||||
func NewVersionService(storage store.IVersionStorage) *VersionService {
|
||||
return &VersionService{storage: storage, snapshotInterval: 0}
|
||||
}
|
||||
|
||||
// SetSnapshotInterval задаёт через сколько версий сохранять snapshot (если >0)
|
||||
func (s *VersionService) SetSnapshotInterval(n int) {
|
||||
s.snapshotInterval = n
|
||||
}
|
||||
|
||||
// SaveNewVersion создаёт новую версию документа (merge-patch или snapshot)
|
||||
func (s *VersionService) SaveNewVersion(docID uuid.UUID, newJSON []byte) (*models.Version, error) {
|
||||
lastVer, err := s.storage.GetLatestVersion(docID)
|
||||
|
||||
var patchBytes []byte
|
||||
isSnapshot := false
|
||||
var parentID *uuid.UUID
|
||||
|
||||
if err != nil {
|
||||
// первая версия — snapshot
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
isSnapshot = true
|
||||
parentID = nil
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// есть предыдущая версия — создаём merge-patch
|
||||
parentID = &lastVer.ID
|
||||
|
||||
var oldJSON []byte
|
||||
if lastVer.IsSnapshot {
|
||||
oldJSON = lastVer.Snapshot
|
||||
} else {
|
||||
oldJSON, err = s.ReconstructVersion(lastVer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём JSON Merge Patch (RFC 7386)
|
||||
// CreateMergePatch возвращает []byte с содержимым merge-patch
|
||||
patchBytes, err = jsonpatch.CreateMergePatch(oldJSON, newJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create merge patch: %w", err)
|
||||
}
|
||||
|
||||
// Если patch пустой (документы равны), можно не сохранять новую версию
|
||||
// В данном примере создаём версию даже если patch пустой — можно изменить поведение
|
||||
}
|
||||
|
||||
newVer := models.Version{
|
||||
ID: uuid.New(),
|
||||
DocumentID: docID,
|
||||
ParentID: parentID,
|
||||
IsSnapshot: isSnapshot,
|
||||
Patch: patchBytes,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if isSnapshot {
|
||||
newVer.Snapshot = newJSON
|
||||
}
|
||||
|
||||
// Сохраняем версию в хранилище
|
||||
if err := s.storage.CreateVersion(&newVer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Обновляем ссылку на последнюю версию документа
|
||||
if err := s.storage.UpdateLatestVersion(docID, newVer.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Опционально: если указан snapshotInterval, проверяем нужно ли создать snapshot
|
||||
if s.snapshotInterval > 0 && !isSnapshot {
|
||||
// считаем количество версий от этого snapshot'а до новой версии
|
||||
// простая реализация: пройти вверх по родителям и посчитать
|
||||
count := 0
|
||||
curr := &newVer
|
||||
for {
|
||||
count++
|
||||
if curr.ParentID == nil {
|
||||
break
|
||||
}
|
||||
parent, err := s.storage.GetParentVersion(curr)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if parent == nil {
|
||||
break
|
||||
}
|
||||
curr = parent
|
||||
if curr.IsSnapshot {
|
||||
break
|
||||
}
|
||||
}
|
||||
if count >= s.snapshotInterval {
|
||||
// делаем snapshot: восстанавливаем полную версию и сохраняем snapshot-версию
|
||||
full, err := s.ReconstructVersion(newVer.ID)
|
||||
if err == nil {
|
||||
snapVer := models.Version{
|
||||
ID: uuid.New(),
|
||||
DocumentID: docID,
|
||||
ParentID: &newVer.ID,
|
||||
IsSnapshot: true,
|
||||
Snapshot: full,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
// сохраняем snapshot-версию и обновляем latest_version
|
||||
_ = s.storage.CreateVersion(&snapVer)
|
||||
_ = s.storage.UpdateLatestVersion(docID, snapVer.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &newVer, nil
|
||||
}
|
||||
|
||||
// ReconstructVersion восстанавливает полную JSON-версию по ID
|
||||
// Патчи — в формате JSON Merge Patch (RFC 7386) применяются последовательно к ближайшему snapshot.
|
||||
func (s *VersionService) ReconstructVersion(versionID uuid.UUID) ([]byte, error) {
|
||||
v, err := s.storage.GetVersionByID(versionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Если это snapshot — возвращаем сразу
|
||||
if v.IsSnapshot {
|
||||
return v.Snapshot, nil
|
||||
}
|
||||
|
||||
// Собираем цепочку версий от ближайшего snapshot (включая snapshot) до целевой версии
|
||||
chain := []models.Version{}
|
||||
curr := v
|
||||
for {
|
||||
// prepend
|
||||
chain = append([]models.Version{*curr}, chain...)
|
||||
if curr.ParentID == nil {
|
||||
// дошли до корня
|
||||
break
|
||||
}
|
||||
parent, err := s.storage.GetParentVersion(curr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent == nil {
|
||||
break
|
||||
}
|
||||
curr = parent
|
||||
// Если parent — snapshot, добавим его и прервём цикл, т.к. от него будем применять
|
||||
if curr.IsSnapshot {
|
||||
chain = append([]models.Version{*curr}, chain...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Найдём snapshot в цепочке
|
||||
startIndex := -1
|
||||
for i, ver := range chain {
|
||||
if ver.IsSnapshot {
|
||||
startIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if startIndex == -1 {
|
||||
return nil, fmt.Errorf("snapshot not found in version chain for %s", versionID)
|
||||
}
|
||||
|
||||
// Начинаем с snapshot
|
||||
data := chain[startIndex].Snapshot
|
||||
|
||||
// Применяем последовательно merge-patch'и от snapshot+1 до конца chain
|
||||
for i := startIndex + 1; i < len(chain); i++ {
|
||||
if len(chain[i].Patch) == 0 {
|
||||
continue
|
||||
}
|
||||
// MergePatch принимает исходный документ и merge-patch и возвращает новый документ
|
||||
newData, err := jsonpatch.MergePatch(data, chain[i].Patch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply merge patch at version %s: %w", chain[i].ID, err)
|
||||
}
|
||||
data = newData
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DiffVersions создаёт JSON Merge Patch между двумя версиями.
|
||||
// Возвращает JSON-патч (diff), показывающий, какие поля изменились.
|
||||
func (s *VersionService) DiffVersions(v1ID, v2ID uuid.UUID) ([]byte, error) {
|
||||
// Восстанавливаем обе версии полностью
|
||||
data1, err := s.ReconstructVersion(v1ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data2, err := s.ReconstructVersion(v2ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Вычисляем разницу
|
||||
patch, err := jsonpatch.CreateMergePatch(data1, data2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return patch, nil
|
||||
}
|
||||
75
store/gorm.go
Normal file
75
store/gorm.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"git.gm6.ru/icewind/seadoc/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GormStorage реализует интерфейс VersionStorage через GORM (SQLite)
|
||||
type GormStorage struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewGormStorage(db *gorm.DB) *GormStorage {
|
||||
return &GormStorage{db: db}
|
||||
}
|
||||
|
||||
func (s *GormStorage) CreateDocument(name string) (uuid.UUID, error) {
|
||||
doc := models.Document{
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
}
|
||||
if err := s.db.Create(&doc).Error; err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return doc.ID, nil
|
||||
}
|
||||
|
||||
func (s *GormStorage) GetDocument(id uuid.UUID) (*models.Document, error) {
|
||||
var d models.Document
|
||||
if err := s.db.First(&d, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (s *GormStorage) UpdateLatestVersion(docID, versionID uuid.UUID) error {
|
||||
return s.db.Model(&models.Document{}).
|
||||
Where("id = ?", docID).
|
||||
Update("latest_version", versionID).Error
|
||||
}
|
||||
|
||||
func (s *GormStorage) CreateVersion(v *models.Version) error {
|
||||
return s.db.Create(v).Error
|
||||
}
|
||||
|
||||
func (s *GormStorage) GetLatestVersion(docID uuid.UUID) (*models.Version, error) {
|
||||
var v models.Version
|
||||
if err := s.db.
|
||||
Where("document_id = ?", docID).
|
||||
Order("created_at desc").
|
||||
First(&v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (s *GormStorage) GetVersionByID(id uuid.UUID) (*models.Version, error) {
|
||||
var v models.Version
|
||||
if err := s.db.First(&v, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (s *GormStorage) GetParentVersion(v *models.Version) (*models.Version, error) {
|
||||
if v.ParentID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var parent models.Version
|
||||
if err := s.db.First(&parent, "id = ?", v.ParentID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parent, nil
|
||||
}
|
||||
19
store/store.go
Normal file
19
store/store.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"git.gm6.ru/icewind/seadoc/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type IVersionStorage interface {
|
||||
// Document
|
||||
CreateDocument(name string) (uuid.UUID, error)
|
||||
GetDocument(id uuid.UUID) (*models.Document, error)
|
||||
UpdateLatestVersion(docID, versionID uuid.UUID) error
|
||||
|
||||
// Version
|
||||
CreateVersion(v *models.Version) error
|
||||
GetLatestVersion(docID uuid.UUID) (*models.Version, error)
|
||||
GetVersionByID(id uuid.UUID) (*models.Version, error)
|
||||
GetParentVersion(v *models.Version) (*models.Version, error)
|
||||
}
|
||||
124
tests/service_test.go
Normal file
124
tests/service_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.gm6.ru/icewind/seadoc/models"
|
||||
"git.gm6.ru/icewind/seadoc/service"
|
||||
"git.gm6.ru/icewind/seadoc/store"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// helper: инициализация базы и сервиса
|
||||
func newTestService(t *testing.T) (*gorm.DB, *service.VersionService, *store.GormStorage) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Document{}, &models.Version{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
storage := store.NewGormStorage(db)
|
||||
service := service.NewVersionService(storage)
|
||||
return db, service, storage
|
||||
}
|
||||
|
||||
func TestVersioningLifecycle(t *testing.T) {
|
||||
db, service, store := newTestService(t)
|
||||
|
||||
// 1️⃣ Создаём документ
|
||||
docID, err := store.CreateDocument("test.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create document: %v", err)
|
||||
}
|
||||
|
||||
// 2️⃣ Данные
|
||||
v1 := []byte(`{"name": "Alice", "age": 30}`)
|
||||
v2 := []byte(`{"name": "Alice", "age": 31, "city": "Paris"}`)
|
||||
v3 := []byte(`{"name": "Alice", "age": 32, "city": "Paris", "lang": "fr"}`)
|
||||
|
||||
// 3️⃣ Сохраняем версии
|
||||
ver1, err := service.SaveNewVersion(docID, v1)
|
||||
if err != nil {
|
||||
t.Fatalf("save v1: %v", err)
|
||||
}
|
||||
if !ver1.IsSnapshot {
|
||||
t.Errorf("v1 должен быть snapshot, но IsSnapshot=%v", ver1.IsSnapshot)
|
||||
}
|
||||
|
||||
ver2, err := service.SaveNewVersion(docID, v2)
|
||||
if err != nil {
|
||||
t.Fatalf("save v2: %v", err)
|
||||
}
|
||||
if ver2.IsSnapshot {
|
||||
t.Errorf("v2 не должен быть snapshot")
|
||||
}
|
||||
|
||||
ver3, err := service.SaveNewVersion(docID, v3)
|
||||
if err != nil {
|
||||
t.Fatalf("save v3: %v", err)
|
||||
}
|
||||
|
||||
// 4️⃣ Проверяем восстановление v2
|
||||
data2, err := service.ReconstructVersion(ver2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct v2: %v", err)
|
||||
}
|
||||
if !jsonEqual(data2, v2) {
|
||||
t.Errorf("восстановленный v2 не совпадает:\n got: %s\nwant: %s", data2, v2)
|
||||
}
|
||||
|
||||
// 5️⃣ Проверяем восстановление v3
|
||||
data3, err := service.ReconstructVersion(ver3.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct v3: %v", err)
|
||||
}
|
||||
if !jsonEqual(data3, v3) {
|
||||
t.Errorf("восстановленный v3 не совпадает:\n got: %s\nwant: %s", data3, v3)
|
||||
}
|
||||
|
||||
// 6️⃣ Проверим, что версии записались в базу
|
||||
var count int64
|
||||
if err := db.Model(&models.Version{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count versions: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Errorf("ожидалось 3 версии, а в БД %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// сравнение JSON без учёта порядка полей
|
||||
func jsonEqual(a, b []byte) bool {
|
||||
var ja, jb interface{}
|
||||
_ = json.Unmarshal(a, &ja)
|
||||
_ = json.Unmarshal(b, &jb)
|
||||
ra, _ := json.Marshal(ja)
|
||||
rb, _ := json.Marshal(jb)
|
||||
return bytes.Equal(ra, rb)
|
||||
}
|
||||
|
||||
func TestDiffVersions(t *testing.T) {
|
||||
_, service, store := newTestService(t)
|
||||
|
||||
docID, _ := store.CreateDocument("diff.json")
|
||||
|
||||
v1 := []byte(`{"name":"Alice","age":30}`)
|
||||
v2 := []byte(`{"name":"Alice","age":31,"city":"Paris"}`)
|
||||
|
||||
ver1, _ := service.SaveNewVersion(docID, v1)
|
||||
ver2, _ := service.SaveNewVersion(docID, v2)
|
||||
|
||||
diff, err := service.DiffVersions(ver1.ID, ver2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("diff error: %v", err)
|
||||
}
|
||||
|
||||
expected := `{"age":31,"city":"Paris"}`
|
||||
if !jsonEqual(diff, []byte(expected)) {
|
||||
t.Errorf("unexpected diff:\n got: %s\nwant: %s", diff, expected)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user