tenseleyflow/shithub / 3d8b283

Browse files

S26: social orchestrator test suite

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3d8b2834d60fb210f8510cf2788730c2abefbc30
Parents
7e36f18
Tree
215f980

1 changed file

StatusFile+-
A internal/social/social_test.go 284 0
internal/social/social_test.goadded
@@ -0,0 +1,284 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package social_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"testing"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/social"
16
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
18
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
+)
20
+
21
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
22
+	"AAAAAAAAAAAAAAAA$" +
23
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
24
+
25
+// setup gives every test its own fresh DB + a public repo + an author
26
+// user. Returns the pool (for direct sqlc reads), the social.Deps
27
+// (without a Limiter so tests don't trip the rate cap), the author
28
+// user id, and the repo id.
29
+func setup(t *testing.T) (*pgxpool.Pool, social.Deps, int64, int64) {
30
+	t.Helper()
31
+	pool := dbtest.NewTestDB(t)
32
+	ctx := context.Background()
33
+
34
+	uq := usersdb.New()
35
+	user, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
36
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
37
+	})
38
+	if err != nil {
39
+		t.Fatalf("CreateUser: %v", err)
40
+	}
41
+	repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
42
+		OwnerUserID:   pgtype.Int8{Int64: user.ID, Valid: true},
43
+		Name:          "demo",
44
+		DefaultBranch: "trunk",
45
+		Visibility:    reposdb.RepoVisibilityPublic,
46
+	})
47
+	if err != nil {
48
+		t.Fatalf("CreateRepo: %v", err)
49
+	}
50
+	deps := social.Deps{
51
+		Pool:   pool,
52
+		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
53
+	}
54
+	return pool, deps, user.ID, repo.ID
55
+}
56
+
57
+func mustCreateUser(t *testing.T, pool *pgxpool.Pool, username string) int64 {
58
+	t.Helper()
59
+	u, err := usersdb.New().CreateUser(context.Background(), pool, usersdb.CreateUserParams{
60
+		Username: username, DisplayName: username, PasswordHash: fixtureHash,
61
+	})
62
+	if err != nil {
63
+		t.Fatalf("CreateUser %s: %v", username, err)
64
+	}
65
+	return u.ID
66
+}
67
+
68
+func repoStarCount(t *testing.T, pool *pgxpool.Pool, repoID int64) int64 {
69
+	t.Helper()
70
+	r, err := reposdb.New().GetRepoByID(context.Background(), pool, repoID)
71
+	if err != nil {
72
+		t.Fatalf("GetRepoByID: %v", err)
73
+	}
74
+	return r.StarCount
75
+}
76
+
77
+func repoWatcherCount(t *testing.T, pool *pgxpool.Pool, repoID int64) int64 {
78
+	t.Helper()
79
+	r, err := reposdb.New().GetRepoByID(context.Background(), pool, repoID)
80
+	if err != nil {
81
+		t.Fatalf("GetRepoByID: %v", err)
82
+	}
83
+	return r.WatcherCount
84
+}
85
+
86
+func TestStar_IncrementsCount_AndIsIdempotent(t *testing.T) {
87
+	pool, deps, _, repoID := setup(t)
88
+	uid := mustCreateUser(t, pool, "bob")
89
+	ctx := context.Background()
90
+
91
+	if err := social.Star(ctx, deps, uid, repoID, true); err != nil {
92
+		t.Fatalf("Star: %v", err)
93
+	}
94
+	if got := repoStarCount(t, pool, repoID); got != 1 {
95
+		t.Errorf("after first star: got %d, want 1", got)
96
+	}
97
+	// Re-star is a no-op (ON CONFLICT DO NOTHING + trigger fires only on
98
+	// real INSERT). Count must not double.
99
+	if err := social.Star(ctx, deps, uid, repoID, true); err != nil {
100
+		t.Fatalf("Star (idempotent): %v", err)
101
+	}
102
+	if got := repoStarCount(t, pool, repoID); got != 1 {
103
+		t.Errorf("after re-star: got %d, want 1 (idempotent)", got)
104
+	}
105
+}
106
+
107
+func TestUnstar_DecrementsCount_AndIsIdempotent(t *testing.T) {
108
+	pool, deps, _, repoID := setup(t)
109
+	uid := mustCreateUser(t, pool, "bob")
110
+	ctx := context.Background()
111
+	_ = social.Star(ctx, deps, uid, repoID, true)
112
+
113
+	if err := social.Unstar(ctx, deps, uid, repoID, true); err != nil {
114
+		t.Fatalf("Unstar: %v", err)
115
+	}
116
+	if got := repoStarCount(t, pool, repoID); got != 0 {
117
+		t.Errorf("after unstar: got %d, want 0", got)
118
+	}
119
+	// Re-unstar is a no-op.
120
+	if err := social.Unstar(ctx, deps, uid, repoID, true); err != nil {
121
+		t.Fatalf("Unstar (idempotent): %v", err)
122
+	}
123
+	if got := repoStarCount(t, pool, repoID); got != 0 {
124
+		t.Errorf("after re-unstar: got %d, want 0", got)
125
+	}
126
+}
127
+
128
+func TestStar_RequiresLogin(t *testing.T) {
129
+	_, deps, _, repoID := setup(t)
130
+	if err := social.Star(context.Background(), deps, 0, repoID, true); err == nil {
131
+		t.Errorf("expected ErrNotLoggedIn for actor=0, got nil")
132
+	}
133
+}
134
+
135
+func TestStar_EmitsDomainEvent(t *testing.T) {
136
+	pool, deps, _, repoID := setup(t)
137
+	uid := mustCreateUser(t, pool, "bob")
138
+	ctx := context.Background()
139
+	if err := social.Star(ctx, deps, uid, repoID, true); err != nil {
140
+		t.Fatalf("Star: %v", err)
141
+	}
142
+	rows, err := socialdb.New().ListEventsForRepo(ctx, pool, socialdb.ListEventsForRepoParams{
143
+		RepoID: pgtype.Int8{Int64: repoID, Valid: true},
144
+		Limit:  10, Offset: 0,
145
+	})
146
+	if err != nil {
147
+		t.Fatalf("ListEventsForRepo: %v", err)
148
+	}
149
+	if len(rows) != 1 || rows[0].Kind != "star" || !rows[0].Public {
150
+		t.Errorf("expected one public 'star' event, got %+v", rows)
151
+	}
152
+}
153
+
154
+func TestSetWatch_All_IncrementsWatcherCount(t *testing.T) {
155
+	pool, deps, _, repoID := setup(t)
156
+	uid := mustCreateUser(t, pool, "bob")
157
+	ctx := context.Background()
158
+
159
+	if err := social.SetWatch(ctx, deps, uid, repoID, social.WatchAll); err != nil {
160
+		t.Fatalf("SetWatch: %v", err)
161
+	}
162
+	if got := repoWatcherCount(t, pool, repoID); got != 1 {
163
+		t.Errorf("after SetWatch=all: got %d, want 1", got)
164
+	}
165
+}
166
+
167
+func TestSetWatch_Ignore_DoesNotCountAsWatcher(t *testing.T) {
168
+	pool, deps, _, repoID := setup(t)
169
+	uid := mustCreateUser(t, pool, "bob")
170
+	ctx := context.Background()
171
+
172
+	if err := social.SetWatch(ctx, deps, uid, repoID, social.WatchIgnore); err != nil {
173
+		t.Fatalf("SetWatch: %v", err)
174
+	}
175
+	// `ignore` is the explicit "do not notify" — must not bump the count.
176
+	if got := repoWatcherCount(t, pool, repoID); got != 0 {
177
+		t.Errorf("after SetWatch=ignore: got %d, want 0", got)
178
+	}
179
+}
180
+
181
+func TestSetWatch_TransitionsAcrossIgnore(t *testing.T) {
182
+	pool, deps, _, repoID := setup(t)
183
+	uid := mustCreateUser(t, pool, "bob")
184
+	ctx := context.Background()
185
+
186
+	// all → 1
187
+	_ = social.SetWatch(ctx, deps, uid, repoID, social.WatchAll)
188
+	if got := repoWatcherCount(t, pool, repoID); got != 1 {
189
+		t.Fatalf("step 1: got %d, want 1", got)
190
+	}
191
+	// all → ignore: 1 → 0 (transition out of "watching").
192
+	_ = social.SetWatch(ctx, deps, uid, repoID, social.WatchIgnore)
193
+	if got := repoWatcherCount(t, pool, repoID); got != 0 {
194
+		t.Errorf("after ignore: got %d, want 0", got)
195
+	}
196
+	// ignore → participating: 0 → 1 (transition back in).
197
+	_ = social.SetWatch(ctx, deps, uid, repoID, social.WatchParticipating)
198
+	if got := repoWatcherCount(t, pool, repoID); got != 1 {
199
+		t.Errorf("after participating: got %d, want 1", got)
200
+	}
201
+}
202
+
203
+func TestUnsetWatch_RestoresImplicitDefault(t *testing.T) {
204
+	pool, deps, _, repoID := setup(t)
205
+	uid := mustCreateUser(t, pool, "bob")
206
+	ctx := context.Background()
207
+
208
+	_ = social.SetWatch(ctx, deps, uid, repoID, social.WatchAll)
209
+	if err := social.UnsetWatch(ctx, deps, uid, repoID); err != nil {
210
+		t.Fatalf("UnsetWatch: %v", err)
211
+	}
212
+	// CurrentLevel resolves the absent-row case to participating.
213
+	got, err := social.CurrentLevel(ctx, deps, uid, repoID)
214
+	if err != nil {
215
+		t.Fatalf("CurrentLevel: %v", err)
216
+	}
217
+	if got != social.WatchParticipating {
218
+		t.Errorf("after UnsetWatch: level=%s, want participating", got)
219
+	}
220
+}
221
+
222
+// TestAutoWatch_NonDestructive is the regression guard for the
223
+// auto-watch contract: collaborator-add inserts level='all', but a
224
+// later involvement event must NOT downgrade to 'participating'.
225
+func TestAutoWatch_NonDestructive(t *testing.T) {
226
+	pool, deps, _, repoID := setup(t)
227
+	uid := mustCreateUser(t, pool, "bob")
228
+	ctx := context.Background()
229
+
230
+	if err := social.AutoWatchOnCollab(ctx, deps, uid, repoID); err != nil {
231
+		t.Fatalf("AutoWatchOnCollab: %v", err)
232
+	}
233
+	if got, _ := social.CurrentLevel(ctx, deps, uid, repoID); got != social.WatchAll {
234
+		t.Fatalf("step 1: level=%s, want all", got)
235
+	}
236
+	if err := social.AutoWatchOnInvolvement(ctx, deps, uid, repoID); err != nil {
237
+		t.Fatalf("AutoWatchOnInvolvement: %v", err)
238
+	}
239
+	if got, _ := social.CurrentLevel(ctx, deps, uid, repoID); got != social.WatchAll {
240
+		t.Errorf("involvement should not overwrite collab default: got %s", got)
241
+	}
242
+}
243
+
244
+// TestAutoWatch_PreservesUserChoice mirrors the spec pitfall about
245
+// permission-revocation cascades: a user who explicitly chose `ignore`
246
+// must keep that choice even when an involvement event fires.
247
+func TestAutoWatch_PreservesUserChoice(t *testing.T) {
248
+	pool, deps, _, repoID := setup(t)
249
+	uid := mustCreateUser(t, pool, "bob")
250
+	ctx := context.Background()
251
+
252
+	_ = social.SetWatch(ctx, deps, uid, repoID, social.WatchIgnore)
253
+	_ = social.AutoWatchOnInvolvement(ctx, deps, uid, repoID)
254
+	if got, _ := social.CurrentLevel(ctx, deps, uid, repoID); got != social.WatchIgnore {
255
+		t.Errorf("user-chosen ignore should win: got %s", got)
256
+	}
257
+}
258
+
259
+// TestStargazerList_ExcludesSuspended guards the spec's "suspended
260
+// users don't taint public lists" pitfall.
261
+func TestStargazerList_ExcludesSuspended(t *testing.T) {
262
+	pool, deps, _, repoID := setup(t)
263
+	ctx := context.Background()
264
+	good := mustCreateUser(t, pool, "good")
265
+	bad := mustCreateUser(t, pool, "bad")
266
+	_ = social.Star(ctx, deps, good, repoID, true)
267
+	_ = social.Star(ctx, deps, bad, repoID, true)
268
+
269
+	// Suspend bad.
270
+	if _, err := pool.Exec(ctx,
271
+		"UPDATE users SET suspended_at = now() WHERE id = $1", bad); err != nil {
272
+		t.Fatalf("suspend: %v", err)
273
+	}
274
+
275
+	rows, err := socialdb.New().ListStargazersForRepo(ctx, pool, socialdb.ListStargazersForRepoParams{
276
+		RepoID: repoID, Limit: 10, Offset: 0,
277
+	})
278
+	if err != nil {
279
+		t.Fatalf("ListStargazersForRepo: %v", err)
280
+	}
281
+	if len(rows) != 1 || rows[0].UserID != good {
282
+		t.Errorf("expected only good user, got %d rows = %+v", len(rows), rows)
283
+	}
284
+}