import: a saved slot that will not read raises
Staged.saved answered a saved.json that was not an object as nothing
saved, and Staged.ids did the same for its contacts, so a broken file
would send every row back through a Save that writes its contact a
second time. Both raise now, #rows' posture; a missing file is still
the gone import it always was.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kqo9sKAi4DnKCQkrtYoAwM
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index e935551..9831491 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -137,20 +137,20 @@ module ProTacts
# What this import has done: the group it files under, and the
# contact each saved card became, by the card's place in the
# file — the name every screen of the walk calls a contact by
- # until there is a minted id to call it by instead. Two empty
- # answers for an import that is gone or a slot that will not
- # parse: this is a file on disk, and one that will not read back
- # in the shape it was written in is a broken assumption JSON
- # says so about.
+ # until there is a minted id to call it by instead. Empty for an
+ # import that is gone, #read's ordinary case. A slot that will
+ # not read back in the shape #open wrote it in raises, #rows'
+ # posture: answering "nothing saved" would send every row back
+ # through a Save that writes its contact a second time.
#: (String id) -> [String, Hash[String, String]]
def self.saved(id)
raw = read(id, SAVED)
- return ["", ids(nil)] if raw.nil?
+ return ["", ids({})] if raw.nil?
parsed = JSON.parse(raw)
- return ["", ids(nil)] unless parsed.is_a?(Hash)
+ raise ArgumentError, "#{id}'s saved slot is not an object" unless parsed.is_a?(Hash)
- [parsed[GROUP].to_s, ids(parsed[CONTACTS])]
+ [parsed.fetch(GROUP).to_s, ids(parsed.fetch(CONTACTS))]
end
# One card, written: its place in the file against the id the
@@ -233,12 +233,13 @@ module ProTacts
end
# One map of a contact's place in the file to the id it was
- # stored under, out of whatever the file actually holds.
+ # stored under, refused where the slot holds anything else
+ # (#saved's reason).
#: (untyped raw) -> Hash[String, String]
def self.ids(raw)
- ids = {} #: Hash[String, String]
- return ids unless raw.is_a?(Hash)
+ raise ArgumentError, "the saved slot's contacts are not an object" unless raw.is_a?(Hash)
+ ids = {} #: Hash[String, String]
raw.each { |index, contact| ids[index.to_s] = contact.to_s }
ids
end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 6ea71b5..0f09b87 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -638,6 +638,18 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # The walk's record of what it saved, read back in a shape it was
+ # never written in: a broken assumption to raise on, not "nothing
+ # saved", which would send every row through a second Save.
+ def test_a_saved_slot_that_is_not_an_object_raises
+ with_contacts({}) do
+ id = upload(JANE)
+ (Pathname.new(ENV.fetch("PRO_TACTS_DATA_DIR")) / ProTacts::Import::Staged::DIRECTORY / id / "saved.json").binwrite("[]")
+
+ assert_raises(ArgumentError) { get "/import/#{id}/0" }
+ end
+ end
+
# The group for the import is named when the file is uploaded;
# which of this book's own groups a contact joins is asked beside
# that contact's card, and written by the same Save as its fields.