mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
Before: a project had no way to list the files its issues carry, and no
issue to attach a file uploaded straight to the project.
After: GET /api/projects/{id}/attachments lists the attachments of every
issue in the project, newest first, paged, filterable by source
(deliverable = agent upload, upload = member upload), with each row's
issue {id, identifier, title}, the total, and both group totals. POST
/api/projects/{id}/library-issue returns the project's "Project files"
issue {id, identifier}, creating it on first use as a top-level,
unassigned backlog issue.
How: one sqlc join attachment -> issue on attachment.issue_id (comment
attachments keep their issue_id, so each row appears once), excluding
tombstoned comments and source-context snapshot copies; existing
idx_issue_project_status and idx_attachment_issue cover the join, so no
index is added. The category filter stays client-side because the
frontend buckets files with preview-kind and extension tables that
cannot be mirrored here without a second copy. The holder issue is
created through IssueService.Create, whose per-(project, title)
duplicate lock makes concurrent first uploads converge on one issue.
DB-backed tests are written and compile-checked but not run here.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
224 lines
9.0 KiB
Go
224 lines
9.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
)
|
|
|
|
type projectAttachmentsPayload struct {
|
|
Attachments []struct {
|
|
ID string `json:"id"`
|
|
Filename string `json:"filename"`
|
|
UploaderType string `json:"uploader_type"`
|
|
IssueID string `json:"issue_id"`
|
|
Issue struct {
|
|
ID string `json:"id"`
|
|
Identifier string `json:"identifier"`
|
|
Title string `json:"title"`
|
|
} `json:"issue"`
|
|
} `json:"attachments"`
|
|
Total int `json:"total"`
|
|
DeliverableTotal int `json:"deliverable_total"`
|
|
UploadTotal int `json:"upload_total"`
|
|
}
|
|
|
|
func listProjectAttachmentsForTest(t *testing.T, projectID, rawQuery string) (projectAttachmentsPayload, *testutil.Response) {
|
|
t.Helper()
|
|
path := "/api/projects/" + projectID + "/attachments"
|
|
if rawQuery != "" {
|
|
path += "?" + rawQuery
|
|
}
|
|
req := withURLParam(newRequest(http.MethodGet, path, nil), "id", projectID)
|
|
resp := testutil.Call(t, testHandler.ListProjectAttachments, req)
|
|
var out projectAttachmentsPayload
|
|
if resp.Code == http.StatusOK {
|
|
resp.JSON(&out)
|
|
}
|
|
return out, resp
|
|
}
|
|
|
|
func insertProjectAttachment(t *testing.T, issueID, filename, uploaderType string, over ...testutil.Cols) string {
|
|
t.Helper()
|
|
// uploader_id carries no foreign key, so the member id stands in for an agent's.
|
|
uploaderID := testUserID
|
|
cols := testutil.Cols{
|
|
"workspace_id": testWorkspaceID,
|
|
"issue_id": issueID,
|
|
"uploader_type": uploaderType,
|
|
"uploader_id": uploaderID,
|
|
"filename": filename,
|
|
"url": "https://example.test/" + filename,
|
|
"content_type": "application/octet-stream",
|
|
"size_bytes": 1,
|
|
}
|
|
for _, o := range over {
|
|
for k, v := range o {
|
|
cols[k] = v
|
|
}
|
|
}
|
|
return dbfx.Insert(t, "attachment", cols)
|
|
}
|
|
|
|
func TestListProjectAttachmentsScopesToProject(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
projectID := dbfx.Project(t, "Library project")
|
|
otherProjectID := dbfx.Project(t, "Other library project")
|
|
issueID := dbfx.Issue(t, "In project", testutil.Cols{"project_id": projectID})
|
|
otherIssueID := dbfx.Issue(t, "In other project", testutil.Cols{"project_id": otherProjectID})
|
|
noProjectIssueID := dbfx.Issue(t, "In no project")
|
|
|
|
insertProjectAttachment(t, issueID, "mine.pdf", "member")
|
|
insertProjectAttachment(t, otherIssueID, "other.pdf", "member")
|
|
insertProjectAttachment(t, noProjectIssueID, "none.pdf", "member")
|
|
|
|
out, _ := listProjectAttachmentsForTest(t, projectID, "")
|
|
if out.Total != 1 || len(out.Attachments) != 1 || out.Attachments[0].Filename != "mine.pdf" {
|
|
t.Fatalf("attachments = %+v (total %d), want only mine.pdf", out.Attachments, out.Total)
|
|
}
|
|
row := out.Attachments[0]
|
|
if row.Issue.ID != issueID || row.Issue.Title != "In project" || row.Issue.Identifier == "" || row.IssueID != issueID {
|
|
t.Fatalf("row issue = %+v, want the project issue with an identifier", row.Issue)
|
|
}
|
|
}
|
|
|
|
func TestListProjectAttachmentsSourceFilter(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
projectID := dbfx.Project(t, "Source filter project")
|
|
issueID := dbfx.Issue(t, "Source filter issue", testutil.Cols{"project_id": projectID})
|
|
insertProjectAttachment(t, issueID, "report.pdf", "agent")
|
|
insertProjectAttachment(t, issueID, "photo.png", "agent")
|
|
insertProjectAttachment(t, issueID, "notes.txt", "member")
|
|
|
|
both, _ := listProjectAttachmentsForTest(t, projectID, "")
|
|
if both.Total != 3 || both.DeliverableTotal != 2 || both.UploadTotal != 1 {
|
|
t.Fatalf("unfiltered totals = %d/%d/%d, want 3/2/1", both.Total, both.DeliverableTotal, both.UploadTotal)
|
|
}
|
|
|
|
deliverables, _ := listProjectAttachmentsForTest(t, projectID, "source=deliverable")
|
|
if deliverables.Total != 2 || len(deliverables.Attachments) != 2 {
|
|
t.Fatalf("deliverables = %d rows (total %d), want 2", len(deliverables.Attachments), deliverables.Total)
|
|
}
|
|
for _, a := range deliverables.Attachments {
|
|
if a.UploaderType != "agent" {
|
|
t.Fatalf("deliverable row uploaded by %q, want agent", a.UploaderType)
|
|
}
|
|
}
|
|
// Both group counts stay available whichever group was asked for.
|
|
if deliverables.UploadTotal != 1 {
|
|
t.Fatalf("upload_total = %d next to a deliverable query, want 1", deliverables.UploadTotal)
|
|
}
|
|
|
|
uploads, _ := listProjectAttachmentsForTest(t, projectID, "source=upload")
|
|
if uploads.Total != 1 || len(uploads.Attachments) != 1 || uploads.Attachments[0].Filename != "notes.txt" {
|
|
t.Fatalf("uploads = %+v (total %d), want notes.txt only", uploads.Attachments, uploads.Total)
|
|
}
|
|
|
|
_, bad := listProjectAttachmentsForTest(t, projectID, "source=everything")
|
|
bad.Want(http.StatusBadRequest)
|
|
}
|
|
|
|
func TestListProjectAttachmentsPagesNewestFirst(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
projectID := dbfx.Project(t, "Paging project")
|
|
issueID := dbfx.Issue(t, "Paging issue", testutil.Cols{"project_id": projectID})
|
|
for i := 0; i < 5; i++ {
|
|
insertProjectAttachment(t, issueID, fmt.Sprintf("file-%d.txt", i), "member", testutil.Cols{
|
|
"created_at": testutil.Raw(fmt.Sprintf("now() - interval '%d minutes'", 10-i)),
|
|
})
|
|
}
|
|
|
|
first, _ := listProjectAttachmentsForTest(t, projectID, "limit=2")
|
|
if first.Total != 5 || len(first.Attachments) != 2 {
|
|
t.Fatalf("first page = %d rows (total %d), want 2 of 5", len(first.Attachments), first.Total)
|
|
}
|
|
if first.Attachments[0].Filename != "file-4.txt" || first.Attachments[1].Filename != "file-3.txt" {
|
|
t.Fatalf("first page = %q, %q, want newest first", first.Attachments[0].Filename, first.Attachments[1].Filename)
|
|
}
|
|
last, _ := listProjectAttachmentsForTest(t, projectID, "limit=2&offset=4")
|
|
if last.Total != 5 || len(last.Attachments) != 1 || last.Attachments[0].Filename != "file-0.txt" {
|
|
t.Fatalf("last page = %+v (total %d), want file-0.txt alone", last.Attachments, last.Total)
|
|
}
|
|
}
|
|
|
|
func TestListProjectAttachmentsCountsEachRowOnce(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
projectID := dbfx.Project(t, "Comment attachments project")
|
|
issueID := dbfx.Issue(t, "Comment attachments issue", testutil.Cols{"project_id": projectID})
|
|
liveComment := dbfx.Comment(t, issueID, "has a file")
|
|
deletedComment := dbfx.Comment(t, issueID, "")
|
|
dbfx.Exec(t, `UPDATE comment SET deleted_at = now() WHERE id = $1`, deletedComment)
|
|
|
|
// A comment attachment keeps the issue id too; it must still appear once.
|
|
insertProjectAttachment(t, issueID, "on-comment.pdf", "member", testutil.Cols{"comment_id": liveComment})
|
|
insertProjectAttachment(t, issueID, "on-issue.pdf", "member")
|
|
insertProjectAttachment(t, issueID, "on-tombstone.pdf", "member", testutil.Cols{"comment_id": deletedComment})
|
|
|
|
out, _ := listProjectAttachmentsForTest(t, projectID, "")
|
|
names := map[string]int{}
|
|
for _, a := range out.Attachments {
|
|
names[a.Filename]++
|
|
}
|
|
if out.Total != 2 || names["on-comment.pdf"] != 1 || names["on-issue.pdf"] != 1 || names["on-tombstone.pdf"] != 0 {
|
|
t.Fatalf("attachments = %v (total %d), want the live comment's and the issue's file once each, no tombstone file", names, out.Total)
|
|
}
|
|
}
|
|
|
|
func TestListProjectAttachmentsRejectsForeignProject(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
foreignWorkspaceID := dbfx.Workspace(t, "Library foreign", "library-foreign-ws")
|
|
foreignProjectID := dbfx.Project(t, "Foreign project", testutil.Cols{"workspace_id": foreignWorkspaceID})
|
|
_, resp := listProjectAttachmentsForTest(t, foreignProjectID, "")
|
|
resp.Want(http.StatusNotFound)
|
|
}
|
|
|
|
func TestEnsureProjectLibraryIssueCreatesOnceThenReuses(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
projectID := dbfx.Project(t, "Holder project")
|
|
call := func() (*testutil.Response, ProjectLibraryIssueResponse) {
|
|
req := withURLParam(newRequest(http.MethodPost, "/api/projects/"+projectID+"/library-issue", nil), "id", projectID)
|
|
resp := testutil.Call(t, testHandler.EnsureProjectLibraryIssue, req)
|
|
var out ProjectLibraryIssueResponse
|
|
resp.JSON(&out)
|
|
return resp, out
|
|
}
|
|
|
|
first, created := call()
|
|
first.Want(http.StatusCreated)
|
|
dbfx.Cleanup(t, `DELETE FROM issue WHERE id = $1`, created.ID)
|
|
if created.ID == "" || created.Identifier == "" {
|
|
t.Fatalf("created = %+v, want an id and identifier", created)
|
|
}
|
|
|
|
second, reused := call()
|
|
second.Want(http.StatusOK)
|
|
if reused.ID != created.ID {
|
|
t.Fatalf("second call returned issue %s, want the first (%s)", reused.ID, created.ID)
|
|
}
|
|
if n := dbfx.Count(t, `SELECT count(*) FROM issue WHERE project_id = $1`, projectID); n != 1 {
|
|
t.Fatalf("project has %d issues, want exactly the holder", n)
|
|
}
|
|
|
|
var title, status string
|
|
var parentSet, assigneeSet bool
|
|
dbfx.QueryRow(t, `SELECT title, status, parent_issue_id IS NOT NULL, assignee_id IS NOT NULL FROM issue WHERE id = $1`, created.ID).
|
|
Scan(&title, &status, &parentSet, &assigneeSet)
|
|
if title != projectLibraryIssueTitle || status != "backlog" || parentSet || assigneeSet {
|
|
t.Fatalf("holder = title %q status %q parent %v assignee %v, want a top-level unassigned backlog issue", title, status, parentSet, assigneeSet)
|
|
}
|
|
}
|