web: hold every form field to the shape its form posts
The routes read their fields with to_s, is_a? and Array(), so a field
in another shape was coerced: a Hash saved as its inspect text, and a
groups field that was not a list read as every box unticked against
a was that still listed them. Every read now goes through
typecast_params, which answers a missing field as nil (the real
absences stay) and a wrong shape as the 400 the error handler sends.
The etag, the group version and the Company box are on every save
their editors post, so they are required. CardForm.company? and its
keep-the-kind branch go; the routes pass the box's answer. A land
that is not a walk row is a 400 rather than a quiet fall back to the
contact's page, and a book-group rename, which only a hand-made POST
can send, raises instead of rendering a notice.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kqo9sKAi4DnKCQkrtYoAwM
diff --git a/docs/plans/2026-10-07-switching-company-and-person.md b/docs/plans/2026-10-07-switching-company-and-person.md
index 32f810b..6bed9d0 100644
--- a/docs/plans/2026-10-07-switching-company-and-person.md
+++ b/docs/plans/2026-10-07-switching-company-and-person.md
@@ -29,8 +29,9 @@ the client, so it goes.
## The save
-`CardForm.contact_card` asks `CardForm.company?` which kind the save
-writes, and writes the name that kind keeps:
+`CardForm.contact_card` is told which kind the save writes, the
+Company box's answer that every save of the editor posts (a POST
+without one is a 400), and writes the name that kind keeps:
- **A company** (`company_name`): the Organization field into `ORG`'s
first component, the units after it rejoined byte for byte as
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index f26159a..76baf9b 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -33,26 +33,15 @@ module ProTacts
)
end
- # Whether the save writes a company card: the editor's Company
- # box, which submits "1" checked and the hidden "0" before it
- # otherwise. A POST carrying neither keeps what the card is
- # (Contact#company?), so no other form's save can switch one.
- #: (Contact contact, Hash[String, untyped] params) -> bool
- def self.company?(contact, params)
- case params["company"]
- when "1" then true
- when "0" then false
- else contact.company?
- end
- end
-
# Whether the save is refused for want of a name, each kind
# asking its own: a company its organization field, a person
# either half of the pair (Admin::NamePair). The server's half
- # of the browser's `required`.
- #: (Contact contact, Hash[String, untyped] params) -> bool
- def self.nameless?(contact, params)
- if company?(contact, params)
+ # of the browser's `required`. `company` is the editor's Company
+ # box, which posts "1" checked and the hidden "0" before it
+ # otherwise, so every save names the kind it writes.
+ #: (bool company, Hash[String, untyped] params) -> bool
+ def self.nameless?(company, params)
+ if company
params["organization"].to_s.strip.empty?
else
params["first"].to_s.strip.empty? && params["last"].to_s.strip.empty?
@@ -62,8 +51,8 @@ module ProTacts
# The contact editor's save, spliced into the stored card. Where
# each kind keeps its name, and the switch between the two, is
# docs/plans/2026-10-07-switching-company-and-person.md.
- #: (Contact contact, String first, String middle, String last, Hash[String, untyped] params) -> VCard
- def self.contact_card(contact, first, middle, last, params)
+ #: (Contact contact, bool company, String first, String middle, String last, Hash[String, untyped] params) -> VCard
+ def self.contact_card(contact, company, first, middle, last, params)
# The rows are read off the contact's own card, the one the form
# rendered (Admin::ContactsEdit) and the one this save splices:
# a line a group lends is on neither, so no digest here can name
@@ -73,10 +62,10 @@ module ProTacts
vcard = contact.stored
vcard =
- if company?(contact, params)
+ if company
company_name(vcard, params["organization"].to_s.strip)
else
- person_name(contact, vcard, first, middle, last, params["organization"]&.to_s&.strip)
+ person_name(contact, vcard, first, middle, last, params["organization"]&.strip)
end
vcard = edited_text(vcard, "NICKNAME", params["nickname"], own.nickname)
vcard = edited_text(vcard, "NOTE", params["note"], note_of(own))
@@ -173,26 +162,25 @@ module ProTacts
# (Admin::ContactsEdit) land as bare lines before END:VCARD, and a
# blank one inserts nothing — inserting absence is a no-op, the
# plan's rule for new rows, and the reason the rows nobody typed
- # in cost nothing whether they were on screen or not. Array()
- # because the field is a list and a request carrying one value
- # is still a list of one.
+ # in cost nothing whether they were on screen or not.
#: (Array[Contact::Phone | Contact::Email] rows, String field, String name, VCard vcard, Hash[String, untyped] params) -> VCard
def self.edited_values(rows, field, name, vcard, params)
edits = hash_field(params, field)
rows.each do |row|
submitted = edits[row.line.digest]
- next if submitted.nil? || submitted.to_s.strip == row.value.strip
+ next if submitted.nil? || submitted.strip == row.value.strip
- value = submitted.to_s.strip
+ value = submitted.strip
vcard = vcard.substitute(
row.line.digest,
value.empty? ? [] : ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
)
end
- submitted = Array(params["new_#{field}"]) #: Array[untyped]
+ none = [] #: Array[String]
+ submitted = params.fetch("new_#{field}", none) #: Array[String]
added = submitted.filter_map {
- value = it.to_s.strip
+ value = it.strip
"#{name}:#{VCard.escape(value)}" unless value.empty?
} #: Array[String]
vcard.insert(added)
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 7c1995c..5f22409 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -213,8 +213,8 @@ module ProTacts
# person.md), under the name it decides the shape of.
# The hidden "0" goes first so an unticked box still
# says so — Rack keeps the last of a repeated name —
- # and CardForm.company? reads a POST with neither as
- # the card's kind unchanged. The caption is a label
+ # and the save refuses a POST with neither as a 400
+ # (Web#apply_edit). The caption is a label
# beside the box rather than one wrapping it: Gloss
# sets a wrapping label in its checkbox type, over
# the caption's.
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 5efa92a..82f52b5 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -85,8 +85,21 @@ module ProTacts
# The shape checks a form's own fields are held to (#card_form_params),
# raising TypecastParams::Error for a POST no page here sends, which
- # the error handler below answers as a 400.
- plugin :typecast_params
+ # the error handler below answers as a 400. A walk row's path
+ # (`/import/:upload/:index`) is the groups dialog's `land`, read
+ # back into its parts: the redirect is not a form field's to
+ # choose, so any other shape — which no page here renders — is
+ # refused rather than followed.
+ plugin :typecast_params do
+ handle_type(:walk_row) do |v|
+ row = (v if v.is_a?(String))&.match(%r{\A/import/([a-z]+)/(\d+)\z})
+ raise Roda::RodaPlugins::TypecastParams::Error, "not a walk row: #{v.inspect}" if row.nil?
+
+ # Kernel's, the block running as a TypecastParams instance
+ # whose own #Integer reads a param by that name.
+ [row[1].to_s, ::Kernel::Integer(row[2], 10)]
+ end
+ end
# The two refusals raised as exceptions and mapped to a response.
# ProxyAuth.login raises MissingLogin for a request that names
@@ -162,22 +175,44 @@ module ProTacts
end
# The params Admin::CardForm reads, held to the shapes the contact
- # and group editors post: each phone, email and address field a
- # hash keyed by its row's digest, every address row and added
- # address a hash of components. Any other shape is a POST made by
- # hand, refused here as a 400 (the error handler) rather than read
- # around in CardForm. An absent field is a form that rendered no
- # such rows, and passes.
+ # and group editors post: each text field a string, each phone and
+ # email field a hash of strings keyed by its row's digest, each
+ # added phone or email a list of strings, and every address row,
+ # added address and the birthday row a hash of component strings.
+ # Any other shape is a POST made by hand, refused here as a 400
+ # (the error handler) rather than read around in CardForm. An
+ # absent field is a form that rendered no such rows — or a name
+ # input a company disables — and passes.
#: () -> Hash[String, untyped]
def card_form_params
- typecast_params.Hash(%w[phone email])
+ typecast_params.str(%w[first middle last organization nickname note])
+ typecast_params.array(:str, %w[new_phone new_email])
+ %w[phone email].each do |key|
+ rows = typecast_params.Hash(key)
+ rows&.each_key { typecast_params[key].str!(it) }
+ end
%w[address new_address].each do |key|
rows = typecast_params.Hash(key)
- rows&.each_key { typecast_params[key].Hash!(it) }
+ rows&.each_key { typecast_params[key][it].str(Contact::ADDRESS_COMPONENTS) }
end
+ typecast_params["birthday"].str(%w[year month day]) if typecast_params.Hash("birthday")
request.params
end
+ # A form's list of ids: none for a field absent — no box ticked —
+ # and a 400 for one that is not a list of strings.
+ #: (String key) -> Array[String]
+ def ids_in(key)
+ typecast_params.array(:str, key) || []
+ end
+
+ # The file part the form sent: none for a POST carrying no file,
+ # and a 400 for a field that is not one.
+ #: (String key) -> Hash[Symbol, untyped]?
+ def file_in(key)
+ typecast_params.file(key)
+ end
+
# One step of a request, timed as a span inside its request's span
# so a slow screen names its slow step. The name is the step's whole
# name; a span carries counts and never a value a person typed or a
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index bc5d1d9..4a43b30 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -39,9 +39,9 @@ module ProTacts
# the refusal the
# re-rendered page can actually show.
r.post do
- first = r.params["first"].to_s.strip
- middle = r.params["middle"].to_s.strip
- last = r.params["last"].to_s.strip
+ first = typecast_params.str("first", "").strip
+ middle = typecast_params.str("middle", "").strip
+ last = typecast_params.str("last", "").strip
# A middle name is not one of the halves that make a name: the
# form asks for it beside a pair it stands outside of
# (Admin::NamePair), and nobody is known by their middle name
@@ -160,19 +160,21 @@ module ProTacts
# name pair (Admin::NamePair) being the browser's own refusal of
# the same, and a company's organization field the one name it
# has (Admin::CardForm.nameless?).
- first = r.params["first"].to_s.strip
- middle = r.params["middle"].to_s.strip
- last = r.params["last"].to_s.strip
- return refuse.call(contact, notice: "A contact needs a name.") if Admin::CardForm.nameless?(contact, r.params)
+ params = card_form_params
+ company = typecast_params.bool!("company")
+ first = params["first"].to_s.strip
+ middle = params["middle"].to_s.strip
+ last = params["last"].to_s.strip
+ return refuse.call(contact, notice: "A contact needs a name.") if Admin::CardForm.nameless?(company, params)
# Request validity, standing with the name check rather than the
# stored-state conditionals below (write_card's ordering rule). A
- # POST that carries no birthday group keeps the model, the phones'
- # is-a-Hash posture — this form's own save does when it rendered
- # no birthday row, and anything else never carried one; a group of
- # three blanks is the row's blank-equals-absent, a removal.
+ # POST that carries no birthday group keeps the model — this
+ # form's own save does when it rendered no birthday row, and
+ # anything else never carried one; a group of three blanks is the
+ # row's blank-equals-absent, a removal.
birthday =
- if (fields = r.params["birthday"]).is_a?(Hash)
+ if (fields = params["birthday"])
begin
Admin::CardForm.birthday(fields)
rescue ArgumentError
@@ -190,7 +192,7 @@ module ProTacts
# it. The refusal re-renders from the current card, so the screen
# shows what changed. Like write_card's, the check is not atomic
# with the write.
- return refuse.call(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
+ return refuse.call(contact, notice: "This contact changed since the page loaded; nothing was saved.") if typecast_params.str!("etag") != contact.etag
# The one state the birthday row cannot write, refused whole:
# docs/plans/2026-09-07-web-birthday-editor.md, "The one hazard:
@@ -200,7 +202,7 @@ module ProTacts
return refuse.call(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
end
- store.save_edit(id, Admin::CardForm.contact_card(contact, first, middle, last, card_form_params), birthday:)
+ store.save_edit(id, Admin::CardForm.contact_card(contact, company, first, middle, last, params), birthday:)
r.redirect land || "/contacts/#{id}", 303
end
@@ -216,17 +218,17 @@ module ProTacts
return if contact.nil?
known = store.group_choices.map(&:id)
- checked = ids_in(r.params["groups"]) & known
- was = ids_in(r.params["was"]) & known
+ checked = ids_in("groups") & known
+ was = ids_in("was") & known
# The names the picker's offer committed by their ticks, each
# made with this save (Admin::GroupFilter::Named); a blank one is
# none.
- names = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
+ names = ids_in("named").map(&:strip).reject(&:empty?)
# The walk's row, when the dialog was opened from one
# (Admin::ImportSaved's `land`): where a save returns to and a
# refusal renders, so a look back is not a navigation away from
# the walk.
- row = walk_row(r.params["land"].to_s)
+ row = typecast_params.walk_row("land")
begin
store.regroup(id, join: checked - was, leave: was - checked, create: names)
rescue Sequel::UniqueConstraintViolation
@@ -248,24 +250,7 @@ module ProTacts
end
return contact_screen(contact, notice:)
end
- r.redirect(row ? r.params["land"].to_s : "/contacts/#{id}", 303)
- end
-
- # A walk row's path (`/import/:upload/:index`), read back into its
- # parts when the groups dialog's save came from one. Only the
- # shape this server renders is honored: `land` is a form field,
- # and the redirect is not its to choose — a path aimed elsewhere
- # lands on the contact's own page as though none was sent.
- #: (String land) -> [String, Integer]?
- def walk_row(land)
- row = land.match(%r{\A/import/([a-z]+)/(\d+)\z})
- [row[1], Integer(row[2])] if row
- end
-
- # A form's list of ids, and none for a param absent or not a list.
- #: (untyped param) -> Array[String]
- def ids_in(param)
- param.is_a?(Array) ? param.map(&:to_s) : []
+ r.redirect(row ? "/import/#{row.fetch(0)}/#{row.fetch(1)}" : "/contacts/#{id}", 303)
end
#: (Contact contact, ?notice: String) -> String
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 3ca0af8..b57b119 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -29,7 +29,7 @@ module ProTacts
end
r.post do
- name = r.params["name"].to_s.strip
+ name = typecast_params.str("name", "").strip
id = store.create_group(name: name.empty? ? nil : name)
r.redirect "/groups/#{id}/edit", 303
rescue Sequel::UniqueConstraintViolation
@@ -109,7 +109,7 @@ module ProTacts
group = store.group(id)
return if group.nil?
- if r.params["version"].to_s != group.version
+ if typecast_params.str!("version") != group.version
return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
end
@@ -117,18 +117,14 @@ module ProTacts
# name as text (Admin::GroupsEdit), where a blank one is a
# request to go nameless. Membership is the members screen's
# (#apply_members); the editor's form carries none.
- name = r.params.key?("name") ? r.params["name"].to_s : group.name
+ name = typecast_params.str("name", group.name)
begin
store.edit_group(id, name:, lines: Admin::CardForm.group_lines(group, card_form_params))
rescue Sequel::UniqueConstraintViolation
# A taken name (db/migrations/008_group_names.rb). The save is
# one transaction, so nothing of it landed.
- return group_edit_screen(group, notice: "Another group is already named #{r.params['name']}; nothing was saved.")
- rescue Store::SyncGroupRename
- # A book's group keeps its name (Store#rename_group); the
- # editor offers no field for it, so this is a doctored save.
- return group_edit_screen(group, notice: "The #{group.name} group names a book and keeps its name; nothing was saved.")
+ return group_edit_screen(group, notice: "Another group is already named #{name}; nothing was saved.")
end
r.redirect "/groups/#{id}", 303
end
@@ -145,8 +141,8 @@ module ProTacts
return if group.nil?
known = store.contact_ids
- checked = ids_in(r.params["members"]) & known
- was = ids_in(r.params["was"]) & known
+ checked = ids_in("members") & known
+ was = ids_in("was") & known
store.edit_members(id, join: checked - was, leave: was - checked)
r.redirect "/groups/#{id}", 303
end
@@ -163,7 +159,7 @@ module ProTacts
return r.redirect("/groups/#{id}", 303)
end
- upload = file_in(r.params["photo"])
+ upload = file_in("photo")
return group_edit_screen(group, notice: "Choose a photo to upload.") if upload.nil?
tempfile = upload.fetch(:tempfile)
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index d8f0a81..2872db6 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -56,7 +56,7 @@ module ProTacts
# chosen against the default, and none at all the default
# itself, the contact the card looks like most.
r.get do
- card_screen(upload, index, into: r.params.key?("into") ? r.params["into"].to_s : nil)
+ card_screen(upload, index, into: typecast_params.str("into"))
end
r.post do
@@ -87,7 +87,7 @@ module ProTacts
# card.
#: (untyped r) -> untyped
def stage_upload(r)
- upload = file_in(r.params["vcf"])
+ upload = file_in("vcf")
return import_screen(notice: "Choose a .vcf or Monica export to import.") if upload.nil?
name = File.basename(upload[:filename].to_s)
@@ -367,7 +367,7 @@ module ProTacts
# than trusted from the page, and refused outright if the
# contact is gone (docs/plans/2026-09-23-merging-on-import.md,
# "The Save").
- into = r.params["into"].to_s
+ into = typecast_params.str("into", "")
target = into.empty? ? nil : store.contact(into)
if !into.empty? && target.nil?
return card_screen(upload, index, notice: "The contact this card was updating is gone; nothing was saved.")
@@ -387,25 +387,27 @@ module ProTacts
# ticked (the plan's "Writing them in").
choices = store.group_choices
known = choices.map(&:id)
- ticked = ids_in(r.params["groups"]) & known
- was = ids_in(r.params["was"]) & known
- wanted = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
+ ticked = ids_in("groups") & known
+ was = ids_in("was") & known
+ wanted = ids_in("named").map(&:strip).reject(&:empty?)
# Everyone's book, which may not exist yet to tick, rides as an
# answer rather than an id (Import::Write.call's `everyone`).
everyone = choices.find { it.name == Group::EVERYONE }
syncing = everyone.nil? || ticked.include?(everyone.id)
# A blank name is refused, #apply_edit's check.
- first = r.params["first"].to_s.strip
- middle = r.params["middle"].to_s.strip
- last = r.params["last"].to_s.strip
- if Admin::CardForm.nameless?(contact, r.params)
+ params = card_form_params
+ company = typecast_params.bool!("company")
+ first = params["first"].to_s.strip
+ middle = params["middle"].to_s.strip
+ last = params["last"].to_s.strip
+ if Admin::CardForm.nameless?(company, params)
return card_screen(upload, index, into:, notice: "A contact needs a name.", joined: ticked, named: wanted)
end
- fields = r.params["birthday"]
+ fields = params["birthday"]
birthday =
- if fields.is_a?(Hash)
+ if fields
begin
Admin::CardForm.birthday(fields)
rescue ArgumentError
@@ -421,12 +423,12 @@ module ProTacts
# import are the case, and applying this save over the other
# one's would revert it. Over a fold, it is the contact's own
# guard too.
- if r.params["etag"].to_s != contact.etag
+ if typecast_params.str!("etag") != contact.etag
return card_screen(upload, index, into:, notice: "This card changed since the page loaded; nothing was saved.",
joined: ticked, named: wanted)
end
- edited = Admin::CardForm.contact_card(contact, first, middle, last, card_form_params)
+ edited = Admin::CardForm.contact_card(contact, company, first, middle, last, params)
if target
# A stored contact, whose birthday the model holds: the contact
# editor's own save (#apply_edit), down to its one refusal.
@@ -562,15 +564,6 @@ module ProTacts
contact.stored.lines.any? { it.names?("BDAY") }
end
- # The file part the form sent, and none for a POST carrying no
- # file: #ids_in's shape over an upload, and its reason for being a
- # method — the empty case needs a type, and the signature is where
- # one fits.
- #: (untyped param) -> untyped
- def file_in(param)
- param if param.is_a?(Hash) && param[:tempfile]
- end
-
# The screen a file is chosen on, which is all this path is: a
# walk that has ended has a group to land on rather than a page
# here saying it ended (#close_walk).
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index 42f8ca4..e02d0ff 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -14,7 +14,7 @@ module ProTacts
#
# Each step is a span (Web#span), and none carries the query.
hash_branch("search") do |r|
- query = r.params["q"].to_s.strip
+ query = typecast_params.str("q", "").strip
r.is do
r.get do
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index 7ab6004..4536f07 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -43,12 +43,12 @@ module ProTacts
r.post "book" do
response["Content-Type"] = "text/plain"
begin
- store.name_book(@login, r.params["name"]&.to_s)
+ store.name_book(@login, typecast_params.str("name"))
"#{@login} syncs #{Group::SYNC_PREFIX}#{store.book_name(@login)}\n"
rescue Sequel::UniqueConstraintViolation, Store::EveryonesBookName
# Another login's name, a group's already, or everyone's.
response.status = 409
- "#{Group::SYNC_PREFIX}#{r.params['name'].to_s.strip} is taken; #{@login}'s book is unchanged.\n"
+ "#{Group::SYNC_PREFIX}#{typecast_params.str('name', '').strip} is taken; #{@login}'s book is unchanged.\n"
end
end
end
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 1f91258..52466f8 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -8,8 +8,11 @@
class Roda
# The configure blocks vary by plugin — error_handler passes the
# exception, not_found and the routes pass nothing — so the block
- # takes anything, like the matchers below.
- def self.plugin: (Symbol | Module plugin, *untyped args, **untyped options) ?{ (*untyped) [self: instance] -> untyped } -> void
+ # takes anything, like the matchers below. typecast_params' block
+ # is the one run elsewhere: class_eval'd on the app's Params class,
+ # where it registers conversion types.
+ def self.plugin: (:typecast_params plugin) { () [self: singleton(RodaPlugins::TypecastParams::Params)] -> void } -> void
+ | (Symbol | Module plugin, *untyped args, **untyped options) ?{ (*untyped) [self: instance] -> untyped } -> void
def self.use: (untyped middleware, **untyped options) -> void
@@ -100,6 +103,12 @@ class Roda
class Error < RodaError
def param_name: () -> String?
end
+
+ class Params
+ # A conversion type: `name` and `name!` methods that run the
+ # block over a submitted value, a raise in it being an Error.
+ def self.handle_type: (Symbol type) { (untyped value) -> untyped } -> void
+ end
end
end
end
diff --git a/test/pro_tacts/admin/test_card_form.rb b/test/pro_tacts/admin/test_card_form.rb
index 3a09390..01c7bf8 100644
--- a/test/pro_tacts/admin/test_card_form.rb
+++ b/test/pro_tacts/admin/test_card_form.rb
@@ -49,7 +49,6 @@ class CardFormTest < Minitest::Test
"address" => own.addresses.to_h { |address|
[address.line.digest, ProTacts::Contact::ADDRESS_COMPONENTS.to_h { [it, address.public_send(it).to_s] }]
},
- "company" => contact.company? ? "1" : "0",
}
organization = ProTacts::Admin::CardForm.organization_of(contact.stored).to_s
params["organization"] = organization unless organization.empty?
@@ -87,7 +86,7 @@ class CardFormTest < Minitest::Test
params = untouched(contact)
saved = ProTacts::Admin::CardForm.contact_card(
- contact, params["first"].strip, params["middle"].strip, params["last"].strip, params,
+ contact, contact.company?, params["first"].strip, params["middle"].strip, params["last"].strip, params,
)
assert_equal stored, saved.to_s
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index dab3b08..5b190cf 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -875,14 +875,17 @@ class AdminContactsPagesTest < Minitest::Test
end
# `land` is a form field, and the redirect is not its to choose: a
- # path not shaped like a walk row lands on the contact's own page
- # as though none was sent (Web#walk_row).
- def test_a_groups_save_ignores_a_land_that_is_not_a_walk_row
- with_contacts({"ada" => ADA}) do
- post "/contacts/ada/groups", "land" => "//evil.example"
+ # path not shaped like a walk row is refused rather than followed,
+ # and the save with it (Web's walk_row type).
+ def test_a_groups_save_refuses_a_land_that_is_not_a_walk_row
+ with_contacts({"ada" => ADA}) do |store|
+ id = FixtureData.seed_group(store, name: "Family")
- assert_equal 303, last_response.status
- assert_equal "/contacts/ada", last_response.headers["location"]
+ post "/contacts/ada/groups", "land" => "//evil.example", "groups" => [id]
+
+ assert_equal 400, last_response.status
+ assert_nil last_response.headers["location"]
+ assert_empty store.groups_of("ada")
end
end
@@ -1406,7 +1409,7 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes body, '<input type="text" name="last" value="Prince" required ' \
':required="firstBlank" @input="lastBlank = !$el.value.trim()" :disabled="company">'
- post "/contacts/prince", first: "", last: "Prince", etag: store.contact("prince").etag
+ post "/contacts/prince", first: "", last: "Prince", company: "0", etag: store.contact("prince").etag
assert_equal 303, last_response.status
assert_includes store.contact("prince").vcard.to_s, "N:Prince;;;;\r\n"
@@ -1483,13 +1486,13 @@ class AdminContactsPagesTest < Minitest::Test
# without the field leaves it be.
def test_saving_a_persons_organization
with_contacts({"company" => SWITCHED_PERSON.sub("ORG:Arbitrary Definitions;", "ORG:Arbitrary Definitions;Ops")}) do |store|
- post "/contacts/company", first: "A", last: "B", organization: "Gloss", etag: store.contact("company").etag
+ post "/contacts/company", first: "A", last: "B", organization: "Gloss", company: "0", etag: store.contact("company").etag
assert_includes store.contact("company").vcard.to_s, "ORG:Gloss;Ops\r\n"
- post "/contacts/company", first: "A", last: "B", etag: store.contact("company").etag
+ post "/contacts/company", first: "A", last: "B", company: "0", etag: store.contact("company").etag
assert_includes store.contact("company").vcard.to_s, "ORG:Gloss;Ops\r\n"
- post "/contacts/company", first: "A", last: "B", organization: "", etag: store.contact("company").etag
+ post "/contacts/company", first: "A", last: "B", organization: "", company: "0", etag: store.contact("company").etag
refute_includes store.contact("company").vcard.to_s, "ORG"
refute store.contact("company").company?
end
@@ -1644,7 +1647,7 @@ class AdminContactsPagesTest < Minitest::Test
lent = contact.addresses.find { it.lent_by }
post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
- etag: contact.etag,
+ company: "0", etag: contact.etag,
address: {lent.line.digest => {"street" => "9 Elsewhere Lane"}}
assert_equal 303, last_response.status
@@ -1727,7 +1730,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_saving_the_edit_replaces_only_the_addressed_properties
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "King", nickname: "Countess",
- note: "First programmer.", etag: store.contact("ada").etag
+ note: "First programmer.", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
assert_equal "/contacts/ada", last_response["Location"]
@@ -1760,7 +1763,7 @@ class AdminContactsPagesTest < Minitest::Test
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", middle: "B.", last: "Lovelace",
note: "Countess of Lovelace.",
- etag: store.contact("ada").etag, phone: {digest => "+1-555-0199"}
+ company: "0", etag: store.contact("ada").etag, phone: {digest => "+1-555-0199"}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -1779,7 +1782,7 @@ class AdminContactsPagesTest < Minitest::Test
.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;B.;Dr.;Jr.")
with_contacts({"ada" => formal}) do |store|
- post "/contacts/ada", first: "Ada", middle: "B.", last: "King", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", middle: "B.", last: "King", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -1793,7 +1796,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_moved_middle_name_rewrites_the_display_name
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", middle: "Byron", last: "Lovelace",
- etag: store.contact("ada").etag
+ company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -1811,7 +1814,7 @@ class AdminContactsPagesTest < Minitest::Test
.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron;;")
with_contacts({"ada" => byron}) do |store|
- post "/contacts/ada", first: "Ada", middle: "", last: "Lovelace", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", middle: "", last: "Lovelace", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -1827,7 +1830,7 @@ class AdminContactsPagesTest < Minitest::Test
nameless = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Lovelace;Ada;;;\r\nUID:ada\r\nEND:VCARD\r\n"
with_contacts({"ada" => nameless}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s, "FN:Ada Lovelace\r\n"
@@ -1840,7 +1843,7 @@ class AdminContactsPagesTest < Minitest::Test
# carried none.
def test_a_blank_nickname_or_note_removes_the_property
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", nickname: "", note: "", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", last: "Lovelace", nickname: "", note: "", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -1858,7 +1861,7 @@ class AdminContactsPagesTest < Minitest::Test
honorific = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;;Countess\\; Dr.")
with_contacts({"ada" => honorific}) do |store|
- post "/contacts/ada", first: "Ada", middle: "", last: "King", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", middle: "", last: "King", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;;Countess\\; Dr.;\r\n"
@@ -1876,7 +1879,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => odd}) do |store|
post "/contacts/ada", first: "Ada", middle: "Byron\\x", last: "King",
- etag: store.contact("ada").etag
+ company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;Byron\\x;;\r\n"
@@ -1889,7 +1892,7 @@ class AdminContactsPagesTest < Minitest::Test
short = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada")
with_contacts({"ada" => short}) do |store|
- post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", last: "King", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;;;\r\n"
@@ -1903,7 +1906,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "King",
nickname: "Countess, of mathematics", note: "line one\nline two",
- etag: store.contact("ada").etag
+ company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -1922,7 +1925,7 @@ class AdminContactsPagesTest < Minitest::Test
folded = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n", "TEL;TYPE=mobile:+1-555-\r\n 0100\r\n")
with_contacts({"ada" => folded}) do |store|
- post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", last: "King", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s, "TEL;TYPE=mobile:+1-555-\r\n 0100\r\n"
@@ -1990,7 +1993,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => two}) do |store|
digest = store.contact("ada").emails.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
email: {digest => "lovelace@example.com"}
assert_equal 303, last_response.status
@@ -2008,7 +2011,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => apple}) do |store|
digest = store.contact("ada").emails.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
email: {digest => "lovelace@example.com"}
assert_equal 303, last_response.status
@@ -2022,7 +2025,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_blank_email_row_removes_the_line
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").emails.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
email: {digest => " "}
assert_equal 303, last_response.status
@@ -2038,7 +2041,7 @@ class AdminContactsPagesTest < Minitest::Test
email_digest = store.contact("ada").emails.first.line.digest
address_digest = store.contact("ada").addresses.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
- etag: store.contact("ada").etag, new_email: "",
+ company: "0", etag: store.contact("ada").etag, new_email: [""],
email: {email_digest => "ada@example.com"},
address: {address_digest => {
"street" => "12 Analytical Way", "locality" => "London",
@@ -2055,7 +2058,7 @@ class AdminContactsPagesTest < Minitest::Test
# and a blank one inserts nothing — the phones' own rules.
def test_an_added_email_lands_as_a_bare_email_before_the_end
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
new_email: ["lovelace@example.com", "", "countess@example.com"]
assert_equal 303, last_response.status
@@ -2097,7 +2100,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_saving_a_changed_address_replaces_only_its_line
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").addresses.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
address: {digest => {
"street" => "5 Analytical Way", "locality" => "London",
"region" => "England", "postal_code" => "NW1 1AA",
@@ -2126,7 +2129,7 @@ class AdminContactsPagesTest < Minitest::Test
{address: "5 Analytical Way"},
{new_address: ["5 Analytical Way"]},
{phone: "+1-555-0199"}].each do |field|
- post "/contacts/ada", {first: "Ada", last: "Lovelace", etag: contact.etag}.merge(field)
+ post "/contacts/ada", {first: "Ada", last: "Lovelace", company: "0", etag: contact.etag}.merge(field)
assert_equal 400, last_response.status, field
assert_equal contact.etag, store.contact("ada").etag
@@ -2134,6 +2137,45 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # The name, note and Company fields, the birthday row and the
+ # snapshot etag are on every save the editor posts, each in one
+ # shape; a save missing the etag or the Company box's answer, or
+ # carrying a field in another shape, is a POST made by hand and a
+ # 400, the card untouched.
+ def test_a_save_missing_a_field_or_in_another_shape_is_a_400
+ with_contacts({"ada" => ADA}) do |store|
+ contact = store.contact("ada")
+ good = {"first" => "Ada", "last" => "Lovelace", "company" => "0", "etag" => contact.etag}
+ [good.except("etag"),
+ good.except("company"),
+ good.merge("company" => "maybe"),
+ good.merge("first" => {"x" => "Ada"}),
+ good.merge("note" => ["two", "notes"]),
+ good.merge("new_phone" => "+1-555-0199"),
+ good.merge("birthday" => "1815-12-10"),
+ good.merge("birthday" => {"year" => {"x" => "1815"}})].each do |params|
+ post "/contacts/ada", params
+
+ assert_equal 400, last_response.status, params
+ assert_equal contact.etag, store.contact("ada").etag
+ end
+ end
+ end
+
+ # The groups dialog posts its boxes as lists; a `groups` in another
+ # shape is a 400, so it cannot read as every box unticked against a
+ # `was` that still lists them.
+ def test_a_groups_save_with_a_groups_field_that_is_not_a_list_is_a_400
+ with_contacts({"ada" => ADA}) do |store|
+ id = FixtureData.seed_group(store, name: "Family", members: %w[ada])
+
+ post "/contacts/ada/groups", "groups" => id, "was" => [id]
+
+ assert_equal 400, last_response.status
+ assert_equal [id], store.groups_of("ada").map(&:id)
+ end
+ end
+
# The header is the line's own, kept byte for byte — the phones'
# own rule, over the parameters an ADR line can carry.
def test_a_saved_address_keeps_its_lines_own_parameters
@@ -2144,7 +2186,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => apple}) do |store|
digest = store.contact("ada").addresses.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
address: {digest => {
"street" => "5 Analytical Way", "locality" => "London",
"region" => "England", "postal_code" => "NW1 1AA",
@@ -2162,7 +2204,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_an_address_row_blank_throughout_removes_the_line
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").addresses.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
address: {digest => {
"street" => " ", "extended" => "", "locality" => "",
"region" => "", "postal_code" => "", "country" => "",
@@ -2178,7 +2220,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_partially_blanked_address_keeps_the_line
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").addresses.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
address: {digest => {
"street" => "12 Analytical Way", "locality" => "London",
"region" => "", "postal_code" => "", "country" => "",
@@ -2202,7 +2244,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => po_box}) do |store|
digest = store.contact("ada").addresses.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
address: {digest => {
"street" => "12 Way, West", "extended" => "Apt \\q",
"locality" => "Manchester", "region" => "England",
@@ -2220,7 +2262,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_address_punctuation_escapes_in_the_written_line
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").addresses.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
address: {digest => {
"street" => "12 Way, East; Annexe", "locality" => "London",
"region" => "England", "postal_code" => "NW1 1AA",
@@ -2239,7 +2281,7 @@ class AdminContactsPagesTest < Minitest::Test
# throughout inserts nothing, the new-row rule.
def test_an_added_address_lands_in_component_order_before_the_end
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
new_address: {"1" => {
"street" => "5 Analytical Way", "extended" => "Apt 9",
"locality" => "London", "country" => "United Kingdom",
@@ -2254,7 +2296,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_blank_address_add_row_inserts_nothing
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
new_address: {"0" => {"street" => " ", "country" => ""}}
assert_equal 303, last_response.status
@@ -2305,7 +2347,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => ADA}) do |store|
before = store.contact("ada").stored.to_s
post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
- etag: store.contact("ada").etag,
+ company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "4", "day" => "12", "year" => "1990"}
assert_equal 303, last_response.status
@@ -2324,7 +2366,7 @@ class AdminContactsPagesTest < Minitest::Test
# verified to accept (docs/plans/2026-08-31-partial-birthdays.md).
def test_a_birthday_without_a_year_is_saved_and_served_apples_way
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "12", "day" => "10", "year" => ""}
assert_equal 303, last_response.status
@@ -2338,7 +2380,7 @@ class AdminContactsPagesTest < Minitest::Test
# behalf: their documented fate.
def test_a_month_alone_is_saved_and_serves_nothing
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "4", "day" => "", "year" => ""}
assert_equal 303, last_response.status
@@ -2355,7 +2397,7 @@ class AdminContactsPagesTest < Minitest::Test
# the model row goes and the composed card with it.
def test_a_blank_birthday_removes_it
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "", "day" => "", "year" => ""}
assert_equal 303, last_response.status
@@ -2375,7 +2417,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => bare}) do |store|
assert_nil store.contact("ada").birthday
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "3", "day" => "14", "year" => "1879"}
assert_equal 303, last_response.status
@@ -2388,12 +2430,12 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # A POST that carries no birthday group keeps the model, the
- # phones' is-a-Hash posture — a group the request never carried
- # touches nothing, this form's own no-row save included.
+ # A POST that carries no birthday group keeps the model — a group
+ # the request never carried touches nothing, this form's own no-row
+ # save included.
def test_a_save_without_birthday_fields_leaves_the_model_alone
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag
assert_equal 303, last_response.status
assert_equal ProTacts::Birthday.new(year: 1985, month: 12, day: 10),
@@ -2408,7 +2450,7 @@ class AdminContactsPagesTest < Minitest::Test
# POST's own, the name toast's backstop sibling.
def test_a_year_and_day_without_a_month_is_refused
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "", "day" => "12", "year" => "1985"}
assert_equal 200, last_response.status
@@ -2421,7 +2463,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_nonsense_in_a_birthday_field_is_refused
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "4", "day" => "12", "year" => "19x5"}
assert_equal 200, last_response.status
@@ -2441,7 +2483,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => resident}) do |store|
assert_nil store.contact("ada").birthday
- post "/contacts/ada", first: "Grace", last: "Hopper", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Grace", last: "Hopper", company: "0", etag: store.contact("ada").etag,
birthday: {"month" => "4", "day" => "", "year" => "1985"}
assert_equal 200, last_response.status
@@ -2549,7 +2591,7 @@ class AdminContactsPagesTest < Minitest::Test
# among them are rows nobody typed in, and they cost nothing.
def test_a_single_save_adds_every_phone_that_was_typed
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
new_phone: ["+1-555-0177", "+1-555-0188", "", ""]
assert_equal 303, last_response.status
@@ -2567,7 +2609,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => two}) do |store|
digest = store.contact("ada").phones.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
phone: {digest => "+1-555-0150"}
assert_equal 303, last_response.status
@@ -2592,7 +2634,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => labeled}) do |store|
digest = store.contact("ada").phones.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
phone: {digest => "+1-555-0150"}
assert_equal 303, last_response.status
@@ -2611,7 +2653,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => apple}) do |store|
digest = store.contact("ada").phones.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
phone: {digest => "+1-555-0150"}
assert_equal 303, last_response.status
@@ -2626,7 +2668,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_blank_phone_row_removes_the_line
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").phones.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
phone: {digest => " "}
assert_equal 303, last_response.status
@@ -2644,7 +2686,7 @@ class AdminContactsPagesTest < Minitest::Test
before = store.contact("ada").stored.to_s
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
- etag: store.contact("ada").etag, new_phone: "",
+ company: "0", etag: store.contact("ada").etag, new_phone: [""],
phone: {digest => " +1-555-0100 "}
assert_equal 303, last_response.status
@@ -2657,8 +2699,8 @@ class AdminContactsPagesTest < Minitest::Test
# blank one inserts nothing, inserting absence being a no-op.
def test_an_added_phone_lands_as_a_bare_tel_before_the_end
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
- new_phone: "+1 (555) 020-0700, x9"
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
+ new_phone: ["+1 (555) 020-0700, x9"]
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2669,8 +2711,8 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_blank_add_row_inserts_nothing
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
- new_phone: " "
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
+ new_phone: [" "]
assert_equal 303, last_response.status
assert_equal 1, store.contact("ada").phones.length
@@ -2682,7 +2724,7 @@ class AdminContactsPagesTest < Minitest::Test
# a doctored index into a list would have rewritten some other line.
def test_a_digest_the_card_never_had_is_ignored
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
phone: {"0" * 64 => "+1-555-9999"},
email: {"1" * 64 => "x@example.com"},
address: {"2" * 64 => {"street" => "nowhere"}}
@@ -2701,7 +2743,7 @@ class AdminContactsPagesTest < Minitest::Test
# the save would have reverted it.
def test_a_save_over_a_stale_snapshot_is_refused
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: "Ada", last: "King", etag: '"stale"'
+ post "/contacts/ada", first: "Ada", last: "King", company: "0", etag: '"stale"'
assert_equal 200, last_response.status
assert_includes last_response.body,
@@ -2717,7 +2759,7 @@ class AdminContactsPagesTest < Minitest::Test
# mandatory, so a name blank throughout cannot be saved.
def test_a_nameless_edit_is_refused_with_a_toast
with_contacts({"ada" => ADA}) do |store|
- post "/contacts/ada", first: " ", last: "", etag: store.contact("ada").etag
+ post "/contacts/ada", first: " ", last: "", company: "0", etag: store.contact("ada").etag
assert_equal 200, last_response.status
assert_includes last_response.body, '<div role="status" data-fixed><span>A contact needs a name.</span></div>'
@@ -2726,7 +2768,7 @@ class AdminContactsPagesTest < Minitest::Test
end
def test_saving_an_unknown_contact_is_404
- with_contacts({}) { post "/contacts/nope", first: "No", etag: '"x"' }
+ with_contacts({}) { post "/contacts/nope", first: "No", company: "0", etag: '"x"' }
assert_equal 404, last_response.status
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index d2eb2ff..13dbb1c 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -304,15 +304,16 @@ class AdminGroupsPagesTest < Minitest::Test
end
# Everyone's book keeps its name: renaming it would empty every
- # device's book (Store#rename_group).
- def test_a_rename_of_a_books_group_is_refused_and_changes_nothing
+ # device's book (Store#rename_group). The editor shows a book's name
+ # as text, so only a POST made by hand names one, and it raises.
+ def test_a_rename_of_a_books_group_raises_and_changes_nothing
with_contacts(BOOLES) do |store|
id = FixtureData.seed_group(store, name: ProTacts::Group::EVERYONE, members: %w[george])
- post "/groups/#{id}", version: store.group(id).version, name: "Everyone"
+ assert_raises(ProTacts::Store::SyncGroupRename) do
+ post "/groups/#{id}", version: store.group(id).version, name: "Everyone"
+ end
- assert_equal 200, last_response.status
- assert_includes last_response.body, "The sync:* group names a book and keeps its name; nothing was saved."
assert_equal ProTacts::Group::EVERYONE, store.group(id).name
assert_equal %w[george], store.group(id).members
end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 0f09b87..3f2f747 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -86,7 +86,8 @@ class AdminImportPagesTest < Minitest::Test
def save(id, index, first:, last:, into: nil, **params)
get "/import/#{id}/#{index}#{"?into=#{into}" if into}"
post "/import/#{id}/#{index}",
- {"etag" => etag, "first" => first, "middle" => "", "last" => last,
+ {"etag" => etag, "company" => ticked("company").first || "0",
+ "first" => first, "middle" => "", "last" => last,
"nickname" => "", "note" => "",
"groups" => ticked("groups[]"), "named" => ticked("named[]"),
"into" => hidden("into").first.to_s, "was" => hidden("was[]")}.merge(params)
@@ -443,7 +444,7 @@ class AdminImportPagesTest < Minitest::Test
refute_includes last_response.body, %(name="groups[]")
post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "Jay", "note" => ""
assert_equal "/import/#{id}/0", last_response.headers["location"]
@@ -517,7 +518,7 @@ class AdminImportPagesTest < Minitest::Test
2.times do
post "/import/#{id}/0",
- "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => ""
end
@@ -581,7 +582,7 @@ class AdminImportPagesTest < Minitest::Test
get "/import/#{id}/0"
post "/import/#{id}/0",
- "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "lost"
assert_includes last_response.body, "This card changed since the page loaded"
@@ -789,7 +790,7 @@ class AdminImportPagesTest < Minitest::Test
get "/import/#{id}/0"
post "/import/#{id}/0",
- "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "groups" => [school], "named" => ["Clarks"]
assert_includes last_response.body,
@@ -857,7 +858,7 @@ class AdminImportPagesTest < Minitest::Test
# own name unticked with it — so Zulus is spared and Group h
# goes under alone.
post "/import/#{id}/0",
- "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "groups" => [zulus], "named" => []
assert_includes last_response.body, %(<label data-label="zulus" :hidden="!visible($el)">)
@@ -972,7 +973,7 @@ class AdminImportPagesTest < Minitest::Test
store.delete_group(school)
post "/import/#{id}/0",
- "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "groups" => [school]
# The last row there was, so the walk ends: nothing was filed
@@ -1110,7 +1111,7 @@ class AdminImportPagesTest < Minitest::Test
store.put("jane", ProTacts::VCard.new(STORED_JANE.sub("END:VCARD", "NICKNAME:Jay\r\nEND:VCARD")))
post "/import/#{id}/0",
- "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "into" => "jane"
assert_includes last_response.body, "This card changed since the page loaded; nothing was saved."
@@ -1128,7 +1129,7 @@ class AdminImportPagesTest < Minitest::Test
store.delete("jane")
post "/import/#{id}/0",
- "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "company" => "0", "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "into" => "jane"
assert_includes last_response.body, "The contact this card was updating is gone; nothing was saved."