test: share group seeding and throwaway stores
Four places seeded a group by reaching into the store the same way;
FixtureData.seed_group is the one they call today.
Three suites each pasted the throwaway store they'd swap;
now ThrowawayContacts holds it, and the copies stop.

Assisted-by: Claude Opus 5 via Claude Code
change yolrpotmkqmlltwmnppzprotmymykrsz
commit 327b24af35f16581214e33a3653a58a780d34f96
author Alpha Chen <alpha@kejadlen.dev>
date
parent ykvvryrn
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 53c1761..2aab554 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -88,20 +88,30 @@ module FixtureData
 
   #: (ProTacts::Store store) -> void
   def self.seed_groups(store)
-    database = store.instance_variable_get(:@database)
+    seed_group(store, name: "Booles", lines: HOUSEHOLD, members: MEMBERS)
+    seed_group(store, members: birthday_cards)
+  end
 
-    household = store.create_group(name: "Booles")
-    HOUSEHOLD.each.with_index do |line, position|
-      database[:group_properties].insert(group_id: household, position:, line:)
+  # A group, seeded without a change-log entry for any member it moves
+  # (see MEMBERS for why), and its id. Tests seed theirs here too, so
+  # the change log they assert on holds only what the test wrote.
+  #: (ProTacts::Store store, ?name: String?, ?lines: Array[String], ?members: Array[String]) -> String
+  def self.seed_group(store, name: nil, lines: [], members: [])
+    group_id = store.create_group(name:)
+    lines.each.with_index do |line, position|
+      database(store)[:group_properties].insert(group_id:, position:, line:)
     end
-    MEMBERS.each do |id|
-      database[:group_members].insert(group_id: household, card_id: id)
+    members.each do |card_id|
+      database(store)[:group_members].insert(group_id:, card_id:)
     end
+    group_id
+  end
 
-    birthdays = store.create_group
-    birthday_cards.each do |id|
-      database[:group_members].insert(group_id: birthdays, card_id: id)
-    end
+  # The store's own database, which nothing public exposes and nothing
+  # public should; seeding is the one reason to reach it.
+  #: (ProTacts::Store store) -> Sequel::Database
+  def self.database(store)
+    store.instance_variable_get(:@database)
   end
 
   # Builds the database and returns a store still open on it, for the
@@ -132,11 +142,10 @@ module FixtureData
   # StoreTest#database in test/pro_tacts/test_store.rb), because nothing
   # public exposes it and nothing public should.
   def self.backdate(store, ids)
-    database = store.instance_variable_get(:@database)
     now = Time.now.utc
     ids.each_with_index do |id, index|
       stamp = (now - ago_seconds(index)).strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
-      database[:cards].where(id:).update(updated_at: stamp)
+      database(store)[:cards].where(id:).update(updated_at: stamp)
     end
   end
 end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 13e92cc..416d073 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -2,11 +2,8 @@ require_relative "../../test_helper"
 require_relative "../../photo_card"
 
 require "date"
-require "pathname"
 require "rack/test"
-require "tmpdir"
 
-require "pro_tacts/store"
 require "pro_tacts/vcard"
 require "pro_tacts/web"
 
@@ -17,6 +14,7 @@ require "pro_tacts/web"
 # date.
 class AdminContactsPagesTest < Minitest::Test
   include Rack::Test::Methods
+  include ThrowawayContacts
 
   def app
     ProTacts::Web
@@ -31,44 +29,11 @@ class AdminContactsPagesTest < Minitest::Test
     "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
     "BDAY:1985-12-10\r\nNOTE:Countess of Lovelace.\r\nUID:ada\r\nEND:VCARD\r\n"
 
-  def with_contacts(cards)
-    Dir.mktmpdir do |dir|
-      original = ProTacts::Web.store
-
-      ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
-        ProTacts::Web.store = store
-        cards.each do |id, card|
-          store.put(id, ProTacts::VCard.new(card))
-        end
-        yield store
-      ensure
-        ProTacts::Web.store = original
-      end
-    end
-  end
-
-  # A household's lines, and the seeding a group takes: a created
-  # group for the row that mints an id, and its properties and members
-  # through the store's own database the way FixtureData's do
-  # (test/fixture_data.rb), so the change log holds only what the test
-  # itself wrote.
   HOUSEHOLD = [
     "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
     "NOTE:Gate code 1854.",
   ].freeze #: Array[String]
 
-  def add_group(store, members:, lines:, name: nil)
-    database = store.instance_variable_get(:@database)
-    group_id = store.create_group(name:)
-    lines.each.with_index do |line, position|
-      database[:group_properties].insert(group_id:, position:, line:)
-    end
-    members.each do |card_id|
-      database[:group_members].insert(group_id:, card_id:)
-    end
-    group_id
-  end
-
   def test_index_lists_recently_updated_contacts
     with_contacts({"ada" => ADA}) do
       get "/"
@@ -320,7 +285,7 @@ class AdminContactsPagesTest < Minitest::Test
   # marked.
   def test_show_marks_the_rows_a_group_lends
     with_contacts({"ada" => ADA}) do |store|
-      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
+      FixtureData.seed_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada"
       marks = last_response.body.scan('<span class="badge">Booles</span>')
@@ -339,8 +304,8 @@ class AdminContactsPagesTest < Minitest::Test
   # navigable").
   def test_show_tags_the_groups_a_contact_belongs_to
     with_contacts({"ada" => ADA}) do |store|
-      named = add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
-      nameless = add_group(store, members: ["ada"], lines: [])
+      named = FixtureData.seed_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
+      nameless = FixtureData.seed_group(store, members: ["ada"], lines: [])
 
       get "/contacts/ada"
       tags = last_response.body.scan(%r{<a href="/groups/([^"]*)" class="tag">([^<]*)</a>})
@@ -367,7 +332,7 @@ class AdminContactsPagesTest < Minitest::Test
   # then not say where.
   def test_show_marks_a_nameless_groups_rows_with_its_id
     with_contacts({"ada" => ADA}) do |store|
-      id = add_group(store, members: ["ada"], lines: HOUSEHOLD)
+      id = FixtureData.seed_group(store, members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada"
       marks = last_response.body.scan(%r{<span class="badge">([^<]*)</span>})
@@ -826,7 +791,7 @@ class AdminContactsPagesTest < Minitest::Test
   # page is where an inherited row is read.
   def test_the_edit_screen_omits_the_rows_a_group_lends
     with_contacts({"ada" => ADA}) do |store|
-      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
+      FixtureData.seed_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada/edit"
 
@@ -843,7 +808,7 @@ class AdminContactsPagesTest < Minitest::Test
     noteless = ADA.sub("NOTE:Countess of Lovelace.\r\n", "")
 
     with_contacts({"ada" => noteless}) do |store|
-      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
+      FixtureData.seed_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada/edit"
 
@@ -857,7 +822,7 @@ class AdminContactsPagesTest < Minitest::Test
   # is not the screen — matches nothing and writes nothing.
   def test_a_save_naming_a_lent_line_touches_nothing
     with_contacts({"ada" => ADA}) do |store|
-      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
+      FixtureData.seed_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
       contact = store.contact("ada")
       lent = contact.addresses.find { contact.group_of(it.line) }
 
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 6dae39b..5839566 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -1,11 +1,7 @@
 require_relative "../../test_helper"
 
-require "pathname"
 require "rack/test"
-require "tmpdir"
 
-require "pro_tacts/store"
-require "pro_tacts/vcard"
 require "pro_tacts/web"
 
 # The group screens, exercised the way AdminContactsPagesTest exercises
@@ -13,6 +9,7 @@ require "pro_tacts/web"
 # store.
 class AdminGroupsPagesTest < Minitest::Test
   include Rack::Test::Methods
+  include ThrowawayContacts
 
   def app
     ProTacts::Web
@@ -22,26 +19,11 @@ class AdminGroupsPagesTest < Minitest::Test
     header "Tailscale-User-Login", "test@example.com"
   end
 
-  def card(id, name)
-    "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\nUID:#{id}\r\nEND:VCARD\r\n"
-  end
-
   ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
   NOTE = "NOTE:Gate code 1854." #: String
 
-  def with_contacts(names)
-    Dir.mktmpdir do |dir|
-      original = ProTacts::Web.store
-
-      ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
-        ProTacts::Web.store = store
-        names.each { |id, name| store.put(id, ProTacts::VCard.new(card(id, name))) }
-        yield store
-      ensure
-        ProTacts::Web.store = original
-      end
-    end
-  end
+  # Contacts by id and name, each a bare card.
+  def with_contacts(names, &) = super(names.to_h { |id, name| [id, card(id, name)] }, &)
 
   def household(store, members: %w[george mary])
     id = store.create_group(name: "Booles")
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index adb106e..3e47a86 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -493,7 +493,7 @@ class StoreTest < Minitest::Test
   # member's write did, not what it inherited while doing it.
   def test_a_lent_line_is_no_part_of_a_members_diff
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub("FN:Aiden", "FN:Aiden Smith")))
@@ -995,23 +995,6 @@ class StoreTest < Minitest::Test
   HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
   HOUSEHOLD_NOTE = "NOTE:Gate code 1854." #: String
 
-  # A group's own row is Store#create_group's, which is what mints the
-  # id; its properties and its members land the way the fixture
-  # seeder's do, straight through the store's own database, so the
-  # change log holds only what the test itself wrote.
-  # Hands back the id, which is the only way a caller learns it.
-  def add_group(store, members:, lines:, name: nil)
-    db = database(store)
-    id = store.create_group(name:)
-    lines.each.with_index do |line, position|
-      db[:group_properties].insert(group_id: id, position:, line:)
-    end
-    members.each do |card_id|
-      db[:group_members].insert(group_id: id, card_id:)
-    end
-    id
-  end
-
   # What a group lends now, in the order it lends it — the rows a
   # propagated edit rewrites.
   def lent_lines(store, group)
@@ -1029,7 +1012,7 @@ class StoreTest < Minitest::Test
     )
 
     with_store({"aiden" => born, "znorth" => ZED}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
 
       assert_equal composed, store.contact("aiden").vcard.to_s
       assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
@@ -1042,7 +1025,7 @@ class StoreTest < Minitest::Test
   def test_a_contact_in_no_group_serves_unchanged_bytes_and_etag
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
       before = store.contact("aiden")
-      add_group(store, members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
 
       after = store.contact("aiden")
       assert_equal AIDEN, after.vcard.to_s
@@ -1056,7 +1039,7 @@ class StoreTest < Minitest::Test
   def test_leaving_a_group_restores_the_stored_bytes
     with_store({"aiden" => AIDEN}) do |store|
       before = store.contact("aiden").etag
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       refute_equal before, store.contact("aiden").etag
 
       database(store)[:group_members].where(card_id: "aiden").delete
@@ -1074,8 +1057,8 @@ class StoreTest < Minitest::Test
   def test_two_groups_compose_in_a_fixed_order
     with_store({"aiden" => AIDEN}) do |store|
       lent = {
-        add_group(store, members: ["aiden"], lines: [HOUSEHOLD_NOTE]) => HOUSEHOLD_NOTE,
-        add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS]) => HOUSEHOLD_ADDRESS,
+        FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_NOTE]) => HOUSEHOLD_NOTE,
+        FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS]) => HOUSEHOLD_ADDRESS,
       }
 
       first, second = lent.sort.map { it.last }
@@ -1089,7 +1072,7 @@ class StoreTest < Minitest::Test
   # no index row, and rebuilding the index leaves it that way.
   def test_the_index_holds_nothing_the_group_composes_in
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
       assert_includes indexed_names(store, "aiden"), "FN"
@@ -1110,7 +1093,7 @@ class StoreTest < Minitest::Test
   # different test's subject.
   def test_writes_log_the_composed_etag_for_a_member
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       put = store.put("aiden", vcard(store.contact("aiden").vcard.to_s))
       edit = store.rewrite("aiden", vcard(AIDEN), birthday: nil)
@@ -1126,7 +1109,7 @@ class StoreTest < Minitest::Test
   # collection — a screen marks a row as the household's from either.
   def test_an_inherited_line_carries_its_groups_name
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, name: "Household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, name: "Household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       listed = store.contacts.find { it.id == "aiden" }
       [store.contact("aiden"), listed].each do |contact|
@@ -1140,7 +1123,7 @@ class StoreTest < Minitest::Test
   # otherwise leave the row marked with nothing at all.
   def test_a_nameless_groups_lines_are_marked_with_its_id
     with_store({"aiden" => AIDEN}) do |store|
-      id = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      id = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       listed = store.contacts.find { it.id == "aiden" }
       [store.contact("aiden"), listed].each do |contact|
@@ -1156,9 +1139,9 @@ class StoreTest < Minitest::Test
   # inherited reads, not this one, that have nothing of it to carry.
   def test_the_groups_a_contact_is_in_are_read_with_their_labels
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
-      named = add_group(store, name: "Booles", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
-      nameless = add_group(store, members: ["aiden"], lines: [])
-      add_group(store, name: "Neighbours", members: [], lines: [HOUSEHOLD_NOTE])
+      named = FixtureData.seed_group(store, name: "Booles", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      nameless = FixtureData.seed_group(store, members: ["aiden"], lines: [])
+      FixtureData.seed_group(store, name: "Neighbours", members: [], lines: [HOUSEHOLD_NOTE])
 
       expected = {named => "Booles", nameless => nameless}.sort.map { it.last }
       assert_equal expected, store.groups_of("aiden").map(&:label)
@@ -1245,7 +1228,7 @@ class StoreTest < Minitest::Test
 
   def test_a_group_reads_whole
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
-      id = add_group(store, name: "Household", members: %w[znorth aiden], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+      id = FixtureData.seed_group(store, name: "Household", members: %w[znorth aiden], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
 
       group = store.group(id)
       assert_equal "Household", group.label
@@ -1270,7 +1253,7 @@ class StoreTest < Minitest::Test
   # "The fan-out").
   def test_setting_a_groups_lines_reaches_every_member
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
-      id = add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+      id = FixtureData.seed_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
 
       store.set_group_lines(id, [EDITED_ADDRESS, HOUSEHOLD_NOTE])
 
@@ -1286,7 +1269,7 @@ class StoreTest < Minitest::Test
 
   def test_a_joining_member_serves_the_group_and_is_logged
     with_store({"aiden" => AIDEN}) do |store|
-      id = add_group(store, members: [], lines: [HOUSEHOLD_ADDRESS])
+      id = FixtureData.seed_group(store, members: [], lines: [HOUSEHOLD_ADDRESS])
 
       store.add_member(id, "aiden")
       store.add_member(id, "aiden")
@@ -1299,7 +1282,7 @@ class StoreTest < Minitest::Test
 
   def test_a_leaving_member_stops_serving_the_group_and_is_logged
     with_store({"aiden" => AIDEN}) do |store|
-      id = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      id = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       store.remove_member(id, "aiden")
 
@@ -1313,7 +1296,7 @@ class StoreTest < Minitest::Test
   # is on no card.
   def test_writes_that_move_no_served_card_log_nothing
     with_store({"aiden" => AIDEN}) do |store|
-      id = add_group(store, members: [], lines: [])
+      id = FixtureData.seed_group(store, members: [], lines: [])
 
       store.add_member(id, "aiden")
       store.rename_group(id, name: "Household")
@@ -1350,7 +1333,7 @@ class StoreTest < Minitest::Test
   # (docs/plans/2026-08-24-vcard-storage-and-groups.md).
   def test_a_members_put_of_its_served_card_stores_what_it_started_with
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
       served = store.contact("aiden").vcard.to_s
 
       contact = store.put("aiden", vcard(served))
@@ -1367,7 +1350,7 @@ class StoreTest < Minitest::Test
     born = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nBDAY:1985-12-10\r\n")
 
     with_store({"aiden" => born}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served))
@@ -1385,7 +1368,7 @@ class StoreTest < Minitest::Test
     own = AIDEN.sub("FN:Aiden\r\n", "#{OWN_ADDRESS}\r\nFN:Aiden\r\n")
 
     with_store({"aiden" => own}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served))
@@ -1401,7 +1384,7 @@ class StoreTest < Minitest::Test
   # (docs/plans/2026-09-09-group-edits-propagate.md).
   def test_an_edited_lent_line_becomes_the_groups_line
     with_store({"aiden" => AIDEN}) do |store|
-      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS)))
@@ -1422,7 +1405,7 @@ class StoreTest < Minitest::Test
   # member's own card, which is what the composition exists to prevent.
   def test_a_reserialized_lent_line_is_still_the_groups
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, RESERIALIZED_ADDRESS)))
@@ -1437,7 +1420,7 @@ class StoreTest < Minitest::Test
   # client does not model going missing.
   def test_a_lent_note_stripped_of_its_parameter_is_still_the_groups
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [TAGGED_NOTE])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [TAGGED_NOTE])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(TAGGED_NOTE, HOUSEHOLD_NOTE)))
@@ -1457,7 +1440,7 @@ class StoreTest < Minitest::Test
     relabeled = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=WORK;type=pref:")
 
     with_store({"aiden" => AIDEN}) do |store|
-      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, relabeled)))
@@ -1474,7 +1457,7 @@ class StoreTest < Minitest::Test
   # cannot model becomes a custom label").
   def test_an_untyped_lent_line_comes_back_untyped
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [UNTYPED_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [UNTYPED_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served))
@@ -1492,7 +1475,7 @@ class StoreTest < Minitest::Test
     labeled = UNTYPED_ADDRESS.sub("ADR:", "ADR;type=HOME;type=pref:")
 
     with_store({"aiden" => AIDEN}) do |store|
-      group = add_group(store, members: ["aiden"], lines: [UNTYPED_ADDRESS])
+      group = FixtureData.seed_group(store, members: ["aiden"], lines: [UNTYPED_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(UNTYPED_ADDRESS, labeled)))
@@ -1511,7 +1494,7 @@ class StoreTest < Minitest::Test
     edited = RESERIALIZED_ADDRESS.sub("7 Calculus", "8 Calculus")
 
     with_store({"aiden" => AIDEN}) do |store|
-      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, edited)))
@@ -1531,7 +1514,7 @@ class StoreTest < Minitest::Test
   # question, decided").
   def test_a_deleted_lent_line_leaves_the_group
     with_store({"aiden" => AIDEN}) do |store|
-      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub("#{HOUSEHOLD_ADDRESS}\r\n", "")))
@@ -1552,7 +1535,7 @@ class StoreTest < Minitest::Test
   # fan-out").
   def test_an_edit_reaches_every_other_member
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
-      add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS)))
@@ -1571,7 +1554,7 @@ class StoreTest < Minitest::Test
   # member just lost.
   def test_a_deletion_reaches_every_other_member
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
-      add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub("#{HOUSEHOLD_ADDRESS}\r\n", "")))
@@ -1590,7 +1573,7 @@ class StoreTest < Minitest::Test
   # re-fetch a card that reads the same.
   def test_an_unchanged_round_trip_fans_out_nothing
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
-      add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
 
       store.put("aiden", vcard(store.contact("aiden").vcard.to_s))
 
@@ -1608,7 +1591,7 @@ class StoreTest < Minitest::Test
       path = Pathname.new(dir) / "contacts.db"
       group = ProTacts::Store.connect(path) do |store|
         store.put("aiden", vcard(AIDEN))
-        add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+        FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       end
 
       ProTacts::Store.connect(path) do |store|
@@ -1636,7 +1619,7 @@ class StoreTest < Minitest::Test
     labeled = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "item1.ADR:") + "\r\nitem1.X-ABLabel:dom"
 
     with_store({"aiden" => AIDEN}) do |store|
-      group = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      group = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, labeled)))
@@ -1655,8 +1638,8 @@ class StoreTest < Minitest::Test
   # read a deletion.
   def test_a_lent_line_is_not_attributed_anothers_untouched_line
     with_store({"aiden" => AIDEN}) do |store|
-      household = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
-      other = add_group(store, members: ["aiden"], lines: [OWN_ADDRESS])
+      household = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      other = FixtureData.seed_group(store, members: ["aiden"], lines: [OWN_ADDRESS])
       served = store.contact("aiden").vcard.to_s
 
       store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, EDITED_ADDRESS)))
@@ -1673,7 +1656,7 @@ class StoreTest < Minitest::Test
   # arrived and the ambiguity is reported rather than guessed at.
   def test_two_candidates_for_one_lent_line_are_stored_and_reported
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
       submitted = served.sub(HOUSEHOLD_ADDRESS, "#{EDITED_ADDRESS}\r\n#{OWN_ADDRESS}")
 
@@ -1692,7 +1675,7 @@ class StoreTest < Minitest::Test
     own = AIDEN.sub("END:VCARD\r\n", "#{HOUSEHOLD_ADDRESS}\r\nEND:VCARD\r\n")
 
     with_store({"aiden" => own}) do |store|
-      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
       served = store.contact("aiden").vcard.to_s
       assert_equal 2, served.scan(HOUSEHOLD_ADDRESS).length
 
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index f1e5aee..53729e1 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -6,10 +6,8 @@ require "digest"
 require "fileutils"
 require "pathname"
 require "rack/test"
-require "tmpdir"
 
 require "pro_tacts/config"
-require "pro_tacts/store"
 require "pro_tacts/vcard"
 require "pro_tacts/web"
 
@@ -17,6 +15,7 @@ class WebTest < Minitest::Test
   include Sentry::TestHelper
   include SentryMessages
   include Rack::Test::Methods
+  include ThrowawayContacts
 
   def app
     ProTacts::Web
@@ -346,7 +345,7 @@ class WebTest < Minitest::Test
     address = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"
 
     with_contacts({"aiden" => "Aiden"}) do |store|
-      lend(store, "aiden", address)
+      FixtureData.seed_group(store, name: "Booles", lines: [address], members: ["aiden"])
 
       get "/dav/addressbook/aiden.vcf"
       served = last_response.body
@@ -370,7 +369,7 @@ class WebTest < Minitest::Test
     address = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"
 
     with_contacts({"aiden" => "Aiden"}) do |store|
-      lend(store, "aiden", address)
+      FixtureData.seed_group(store, name: "Booles", lines: [address], members: ["aiden"])
 
       get "/dav/addressbook/aiden.vcf"
       edited = last_response.body.sub("7 Calculus", "8 Calculus")
@@ -727,48 +726,14 @@ class WebTest < Minitest::Test
     end
   end
 
-  # A card as Contacts would send one, so the routes are exercised
-  # against stored bytes rather than anything this test renders.
-  def card(id, name)
-    "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\nUID:#{id}\r\nEND:VCARD\r\n"
-  end
-
   # The route's xml_escape, reversed, for reading a card back out of
   # a multiget body and comparing it with what was PUT.
   def xml_unescape(text)
     text.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
   end
 
-  # Hands the app a throwaway store so the multi-contact routes can be
-  # exercised without touching the exchange fixture data, and yields it
-  # so a test can change a card mid-request-sequence. Only the store is
-  # swapped: nothing in a request reads configuration.
-  def with_contacts(names)
-    Dir.mktmpdir do |dir|
-      original = ProTacts::Web.store
-
-      ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
-        ProTacts::Web.store = store
-        names.each do |id, name|
-          store.put(id, ProTacts::VCard.new(card(id, name)))
-        end
-        yield store
-      ensure
-        ProTacts::Web.store = original
-      end
-    end
-  end
-
-  # Puts a card in a group of its own that lends it one line, through
-  # the store's own database the way the fixture seeder's do
-  # (test/fixture_data.rb), so the change log holds only what the test
-  # itself wrote.
-  def lend(store, id, line)
-    database = store.instance_variable_get(:@database)
-    group = store.create_group(name: "Booles")
-    database[:group_properties].insert(group_id: group, position: 0, line:)
-    database[:group_members].insert(group_id: group, card_id: id)
-  end
+  # Contacts by id and name, each a bare card.
+  def with_contacts(names, &) = super(names.to_h { |id, name| [id, card(id, name)] }, &)
 
   def etag_only_propfind
     <<~XML
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 7a291a3..c99f9fa 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -83,3 +83,31 @@ module SentryMessages
     sentry_events.map { it.message }
   end
 end
+
+# Hands the app a throwaway store holding just the given cards (id to
+# bytes), so a route can be exercised without touching the fixture
+# book, and yields it so a test can change a card mid-sequence. Only
+# the store is swapped: nothing in a request reads configuration.
+module ThrowawayContacts
+  def with_contacts(cards)
+    Dir.mktmpdir do |dir|
+      original = ProTacts::Web.store
+
+      ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
+        ProTacts::Web.store = store
+        cards.each do |id, card|
+          store.put(id, ProTacts::VCard.new(card))
+        end
+        yield store
+      ensure
+        ProTacts::Web.store = original
+      end
+    end
+  end
+
+  # A card as Contacts would send one, so the routes are exercised
+  # against stored bytes rather than anything a test renders.
+  def card(id, name)
+    "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\nUID:#{id}\r\nEND:VCARD\r\n"
+  end
+end