Changeset 0.28.0.8 (#180)
This commit is contained in:
@@ -330,6 +330,16 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
// Tasks (v0.28.0)
|
||||
taskH := NewTaskHandler(stores)
|
||||
protected.GET("/tasks", taskH.ListMine)
|
||||
|
||||
// Memory (v0.18.0, audit v0.28.0)
|
||||
memH := NewMemoryHandler(stores)
|
||||
protected.GET("/memories", memH.ListMyMemories)
|
||||
protected.PUT("/memories/:id", memH.UpdateMemory)
|
||||
protected.DELETE("/memories/:id", memH.DeleteMemory)
|
||||
protected.POST("/memories/:id/approve", memH.ApproveMemory)
|
||||
protected.POST("/memories/:id/reject", memH.RejectMemory)
|
||||
protected.GET("/memories/count", memH.MemoryCount)
|
||||
|
||||
protected.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Create)
|
||||
protected.GET("/tasks/:id", taskH.Get)
|
||||
protected.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Update)
|
||||
@@ -423,6 +433,11 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.POST("/tasks/:id/kill", taskAdm.KillRun)
|
||||
admin.DELETE("/tasks/:id", taskAdm.Delete)
|
||||
|
||||
// Admin memory review
|
||||
adminMemH := NewMemoryHandler(stores)
|
||||
admin.GET("/memories/pending", adminMemH.ListPendingReview)
|
||||
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
|
||||
|
||||
return &testHarness{router: r, t: t}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"memories": memories})
|
||||
if memories == nil {
|
||||
memories = []models.Memory{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": memories})
|
||||
}
|
||||
|
||||
// UpdateMemory updates a memory's key/value/confidence.
|
||||
@@ -84,7 +87,7 @@ func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
|
||||
existing.Confidence = *body.Confidence
|
||||
}
|
||||
|
||||
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
|
||||
if err := h.stores.Memories.Update(c.Request.Context(), existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -128,6 +131,11 @@ func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if existing.Status != models.MemoryStatusPendingReview {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be approved"})
|
||||
return
|
||||
}
|
||||
|
||||
existing.Status = models.MemoryStatusActive
|
||||
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -140,8 +148,26 @@ func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
|
||||
// RejectMemory archives a pending_review memory.
|
||||
// POST /api/v1/memories/:id/reject
|
||||
func (h *MemoryHandler) RejectMemory(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
memoryID := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ownership check: user can only reject their own user-scope memories
|
||||
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
|
||||
return
|
||||
}
|
||||
|
||||
if existing.Status != models.MemoryStatusPendingReview {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be rejected"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -156,15 +182,23 @@ func (h *MemoryHandler) MemoryCount(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
active, _ := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
|
||||
active, err := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Count pending by listing
|
||||
pending, _ := h.stores.Memories.List(ctx, models.MemoryFilter{
|
||||
pending, err := h.stores.Memories.List(ctx, models.MemoryFilter{
|
||||
Scope: models.MemoryScopeUser,
|
||||
OwnerID: userID,
|
||||
Status: models.MemoryStatusPendingReview,
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": active,
|
||||
@@ -177,22 +211,18 @@ func (h *MemoryHandler) MemoryCount(c *gin.Context) {
|
||||
// ListPendingReview returns all pending_review memories (admin only).
|
||||
// GET /api/v1/admin/memories/pending
|
||||
func (h *MemoryHandler) ListPendingReview(c *gin.Context) {
|
||||
// This should be called from the admin route group which already checks admin role.
|
||||
// We query across all users by listing each scope.
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Query pending memories directly via raw SQL for efficiency
|
||||
var memories []models.Memory
|
||||
memories, err := h.stores.Memories.ListByStatus(ctx, models.MemoryStatusPendingReview, 200)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userMemories, _ := h.stores.Memories.List(ctx, models.MemoryFilter{
|
||||
Scope: models.MemoryScopeUser,
|
||||
OwnerID: "%", // This won't work with List — we need a custom query
|
||||
Status: models.MemoryStatusPendingReview,
|
||||
Limit: 100,
|
||||
})
|
||||
memories = append(memories, userMemories...)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"memories": memories})
|
||||
if memories == nil {
|
||||
memories = []models.Memory{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": memories})
|
||||
}
|
||||
|
||||
// BulkApprove approves multiple memories at once.
|
||||
@@ -213,6 +243,9 @@ func (h *MemoryHandler) BulkApprove(c *gin.Context) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if existing.Status != models.MemoryStatusPendingReview {
|
||||
continue
|
||||
}
|
||||
existing.Status = models.MemoryStatusActive
|
||||
if err := h.stores.Memories.Upsert(ctx, existing); err == nil {
|
||||
approved++
|
||||
|
||||
435
server/handlers/memory_test.go
Normal file
435
server/handlers/memory_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// seedMemory inserts a memory directly into the DB for testing.
|
||||
func seedMemory(t *testing.T, ownerID, key, value, status string) string {
|
||||
t.Helper()
|
||||
id := seedID()
|
||||
q := dialectSQL(`
|
||||
INSERT INTO memories (id, scope, owner_id, key, value, confidence, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`)
|
||||
_, err := database.TestDB.Exec(q, id, models.MemoryScopeUser, ownerID, key, value, 0.9, status)
|
||||
if err != nil {
|
||||
t.Fatalf("seedMemory: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// List
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_ListEmpty(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("memuser1", "memuser1@test.com")
|
||||
|
||||
w := h.request("GET", "/api/v1/memories", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Must use "data" key (envelope convention)
|
||||
data, ok := resp["data"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("response must have 'data' array key, got: %s", w.Body.String())
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Fatalf("expected 0 memories, got %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_ListWithData(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memuser2", "memuser2@test.com")
|
||||
|
||||
seedMemory(t, userID, "lang", "Go", models.MemoryStatusActive)
|
||||
seedMemory(t, userID, "editor", "Vim", models.MemoryStatusActive)
|
||||
seedMemory(t, userID, "pending-fact", "something", models.MemoryStatusPendingReview)
|
||||
|
||||
// Default status=active
|
||||
w := h.request("GET", "/api/v1/memories", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
data := resp["data"].([]interface{})
|
||||
if len(data) != 2 {
|
||||
t.Fatalf("expected 2 active memories, got %d", len(data))
|
||||
}
|
||||
|
||||
// Explicit status=pending_review
|
||||
w = h.request("GET", "/api/v1/memories?status=pending_review", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list pending: want 200, got %d", w.Code)
|
||||
}
|
||||
decode(w, &resp)
|
||||
data = resp["data"].([]interface{})
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("expected 1 pending memory, got %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_ListIsolation(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userA, tokenA := h.createAdminUser("memA", "memA@test.com")
|
||||
userB, _ := h.createAdminUser("memB", "memB@test.com")
|
||||
|
||||
seedMemory(t, userA, "a-fact", "user A", models.MemoryStatusActive)
|
||||
seedMemory(t, userB, "b-fact", "user B", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("GET", "/api/v1/memories", tokenA, nil)
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
data := resp["data"].([]interface{})
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("user A should only see 1 memory, got %d", len(data))
|
||||
}
|
||||
mem := data[0].(map[string]interface{})
|
||||
if mem["key"].(string) != "a-fact" {
|
||||
t.Fatalf("wrong memory: got key %q", mem["key"])
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Update
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_Update(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memupdater", "memupdater@test.com")
|
||||
|
||||
memID := seedMemory(t, userID, "old-key", "old-value", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("PUT", "/api/v1/memories/"+memID, token, map[string]interface{}{
|
||||
"key": "new-key",
|
||||
"value": "new-value",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
mem := resp["memory"].(map[string]interface{})
|
||||
if mem["key"].(string) != "new-key" {
|
||||
t.Fatalf("key not updated: got %q", mem["key"])
|
||||
}
|
||||
if mem["value"].(string) != "new-value" {
|
||||
t.Fatalf("value not updated: got %q", mem["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_UpdateOwnershipDenied(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
ownerID, _ := h.createAdminUser("memowner", "memowner@test.com")
|
||||
_, otherToken := h.createAdminUser("memother", "memother@test.com")
|
||||
|
||||
memID := seedMemory(t, ownerID, "secret", "mine", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("PUT", "/api/v1/memories/"+memID, otherToken, map[string]interface{}{
|
||||
"value": "hacked",
|
||||
})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("update other's memory: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_UpdateNotFound(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("memghost", "memghost@test.com")
|
||||
|
||||
w := h.request("PUT", "/api/v1/memories/"+seedID(), token, map[string]interface{}{
|
||||
"value": "x",
|
||||
})
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("update missing: want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Delete
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_Delete(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memdeleter", "memdeleter@test.com")
|
||||
|
||||
memID := seedMemory(t, userID, "to-delete", "gone", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("DELETE", "/api/v1/memories/"+memID, token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify gone — list should be empty
|
||||
w = h.request("GET", "/api/v1/memories", token, nil)
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
data := resp["data"].([]interface{})
|
||||
if len(data) != 0 {
|
||||
t.Fatalf("memory should be deleted, got %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_DeleteOwnershipDenied(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
ownerID, _ := h.createAdminUser("delowner", "delowner@test.com")
|
||||
_, otherToken := h.createAdminUser("delother", "delother@test.com")
|
||||
|
||||
memID := seedMemory(t, ownerID, "no-touch", "x", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("DELETE", "/api/v1/memories/"+memID, otherToken, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete other's memory: want 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Approve
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_Approve(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memapprover", "memapprover@test.com")
|
||||
|
||||
memID := seedMemory(t, userID, "pending", "val", models.MemoryStatusPendingReview)
|
||||
|
||||
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("approve: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
mem := resp["memory"].(map[string]interface{})
|
||||
if mem["status"].(string) != models.MemoryStatusActive {
|
||||
t.Fatalf("status should be active, got %q", mem["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_ApproveStatusGuard(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memguard1", "memguard1@test.com")
|
||||
|
||||
// Try to approve an already-active memory
|
||||
memID := seedMemory(t, userID, "already-active", "val", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("approve active memory: want 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Try to approve an archived memory
|
||||
archivedID := seedMemory(t, userID, "archived", "val", models.MemoryStatusArchived)
|
||||
w = h.request("POST", "/api/v1/memories/"+archivedID+"/approve", token, nil)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("approve archived memory: want 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Reject
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_Reject(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memrejecter", "memrejecter@test.com")
|
||||
|
||||
memID := seedMemory(t, userID, "to-reject", "val", models.MemoryStatusPendingReview)
|
||||
|
||||
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("reject: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
if resp["rejected"] != true {
|
||||
t.Fatal("response should have rejected: true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_RejectStatusGuard(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memguard2", "memguard2@test.com")
|
||||
|
||||
// Can't reject an already-active memory
|
||||
memID := seedMemory(t, userID, "active-reject", "val", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("reject active memory: want 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_RejectOwnershipDenied(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
ownerID, _ := h.createAdminUser("rejowner", "rejowner@test.com")
|
||||
_, otherToken := h.createAdminUser("rejother", "rejother@test.com")
|
||||
|
||||
memID := seedMemory(t, ownerID, "not-yours", "val", models.MemoryStatusPendingReview)
|
||||
|
||||
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", otherToken, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("reject other's memory: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Count
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_Count(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memcounter", "memcounter@test.com")
|
||||
|
||||
seedMemory(t, userID, "fact1", "a", models.MemoryStatusActive)
|
||||
seedMemory(t, userID, "fact2", "b", models.MemoryStatusActive)
|
||||
seedMemory(t, userID, "pend1", "c", models.MemoryStatusPendingReview)
|
||||
|
||||
w := h.request("GET", "/api/v1/memories/count", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("count: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Response shape: {"active": N, "pending": N}
|
||||
active := int(resp["active"].(float64))
|
||||
pending := int(resp["pending"].(float64))
|
||||
if active != 2 {
|
||||
t.Fatalf("expected 2 active, got %d", active)
|
||||
}
|
||||
if pending != 1 {
|
||||
t.Fatalf("expected 1 pending, got %d", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Admin — List Pending
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_AdminListPending(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userA, _ := h.createAdminUser("adminmemA", "adminmemA@test.com")
|
||||
userB, _ := h.createAdminUser("adminmemB", "adminmemB@test.com")
|
||||
_, adminToken := h.createAdminUser("memadmin", "memadmin@test.com")
|
||||
|
||||
// Seed pending memories for both users
|
||||
seedMemory(t, userA, "factA", "from A", models.MemoryStatusPendingReview)
|
||||
seedMemory(t, userB, "factB", "from B", models.MemoryStatusPendingReview)
|
||||
seedMemory(t, userA, "active-A", "active", models.MemoryStatusActive) // should NOT appear
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/memories/pending", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin pending: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
data := resp["data"].([]interface{})
|
||||
if len(data) != 2 {
|
||||
t.Fatalf("expected 2 pending memories across users, got %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_AdminListPendingRequiresAdmin(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
_, userToken := h.registerUser("normalmem", "normalmem@test.com", "password123")
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/memories/pending", userToken, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin should be forbidden: want 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Admin — Bulk Approve
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_AdminBulkApprove(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, _ := h.createAdminUser("bulkowner", "bulkowner@test.com")
|
||||
_, adminToken := h.createAdminUser("bulkadmin", "bulkadmin@test.com")
|
||||
|
||||
id1 := seedMemory(t, userID, "bulk1", "a", models.MemoryStatusPendingReview)
|
||||
id2 := seedMemory(t, userID, "bulk2", "b", models.MemoryStatusPendingReview)
|
||||
id3 := seedMemory(t, userID, "already-active", "c", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, map[string]interface{}{
|
||||
"ids": []string{id1, id2, id3},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("bulk approve: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
approved := int(resp["approved"].(float64))
|
||||
if approved != 2 {
|
||||
t.Fatalf("expected 2 approved (skipping already-active), got %d", approved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemory_AdminBulkApproveInvalidBody(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("bulkbad", "bulkbad@test.com")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, "not-json")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad body: want 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Field Shape Verification
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestMemory_FieldShape(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
userID, token := h.createAdminUser("memshape", "memshape@test.com")
|
||||
|
||||
seedMemory(t, userID, "test-key", "test-value", models.MemoryStatusActive)
|
||||
|
||||
w := h.request("GET", "/api/v1/memories", token, nil)
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
data := resp["data"].([]interface{})
|
||||
mem := data[0].(map[string]interface{})
|
||||
|
||||
// Verify all expected fields exist
|
||||
requiredFields := []string{"id", "scope", "owner_id", "key", "value", "confidence", "status", "created_at", "updated_at"}
|
||||
for _, f := range requiredFields {
|
||||
if _, ok := mem[f]; !ok {
|
||||
t.Errorf("missing field %q in memory object", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no legacy "content" field
|
||||
if _, ok := mem["content"]; ok {
|
||||
t.Error("memory should not have 'content' field (legacy ICD)")
|
||||
}
|
||||
// Verify no legacy "persona_id" field
|
||||
if _, ok := mem["persona_id"]; ok {
|
||||
t.Error("memory should not have 'persona_id' field (legacy ICD)")
|
||||
}
|
||||
|
||||
// Verify scope is correct
|
||||
if mem["scope"].(string) != models.MemoryScopeUser {
|
||||
t.Errorf("scope should be %q, got %q", models.MemoryScopeUser, mem["scope"])
|
||||
}
|
||||
|
||||
// Confidence should be a number
|
||||
confidence, ok := mem["confidence"].(float64)
|
||||
if !ok || confidence < 0 || confidence > 1 {
|
||||
t.Errorf("confidence should be 0-1 float, got %v", mem["confidence"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user