Files
xinmi/internal/application/service/tenant_skill_catalog_test.go
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

469 lines
18 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/application/repository"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestListCatalogGroupsInstallsByDefinition(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:catalog-list?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&types.TenantSkillEntity{}, &types.TenantSkillCatalogEntity{},
&types.TenantSkillSnapshotEntity{}, &types.TenantUserEnvVar{},
))
repo := repository.NewTenantSkillRepository(db)
ctx := context.Background()
require.NoError(t, repo.CreateCatalog(ctx, &types.TenantSkillCatalogEntity{
ID: "cat-pdf", TenantID: 7, Name: "pdf", Description: "extract",
}))
require.NoError(t, repo.CreateSkill(ctx, &types.TenantSkillEntity{
ID: "sk-1", TenantID: 7, SandboxConfigID: "cfg-a", CatalogID: "cat-pdf",
Name: "pdf", Status: types.SkillStatusReady, Enabled: true,
}))
require.NoError(t, repo.CreateSkill(ctx, &types.TenantSkillEntity{
ID: "sk-2", TenantID: 7, SandboxConfigID: "cfg-b", CatalogID: "cat-pdf",
Name: "pdf", Status: types.SkillStatusInstalling, Enabled: true,
}))
svc := NewTenantSkillService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
list, err := svc.ListCatalog(ctx, 7)
require.NoError(t, err)
require.Len(t, list, 1)
require.Equal(t, "pdf", list[0].Name)
require.Len(t, list[0].Installations, 2)
}
func TestResolveCatalogFindsLegacySkillID(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:catalog-legacy?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&types.TenantSkillEntity{}, &types.TenantSkillCatalogEntity{},
&types.TenantSkillSnapshotEntity{}, &types.TenantUserEnvVar{},
))
repo := repository.NewTenantSkillRepository(db)
ctx := context.Background()
require.NoError(t, repo.CreateSkill(ctx, &types.TenantSkillEntity{
ID: "sk-old", TenantID: 7, SandboxConfigID: "cfg-a",
Name: "pdf", BundleRef: "local://7/tenant-skills/sk-old.zip",
Status: types.SkillStatusReady, Enabled: true,
}))
svc := NewTenantSkillService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
cat, err := svc.resolveCatalog(ctx, 7, "sk-old")
require.NoError(t, err)
require.NotNil(t, cat)
require.Equal(t, "pdf", cat.Name)
require.Equal(t, "local://7/tenant-skills/sk-old.zip", cat.BundleRef)
}
func catalogTestRepo(t *testing.T, dsn string) repository.TenantSkillRepository {
t.Helper()
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&types.TenantSkillEntity{}, &types.TenantSkillCatalogEntity{},
&types.TenantSkillSnapshotEntity{}, &types.TenantUserEnvVar{},
))
return repository.NewTenantSkillRepository(db)
}
func TestListCatalogShowsInstallsWhoseCatalogWasDeleted(t *testing.T) {
repo := catalogTestRepo(t, "file:catalog-orphan?mode=memory&cache=shared")
ctx := context.Background()
require.NoError(t, repo.CreateCatalog(ctx, &types.TenantSkillCatalogEntity{
ID: "cat-gone", TenantID: 7, Name: "pdf",
}))
require.NoError(t, repo.CreateSkill(ctx, &types.TenantSkillEntity{
ID: "sk-1", TenantID: 7, SandboxConfigID: "cfg-a", CatalogID: "cat-gone",
Name: "pdf", Status: types.SkillStatusReady, Enabled: true,
}))
require.NoError(t, repo.DeleteCatalog(ctx, 7, "cat-gone"))
svc := NewTenantSkillService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
list, err := svc.ListCatalog(ctx, 7)
require.NoError(t, err)
require.Len(t, list, 1)
require.Equal(t, "pdf", list[0].Name)
require.Len(t, list[0].Installations, 1)
require.Equal(t, "sk-1", list[0].Installations[0].SkillID)
}
func TestDeleteCatalogRefusesWhileARemovalIsInFlight(t *testing.T) {
repo := catalogTestRepo(t, "file:catalog-removing?mode=memory&cache=shared")
ctx := context.Background()
require.NoError(t, repo.CreateCatalog(ctx, &types.TenantSkillCatalogEntity{
ID: "cat-pdf", TenantID: 7, Name: "pdf",
}))
require.NoError(t, repo.CreateSkill(ctx, &types.TenantSkillEntity{
ID: "sk-1", TenantID: 7, SandboxConfigID: "cfg-a", CatalogID: "cat-pdf",
Name: "pdf", Status: types.SkillStatusRemoving, Enabled: true,
}))
svc := NewTenantSkillService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
err := svc.DeleteCatalog(ctx, 7, "cat-pdf")
require.Error(t, err)
appErr, ok := apperrors.IsAppError(err)
require.True(t, ok)
require.Equal(t, 409, appErr.HTTPCode)
}
func TestInstallSkillStoresTheArchiveOnTheCatalog(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
archive := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('hi')\n",
})
id, err := fx.svc.InstallSkill(ctx, 7, "cfg-1", archive)
require.NoError(t, err)
cat, err := fx.skillRepo.GetCatalogByName(ctx, 7, "pdf-tools")
require.NoError(t, err)
require.NotNil(t, cat)
require.NotEmpty(t, cat.BundleRef)
require.Equal(t, 1, fx.savedBundles)
skill, err := fx.skillRepo.GetSkill(ctx, 7, "cfg-1", id)
require.NoError(t, err)
require.Equal(t, cat.ID, skill.CatalogID)
require.Empty(t, skill.BundleRef,
"the install row must not copy the catalog object; uninstall must not be able to delete it")
files, err := fx.svc.ListSkillFiles(ctx, 7, "cfg-1", id)
require.NoError(t, err)
require.NotEmpty(t, files)
}
func TestRemovingLastSandboxInstallKeepsTheCatalogArchive(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
archive := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('hi')\n",
})
id, err := fx.svc.InstallSkill(ctx, 7, "cfg-1", archive)
require.NoError(t, err)
skill, err := fx.skillRepo.GetSkill(ctx, 7, "cfg-1", id)
require.NoError(t, err)
skill.Status = types.SkillStatusRemoving
require.NoError(t, fx.skillRepo.UpdateSkill(ctx, skill))
require.NoError(t, fx.svc.runRemove(ctx, 7, "cfg-1", id))
cat, err := fx.skillRepo.GetCatalogByName(ctx, 7, "pdf-tools")
require.NoError(t, err)
require.NotNil(t, cat)
files, err := fx.svc.ListCatalogFiles(ctx, 7, cat.ID)
require.NoError(t, err)
require.NotEmpty(t, files)
require.Empty(t, fx.deletedBundles,
"uninstalling from the last sandbox must not delete the definition zip")
gone, err := fx.skillRepo.GetSkill(ctx, 7, "cfg-1", id)
require.NoError(t, err)
require.Nil(t, gone)
require.NoError(t, fx.svc.DeleteCatalog(ctx, 7, cat.ID))
require.Equal(t, []string{cat.BundleRef}, fx.deletedBundles,
"only deleting the skill from the catalog drops the stored zip")
}
func TestUpsertCatalogDoesNotStampSHAWhenStoreFails(t *testing.T) {
fx := newInstallFixture(t)
fx.saveErr = errors.New("disk full")
ctx := context.Background()
oldSHA := strings.Repeat("c", 64)
require.NoError(t, fx.skillRepo.CreateCatalog(ctx, &types.TenantSkillCatalogEntity{
ID: "cat-1", TenantID: 7, Name: "pdf-tools",
BundleRef: "file://old.zip", BundleSHA256: oldSHA,
}))
archive := zipBundle(t, map[string]string{"SKILL.md": validSkillMD})
bundle, err := ParseSkillBundle(archive)
require.NoError(t, err)
require.NotEqual(t, oldSHA, bundle.SHA256)
got, err := fx.svc.upsertCatalogFromBundle(ctx, 7, bundle, archive, false)
require.NoError(t, err)
require.Equal(t, oldSHA, got.BundleSHA256)
require.Equal(t, "file://old.zip", got.BundleRef)
}
func TestInstallCatalogToConfigsReportsPartialFailure(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
archive := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('hi')\n",
})
cat, err := fx.svc.RegisterCatalogFromArchive(ctx, 7, archive)
require.NoError(t, err)
require.NotNil(t, cat)
result, err := fx.svc.InstallCatalogToConfigs(ctx, 7, cat.ID, []string{"cfg-1", "missing"})
require.NoError(t, err)
require.NotNil(t, result)
require.Contains(t, result.Installs, "cfg-1")
require.Equal(t, "sandbox config not found", result.Errors["missing"])
require.Equal(t, 1, fx.savedBundles,
"installing onto a sandbox must reuse the zip already stored on the catalog")
}
func TestRegisterCatalogReplacesThePreviousZip(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
first := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v1')\n",
})
cat, err := fx.svc.RegisterCatalogFromArchive(ctx, 7, first)
require.NoError(t, err)
firstRef := cat.BundleRef
require.Equal(t, 1, fx.savedBundles)
second := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v2')\n",
})
cat, err = fx.svc.RegisterCatalogFromArchive(ctx, 7, second)
require.NoError(t, err)
require.Equal(t, 2, fx.savedBundles)
require.NotEqual(t, firstRef, cat.BundleRef)
require.Equal(t, []string{firstRef}, fx.deletedBundles,
"replacing the definition zip must drop the previous object")
}
// A re-register that cannot commit the row has to be a no-op. Retiring the
// previous object before the write would leave the stored definition naming a
// ref that no longer resolves, which takes its files down for good — far worse
// than the failed upload the caller asked about.
func TestRegisterCatalogKeepsThePreviousZipWhenTheRowFailsToCommit(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
first := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v1')\n",
})
cat, err := fx.svc.RegisterCatalogFromArchive(ctx, 7, first)
require.NoError(t, err)
firstRef := cat.BundleRef
fx.skillRepo.updateCatalogErr = errors.New("database unavailable")
second := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v2')\n",
})
_, err = fx.svc.RegisterCatalogFromArchive(ctx, 7, second)
require.Error(t, err)
require.Empty(t, fx.deletedBundles,
"the stored definition still names the previous archive, so it must survive")
stored, err := fx.skillRepo.GetCatalog(ctx, 7, cat.ID)
require.NoError(t, err)
require.Equal(t, firstRef, stored.BundleRef)
require.NotEmpty(t, fx.storedBundles[firstRef],
"the ref the row still names has to resolve to bytes")
}
// Two first-time registrations of one name race the unique index. The loser's
// object is keyed by the row ID that was never written, so nothing will ever
// name it again and the retry stores its own copy under the winner's key.
func TestRegisterCatalogDropsTheObjectOfTheRowThatLostTheNameRace(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
archive := zipBundle(t, map[string]string{"SKILL.md": validSkillMD})
fx.skillRepo.createCatalogHook = func(e *types.TenantSkillCatalogEntity) error {
// The winner appears between this caller's name lookup and its insert.
fx.skillRepo.catalogs["cat-winner"] = &types.TenantSkillCatalogEntity{
ID: "cat-winner", TenantID: e.TenantID, Name: e.Name,
}
fx.skillRepo.createCatalogHook = nil
return errors.New("duplicate key value violates unique constraint")
}
cat, err := fx.svc.RegisterCatalogFromArchive(ctx, 7, archive)
require.NoError(t, err)
require.Equal(t, "cat-winner", cat.ID)
require.Equal(t, 2, fx.savedBundles, "the retry stores the archive under the winner's key")
require.Equal(t, []string{"file://bundle-1.zip"}, fx.deletedBundles,
"the object of the row that was never written is unreachable and must go")
require.NotEqual(t, "file://bundle-1.zip", cat.BundleRef)
}
// The sandbox that installed v1 goes on running v1 no matter what the
// definition says next, so the archive it was built from has to outlive the
// re-registration that supersedes it. Deleting it here is what would take
// read_skill and the admin file browser down for an install that works.
func TestRegisterCatalogKeepsTheReplacedZipForInstallsStillOnIt(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
first := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v1')\n",
})
firstBundle, err := ParseSkillBundle(first)
require.NoError(t, err)
skillID, err := fx.svc.InstallSkill(ctx, 7, "cfg-1", first)
require.NoError(t, err)
installed, err := fx.skillRepo.GetSkill(ctx, 7, "cfg-1", skillID)
require.NoError(t, err)
require.Empty(t, installed.BundleRef, "a fresh install reads the definition's copy")
firstRef := fx.catalogRefFor(t, installed.CatalogID)
second := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v2')\n",
})
_, err = fx.svc.RegisterCatalogFromArchive(ctx, 7, second)
require.NoError(t, err)
require.Empty(t, fx.deletedBundles,
"an archive a live install was built from must survive the definition moving on")
installed, err = fx.skillRepo.GetSkill(ctx, 7, "cfg-1", skillID)
require.NoError(t, err)
require.Equal(t, firstRef, installed.BundleRef,
"the install now names the archive itself, so nothing has to remember its version")
require.Equal(t, firstBundle.SHA256, installed.BundleSHA256)
// The point of keeping the object: the file browser still answers with the
// tree this sandbox actually has.
files, err := fx.svc.ListSkillFiles(ctx, 7, "cfg-1", skillID)
require.NoError(t, err)
require.NotEmpty(t, files)
content, err := fx.svc.ReadSkillFile(ctx, 7, "cfg-1", skillID, "scripts/extract.py")
require.NoError(t, err)
require.Contains(t, content.Content, "v1")
}
// A stamp that fails must not move the definition: the install still has an
// empty BundleRef, so committing the new catalog object would make read_skill
// and the file browser follow v2 while this sandbox is still running v1.
func TestRegisterCatalogDoesNotMoveTheDefinitionWhenPinFails(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
first := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v1')\n",
})
skillID, err := fx.svc.InstallSkill(ctx, 7, "cfg-1", first)
require.NoError(t, err)
installed, err := fx.skillRepo.GetSkill(ctx, 7, "cfg-1", skillID)
require.NoError(t, err)
catalogID := installed.CatalogID
firstRef := fx.catalogRefFor(t, catalogID)
fx.skillRepo.updateFailsWhen = func(e *types.TenantSkillEntity) bool {
return strings.TrimSpace(e.BundleRef) == firstRef
}
second := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v2')\n",
})
_, err = fx.svc.RegisterCatalogFromArchive(ctx, 7, second)
require.Error(t, err)
stored, err := fx.skillRepo.GetCatalog(ctx, 7, catalogID)
require.NoError(t, err)
require.Equal(t, firstRef, stored.BundleRef,
"the definition must keep naming v1 when the install could not be pinned")
installed, err = fx.skillRepo.GetSkill(ctx, 7, "cfg-1", skillID)
require.NoError(t, err)
require.Empty(t, installed.BundleRef)
content, err := fx.svc.ReadSkillFile(ctx, 7, "cfg-1", skillID, "scripts/extract.py")
require.NoError(t, err)
require.Contains(t, content.Content, "v1")
}
func TestRegisterCatalogSkipsSaveWhenTheSameZipIsStillStored(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
archive := zipBundle(t, map[string]string{"SKILL.md": validSkillMD})
cat, err := fx.svc.RegisterCatalogFromArchive(ctx, 7, archive)
require.NoError(t, err)
require.Equal(t, 1, fx.savedBundles)
firstRef := cat.BundleRef
cat, err = fx.svc.RegisterCatalogFromArchive(ctx, 7, archive)
require.NoError(t, err)
require.Equal(t, 1, fx.savedBundles, "the same bytes must not mint a second object")
require.Equal(t, firstRef, cat.BundleRef)
}
func TestRegisterCatalogRewritesAMissingObjectWithTheSameSHA(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
archive := zipBundle(t, map[string]string{"SKILL.md": validSkillMD})
cat, err := fx.svc.RegisterCatalogFromArchive(ctx, 7, archive)
require.NoError(t, err)
firstRef := cat.BundleRef
delete(fx.storedBundles, firstRef)
cat, err = fx.svc.RegisterCatalogFromArchive(ctx, 7, archive)
require.NoError(t, err)
require.Equal(t, 2, fx.savedBundles)
require.NotEqual(t, firstRef, cat.BundleRef)
files, err := fx.svc.ListCatalogFiles(ctx, 7, cat.ID)
require.NoError(t, err)
require.NotEmpty(t, files)
}
// The mirror of the test above: once the last install of a pinned archive is
// gone, nothing can reach those bytes again, so removal is what reclaims them.
func TestRemovingThePinnedInstallReclaimsTheReplacedZip(t *testing.T) {
fx := newInstallFixture(t)
ctx := context.Background()
first := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v1')\n",
})
skillID, err := fx.svc.InstallSkill(ctx, 7, "cfg-1", first)
require.NoError(t, err)
installed, err := fx.skillRepo.GetSkill(ctx, 7, "cfg-1", skillID)
require.NoError(t, err)
catalogID := installed.CatalogID
firstRef := fx.catalogRefFor(t, catalogID)
second := zipBundle(t, map[string]string{
"SKILL.md": validSkillMD,
"scripts/extract.py": "print('v2')\n",
})
_, err = fx.svc.RegisterCatalogFromArchive(ctx, 7, second)
require.NoError(t, err)
secondRef := fx.catalogRefFor(t, catalogID)
require.NotEqual(t, firstRef, secondRef)
pinned, err := fx.skillRepo.GetSkill(ctx, 7, "cfg-1", skillID)
require.NoError(t, err)
require.Equal(t, firstRef, pinned.BundleRef)
pinned.Status = types.SkillStatusRemoving
require.NoError(t, fx.skillRepo.UpdateSkill(ctx, pinned))
require.NoError(t, fx.svc.runRemove(ctx, 7, "cfg-1", skillID))
require.Equal(t, []string{firstRef}, fx.deletedBundles,
"the pinned archive is reclaimed with its last reader, the definition's is not")
}
func (f *installFixture) catalogRefFor(t *testing.T, catalogID string) string {
t.Helper()
cat, err := f.skillRepo.GetCatalog(context.Background(), 7, catalogID)
require.NoError(t, err)
require.NotNil(t, cat)
return cat.BundleRef
}