Files
kandev/apps/backend/internal/task/share/service_test.go
T
2026-08-26 00:58:23 +00:00

316 lines
9.7 KiB
Go

package share
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/task/models"
)
// mockBackend records uploads and supports configurable upload/delete errors.
type mockBackend struct {
uploads int
uploadWorkspaces []string
uploadLocales []string
deletes []string
deleteWorkspaces []string
accessWorkspaces []string
uploadErr error
deleteErr error
accessErr error
nextID string
nextURL string
}
func (m *mockBackend) Name() string { return BackendGitHubGist }
func (m *mockBackend) CheckAccess(_ context.Context, workspaceID string) error {
m.accessWorkspaces = append(m.accessWorkspaces, workspaceID)
return m.accessErr
}
func (m *mockBackend) Upload(
_ context.Context, workspaceID string, _ *Snapshot, locale string,
) (string, string, error) {
if m.uploadErr != nil {
return "", "", m.uploadErr
}
m.uploads++
m.uploadWorkspaces = append(m.uploadWorkspaces, workspaceID)
m.uploadLocales = append(m.uploadLocales, locale)
id := m.nextID
if id == "" {
id = "gist-1"
}
url := m.nextURL
if url == "" {
url = "https://gist.github.com/u/" + id
}
return id, url, nil
}
func (m *mockBackend) Delete(_ context.Context, workspaceID, id string) error {
if m.deleteErr != nil {
return m.deleteErr
}
m.deletes = append(m.deletes, id)
m.deleteWorkspaces = append(m.deleteWorkspaces, workspaceID)
return nil
}
func completedSession() *stubReader {
completed := time.Now().UTC()
return &stubReader{
task: &models.Task{ID: "t-1", WorkspaceID: "workspace-1", Title: "test task"},
session: &models.TaskSession{
ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
StartedAt: completed.Add(-time.Minute), CompletedAt: &completed,
},
messages: []*models.Message{
{ID: "m-1", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "hi", CreatedAt: completed},
},
}
}
func TestService_CreateShare_HappyPath(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
mock := &mockBackend{nextID: "abc", nextURL: "https://gist.github.com/u/abc"}
svc := New(repo, completedSession(), allowShareAccess{}, mock, nil, "test-version")
share, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
if share.ExternalID != "abc" || share.ExternalURL != "https://gist.github.com/u/abc" {
t.Fatalf("unexpected share: %+v", share)
}
if len(mock.uploadWorkspaces) != 1 || mock.uploadWorkspaces[0] != "workspace-1" {
t.Fatalf("upload workspaces = %v, want workspace-1", mock.uploadWorkspaces)
}
if share.SnapshotSizeBytes <= 0 {
t.Fatalf("expected non-zero snapshot size, got %d", share.SnapshotSizeBytes)
}
got, err := repo.GetByID(context.Background(), share.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.ID != share.ID {
t.Fatalf("row missing after create")
}
}
func TestService_CreateShare_RejectsPreHistorySession(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
reader := completedSession()
reader.session.State = models.TaskSessionStateCreated
mock := &mockBackend{}
svc := New(repo, reader, allowShareAccess{}, mock, nil, "v")
_, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if !errors.Is(err, ErrSessionNotShareable) {
t.Fatalf("expected ErrSessionNotShareable, got %v", err)
}
if mock.uploads != 0 {
t.Fatalf("backend should not be called when session is pre-history")
}
}
func TestService_CreateShare_AllowsRunningSession(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
reader := completedSession()
reader.session.State = models.TaskSessionStateRunning
mock := &mockBackend{nextID: "g1"}
svc := New(repo, reader, allowShareAccess{}, mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if err != nil {
t.Fatalf("running session should be shareable, got %v", err)
}
if share.ExternalID != "g1" {
t.Fatalf("expected backend to be called, got share %+v", share)
}
}
func TestService_CreateShare_BackendErrorLeavesNoRow(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
mock := &mockBackend{uploadErr: errors.New("gateway down")}
svc := New(repo, completedSession(), allowShareAccess{}, mock, nil, "v")
_, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if err == nil {
t.Fatal("expected upload error")
}
if !strings.Contains(err.Error(), "gateway down") {
t.Fatalf("unexpected error: %v", err)
}
rows, err := repo.ListByTaskSession(context.Background(), "s-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected zero rows after backend failure, got %d", len(rows))
}
}
func TestService_RevokeShare_DeletesAndMarksRevoked(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), allowShareAccess{}, mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
if err := svc.RevokeShare(context.Background(), share.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if len(mock.deletes) != 1 || mock.deletes[0] != "abc" {
t.Fatalf("expected backend Delete(abc), got %v", mock.deletes)
}
if len(mock.deleteWorkspaces) != 1 || mock.deleteWorkspaces[0] != "workspace-1" {
t.Fatalf("delete workspaces = %v, want workspace-1", mock.deleteWorkspaces)
}
got, err := repo.GetByID(context.Background(), share.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.RevokedAt == nil {
t.Fatal("expected RevokedAt to be set")
}
}
func TestService_CreateShare_FailsClosedWithoutTaskWorkspace(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
reader := completedSession()
reader.task.WorkspaceID = ""
mock := &mockBackend{}
svc := New(repo, reader, allowShareAccess{}, mock, nil, "v")
_, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if !errors.Is(err, ErrWorkspaceRequired) || mock.uploads != 0 {
t.Fatalf("CreateShare() error = %v, uploads = %d", err, mock.uploads)
}
}
func TestService_RevokeShare_IsIdempotentOnSecondCall(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), allowShareAccess{}, mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
if err := svc.RevokeShare(context.Background(), share.ID); err != nil {
t.Fatalf("first revoke: %v", err)
}
if err := svc.RevokeShare(context.Background(), share.ID); err != nil {
t.Fatalf("second revoke should be a no-op, got %v", err)
}
// Backend Delete should only have been called once; the second revoke
// short-circuits because IsRevoked() is true.
if len(mock.deletes) != 1 {
t.Fatalf("expected 1 delete call, got %d", len(mock.deletes))
}
}
func TestService_RevokeShare_SurvivesUpstream404(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), allowShareAccess{}, mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
mock.deleteErr = &github.GitHubAPIError{StatusCode: http.StatusNotFound, Endpoint: "/gists/abc"}
if err := svc.RevokeShare(context.Background(), share.ID); err != nil {
t.Fatalf("revoke should swallow 404, got %v", err)
}
got, err := repo.GetByID(context.Background(), share.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.RevokedAt == nil {
t.Fatal("revoked_at should still be set after upstream 404")
}
}
func TestService_RevokeShare_PropagatesOtherBackendErrors(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
mock := &mockBackend{nextID: "abc"}
svc := New(repo, completedSession(), allowShareAccess{}, mock, nil, "v")
share, err := svc.CreateShare(context.Background(), "t-1", "s-1", "en")
if err != nil {
t.Fatalf("create: %v", err)
}
mock.deleteErr = errors.New("network down")
err = svc.RevokeShare(context.Background(), share.ID)
if err == nil || !strings.Contains(err.Error(), "network down") {
t.Fatalf("expected propagated error, got %v", err)
}
got, err := repo.GetByID(context.Background(), share.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.RevokedAt != nil {
t.Fatal("row should NOT be marked revoked when backend delete failed for a non-404 reason")
}
}
func TestService_PreviewSnapshot_ReturnsRedactedWithoutUpload(t *testing.T) {
t.Parallel()
repo := newTestRepo(t)
mock := &mockBackend{}
svc := New(repo, completedSession(), allowShareAccess{}, mock, nil, "v")
snap, err := svc.PreviewSnapshot(context.Background(), "t-1", "s-1")
if err != nil {
t.Fatalf("preview: %v", err)
}
if snap == nil || snap.Task.Title == "" {
t.Fatalf("expected non-empty snapshot, got %+v", snap)
}
if mock.uploads != 0 {
t.Fatalf("preview must not call backend Upload")
}
}
// TestService_CreateShare_ForwardsLocaleToBackend pins the middle hop of the
// locale chain. The published artifact is static, so this argument is the only
// thing that decides its language — a hop that dropped it would silently
// publish English and no render test would notice.
func TestService_CreateShare_ForwardsLocaleToBackend(t *testing.T) {
t.Parallel()
for _, locale := range []string{"en", "pseudo"} {
locale := locale
t.Run(locale, func(t *testing.T) {
t.Parallel()
mock := &mockBackend{}
svc := New(newTestRepo(t), completedSession(), allowShareAccess{}, mock, nil, "v")
if _, err := svc.CreateShare(context.Background(), "t-1", "s-1", locale); err != nil {
t.Fatalf("CreateShare: %v", err)
}
if len(mock.uploadLocales) != 1 || mock.uploadLocales[0] != locale {
t.Fatalf("backend received locales %v, want [%s]", mock.uploadLocales, locale)
}
})
}
}