From fb32f7dc8de47b6c7282df49ded8418fc09897da Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Fri, 3 Jul 2026 09:33:32 -0400 Subject: [PATCH] Seed user profiles and social links --- internal/seed/seed.go | 151 +++++++++++++++++++++++++++++++++-- internal/seed/seed_test.go | 28 +++++++ internal/store/reset_test.go | 29 ++++++- 3 files changed, 199 insertions(+), 9 deletions(-) diff --git a/internal/seed/seed.go b/internal/seed/seed.go index 4f01010..9bfc21d 100644 --- a/internal/seed/seed.go +++ b/internal/seed/seed.go @@ -1,9 +1,10 @@ -// Package seed populates the database with realistic fake data (users, orgs, -// memberships, repeaters, shares, docs, locations) for local testing of things -// like directory pagination, maps, and the public pages. It is strictly -// additive — it never deletes or overwrites existing rows — so it is safe to run -// against a dev database more than once. Realistic values come from gofakeit -// (https://github.com/brianvoe/gofakeit), Go's analogue to .NET's Bogus. +// Package seed populates the database with realistic fake data (users with +// profiles and social links, orgs with public links, memberships, repeaters, +// shares, docs, locations) for local testing of things like directory +// pagination, maps, and the public pages. It is strictly additive — it never deletes or overwrites +// existing rows — so it is safe to run against a dev database more than once. +// Realistic values come from gofakeit (https://github.com/brianvoe/gofakeit), +// Go's analogue to .NET's Bogus. package seed import ( @@ -79,6 +80,7 @@ func seedUsers(ctx context.Context, st *store.Store, f *gofakeit.Faker, logger * continue } taken[username] = true + seedUserProfile(ctx, st, f, u) users = append(users, u) } if len(users) == 0 { @@ -87,6 +89,140 @@ func seedUsers(ctx context.Context, st *store.Store, f *gofakeit.Faker, logger * return users, nil } +// mastodonInstances are a few real-looking Mastodon instances to spread seeded +// Mastodon handles across. +var mastodonInstances = []string{"mastodon.social", "fosstodon.org", "hachyderm.io", "mstdn.social"} + +// socialSeeds lists the handle-based platforms sprinkled onto seeded profiles, +// with the odds each user gets one. Keys must exist in store.UserLinkPlatforms. +var socialSeeds = []struct { + key string + pct int +}{ + {"github", 45}, {"x", 40}, {"instagram", 35}, {"mastodon", 25}, + {"bluesky", 25}, {"youtube", 20}, {"linkedin", 20}, {"telegram", 15}, + {"reddit", 15}, {"tiktok", 10}, {"twitch", 10}, {"facebook", 15}, +} + +// seedUserProfile fills a seeded user's public profile fields and a random spread +// of contact/social links. Best-effort: it logs nothing and ignores errors, like +// the other optional seed steps. +func seedUserProfile(ctx context.Context, st *store.Store, f *gofakeit.Faker, u *store.User) { + var bio, location, callsign string + if chance(f, 80) { + bio = f.Sentence(f.Number(8, 18)) + } + if chance(f, 70) { + location = f.City() + ", " + f.StateAbr() + } + if chance(f, 40) { + callsign = fakeCallsign(f) + } + if bio != "" || location != "" || callsign != "" { + _ = st.SetProfile(ctx, u.ID, bio, location, callsign) + } + + if links := seedUserLinks(f); len(links) > 0 { + _ = st.ReplaceUserLinks(ctx, u.ID, links) + } +} + +// seedUserLinks builds a random, plausible set of profile links, all pre-stored +// in canonical form (handle platforms hold their canonical profile URL, matching +// what the editor produces). One non-MeshCore link is usually the primary contact. +func seedUserLinks(f *gofakeit.Faker) []store.UserLink { + var links []store.UserLink + if chance(f, 40) { + links = append(links, store.UserLink{Platform: "website", URL: f.URL()}) + } + if chance(f, 30) { + links = append(links, store.UserLink{Platform: store.EmailPlatform, URL: f.Email()}) + } + for _, s := range socialSeeds { + if !chance(f, s.pct) { + continue + } + if p, ok := store.UserLinkPlatform(s.key); ok { + if v, ok := handleSeedURL(p, f); ok { + links = append(links, store.UserLink{Platform: s.key, URL: v}) + } + } + } + if chance(f, 25) { + links = append(links, store.UserLink{Platform: store.SignalPlatform, URL: seedHandle(f)}) + } + if chance(f, 25) { + links = append(links, store.UserLink{Platform: "discord", URL: seedHandle(f)}) + } + if chance(f, 50) { + if pk, err := randomHex(32); err == nil { + links = append(links, store.UserLink{Platform: store.MeshCorePlatform, Label: f.City() + " Node", URL: pk}) + } + } + // Promote the first reachable (non-MeshCore) link to primary contact. + if chance(f, 75) { + for i := range links { + if links[i].Platform != store.MeshCorePlatform { + links[i].IsPrimary = true + break + } + } + } + return links +} + +// handleSeedURL builds a canonical profile URL for a freshly generated handle on +// platform p (the social descriptors are shared by org and user links), via the +// platform's own canonicaliser. Mastodon handles get a random instance. +func handleSeedURL(p store.LinkPlatform, f *gofakeit.Faker) (string, bool) { + h := seedHandle(f) + if p.Key == "mastodon" { + h += "@" + mastodonInstances[f.Number(0, len(mastodonInstances)-1)] + } + return p.CanonicalHandleURL(h) +} + +// seedOrgLinks builds a random set of public links for a seeded org: a website, a +// community Discord, and a spread of social platforms, all in canonical form. Org +// links have no email/Signal/MeshCore or primary contact. +func seedOrgLinks(f *gofakeit.Faker) []store.OrgLink { + var links []store.OrgLink + if chance(f, 70) { + links = append(links, store.OrgLink{Platform: "website", URL: f.URL()}) + } + if chance(f, 45) { + links = append(links, store.OrgLink{Platform: "discord", URL: seedHandle(f)}) + } + for _, s := range socialSeeds { + if !chance(f, s.pct) { + continue + } + if p, ok := store.OrgLinkPlatform(s.key); ok { + if v, ok := handleSeedURL(p, f); ok { + links = append(links, store.OrgLink{Platform: s.key, URL: v}) + } + } + } + return links +} + +// seedHandle returns a valid social handle (letters/digits/._-). +func seedHandle(f *gofakeit.Faker) string { return sanitizeUsername(f.Username()) } + +// fakeCallsign builds a plausible amateur-radio callsign, e.g. "W1AW" or "KD7ABC". +func fakeCallsign(f *gofakeit.Faker) string { + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + prefix := []string{"K", "N", "W", "A"}[f.Number(0, 3)] + if chance(f, 40) { + prefix += string(letters[f.Number(0, 25)]) + } + var suffix strings.Builder + for i, n := 0, f.Number(1, 3); i < n; i++ { + suffix.WriteByte(letters[f.Number(0, 25)]) + } + return fmt.Sprintf("%s%d%s", prefix, f.Number(0, 9), suffix.String()) +} + // repRef is the minimum we keep about a created repeater for the sharing pass. type repRef struct{ id, ownerID int64 } @@ -153,6 +289,9 @@ func seedOrgs(ctx context.Context, st *store.Store, f *gofakeit.Faker, users []* continue } _ = st.UpdateOrg(ctx, org.ID, org.Slug, org.Name, f.Sentence(f.Number(8, 20)), f.State()) + if links := seedOrgLinks(f); len(links) > 0 { + _ = st.ReplaceOrgLinks(ctx, org.ID, links) + } // A random subset of users join, so member counts vary across orgs. joined := map[int64]bool{creator.ID: true} for s := 0; s < f.Number(0, len(users)-1); s++ { diff --git a/internal/seed/seed_test.go b/internal/seed/seed_test.go index df3b3b4..b983213 100644 --- a/internal/seed/seed_test.go +++ b/internal/seed/seed_test.go @@ -60,4 +60,32 @@ func TestRunSeeds(t *testing.T) { if publicOnOrg == 0 { t.Fatalf("no repeaters surface on any org public page") } + + // Seeded users should have profile data and a spread of links. With 60 users + // and the seeding odds, all of these being zero is astronomically unlikely. + var withProfile, totalLinks, primaryLinks, meshLinks int + _ = st.Pool().QueryRow(ctx, `SELECT count(*) FROM users WHERE bio <> '' OR location <> '' OR callsign <> ''`).Scan(&withProfile) + _ = st.Pool().QueryRow(ctx, `SELECT count(*) FROM user_links`).Scan(&totalLinks) + _ = st.Pool().QueryRow(ctx, `SELECT count(*) FROM user_links WHERE is_primary`).Scan(&primaryLinks) + _ = st.Pool().QueryRow(ctx, `SELECT count(*) FROM user_links WHERE platform = 'meshcore'`).Scan(&meshLinks) + if withProfile == 0 || totalLinks == 0 || primaryLinks == 0 || meshLinks == 0 { + t.Fatalf("seed profile data too sparse: withProfile=%d links=%d primary=%d mesh=%d", withProfile, totalLinks, primaryLinks, meshLinks) + } + + // Orgs also get public links. + var orgLinks int + _ = st.Pool().QueryRow(ctx, `SELECT count(*) FROM org_links`).Scan(&orgLinks) + if orgLinks == 0 { + t.Fatalf("no org links were seeded") + } + + // Handle-platform links are stored in canonical URL form (as the editor would), + // so the public page can link them directly. + for _, tbl := range []string{"user_links", "org_links"} { + var bad int + _ = st.Pool().QueryRow(ctx, `SELECT count(*) FROM `+tbl+` WHERE platform = 'github' AND url NOT LIKE 'https://github.com/%'`).Scan(&bad) + if bad != 0 { + t.Fatalf("%s: %d github links are not canonical https://github.com/ URLs", tbl, bad) + } + } } diff --git a/internal/store/reset_test.go b/internal/store/reset_test.go index f61bdab..2bad534 100644 --- a/internal/store/reset_test.go +++ b/internal/store/reset_test.go @@ -26,10 +26,21 @@ func TestReset(t *testing.T) { if err := st.SetPassword(ctx, owner.ID, "bcrypt-hash"); err != nil { t.Fatal(err) } - // A seeded-style account with no password and no passkey: Reset should prune it. - if _, err := st.CreateUser(ctx, "seeded", ""); err != nil { + // A seeded-style account with no password and no passkey: Reset should prune it, + // along with its profile fields (columns on the deleted row). + seeded, err := st.CreateUser(ctx, "seeded", "") + if err != nil { t.Fatal(err) } + if err := st.SetProfile(ctx, seeded.ID, "hi", "NYC", "W1AW"); err != nil { + t.Fatal(err) + } + // Profile links on both users — user_links is disposable and wiped entirely. + for _, uid := range []int64{owner.ID, seeded.ID} { + if err := st.ReplaceUserLinks(ctx, uid, []UserLink{{Platform: "github", URL: "https://github.com/x"}}); err != nil { + t.Fatal(err) + } + } if err := st.InsertServerIdentity(ctx, strings.Repeat("a", 64), []byte("sealed")); err != nil { t.Fatal(err) } @@ -40,7 +51,11 @@ func TestReset(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := st.CreateOrg(ctx, "Region", owner.ID); err != nil { + org, err := st.CreateOrg(ctx, "Region", owner.ID) + if err != nil { + t.Fatal(err) + } + if err := st.ReplaceOrgLinks(ctx, org.ID, []OrgLink{{Platform: "website", URL: "https://example.org"}}); err != nil { t.Fatal(err) } _ = rep @@ -79,6 +94,14 @@ func TestReset(t *testing.T) { if got := count("org_members"); got != 0 { t.Errorf("org_members after reset = %d, want 0", got) } + // Seeded profile data is gone: user_links wiped for everyone (disposable table), + // and the pruned user took its bio/location/callsign with it. + if got := count("user_links"); got != 0 { + t.Errorf("user_links after reset = %d, want 0", got) + } + if got := count("org_links"); got != 0 { + t.Errorf("org_links after reset = %d, want 0", got) + } // The kept user can still be looked up (login still works). if _, err := st.GetUserByID(ctx, owner.ID); err != nil {