Changeset 0.28.2.3 (#186)
This commit is contained in:
@@ -4,84 +4,268 @@
|
||||
knowledge bases, notes, and files. They follow the scope model
|
||||
(personal, team, global).
|
||||
|
||||
### Project CRUD
|
||||
All endpoints require authentication (`Authorization: Bearer <token>`).
|
||||
Admin endpoints additionally require `role=admin`.
|
||||
|
||||
```
|
||||
GET /projects → { "data": [...] }
|
||||
POST /projects ← { "name", "description", "scope", "team_id", "persona_id", "system_prompt" }
|
||||
GET /projects/:id → project object
|
||||
PUT /projects/:id ← partial update
|
||||
DELETE /projects/:id
|
||||
```
|
||||
|
||||
Project object:
|
||||
## Project Object
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Q3 Research",
|
||||
"description": "...",
|
||||
"color": "#3b82f6",
|
||||
"icon": "folder",
|
||||
"scope": "personal|team|global",
|
||||
"owner_id": "uuid",
|
||||
"team_id": "uuid|null",
|
||||
"persona_id": "uuid|null",
|
||||
"system_prompt": "...|null",
|
||||
"workspace_id": "uuid|null",
|
||||
"is_archived": false,
|
||||
"settings": {},
|
||||
"channel_count": 5,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
"kb_count": 2,
|
||||
"note_count": 3,
|
||||
"created_at": "2025-01-15T10:30:00Z",
|
||||
"updated_at": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Channel Association
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `id` | uuid | PK, auto-generated |
|
||||
| `name` | string | Required, max 200 chars |
|
||||
| `description` | string | Optional |
|
||||
| `color` | string? | CSS hex color, max 7 chars |
|
||||
| `icon` | string? | Icon identifier, max 50 chars |
|
||||
| `scope` | enum | `personal`, `team`, `global` |
|
||||
| `owner_id` | uuid | Set from JWT, immutable |
|
||||
| `team_id` | uuid? | Set for team-scoped projects |
|
||||
| `workspace_id` | uuid? | Linked workspace |
|
||||
| `is_archived` | bool | Default `false` |
|
||||
| `settings` | object | JSONB, default `{}` |
|
||||
| `channel_count` | int | Computed, omitted when 0 |
|
||||
| `kb_count` | int | Computed, omitted when 0 |
|
||||
| `note_count` | int | Computed, omitted when 0 |
|
||||
| `created_at` | datetime | Auto-set |
|
||||
| `updated_at` | datetime | Auto-updated on change |
|
||||
|
||||
## Project CRUD
|
||||
|
||||
### List Projects
|
||||
|
||||
```
|
||||
GET /projects/:id/channels → { "data": [channel objects] }
|
||||
POST /projects/:id/channels ← { "channel_id": "uuid" }
|
||||
DELETE /projects/:id/channels/:channelId
|
||||
GET /projects → { "data": [...] }
|
||||
```
|
||||
|
||||
Query params: `?include_archived=true` to include archived projects.
|
||||
|
||||
Returns projects the user can access: personal (own), team (member),
|
||||
and global. Ordered by name.
|
||||
|
||||
### Create Project
|
||||
|
||||
```
|
||||
POST /projects → 201, project object
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Project",
|
||||
"description": "Optional description",
|
||||
"color": "#3b82f6",
|
||||
"icon": "folder"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `name` | yes | Max 200 chars |
|
||||
| `description` | no | |
|
||||
| `color` | no | CSS hex |
|
||||
| `icon` | no | Icon identifier |
|
||||
|
||||
Scope is set to `personal` and `owner_id` is taken from the JWT.
|
||||
Returns the created project object (bare, no envelope).
|
||||
|
||||
### Get Project
|
||||
|
||||
```
|
||||
GET /projects/:id → project object
|
||||
```
|
||||
|
||||
Returns bare project object (no envelope). 404 if not found or
|
||||
not accessible.
|
||||
|
||||
### Update Project
|
||||
|
||||
```
|
||||
PUT /projects/:id → updated project object
|
||||
```
|
||||
|
||||
Partial update — only supplied fields are changed.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "New Name",
|
||||
"description": "Updated",
|
||||
"color": "#ef4444",
|
||||
"icon": "star",
|
||||
"is_archived": true,
|
||||
"workspace_id": "uuid|null",
|
||||
"settings": { "key": "value" }
|
||||
}
|
||||
```
|
||||
|
||||
Settings are merged (overlay, not replace). Returns the refreshed
|
||||
project object after update.
|
||||
|
||||
### Delete Project
|
||||
|
||||
```
|
||||
DELETE /projects/:id → { "message": "project deleted" }
|
||||
```
|
||||
|
||||
Only the project owner or an admin can delete. Channels get
|
||||
`project_id` set to NULL; junction rows cascade.
|
||||
|
||||
---
|
||||
|
||||
## Channel Association
|
||||
|
||||
```
|
||||
GET /projects/:id/channels → { "data": [...] }
|
||||
POST /projects/:id/channels ← { "channel_id", "position" }
|
||||
DELETE /projects/:id/channels/:channelId → { "message": "channel removed" }
|
||||
PUT /projects/:id/channels/reorder ← { "channel_ids": ["uuid1", "uuid2"] }
|
||||
```
|
||||
|
||||
A channel can only belong to one project. `POST` performs an atomic
|
||||
move if the channel is already in a different project.
|
||||
move if the channel is already in a different project. The user must
|
||||
own the channel being added.
|
||||
|
||||
### KB Association
|
||||
**Channel association object:**
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"position": 0,
|
||||
"folder": "",
|
||||
"added_at": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**POST request:**
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `channel_id` | yes | UUID of channel to add |
|
||||
| `position` | no | Sort order (default 0) |
|
||||
|
||||
---
|
||||
|
||||
## KB Association
|
||||
|
||||
```
|
||||
GET /projects/:id/knowledge-bases → { "data": [KB objects] }
|
||||
POST /projects/:id/knowledge-bases ← { "kb_id": "uuid" }
|
||||
DELETE /projects/:id/knowledge-bases/:kbId
|
||||
GET /projects/:id/knowledge-bases → { "data": [...] }
|
||||
POST /projects/:id/knowledge-bases ← { "kb_id", "auto_search" }
|
||||
DELETE /projects/:id/knowledge-bases/:kbId → { "message": "KB removed" }
|
||||
```
|
||||
|
||||
KBs bound to a project are automatically available to every channel
|
||||
in that project (see §5.7 resolution chain).
|
||||
in that project (resolution chain injection at completion time).
|
||||
Upsert on conflict — re-posting updates `auto_search`.
|
||||
|
||||
### Note Association
|
||||
**KB association object:**
|
||||
|
||||
```
|
||||
GET /projects/:id/notes → { "data": [note objects] }
|
||||
POST /projects/:id/notes ← { "note_id": "uuid" }
|
||||
DELETE /projects/:id/notes/:noteId
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid",
|
||||
"kb_id": "uuid",
|
||||
"auto_search": true,
|
||||
"added_at": "2025-01-15T10:30:00Z",
|
||||
"name": "KB Name"
|
||||
}
|
||||
```
|
||||
|
||||
### Project Files
|
||||
`name` is enriched via JOIN from `knowledge_bases.name`.
|
||||
|
||||
**POST request:**
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `kb_id` | yes | UUID of knowledge base |
|
||||
| `auto_search` | no | Default `false` |
|
||||
|
||||
---
|
||||
|
||||
## Note Association
|
||||
|
||||
```
|
||||
GET /projects/:id/files → { "files": [...] }
|
||||
GET /projects/:id/notes → { "data": [...] }
|
||||
POST /projects/:id/notes ← { "note_id" }
|
||||
DELETE /projects/:id/notes/:noteId → { "message": "note removed" }
|
||||
```
|
||||
|
||||
**Note association object:**
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid",
|
||||
"note_id": "uuid",
|
||||
"added_at": "2025-01-15T10:30:00Z",
|
||||
"title": "Note Title"
|
||||
}
|
||||
```
|
||||
|
||||
`title` is enriched via JOIN from `notes.title`.
|
||||
Insert uses ON CONFLICT DO NOTHING (idempotent).
|
||||
|
||||
---
|
||||
|
||||
## Project Files
|
||||
|
||||
```
|
||||
GET /projects/:id/files → { "files": [...], "count": N }
|
||||
POST /projects/:id/files ← multipart/form-data
|
||||
```
|
||||
|
||||
Project-level file uploads (distinct from workspace files and channel
|
||||
attachments).
|
||||
attachments). Note: `GET` uses `"files"` key, not `"data"`.
|
||||
|
||||
### Admin Project Management
|
||||
---
|
||||
|
||||
## Admin Project Management
|
||||
|
||||
```
|
||||
GET /admin/projects → { "data": [...] }
|
||||
DELETE /admin/projects/:id
|
||||
DELETE /admin/projects/:id → { "message": "project deleted" }
|
||||
```
|
||||
|
||||
Cross-instance visibility for platform admins. BYOK personal projects
|
||||
remain private (admin can delete but not read content).
|
||||
Query params: `?include_archived=true`.
|
||||
|
||||
Cross-instance visibility for platform admins. Admin list returns
|
||||
an enriched object with extra fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "...",
|
||||
"description": "...",
|
||||
"scope": "personal",
|
||||
"owner_id": "uuid",
|
||||
"team_id": null,
|
||||
"is_archived": false,
|
||||
"created_at": "...",
|
||||
"updated_at": "...",
|
||||
"channel_count": 5,
|
||||
"kb_count": 2,
|
||||
"note_count": 3,
|
||||
"owner_name": "jdoe"
|
||||
}
|
||||
```
|
||||
|
||||
Note: admin list object omits `color`, `icon`, `workspace_id`,
|
||||
and `settings` (uses a local struct, not the full model).
|
||||
|
||||
---
|
||||
|
||||
@@ -618,6 +618,9 @@ func (h *FileHandler) ListByProject(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||
return
|
||||
}
|
||||
if files == nil {
|
||||
files = []models.File{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
|
||||
}
|
||||
|
||||
372
server/handlers/projects_test.go
Normal file
372
server/handlers/projects_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Project Test Harness ──────────────────
|
||||
|
||||
type projectHarness struct {
|
||||
*testHarness
|
||||
stores store.Stores
|
||||
userToken string
|
||||
userID string
|
||||
adminToken string
|
||||
adminID string
|
||||
}
|
||||
|
||||
func setupProjectHarness(t *testing.T) *projectHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
|
||||
projH := NewProjectHandler(stores)
|
||||
protected.GET("/projects", projH.List)
|
||||
protected.POST("/projects", projH.Create)
|
||||
protected.GET("/projects/:id", projH.Get)
|
||||
protected.PUT("/projects/:id", projH.Update)
|
||||
protected.DELETE("/projects/:id", projH.Delete)
|
||||
protected.POST("/projects/:id/channels", projH.AddChannel)
|
||||
protected.DELETE("/projects/:id/channels/:channelId", projH.RemoveChannel)
|
||||
protected.GET("/projects/:id/channels", projH.ListChannels)
|
||||
protected.PUT("/projects/:id/channels/reorder", projH.ReorderChannels)
|
||||
protected.POST("/projects/:id/knowledge-bases", projH.AddKB)
|
||||
protected.DELETE("/projects/:id/knowledge-bases/:kbId", projH.RemoveKB)
|
||||
protected.GET("/projects/:id/knowledge-bases", projH.ListKBs)
|
||||
protected.POST("/projects/:id/notes", projH.AddNote)
|
||||
protected.DELETE("/projects/:id/notes/:noteId", projH.RemoveNote)
|
||||
protected.GET("/projects/:id/notes", projH.ListNotes)
|
||||
|
||||
// Admin routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.GET("/projects", projH.AdminList)
|
||||
admin.DELETE("/projects/:id", projH.Delete)
|
||||
|
||||
userID := database.SeedTestUser(t, "projuser", "projuser@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "projuser@test.com", "user")
|
||||
|
||||
adminID := database.SeedTestUser(t, "projadmin", "projadmin@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, role = 'admin' WHERE id = $1"), adminID)
|
||||
adminToken := makeToken(adminID, "projadmin@test.com", "admin")
|
||||
|
||||
return &projectHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
stores: stores,
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
adminToken: adminToken,
|
||||
adminID: adminID,
|
||||
}
|
||||
}
|
||||
|
||||
// seedProject creates a project via the store and returns it.
|
||||
func (h *projectHarness) seedProject(name, ownerID string) *models.Project {
|
||||
h.t.Helper()
|
||||
p := &models.Project{
|
||||
Name: name,
|
||||
Scope: models.ScopePersonal,
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
if err := h.stores.Projects.Create(context.Background(), p); err != nil {
|
||||
h.t.Fatalf("seedProject: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ── GET /projects — envelope ──────────────
|
||||
|
||||
func TestProjects_List_Empty_Envelope(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/projects", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
data, ok := body["data"]
|
||||
if !ok {
|
||||
t.Fatal("GET /projects must return {\"data\": [...]}, missing \"data\" key")
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data should be an array, got %T", data)
|
||||
}
|
||||
if len(arr) != 0 {
|
||||
t.Errorf("expected empty array, got %d items", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjects_List_WithData_Shape(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
h.seedProject("test-proj", h.userID)
|
||||
|
||||
resp := h.request("GET", "/api/v1/projects", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
data, ok := body["data"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("data must be an array")
|
||||
}
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("expected 1 project, got %d", len(data))
|
||||
}
|
||||
|
||||
proj := data[0].(map[string]interface{})
|
||||
for _, key := range []string{"id", "name", "scope", "owner_id", "is_archived", "created_at", "updated_at"} {
|
||||
if _, exists := proj[key]; !exists {
|
||||
t.Errorf("missing field %q in project object", key)
|
||||
}
|
||||
}
|
||||
if proj["name"] != "test-proj" {
|
||||
t.Errorf("name: got %v, want test-proj", proj["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /projects — create ──────────────
|
||||
|
||||
func TestProjects_Create_201_Shape(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{
|
||||
"name": "new-project",
|
||||
"description": "test desc",
|
||||
"color": "#ff0000",
|
||||
"icon": "star",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
// Bare object, not wrapped in "data"
|
||||
if _, exists := body["data"]; exists {
|
||||
t.Error("POST /projects should return bare object, not wrapped in 'data'")
|
||||
}
|
||||
if body["name"] != "new-project" {
|
||||
t.Errorf("name: got %v, want new-project", body["name"])
|
||||
}
|
||||
if body["scope"] != "personal" {
|
||||
t.Errorf("scope should be forced to personal, got %v", body["scope"])
|
||||
}
|
||||
if body["owner_id"] != h.userID {
|
||||
t.Errorf("owner_id: got %v, want %s", body["owner_id"], h.userID)
|
||||
}
|
||||
if body["color"] != "#ff0000" {
|
||||
t.Errorf("color: got %v, want #ff0000", body["color"])
|
||||
}
|
||||
if body["icon"] != "star" {
|
||||
t.Errorf("icon: got %v, want star", body["icon"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjects_Create_RequiresName(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{
|
||||
"description": "no name",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing name, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /projects/:id — single object ─────
|
||||
|
||||
func TestProjects_Get_Shape(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
p := h.seedProject("detail-proj", h.userID)
|
||||
|
||||
resp := h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
// Bare object
|
||||
if _, exists := body["data"]; exists {
|
||||
t.Error("GET /:id should return object directly, not wrapped in 'data'")
|
||||
}
|
||||
if body["id"] != p.ID {
|
||||
t.Errorf("id: got %v, want %s", body["id"], p.ID)
|
||||
}
|
||||
// Computed counts use omitempty — absent when zero, present as number when nonzero.
|
||||
// With a fresh project all counts are 0, so they'll be omitted. Verify that if
|
||||
// present they're numeric (the AdminList test covers nonzero count visibility).
|
||||
for _, key := range []string{"channel_count", "kb_count", "note_count"} {
|
||||
if val, exists := body[key]; exists {
|
||||
if _, ok := val.(float64); !ok {
|
||||
t.Errorf("%s should be numeric, got %T", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjects_Get_NotFound(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/projects/00000000-0000-0000-0000-000000000000", h.userToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT /projects/:id — update ────────────
|
||||
|
||||
func TestProjects_Update_ReturnsRefreshed(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
p := h.seedProject("old-name", h.userID)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/projects/"+p.ID, h.userToken, map[string]interface{}{
|
||||
"name": "new-name",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
if body["name"] != "new-name" {
|
||||
t.Errorf("name should be updated to new-name, got %v", body["name"])
|
||||
}
|
||||
if body["id"] != p.ID {
|
||||
t.Errorf("id mismatch: got %v, want %s", body["id"], p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /projects/:id ──────────────────
|
||||
|
||||
func TestProjects_Delete_Shape(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
p := h.seedProject("doomed", h.userID)
|
||||
|
||||
resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
if body["message"] != "project deleted" {
|
||||
t.Errorf("expected message 'project deleted', got %v", body["message"])
|
||||
}
|
||||
|
||||
// Confirm actually deleted
|
||||
resp = h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("project should be gone, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjects_Delete_ForbiddenForNonOwner(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
|
||||
otherID := database.SeedTestUser(t, "other", "other@test.com")
|
||||
p := h.seedProject("others-proj", otherID)
|
||||
|
||||
resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil)
|
||||
// Non-owner, non-admin user should not be able to access (404 from loadAndAuthorize)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for other user's personal project, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────
|
||||
|
||||
func TestProjects_RequiresAuth(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/projects", "", nil)
|
||||
if resp.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without token, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin List ────────────────────────────
|
||||
|
||||
func TestProjects_AdminList_Envelope(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
h.seedProject("admin-visible", h.userID)
|
||||
|
||||
resp := h.request("GET", "/api/v1/admin/projects", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
data, ok := body["data"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("admin list must return {\"data\": [...]}")
|
||||
}
|
||||
if len(data) < 1 {
|
||||
t.Fatal("expected at least 1 project in admin list")
|
||||
}
|
||||
|
||||
proj := data[0].(map[string]interface{})
|
||||
// Admin list has extra owner_name field
|
||||
if _, exists := proj["owner_name"]; !exists {
|
||||
t.Error("admin list project should include owner_name")
|
||||
}
|
||||
for _, key := range []string{"channel_count", "kb_count", "note_count"} {
|
||||
if _, exists := proj[key]; !exists {
|
||||
t.Errorf("admin list missing computed field %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjects_AdminList_ForbiddenForUser(t *testing.T) {
|
||||
h := setupProjectHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/admin/projects", h.userToken, nil)
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
|
||||
type modelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Capabilities models.ModelCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
@@ -331,7 +332,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
for _, m := range modelList {
|
||||
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities, nil)
|
||||
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
|
||||
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
|
||||
out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": out, "provider": name})
|
||||
|
||||
Reference in New Issue
Block a user