Go · 14166 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package social_test
4
5 import (
6 "context"
7 "errors"
8 "io"
9 "log/slog"
10 "testing"
11
12 "github.com/jackc/pgx/v5/pgtype"
13 "github.com/jackc/pgx/v5/pgxpool"
14
15 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
16 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17 "github.com/tenseleyFlow/shithub/internal/social"
18 socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
19 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21 )
22
23 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
24 "AAAAAAAAAAAAAAAA$" +
25 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
26
27 // setup gives every test its own fresh DB + a public repo + an author
28 // user. Returns the pool (for direct sqlc reads), the social.Deps
29 // (without a Limiter so tests don't trip the rate cap), the author
30 // user id, and the repo id.
31 func setup(t *testing.T) (*pgxpool.Pool, social.Deps, int64, int64) {
32 t.Helper()
33 pool := dbtest.NewTestDB(t)
34 ctx := context.Background()
35
36 uq := usersdb.New()
37 user, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
38 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
39 })
40 if err != nil {
41 t.Fatalf("CreateUser: %v", err)
42 }
43 repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
44 OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true},
45 Name: "demo",
46 DefaultBranch: "trunk",
47 Visibility: reposdb.RepoVisibilityPublic,
48 })
49 if err != nil {
50 t.Fatalf("CreateRepo: %v", err)
51 }
52 deps := social.Deps{
53 Pool: pool,
54 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
55 }
56 return pool, deps, user.ID, repo.ID
57 }
58
59 func mustCreateUser(t *testing.T, pool *pgxpool.Pool, username string) int64 {
60 t.Helper()
61 u, err := usersdb.New().CreateUser(context.Background(), pool, usersdb.CreateUserParams{
62 Username: username, DisplayName: username, PasswordHash: fixtureHash,
63 })
64 if err != nil {
65 t.Fatalf("CreateUser %s: %v", username, err)
66 }
67 return u.ID
68 }
69
70 func mustCreateOrg(t *testing.T, pool *pgxpool.Pool, slug string, creatorID int64) int64 {
71 t.Helper()
72 o, err := orgsdb.New().CreateOrg(context.Background(), pool, orgsdb.CreateOrgParams{
73 Slug: slug,
74 DisplayName: slug,
75 BillingEmail: slug + "@example.test",
76 CreatedByUserID: pgtype.Int8{Int64: creatorID, Valid: creatorID != 0},
77 })
78 if err != nil {
79 t.Fatalf("CreateOrg %s: %v", slug, err)
80 }
81 return o.ID
82 }
83
84 func repoStarCount(t *testing.T, pool *pgxpool.Pool, repoID int64) int64 {
85 t.Helper()
86 r, err := reposdb.New().GetRepoByID(context.Background(), pool, repoID)
87 if err != nil {
88 t.Fatalf("GetRepoByID: %v", err)
89 }
90 return r.StarCount
91 }
92
93 func repoWatcherCount(t *testing.T, pool *pgxpool.Pool, repoID int64) int64 {
94 t.Helper()
95 r, err := reposdb.New().GetRepoByID(context.Background(), pool, repoID)
96 if err != nil {
97 t.Fatalf("GetRepoByID: %v", err)
98 }
99 return r.WatcherCount
100 }
101
102 func TestStar_IncrementsCount_AndIsIdempotent(t *testing.T) {
103 pool, deps, _, repoID := setup(t)
104 uid := mustCreateUser(t, pool, "bob")
105 ctx := context.Background()
106
107 if err := social.Star(ctx, deps, uid, repoID, true); err != nil {
108 t.Fatalf("Star: %v", err)
109 }
110 if got := repoStarCount(t, pool, repoID); got != 1 {
111 t.Errorf("after first star: got %d, want 1", got)
112 }
113 // Re-star is a no-op (ON CONFLICT DO NOTHING + trigger fires only on
114 // real INSERT). Count must not double.
115 if err := social.Star(ctx, deps, uid, repoID, true); err != nil {
116 t.Fatalf("Star (idempotent): %v", err)
117 }
118 if got := repoStarCount(t, pool, repoID); got != 1 {
119 t.Errorf("after re-star: got %d, want 1 (idempotent)", got)
120 }
121 }
122
123 func TestFollowUser_IdempotentCountsAndEvent(t *testing.T) {
124 pool, deps, targetID, _ := setup(t)
125 followerID := mustCreateUser(t, pool, "bob")
126 ctx := context.Background()
127
128 if err := social.FollowUser(ctx, deps, followerID, targetID); err != nil {
129 t.Fatalf("FollowUser: %v", err)
130 }
131 if err := social.FollowUser(ctx, deps, followerID, targetID); err != nil {
132 t.Fatalf("FollowUser duplicate: %v", err)
133 }
134 q := socialdb.New()
135 followers, err := q.CountFollowersForUser(ctx, pool, pgtype.Int8{Int64: targetID, Valid: true})
136 if err != nil {
137 t.Fatalf("CountFollowersForUser: %v", err)
138 }
139 if followers != 1 {
140 t.Fatalf("followers = %d, want 1", followers)
141 }
142 following, err := q.CountFollowingForUser(ctx, pool, followerID)
143 if err != nil {
144 t.Fatalf("CountFollowingForUser: %v", err)
145 }
146 if following != 1 {
147 t.Fatalf("following = %d, want 1", following)
148 }
149 var eventCount int
150 if err := pool.QueryRow(ctx,
151 `SELECT count(*) FROM domain_events WHERE actor_user_id = $1 AND kind = 'followed_user'`,
152 followerID,
153 ).Scan(&eventCount); err != nil {
154 t.Fatalf("count follow events: %v", err)
155 }
156 if eventCount != 1 {
157 t.Fatalf("event count = %d, want 1", eventCount)
158 }
159 }
160
161 func TestFollowUser_RejectsSelf(t *testing.T) {
162 _, deps, userID, _ := setup(t)
163 if err := social.FollowUser(context.Background(), deps, userID, userID); !errors.Is(err, social.ErrCannotFollowSelf) {
164 t.Fatalf("FollowUser self err = %v, want ErrCannotFollowSelf", err)
165 }
166 }
167
168 func TestFollowOrg_IdempotentCountsAndEvent(t *testing.T) {
169 pool, deps, creatorID, _ := setup(t)
170 followerID := mustCreateUser(t, pool, "bob")
171 orgID := mustCreateOrg(t, pool, "octo-org", creatorID)
172 ctx := context.Background()
173
174 if err := social.FollowOrg(ctx, deps, followerID, orgID); err != nil {
175 t.Fatalf("FollowOrg: %v", err)
176 }
177 if err := social.FollowOrg(ctx, deps, followerID, orgID); err != nil {
178 t.Fatalf("FollowOrg duplicate: %v", err)
179 }
180 followers, err := socialdb.New().CountFollowersForOrg(ctx, pool, pgtype.Int8{Int64: orgID, Valid: true})
181 if err != nil {
182 t.Fatalf("CountFollowersForOrg: %v", err)
183 }
184 if followers != 1 {
185 t.Fatalf("org followers = %d, want 1", followers)
186 }
187 var eventCount int
188 if err := pool.QueryRow(ctx,
189 `SELECT count(*) FROM domain_events WHERE actor_user_id = $1 AND kind = 'followed_org'`,
190 followerID,
191 ).Scan(&eventCount); err != nil {
192 t.Fatalf("count org follow events: %v", err)
193 }
194 if eventCount != 1 {
195 t.Fatalf("event count = %d, want 1", eventCount)
196 }
197 }
198
199 func TestUnfollowUser_Idempotent(t *testing.T) {
200 pool, deps, targetID, _ := setup(t)
201 followerID := mustCreateUser(t, pool, "bob")
202 ctx := context.Background()
203
204 if err := social.FollowUser(ctx, deps, followerID, targetID); err != nil {
205 t.Fatalf("FollowUser: %v", err)
206 }
207 if err := social.UnfollowUser(ctx, deps, followerID, targetID); err != nil {
208 t.Fatalf("UnfollowUser: %v", err)
209 }
210 if err := social.UnfollowUser(ctx, deps, followerID, targetID); err != nil {
211 t.Fatalf("UnfollowUser duplicate: %v", err)
212 }
213 followers, err := socialdb.New().CountFollowersForUser(ctx, pool, pgtype.Int8{Int64: targetID, Valid: true})
214 if err != nil {
215 t.Fatalf("CountFollowersForUser: %v", err)
216 }
217 if followers != 0 {
218 t.Fatalf("followers = %d, want 0", followers)
219 }
220 }
221
222 func TestUnstar_DecrementsCount_AndIsIdempotent(t *testing.T) {
223 pool, deps, _, repoID := setup(t)
224 uid := mustCreateUser(t, pool, "bob")
225 ctx := context.Background()
226 _ = social.Star(ctx, deps, uid, repoID, true)
227
228 if err := social.Unstar(ctx, deps, uid, repoID, true); err != nil {
229 t.Fatalf("Unstar: %v", err)
230 }
231 if got := repoStarCount(t, pool, repoID); got != 0 {
232 t.Errorf("after unstar: got %d, want 0", got)
233 }
234 // Re-unstar is a no-op.
235 if err := social.Unstar(ctx, deps, uid, repoID, true); err != nil {
236 t.Fatalf("Unstar (idempotent): %v", err)
237 }
238 if got := repoStarCount(t, pool, repoID); got != 0 {
239 t.Errorf("after re-unstar: got %d, want 0", got)
240 }
241 }
242
243 func TestStar_RequiresLogin(t *testing.T) {
244 _, deps, _, repoID := setup(t)
245 if err := social.Star(context.Background(), deps, 0, repoID, true); err == nil {
246 t.Errorf("expected ErrNotLoggedIn for actor=0, got nil")
247 }
248 }
249
250 func TestStar_EmitsDomainEvent(t *testing.T) {
251 pool, deps, _, repoID := setup(t)
252 uid := mustCreateUser(t, pool, "bob")
253 ctx := context.Background()
254 if err := social.Star(ctx, deps, uid, repoID, true); err != nil {
255 t.Fatalf("Star: %v", err)
256 }
257 rows, err := socialdb.New().ListEventsForRepo(ctx, pool, socialdb.ListEventsForRepoParams{
258 RepoID: pgtype.Int8{Int64: repoID, Valid: true},
259 Limit: 10, Offset: 0,
260 })
261 if err != nil {
262 t.Fatalf("ListEventsForRepo: %v", err)
263 }
264 if len(rows) != 1 || rows[0].Kind != "star" || !rows[0].Public {
265 t.Errorf("expected one public 'star' event, got %+v", rows)
266 }
267 }
268
269 func TestSetWatch_All_IncrementsWatcherCount(t *testing.T) {
270 pool, deps, _, repoID := setup(t)
271 uid := mustCreateUser(t, pool, "bob")
272 ctx := context.Background()
273
274 if err := social.SetWatch(ctx, deps, uid, repoID, social.WatchAll); err != nil {
275 t.Fatalf("SetWatch: %v", err)
276 }
277 if got := repoWatcherCount(t, pool, repoID); got != 1 {
278 t.Errorf("after SetWatch=all: got %d, want 1", got)
279 }
280 }
281
282 func TestSetWatch_Ignore_DoesNotCountAsWatcher(t *testing.T) {
283 pool, deps, _, repoID := setup(t)
284 uid := mustCreateUser(t, pool, "bob")
285 ctx := context.Background()
286
287 if err := social.SetWatch(ctx, deps, uid, repoID, social.WatchIgnore); err != nil {
288 t.Fatalf("SetWatch: %v", err)
289 }
290 // `ignore` is the explicit "do not notify" — must not bump the count.
291 if got := repoWatcherCount(t, pool, repoID); got != 0 {
292 t.Errorf("after SetWatch=ignore: got %d, want 0", got)
293 }
294 }
295
296 func TestSetWatch_TransitionsAcrossIgnore(t *testing.T) {
297 pool, deps, _, repoID := setup(t)
298 uid := mustCreateUser(t, pool, "bob")
299 ctx := context.Background()
300
301 // all → 1
302 _ = social.SetWatch(ctx, deps, uid, repoID, social.WatchAll)
303 if got := repoWatcherCount(t, pool, repoID); got != 1 {
304 t.Fatalf("step 1: got %d, want 1", got)
305 }
306 // all → ignore: 1 → 0 (transition out of "watching").
307 _ = social.SetWatch(ctx, deps, uid, repoID, social.WatchIgnore)
308 if got := repoWatcherCount(t, pool, repoID); got != 0 {
309 t.Errorf("after ignore: got %d, want 0", got)
310 }
311 // ignore → participating: 0 → 1 (transition back in).
312 _ = social.SetWatch(ctx, deps, uid, repoID, social.WatchParticipating)
313 if got := repoWatcherCount(t, pool, repoID); got != 1 {
314 t.Errorf("after participating: got %d, want 1", got)
315 }
316 }
317
318 func TestUnsetWatch_RestoresImplicitDefault(t *testing.T) {
319 pool, deps, _, repoID := setup(t)
320 uid := mustCreateUser(t, pool, "bob")
321 ctx := context.Background()
322
323 _ = social.SetWatch(ctx, deps, uid, repoID, social.WatchAll)
324 if err := social.UnsetWatch(ctx, deps, uid, repoID); err != nil {
325 t.Fatalf("UnsetWatch: %v", err)
326 }
327 // CurrentLevel resolves the absent-row case to participating.
328 got, err := social.CurrentLevel(ctx, deps, uid, repoID)
329 if err != nil {
330 t.Fatalf("CurrentLevel: %v", err)
331 }
332 if got != social.WatchParticipating {
333 t.Errorf("after UnsetWatch: level=%s, want participating", got)
334 }
335 }
336
337 // TestAutoWatch_NonDestructive is the regression guard for the
338 // auto-watch contract: collaborator-add inserts level='all', but a
339 // later involvement event must NOT downgrade to 'participating'.
340 func TestAutoWatch_NonDestructive(t *testing.T) {
341 pool, deps, _, repoID := setup(t)
342 uid := mustCreateUser(t, pool, "bob")
343 ctx := context.Background()
344
345 if err := social.AutoWatchOnCollab(ctx, deps, uid, repoID); err != nil {
346 t.Fatalf("AutoWatchOnCollab: %v", err)
347 }
348 if got, _ := social.CurrentLevel(ctx, deps, uid, repoID); got != social.WatchAll {
349 t.Fatalf("step 1: level=%s, want all", got)
350 }
351 if err := social.AutoWatchOnInvolvement(ctx, deps, uid, repoID); err != nil {
352 t.Fatalf("AutoWatchOnInvolvement: %v", err)
353 }
354 if got, _ := social.CurrentLevel(ctx, deps, uid, repoID); got != social.WatchAll {
355 t.Errorf("involvement should not overwrite collab default: got %s", got)
356 }
357 }
358
359 // TestAutoWatch_PreservesUserChoice mirrors the spec pitfall about
360 // permission-revocation cascades: a user who explicitly chose `ignore`
361 // must keep that choice even when an involvement event fires.
362 func TestAutoWatch_PreservesUserChoice(t *testing.T) {
363 pool, deps, _, repoID := setup(t)
364 uid := mustCreateUser(t, pool, "bob")
365 ctx := context.Background()
366
367 _ = social.SetWatch(ctx, deps, uid, repoID, social.WatchIgnore)
368 _ = social.AutoWatchOnInvolvement(ctx, deps, uid, repoID)
369 if got, _ := social.CurrentLevel(ctx, deps, uid, repoID); got != social.WatchIgnore {
370 t.Errorf("user-chosen ignore should win: got %s", got)
371 }
372 }
373
374 // TestStargazerList_ExcludesSuspended guards the spec's "suspended
375 // users don't taint public lists" pitfall.
376 func TestStargazerList_ExcludesSuspended(t *testing.T) {
377 pool, deps, _, repoID := setup(t)
378 ctx := context.Background()
379 good := mustCreateUser(t, pool, "good")
380 bad := mustCreateUser(t, pool, "bad")
381 _ = social.Star(ctx, deps, good, repoID, true)
382 _ = social.Star(ctx, deps, bad, repoID, true)
383
384 // Suspend bad.
385 if _, err := pool.Exec(ctx,
386 "UPDATE users SET suspended_at = now() WHERE id = $1", bad); err != nil {
387 t.Fatalf("suspend: %v", err)
388 }
389
390 rows, err := socialdb.New().ListStargazersForRepo(ctx, pool, socialdb.ListStargazersForRepoParams{
391 RepoID: repoID, Limit: 10, Offset: 0,
392 })
393 if err != nil {
394 t.Fatalf("ListStargazersForRepo: %v", err)
395 }
396 if len(rows) != 1 || rows[0].UserID != good {
397 t.Errorf("expected only good user, got %d rows = %+v", len(rows), rows)
398 }
399 }
400
401 func TestCaptureTrendingSnapshots_FeedsCachedTrending(t *testing.T) {
402 pool, deps, _, repoID := setup(t)
403 actorID := mustCreateUser(t, pool, "bob")
404 ctx := context.Background()
405
406 if err := social.Star(ctx, deps, actorID, repoID, true); err != nil {
407 t.Fatalf("Star: %v", err)
408 }
409 if err := social.CaptureTrendingSnapshots(ctx, deps); err != nil {
410 t.Fatalf("CaptureTrendingSnapshots: %v", err)
411 }
412 repos, err := social.CachedTrendingRepos(ctx, deps, social.TrendingScopeWeek, 7, 5)
413 if err != nil {
414 t.Fatalf("CachedTrendingRepos: %v", err)
415 }
416 if len(repos) == 0 || repos[0].RepoID != repoID {
417 t.Fatalf("cached trending repos = %+v, want repo %d first", repos, repoID)
418 }
419 users, err := social.CachedTrendingUsers(ctx, deps, social.TrendingScopeWeek, 7, 5)
420 if err != nil {
421 t.Fatalf("CachedTrendingUsers: %v", err)
422 }
423 if len(users) == 0 || users[0].UserID != actorID {
424 t.Fatalf("cached trending users = %+v, want actor %d first", users, actorID)
425 }
426 }
427