tenseleyflow/shithub / 348be3f

Browse files

api/issue_events: integration tests for issue timeline endpoint

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
348be3fa0419380eb6139470c5ad4493c959f1f2
Parents
1c90bb7
Tree
88b2cf2

1 changed file

StatusFile+-
A internal/web/handlers/api/issue_events_test.go 161 0
internal/web/handlers/api/issue_events_test.goadded
@@ -0,0 +1,161 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"net/http"
9
+	"net/http/httptest"
10
+	"testing"
11
+
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+	"github.com/jackc/pgx/v5/pgxpool"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
16
+	"github.com/tenseleyFlow/shithub/internal/issues"
17
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
18
+)
19
+
20
+type apiIssueEvent struct {
21
+	ID            int64           `json:"id"`
22
+	Kind          string          `json:"kind"`
23
+	ActorUserID   int64           `json:"actor_user_id,omitempty"`
24
+	ActorUsername string          `json:"actor_username,omitempty"`
25
+	Meta          json.RawMessage `json:"meta,omitempty"`
26
+	RefTargetID   int64           `json:"ref_target_id,omitempty"`
27
+	CreatedAt     string          `json:"created_at"`
28
+}
29
+
30
+// seedIssueRow inserts an issue via the orchestrator and returns the
31
+// row. Tests then layer events on top via seedIssueEvent.
32
+func seedIssueRow(t *testing.T, pool *pgxpool.Pool, repoID, authorID int64, title string) issuesdb.Issue {
33
+	t.Helper()
34
+	row, err := issues.Create(context.Background(), issues.Deps{Pool: pool}, issues.CreateParams{
35
+		RepoID:       repoID,
36
+		AuthorUserID: authorID,
37
+		Title:        title,
38
+	})
39
+	if err != nil {
40
+		t.Fatalf("issues.Create: %v", err)
41
+	}
42
+	return row
43
+}
44
+
45
+// seedIssueEvent inserts one event row. Bypasses the orchestrators so
46
+// each test can stage exactly the timeline it wants to assert against.
47
+func seedIssueEvent(t *testing.T, pool *pgxpool.Pool, issueID, actorID int64, kind string, meta []byte) int64 {
48
+	t.Helper()
49
+	row, err := issuesdb.New().InsertIssueEvent(context.Background(), pool, issuesdb.InsertIssueEventParams{
50
+		IssueID:     issueID,
51
+		ActorUserID: pgtype.Int8{Int64: actorID, Valid: actorID != 0},
52
+		Kind:        kind,
53
+		Meta:        meta,
54
+	})
55
+	if err != nil {
56
+		t.Fatalf("InsertIssueEvent(%q): %v", kind, err)
57
+	}
58
+	return row.ID
59
+}
60
+
61
+func TestIssueEvents_ListReturnsTimelineInChronologicalOrder(t *testing.T) {
62
+	pool, router, userID, repoID, token := seedIssuesEnv(t, "alice")
63
+	issue := seedIssueRow(t, pool, repoID, userID, "first")
64
+	seedIssueEvent(t, pool, issue.ID, userID, "closed", []byte(`{"reason":"completed"}`))
65
+	seedIssueEvent(t, pool, issue.ID, userID, "reopened", []byte(`{}`))
66
+
67
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/1/events", nil)
68
+	req.Header.Set("Authorization", "Bearer "+token)
69
+	rr := httptest.NewRecorder()
70
+	router.ServeHTTP(rr, req)
71
+	if rr.Code != http.StatusOK {
72
+		t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
73
+	}
74
+	var listed []apiIssueEvent
75
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
76
+		t.Fatalf("decode: %v", err)
77
+	}
78
+	if len(listed) != 2 {
79
+		t.Fatalf("expected 2 events; got %+v", listed)
80
+	}
81
+	// ASC sort: closed first, reopened second.
82
+	if listed[0].Kind != "closed" || listed[1].Kind != "reopened" {
83
+		t.Errorf("chronological order: got %s, %s", listed[0].Kind, listed[1].Kind)
84
+	}
85
+	if listed[0].ActorUsername != "alice" {
86
+		t.Errorf("actor_username: got %q, want alice", listed[0].ActorUsername)
87
+	}
88
+	if listed[0].ActorUserID != userID {
89
+		t.Errorf("actor_user_id: got %d, want %d", listed[0].ActorUserID, userID)
90
+	}
91
+}
92
+
93
+func TestIssueEvents_MetaIsRoundTripped(t *testing.T) {
94
+	pool, router, userID, repoID, token := seedIssuesEnv(t, "alice")
95
+	issue := seedIssueRow(t, pool, repoID, userID, "meta-test")
96
+	seedIssueEvent(t, pool, issue.ID, userID, "labeled", []byte(`{"label_id":42,"label_name":"bug"}`))
97
+
98
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/1/events", nil)
99
+	req.Header.Set("Authorization", "Bearer "+token)
100
+	rr := httptest.NewRecorder()
101
+	router.ServeHTTP(rr, req)
102
+	if rr.Code != http.StatusOK {
103
+		t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
104
+	}
105
+	var listed []apiIssueEvent
106
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
107
+		t.Fatalf("decode: %v", err)
108
+	}
109
+	if len(listed) == 0 {
110
+		t.Fatalf("no events returned")
111
+	}
112
+	var meta map[string]any
113
+	if err := json.Unmarshal(listed[0].Meta, &meta); err != nil {
114
+		t.Fatalf("meta decode: %v; raw=%s", err, string(listed[0].Meta))
115
+	}
116
+	if meta["label_name"] != "bug" {
117
+		t.Errorf("meta round-trip: got %+v", meta)
118
+	}
119
+}
120
+
121
+func TestIssueEvents_RequiresRepoRead(t *testing.T) {
122
+	pool, router, userID, _, _ := seedIssuesEnv(t, "alice")
123
+	wrong := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
124
+
125
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/1/events", nil)
126
+	req.Header.Set("Authorization", "Bearer "+wrong)
127
+	rr := httptest.NewRecorder()
128
+	router.ServeHTTP(rr, req)
129
+	if rr.Code != http.StatusForbidden {
130
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
131
+	}
132
+}
133
+
134
+func TestIssueEvents_UnknownIssue404(t *testing.T) {
135
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
136
+
137
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/9999/events", nil)
138
+	req.Header.Set("Authorization", "Bearer "+token)
139
+	rr := httptest.NewRecorder()
140
+	router.ServeHTTP(rr, req)
141
+	if rr.Code != http.StatusNotFound {
142
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
143
+	}
144
+}
145
+
146
+func TestIssueEvents_EmptyTimelineReturnsEmptyArray(t *testing.T) {
147
+	pool, router, userID, repoID, token := seedIssuesEnv(t, "alice")
148
+	seedIssueRow(t, pool, repoID, userID, "no-events-yet")
149
+
150
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/1/events", nil)
151
+	req.Header.Set("Authorization", "Bearer "+token)
152
+	rr := httptest.NewRecorder()
153
+	router.ServeHTTP(rr, req)
154
+	if rr.Code != http.StatusOK {
155
+		t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
156
+	}
157
+	// Empty slice, not null: a CLI shouldn't have to nil-check.
158
+	if got := rr.Body.String(); got != "[]" && got != "[]\n" {
159
+		t.Errorf("empty body: got %q, want []", got)
160
+	}
161
+}