mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-10 21:55:42 +00:00
Seed user profiles and social links
This commit is contained in:
+145
-6
@@ -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++ {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user