Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
284 lines
10 KiB
Go
284 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/application/repository"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type interveningWikiPageRepository struct {
|
|
interfaces.WikiPageRepository
|
|
beforeWrite func()
|
|
}
|
|
|
|
func (r *interveningWikiPageRepository) UpdateWithRevision(ctx context.Context, page *types.WikiPage, revision *types.WikiPageRevision) error {
|
|
r.beforeWrite()
|
|
return r.WikiPageRepository.UpdateWithRevision(ctx, page, revision)
|
|
}
|
|
|
|
func (r *interveningWikiPageRepository) UpdateMeta(ctx context.Context, page *types.WikiPage) error {
|
|
r.beforeWrite()
|
|
return r.WikiPageRepository.UpdateMeta(ctx, page)
|
|
}
|
|
|
|
func (r *interveningWikiPageRepository) UpdateAutoLinkedContent(ctx context.Context, page *types.WikiPage) error {
|
|
r.beforeWrite()
|
|
return r.WikiPageRepository.UpdateAutoLinkedContent(ctx, page)
|
|
}
|
|
|
|
func TestWikiGeneratedPageRejectsEditBeforeServiceRefetch(t *testing.T) {
|
|
for _, metadataOnly := range []bool{false, true} {
|
|
name := "content"
|
|
if metadataOnly {
|
|
name = "metadata"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
ctx, h, db := newWikiRevisionTestService(t)
|
|
page, err := h.svc.CreatePage(ctx, &types.WikiPage{
|
|
KnowledgeBaseID: "kb-guard", TenantID: 1, Slug: "concept/guard",
|
|
Title: "Guard", PageType: types.WikiPageTypeConcept, Content: "original",
|
|
})
|
|
require.NoError(t, err)
|
|
writeCtx := types.WithWikiPageWriteGuard(ctx, page)
|
|
generated := *page
|
|
generated.Content = "AI result based on old row"
|
|
edit := *page
|
|
if metadataOnly {
|
|
edit.SortOrder = 7
|
|
} else {
|
|
edit.Content = "user revision"
|
|
}
|
|
latest, err := h.svc.UpdatePage(ctx, &edit)
|
|
require.NoError(t, err)
|
|
_, err = h.svc.UpdatePage(writeCtx, &generated)
|
|
require.ErrorIs(t, err, repository.ErrWikiPageConflict)
|
|
stored, err := h.svc.GetPageBySlug(ctx, page.KnowledgeBaseID, page.Slug)
|
|
require.NoError(t, err)
|
|
require.Equal(t, latest.Content, stored.Content)
|
|
require.Equal(t, latest.SortOrder, stored.SortOrder)
|
|
var count int64
|
|
require.NoError(t, db.Model(&types.WikiPageRevision{}).Count(&count).Error)
|
|
if metadataOnly {
|
|
require.Zero(t, count)
|
|
} else {
|
|
require.EqualValues(t, 1, count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWikiGeneratedPageRejectsEditBetweenRefetchAndWrite(t *testing.T) {
|
|
for _, contentChanged := range []bool{false, true} {
|
|
name := "metadata_write"
|
|
if contentChanged {
|
|
name = "content_write"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
ctx, h, db := newWikiRevisionTestService(t)
|
|
page, err := h.svc.CreatePage(ctx, &types.WikiPage{
|
|
KnowledgeBaseID: "kb-guard", TenantID: 1, Slug: "concept/guard",
|
|
Title: "Guard", PageType: types.WikiPageTypeConcept, Content: "original",
|
|
})
|
|
require.NoError(t, err)
|
|
repo := &interveningWikiPageRepository{
|
|
WikiPageRepository: repository.NewWikiPageRepository(db),
|
|
beforeWrite: func() {
|
|
// Metadata changes keep the content version. They must still
|
|
// invalidate the row used by an in-flight AI replacement.
|
|
require.NoError(t, db.Model(&types.WikiPage{}).Where("id = ?", page.ID).
|
|
Updates(map[string]any{"sort_order": 9, "updated_at": page.UpdatedAt.Add(time.Second)}).Error)
|
|
},
|
|
}
|
|
svc := NewWikiPageService(repo, nil, nil, nil, nil)
|
|
generated := *page
|
|
generated.SourceRefs = types.StringArray{"new-source"}
|
|
if contentChanged {
|
|
generated.Content = "AI replacement"
|
|
}
|
|
_, err = svc.UpdatePage(types.WithWikiPageWriteGuard(ctx, page), &generated)
|
|
require.ErrorIs(t, err, repository.ErrWikiPageConflict)
|
|
stored, err := h.svc.GetPageBySlug(ctx, page.KnowledgeBaseID, page.Slug)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "original", stored.Content)
|
|
require.Equal(t, 9, stored.SortOrder)
|
|
require.Equal(t, 1, stored.Version)
|
|
require.Empty(t, stored.SourceRefs)
|
|
var count int64
|
|
require.NoError(t, db.Model(&types.WikiPageRevision{}).Count(&count).Error)
|
|
require.Zero(t, count, "rejected writes must roll back their revision snapshot")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWikiAutoLinksRejectConcurrentEdits(t *testing.T) {
|
|
for _, scenario := range []struct {
|
|
name string
|
|
beforeRefetch bool
|
|
metadataOnly bool
|
|
}{
|
|
{"content_before_refetch", true, false},
|
|
{"metadata_before_refetch", true, true},
|
|
{"content_before_write", false, false},
|
|
{"metadata_before_write", false, true},
|
|
} {
|
|
t.Run(scenario.name, func(t *testing.T) {
|
|
ctx, h, db := newWikiRevisionTestService(t)
|
|
page, err := h.svc.CreatePage(ctx, &types.WikiPage{
|
|
KnowledgeBaseID: "kb-guard", TenantID: 1, Slug: "concept/guard",
|
|
Title: "Guard", PageType: types.WikiPageTypeConcept, Content: "original",
|
|
})
|
|
require.NoError(t, err)
|
|
decorated := *page
|
|
decorated.Content = "[[concept/other|original]]"
|
|
var latest *types.WikiPage
|
|
edit := func() {
|
|
changed := *page
|
|
changed.SourceRefs = types.StringArray{"new-source"}
|
|
if !scenario.metadataOnly {
|
|
changed.Content = "user revision"
|
|
}
|
|
latest, err = h.svc.UpdatePage(ctx, &changed)
|
|
require.NoError(t, err)
|
|
}
|
|
svc := h.svc
|
|
if scenario.beforeRefetch {
|
|
edit()
|
|
} else {
|
|
svc = NewWikiPageService(&interveningWikiPageRepository{
|
|
WikiPageRepository: repository.NewWikiPageRepository(db), beforeWrite: edit,
|
|
}, nil, nil, nil, nil)
|
|
}
|
|
require.ErrorIs(t, svc.UpdateAutoLinkedContent(ctx, &decorated), repository.ErrWikiPageConflict)
|
|
stored, err := h.svc.GetPageBySlug(ctx, page.KnowledgeBaseID, page.Slug)
|
|
require.NoError(t, err)
|
|
require.Equal(t, latest.Content, stored.Content)
|
|
require.Equal(t, latest.SourceRefs, stored.SourceRefs)
|
|
require.Equal(t, latest.Version, stored.Version)
|
|
require.True(t, latest.UpdatedAt.Equal(stored.UpdatedAt))
|
|
var revisions int64
|
|
require.NoError(t, db.Model(&types.WikiPageRevision{}).Count(&revisions).Error)
|
|
require.EqualValues(t, latest.Version-1, revisions)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWikiMetadataRejectsStalePage(t *testing.T) {
|
|
for _, archive := range []bool{false, true} {
|
|
name := "new_source_refs"
|
|
if archive {
|
|
name = "archived"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
ctx, h, _ := newWikiRevisionTestService(t)
|
|
page, err := h.svc.CreatePage(ctx, &types.WikiPage{
|
|
KnowledgeBaseID: "kb-guard", TenantID: 1, Slug: "concept/guard",
|
|
Title: "Guard", PageType: types.WikiPageTypeConcept, Content: "original",
|
|
})
|
|
require.NoError(t, err)
|
|
changed := *page
|
|
changed.SourceRefs = types.StringArray{"new-source"}
|
|
changed.ChunkRefs = types.StringArray{"new-chunk"}
|
|
if archive {
|
|
changed.Status = types.WikiPageStatusArchived
|
|
}
|
|
latest, err := h.svc.UpdatePage(ctx, &changed)
|
|
require.NoError(t, err)
|
|
page.SortOrder = 7
|
|
require.ErrorIs(t, h.svc.UpdatePageMeta(ctx, page), repository.ErrWikiPageConflict)
|
|
stored, err := h.svc.GetPageBySlug(ctx, page.KnowledgeBaseID, page.Slug)
|
|
require.NoError(t, err)
|
|
require.Equal(t, latest.Status, stored.Status)
|
|
require.Equal(t, latest.SourceRefs, stored.SourceRefs)
|
|
require.Equal(t, latest.ChunkRefs, stored.ChunkRefs)
|
|
require.Equal(t, latest.SortOrder, stored.SortOrder)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWikiLinkMaintenancePreservesConcurrentMetadata(t *testing.T) {
|
|
for _, remove := range []bool{false, true} {
|
|
name := "add"
|
|
if remove {
|
|
name = "remove"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
ctx, h, db := newWikiRevisionTestService(t)
|
|
page, err := h.svc.CreatePage(ctx, &types.WikiPage{
|
|
KnowledgeBaseID: "kb-guard", TenantID: 1, Slug: "concept/guard",
|
|
Title: "Guard", PageType: types.WikiPageTypeConcept, Content: "original",
|
|
InLinks: types.StringArray{"concept/source"},
|
|
})
|
|
require.NoError(t, err)
|
|
repo := &interveningWikiPageRepository{
|
|
WikiPageRepository: repository.NewWikiPageRepository(db),
|
|
beforeWrite: func() {
|
|
require.NoError(t, db.Model(&types.WikiPage{}).Where("id = ?", page.ID).
|
|
Updates(map[string]any{
|
|
"source_refs": types.StringArray{"new-source"}, "chunk_refs": types.StringArray{"new-chunk"},
|
|
"status": types.WikiPageStatusArchived, "updated_at": page.UpdatedAt.Add(time.Second),
|
|
}).Error)
|
|
},
|
|
}
|
|
svc := NewWikiPageService(repo, nil, nil, nil, nil).(*wikiPageService)
|
|
if remove {
|
|
svc.removeInLinks(ctx, page.KnowledgeBaseID, "concept/source", types.StringArray{page.Slug})
|
|
} else {
|
|
svc.updateInLinks(ctx, page.KnowledgeBaseID, "concept/another", types.StringArray{page.Slug})
|
|
}
|
|
stored, err := h.svc.GetPageBySlug(ctx, page.KnowledgeBaseID, page.Slug)
|
|
require.NoError(t, err)
|
|
require.Equal(t, types.WikiPageStatusArchived, stored.Status)
|
|
require.Equal(t, types.StringArray{"new-source"}, stored.SourceRefs)
|
|
require.Equal(t, types.StringArray{"new-chunk"}, stored.ChunkRefs)
|
|
require.Equal(t, page.InLinks, stored.InLinks)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWikiPublishPreservesConcurrentArchive(t *testing.T) {
|
|
ctx, h, db := newWikiRevisionTestService(t)
|
|
page, err := h.svc.CreatePage(ctx, &types.WikiPage{
|
|
KnowledgeBaseID: "kb-guard", TenantID: 1, Slug: "concept/guard",
|
|
Title: "Guard", PageType: types.WikiPageTypeConcept, Content: "original", Status: types.WikiPageStatusDraft,
|
|
})
|
|
require.NoError(t, err)
|
|
repo := &interveningWikiPageRepository{
|
|
WikiPageRepository: repository.NewWikiPageRepository(db),
|
|
beforeWrite: func() {
|
|
require.NoError(t, db.Model(&types.WikiPage{}).Where("id = ?", page.ID).
|
|
Updates(map[string]any{"status": types.WikiPageStatusArchived, "updated_at": page.UpdatedAt.Add(time.Second)}).Error)
|
|
},
|
|
}
|
|
svc := &wikiIngestService{wikiService: NewWikiPageService(repo, nil, nil, nil, nil)}
|
|
failed := svc.publishDraftPages(ctx, page.KnowledgeBaseID, []string{page.Slug})
|
|
require.ErrorIs(t, failed[page.Slug], repository.ErrWikiPageConflict)
|
|
stored, err := h.svc.GetPageBySlug(ctx, page.KnowledgeBaseID, page.Slug)
|
|
require.NoError(t, err)
|
|
require.Equal(t, types.WikiPageStatusArchived, stored.Status)
|
|
}
|
|
|
|
func TestWikiAutoLinkWritesKeepSnapshotForCompensation(t *testing.T) {
|
|
ctx, h, db := newWikiRevisionTestService(t)
|
|
page, err := h.svc.CreatePage(ctx, &types.WikiPage{
|
|
KnowledgeBaseID: "kb-guard", TenantID: 1, Slug: "concept/guard",
|
|
Title: "Guard", PageType: types.WikiPageTypeConcept, Content: "original",
|
|
})
|
|
require.NoError(t, err)
|
|
page.Content = "decorated"
|
|
require.NoError(t, h.svc.UpdateAutoLinkedContent(ctx, page))
|
|
page.Content = "original"
|
|
require.NoError(t, h.svc.UpdateAutoLinkedContent(ctx, page))
|
|
stored, err := h.svc.GetPageBySlug(ctx, page.KnowledgeBaseID, page.Slug)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "original", stored.Content)
|
|
require.Equal(t, 1, stored.Version)
|
|
var revisions int64
|
|
require.NoError(t, db.Model(&types.WikiPageRevision{}).Count(&revisions).Error)
|
|
require.Zero(t, revisions)
|
|
}
|