Seed user profiles and social links

This commit is contained in:
Jonathon Leight
2026-07-03 09:33:32 -04:00
parent 41bd03f026
commit fb32f7dc8d
3 changed files with 199 additions and 9 deletions
+145 -6
View File
@@ -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++ {
+28
View File
@@ -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)
}
}
}
+26 -3
View File
@@ -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 {