31e07fab34
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
466 lines
18 KiB
Go
466 lines
18 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
goruntime "runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
|
|
"github.com/kandev/kandev/internal/plugins/state"
|
|
)
|
|
|
|
// newTestUserStateStore returns an in-memory-sqlite-backed *state.UserStore.
|
|
func newTestUserStateStore(t *testing.T) *state.UserStore {
|
|
t.Helper()
|
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
|
|
st, err := state.NewUserStore(db.NewPool(conn, conn))
|
|
if err != nil {
|
|
t.Fatalf("new user state store: %v", err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
// userStatePluginPackage builds a valid, runtime-managed plugin tar.gz
|
|
// declaring (or not) the user_state capability.
|
|
func userStatePluginPackage(t *testing.T, id string, userStateEnabled bool) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: 1.0.0
|
|
display_name: Test Plugin
|
|
capabilities:
|
|
user_state: %v
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
`, id, userStateEnabled, platformKey)
|
|
|
|
var buf bytes.Buffer
|
|
files := map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
}
|
|
if err := pkgtartest.WritePackage(&buf, files); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
// newUserStateTestRouter wires a Service (with UserState + EventBus) behind
|
|
// a router that injects an identity from the X-Test-User-ID header
|
|
// (defaulting to "user_1"), mirroring how the real global auth middleware
|
|
// injects authn.Identity before plugin routes run.
|
|
func newUserStateTestRouter(t *testing.T) (*gin.Engine, *Service, bus.EventBus) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
svc, _, _ := newTestService(t)
|
|
svc.SetUserState(newTestUserStateStore(t))
|
|
memBus := bus.NewMemoryEventBus(testLogger(t))
|
|
svc.eventBus = memBus
|
|
|
|
router := gin.New()
|
|
router.Use(func(c *gin.Context) {
|
|
userID := c.GetHeader("X-Test-User-ID")
|
|
if userID == "" {
|
|
userID = "user_1"
|
|
}
|
|
authn.SetOnGin(c, authn.Identity{UserID: userID, Role: authn.RoleMember})
|
|
c.Next()
|
|
})
|
|
RegisterRoutes(router, svc, nil, testLogger(t))
|
|
return router, svc, memBus
|
|
}
|
|
|
|
func installUserStatePlugin(t *testing.T, svc *Service, id string, userStateEnabled bool) {
|
|
t.Helper()
|
|
if _, err := svc.Install(context.Background(), userStatePluginPackage(t, id, userStateEnabled)); err != nil {
|
|
t.Fatalf("install %q: %v", id, err)
|
|
}
|
|
}
|
|
|
|
func userStateReq(router *gin.Engine, method, path, userID, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
if userID != "" {
|
|
req.Header.Set("X-Test-User-ID", userID)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// TestUserStatePUTThenGETRoundTripsForCallingUser pins AC15's positive path.
|
|
func TestUserStatePUTThenGETRoundTripsForCallingUser(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", `{"value":"hello"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec = userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if string(resp.Value) != `"hello"` {
|
|
t.Fatalf("value = %q, want %q", resp.Value, `"hello"`)
|
|
}
|
|
}
|
|
|
|
// TestUserStateGETFromDifferentUserReturns404 pins AC15's cross-user negative.
|
|
func TestUserStateGETFromDifferentUserReturns404(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", `{"value":"alice's note"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec = userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_2", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET (different user) status = %d, want 404, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUserStateWorkspaceScopeIsolatesUsersAndScopes covers the workspace
|
|
// scope the way TestUserStateGETFromDifferentUserReturns404 covers the task
|
|
// scope. Workspace-scoped entries only became a shipping surface with the
|
|
// sidebar-workspace-actions slot (kandev-plugin-notes' per-workspace note),
|
|
// and that note's UI promises "only you can see this note" — so the negative
|
|
// is asserted for every verb, not just GET: a second user must not read,
|
|
// overwrite, delete, or enumerate the first user's workspace note.
|
|
//
|
|
// The same subtest also pins scope separation on an identical raw id:
|
|
// task/X and workspace/X are different entries, which is what lets the
|
|
// plugin key one cache by "<scope>:<id>" without conflating the two.
|
|
func TestUserStateWorkspaceScopeIsolatesUsersAndScopes(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
const (
|
|
wsPath = "/api/plugins/kandev-plugin-notes/user-state/workspace/ws_1/note"
|
|
wsList = "/api/plugins/kandev-plugin-notes/user-state/workspace/ws_1"
|
|
taskPath = "/api/plugins/kandev-plugin-notes/user-state/task/ws_1/note"
|
|
)
|
|
|
|
rec := userStateReq(router, http.MethodPut, wsPath, "user_1", `{"value":"alice's workspace note"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec = userStateReq(router, http.MethodPut, taskPath, "user_1", `{"value":"task-scoped"}`); rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT task scope status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// user_2 must not read it.
|
|
if rec = userStateReq(router, http.MethodGet, wsPath, "user_2", ""); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET (different user) status = %d, want 404, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// user_2 must not enumerate it.
|
|
rec = userStateReq(router, http.MethodGet, wsList, "user_2", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("LIST (different user) status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var user2List struct {
|
|
Entries []state.UserStateEntry `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &user2List); err != nil {
|
|
t.Fatalf("unmarshal LIST (different user): %v", err)
|
|
}
|
|
if len(user2List.Entries) != 0 {
|
|
t.Fatalf("LIST (different user) returned entries: %+v", user2List.Entries)
|
|
}
|
|
|
|
assertUserStateValue(t, router, taskPath, "user_1", `"task-scoped"`)
|
|
rec = userStateReq(router, http.MethodGet, wsList, "user_1", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("LIST (workspace scope) status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var workspaceList struct {
|
|
Entries []state.UserStateEntry `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &workspaceList); err != nil {
|
|
t.Fatalf("unmarshal LIST (workspace scope): %v", err)
|
|
}
|
|
if len(workspaceList.Entries) != 1 || workspaceList.Entries[0].Key != "note" {
|
|
t.Fatalf("LIST (workspace scope) entries = %+v, want only note", workspaceList.Entries)
|
|
}
|
|
|
|
// user_2 writing the same path must not disturb user_1's value, and
|
|
// user_2 deleting it must not delete user_1's.
|
|
if rec = userStateReq(router, http.MethodPut, wsPath, "user_2", `{"value":"bob's own"}`); rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT (different user) status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec = userStateReq(router, http.MethodDelete, wsPath, "user_2", ""); rec.Code != http.StatusOK {
|
|
t.Fatalf("DELETE (different user) status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
assertUserStateValue(t, router, wsPath, "user_1", `"alice's workspace note"`)
|
|
assertUserStateValue(t, router, taskPath, "user_1", `"task-scoped"`)
|
|
|
|
// task/ws_1 and workspace/ws_1 are distinct entries for the same user.
|
|
if rec = userStateReq(router, http.MethodDelete, wsPath, "user_1", ""); rec.Code != http.StatusOK {
|
|
t.Fatalf("DELETE workspace scope status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
assertUserStateValue(t, router, taskPath, "user_1", `"task-scoped"`)
|
|
}
|
|
|
|
// assertUserStateValue fails unless userID's entry at path holds want.
|
|
func assertUserStateValue(t *testing.T, router *gin.Engine, path, userID, want string) {
|
|
t.Helper()
|
|
rec := userStateReq(router, http.MethodGet, path, userID, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s status = %d, want 200, body=%s", path, rec.Code, rec.Body.String())
|
|
}
|
|
var resp struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", path, err)
|
|
}
|
|
if string(resp.Value) != want {
|
|
t.Fatalf("GET %s value = %s, want %s", path, resp.Value, want)
|
|
}
|
|
}
|
|
|
|
// TestUserStateWithoutCapabilityReturns403 pins AC17.
|
|
func TestUserStateWithoutCapabilityReturns403(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-no-user-state", false)
|
|
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-no-user-state/user-state/task/task_1/note", "user_1", `{"value":"x"}`)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUserStateUnknownPluginReturns404 and disabled-plugin variant pin AC17's
|
|
// 404 half.
|
|
func TestUserStateUnknownPluginReturns404(t *testing.T) {
|
|
router, _, _ := newUserStateTestRouter(t)
|
|
|
|
rec := userStateReq(router, http.MethodGet, "/api/plugins/does-not-exist/user-state/task/task_1/note", "user_1", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUserStateDisabledPluginReturns404(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
if err := svc.Disable("kandev-plugin-notes"); err != nil {
|
|
t.Fatalf("disable: %v", err)
|
|
}
|
|
|
|
rec := userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUserStateInvalidScopeReturns400 and TestUserStateInvalidScopeIDReturns400
|
|
// pin AC18.
|
|
func TestUserStateInvalidScopeReturns400(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
rec := userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/bogus-scope/task_1/note", "user_1", "")
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUserStateInvalidScopeIDReturns400(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
// A leading "." fails the [A-Za-z0-9] leading-char requirement.
|
|
rec := userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/task/.bad/note", "user_1", "")
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUserStateInvalidKeyReturns400(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/.bad-key", "user_1", `{"value":"x"}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUserStateOversizedBodyReturns413 pins AC18's body cap.
|
|
func TestUserStateOversizedBodyReturns413(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
huge := `{"value":"` + strings.Repeat("x", maxUserStateBodyBytes+1) + `"}`
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", huge)
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want 413, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUserStateListReturnsOrderedEntries pins AC27.
|
|
func TestUserStateListReturnsOrderedEntries(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
for _, key := range []string{"zeta", "alpha"} {
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/"+key, "user_1", `{"value":1}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT %s status = %d, body=%s", key, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
rec := userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/task/task_1", "user_1", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("LIST status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp struct {
|
|
Entries []state.UserStateEntry `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(resp.Entries) != 2 || resp.Entries[0].Key != "alpha" || resp.Entries[1].Key != "zeta" {
|
|
t.Fatalf("entries = %+v, want [alpha, zeta] order", resp.Entries)
|
|
}
|
|
}
|
|
|
|
// TestUserStateConditionalWriteConflictReturns409 pins AC28.
|
|
func TestUserStateConditionalWriteConflictReturns409(t *testing.T) {
|
|
router, svc, _ := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", `{"value":"v1"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("first PUT status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
stale := `{"value":"v2","ifUnmodifiedSince":"2000-01-01T00:00:00Z"}`
|
|
rec = userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", stale)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("conflicting PUT status = %d, want 409, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec = userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", "")
|
|
var resp struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if string(resp.Value) != `"v1"` {
|
|
t.Fatalf("value after conflicting write = %q, want unchanged \"v1\"", resp.Value)
|
|
}
|
|
}
|
|
|
|
// TestUserStatePUTPublishesEventScopedToWriter pins AC24: a successful PUT
|
|
// publishes plugin.user-state.updated carrying the writer's user_id.
|
|
//
|
|
// user_id is deliberately readable here, on the raw published bus event —
|
|
// that is what lets UserEventBroadcaster route it under a NATS-backed
|
|
// EventBus, which marshals every event before publishing and would silently
|
|
// drop an unexported field (see pluginUserStateUpdatedEvent's doc comment).
|
|
// The "never reaches the browser" half of that contract is enforced one
|
|
// layer up, in the broadcaster itself: see
|
|
// internal/gateway/websocket/user_notifications_test.go's
|
|
// TestRegisterUserNotifications_PluginUserStatePayloadShape and its
|
|
// NATS-transport-simulating sibling.
|
|
func TestUserStatePUTPublishesEventScopedToWriter(t *testing.T) {
|
|
router, svc, memBus := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
var gotPayload map[string]any
|
|
_, err := memBus.Subscribe(events.PluginUserStateUpdated, func(_ context.Context, evt *bus.Event) error {
|
|
raw, _ := json.Marshal(evt.Data)
|
|
_ = json.Unmarshal(raw, &gotPayload)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("subscribe: %v", err)
|
|
}
|
|
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", `{"value":"hi","writerId":"tab-42"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
if gotPayload["user_id"] != "user_1" {
|
|
t.Fatalf("published event user_id = %v, want %q (must survive marshal for NATS routing)", gotPayload["user_id"], "user_1")
|
|
}
|
|
if gotPayload["pluginId"] != "kandev-plugin-notes" || gotPayload["scope"] != "task" ||
|
|
gotPayload["scopeId"] != "task_1" || gotPayload["key"] != "note" || gotPayload["writerId"] != "tab-42" {
|
|
t.Fatalf("event payload = %+v, missing expected fields", gotPayload)
|
|
}
|
|
if deleted, _ := gotPayload["deleted"].(bool); deleted {
|
|
t.Fatalf("PUT event payload.deleted = true, want false/absent")
|
|
}
|
|
}
|
|
|
|
// TestUserStateDELETEPublishesDeletedEvent covers the DELETE half of AC24.
|
|
func TestUserStateDELETEPublishesDeletedEvent(t *testing.T) {
|
|
router, svc, memBus := newUserStateTestRouter(t)
|
|
installUserStatePlugin(t, svc, "kandev-plugin-notes", true)
|
|
|
|
var gotDeleted bool
|
|
_, err := memBus.Subscribe(events.PluginUserStateUpdated, func(_ context.Context, evt *bus.Event) error {
|
|
raw, _ := json.Marshal(evt.Data)
|
|
var payload map[string]any
|
|
_ = json.Unmarshal(raw, &payload)
|
|
gotDeleted, _ = payload["deleted"].(bool)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("subscribe: %v", err)
|
|
}
|
|
|
|
rec := userStateReq(router, http.MethodPut, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", `{"value":"hi"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = userStateReq(router, http.MethodDelete, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("DELETE status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !gotDeleted {
|
|
t.Fatalf("expected DELETE to publish an event with deleted=true")
|
|
}
|
|
|
|
rec = userStateReq(router, http.MethodGet, "/api/plugins/kandev-plugin-notes/user-state/task/task_1/note", "user_1", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET after delete status = %d, want 404", rec.Code)
|
|
}
|
|
}
|