Model birthdays as partial dates
A partial date has no vCard 3.0 spelling (RFC 2425 section 5.8.4 requires
all three date components), so BDAY moves into its own table — the third
the database cannot rebuild — composed into served cards on read and
subtracted on write, the way a group's shared attributes are planned to
be. A birthday-carrying PUT no longer stores octet for octet, so those
responses drop the strong ETag and the client refetches (RFC 6352 section
6.3.2.3); the plan and the wire mapping are docs/plans/2026-08-31-partial-birthdays.md.
Assisted-by: GLM-5.3 via pi
diff --git a/AGENTS.md b/AGENTS.md
index b79dc95..23b973a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -135,12 +135,14 @@ not a fixture; edit a `.vcf` to change what the replay serves.
configuration as it loads, since the unhandled-request middleware is
given its directory at class-definition time. Set it afterwards and
the 404 captures land in `log/` instead of `tmp/`.
-- Only two things in the database cannot be rebuilt: the cards and the
- change log. Everything else is a projection of the cards that
- `rake index:rebuild` will make again, so no repair is ever needed for
- it. A write that touches a card must land its change-log entry in the
- same transaction, because a client's sync token silently skips
- whatever the log missed.
+- Only three things in the database cannot be rebuilt: the cards, the
+ change log, and the birthdays — a partial date has no vCard 3.0
+ spelling, so it lives beside its card rather than in it (see
+ docs/plans/2026-08-31-partial-birthdays.md). Everything else is a
+ projection of the cards that `rake index:rebuild` will make again,
+ so no repair is ever needed for it. A write that touches a card must
+ land its change-log entry in the same transaction, because a client's
+ sync token silently skips whatever the log missed.
- A read whose filter is meant to identify one row uses `sole`, not
`first` — `first` answers with one of several rather than saying the
filter was too loose. `sole` raises either way it is wrong:
diff --git a/README.md b/README.md
index 4b24f40..5f82b7d 100644
--- a/README.md
+++ b/README.md
@@ -38,10 +38,11 @@ END:VCARD
Storing the card rather than a parse of it is what lets the server keep
properties it does not model, which RFC 6352 section 6.3.2.2 requires of
anything accepting writes; `docs/plans/2026-08-24-vcard-storage-and-groups.md`
-makes the case at length. The same database holds the change log, which
-is the one thing in it that cannot be rebuilt, and an index of parsed
-properties, which can — `rake index:rebuild` derives that again from the
-cards alone.
+makes the case at length. The same database holds the change log and
+the birthdays — the two things in it that cannot be rebuilt, the
+birthdays because a partial date has no vCard 3.0 spelling — and an
+index of parsed properties, which can: `rake index:rebuild` derives
+that again from the stored cards alone.
macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
later work has a known-good baseline to change. See
@@ -93,11 +94,14 @@ carry:
cards through multiget or `GET` on its own.
- `GET` a card: the vCard body plus an `ETag` header.
- `PUT` a card: 201 for a create at an unmapped href, 204 for a replace,
- both carrying the strong `ETag` — permitted because what is stored is
- what was submitted (RFC 6352 section 6.3.2.3). A refused write is a
- 412 naming the precondition in a `DAV:error` body. This row follows
- the RFC rather than client evidence: no live write had been answered
- when it was written, so the first synced device to edit is its test.
+ each carrying the strong `ETag` only when what is stored is what was
+ submitted, octet for octet (RFC 6352 section 6.3.2.3) — a card whose
+ birthday is subtracted out before storage and composed back in on
+ read goes without the tag and the client refetches. A refused write
+ is a 412 naming the precondition in a `DAV:error` body. The statuses
+ follow the RFC rather than client evidence: no live write had been
+ answered when they were written, so the first synced device to edit
+ is its test.
Three properties are load-bearing in non-obvious ways, documented in
`docs/macos-contacts.md`: the collection's `resourcetype` (without
diff --git a/db/migrations/002_birthdays.rb b/db/migrations/002_birthdays.rb
new file mode 100644
index 0000000..44fd9a2
--- /dev/null
+++ b/db/migrations/002_birthdays.rb
@@ -0,0 +1,51 @@
+# Birthdays leave the cards and become state of their own: a partial
+# date has no vCard 3.0 spelling (RFC 2425 section 5.8.4 requires all
+# three components), so the card stops carrying BDAY and the database
+# holds it — docs/plans/2026-08-31-partial-birthdays.md.
+#
+# `text: true` on the string column because these are STRICT tables,
+# which refuse Sequel's plain String. No timestamp: a birthday changes
+# with its card's write and the card's stamp is the write's stamp.
+require "pro_tacts/birthday"
+
+Sequel.migration do
+ change do
+ # One birthday per card, keyed by it and dying with it. The columns
+ # are nullable integers whose nulls carry the shape, validated by
+ # Birthday rather than by the table: the six legal shapes are a
+ # grammar fact, not a constraint SQLite can check.
+ #
+ # This is the third table in the database that cannot be rebuilt.
+ # The cards and the change log were the first two; the index tables
+ # remain projections that `rake index:rebuild` will make again. A
+ # birthday cannot be re-derived from its card because it no longer
+ # lives there.
+ create_table(:birthdays, strict: true) do
+ foreign_key :card_id, :cards, type: String, text: true, null: false, primary_key: true, on_delete: :cascade
+ Integer :year
+ Integer :month
+ Integer :day
+ end
+
+ # The same move a write makes, run over the cards already stored: a
+ # BDAY in a modeled spelling moves into the new table and out of the
+ # card, and any other BDAY stays in the card verbatim. Composed
+ # again on read, a modeled birthday changes served bytes only by
+ # position — it lands before END:VCARD — so clients refetch each
+ # birthday-carrying card once, by design rather than by accident.
+ # The change log is not written: as far as any client can tell, no
+ # contact changed, and the stamps are left alone to say the same.
+ self[:cards].order(:id).each do |row|
+ split = ProTacts::Birthday.subtract(row.fetch(:vcard))
+ next if split.birthday.nil?
+
+ self[:cards].where(id: row.fetch(:id)).update(vcard: split.card)
+ self[:birthdays].insert(
+ card_id: row.fetch(:id),
+ year: split.birthday.year,
+ month: split.birthday.month,
+ day: split.birthday.day,
+ )
+ end
+ end
+end
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
new file mode 100644
index 0000000..86046e6
--- /dev/null
+++ b/lib/pro_tacts/birthday.rb
@@ -0,0 +1,220 @@
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+ # A birthday as the app models it: a partial date, where any of year,
+ # month, and day may be missing.
+ #
+ # The shape is RFC 6350 section 4.3.1's date grammar, which admits six
+ # of the eight presence patterns: all three, year-month, year alone,
+ # month-day, month alone, and day alone. Year-with-day and nothing-at-
+ # all are not forms a date can take there, so the constructor refuses
+ # them. Five of the six appear as examples in the vendored text; month
+ # alone follows from its allowance of ISO 8601 truncated representation
+ # f), which it references but does not spell out.
+ #
+ # vCard 3.0 cannot carry any of this: RFC 2425 section 5.8.4 defines
+ # the date value as `date-fullyear ["-"] date-month ["-"] date-mday`
+ # with all three components required, and RFC 2426 section 3.1.5 builds
+ # BDAY on it. So a birthday is database state that no stored card
+ # carries, composed into the served card on read and subtracted out of
+ # a submitted one on write — the same split a group's shared attributes
+ # take (docs/plans/2026-08-24-vcard-storage-and-groups.md), and the
+ # full reasoning is docs/plans/2026-08-31-partial-birthdays.md.
+ #
+ # The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
+ # no constant super class for the inline syntax to read.
+ # @rbs skip
+ class Birthday < Data.define(:year, :month, :day)
+ # A full date, the one partial shape vCard 3.0 carries natively
+ # (RFC 2425 section 5.8.4). The dashed form is what macOS writes;
+ # the undashed one is legal too and deliberately unmodeled, so it
+ # stays in a card verbatim rather than drift to this model's
+ # spelling. The optional time is RFC 2426 section 3.1.5's other
+ # value shape, and it is accepted and dropped: a birthday is a
+ # date, the one deliberate loss the design records.
+ FULL_DATE = /\A(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:?\d{2}(:?\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/ #: Regexp
+
+ # The no-year shape macOS writes, with 1604 standing in for the year
+ # in both the parameter and the value (docs/macos-contacts.md,
+ # "Birthdays without a year"). 1604 because it is the first year of
+ # the Gregorian calendar; the parameter names the year to omit.
+ OMIT_YEAR_DATE = /\A1604-(\d{2})-(\d{2})\z/ #: Regexp
+ OMIT_YEAR = "1604" #: String
+
+ # A content line naming BDAY, optionally group-prefixed the way
+ # macOS labels properties (`item1.BDAY`). A group's first physical
+ # line is all that is matched; continuations travel with it.
+ BDAY_LINE = /\A(?:[A-Za-z0-9-]+\.)?BDAY[;:]/i #: Regexp
+
+ # The anchor compose inserts before, so the property lands inside
+ # the envelope however bare the card is.
+ END_LINE = /\AEND:VCARD/i #: Regexp
+
+ # A physical line that continues the one above it (RFC 2426 section
+ # 2.6 folding).
+ CONTINUATION = /\A[ \t]/ #: Regexp
+
+ # What subtract found in a card: the card to store, the birthday it
+ # gave up, and whether a BDAY line is still in the card because no
+ # modeled spelling would recompose it — the probe forms ride on
+ # exactly that, served as stored.
+ #
+ # The signature lives in sig/pro_tacts/birthday.rbs with the class's.
+ Split = Data.define(:card, :birthday, :bday_kept)
+
+ # The only constructor, so no birthday exists in a shape the grammar
+ # rejects. Components are not calendar-validated beyond their ranges:
+ # a partial date has no whole date to be wrong about, and a nonsense
+ # but well-shaped one round-trips rather than 500s a write.
+ def self.new(year: nil, month: nil, day: nil)
+ if year.nil? && month.nil? && day.nil?
+ raise ArgumentError, "a birthday needs at least one of year, month, and day"
+ end
+ if year && month.nil? && day
+ raise ArgumentError, "year with day but no month is not a shape RFC 6350 section 4.3.1 admits"
+ end
+ if month && !month.between?(1, 12)
+ raise ArgumentError, "month out of range: #{month}"
+ end
+ if day && !day.between?(1, 31)
+ raise ArgumentError, "day out of range: #{day}"
+ end
+ if year && !year.between?(0, 9999)
+ raise ArgumentError, "year out of range: #{year}"
+ end
+
+ super
+ end
+
+ # The two wire forms a client has been verified to accept: a plain
+ # full date, and Apple's 1604 sentinel for a month and day. The other
+ # four shapes have no verified spelling and reach no client — this
+ # returns nil for them and compose leaves the card alone.
+ def to_line
+ if year && month && day
+ format("BDAY:%04d-%02d-%02d", year, month, day)
+ elsif month && day
+ format("BDAY;X-APPLE-OMIT-YEAR=1604:1604-%02d-%02d", month, day)
+ end
+ end
+
+ # Whether any served card carries this birthday. The deletion rule
+ # turns on it: a client that never saw the birthday cannot have
+ # deleted it.
+ def served?
+ !to_line.nil?
+ end
+
+ # Reads one parsed BDAY property into the model, or nil for every
+ # spelling the model does not recompose — which keeps it in the card
+ # verbatim rather than losing it (RFC 6352 section 6.3.2.2). Only the
+ # two forms compose emits are accepted, and only bare: any extra
+ # parameter travels with the line, so a line carrying one is left
+ # whole. A grouped `item1.BDAY` likewise stays, because its label
+ # pairing belongs to the card.
+ def self.from_property(property)
+ return nil if property.group
+
+ if property.parameters.empty? && (m = property.value.match(FULL_DATE))
+ new(year: m[1].to_i, month: m[2].to_i, day: m[3].to_i)
+ elsif apple_no_year?(property) && (m = property.value.match(OMIT_YEAR_DATE))
+ new(month: m[1].to_i, day: m[2].to_i)
+ end
+ rescue ArgumentError
+ # A component out of range is unmodeled, not exceptional: the line
+ # stays in the card as it came.
+ nil
+ end
+
+ # The parameter half of the Apple form, exactly: the one parameter,
+ # named for the year it stands in. A different sentinel or a value
+ # disagreeing with the year in the date is not the verified form and
+ # is not claimed.
+ def self.apple_no_year?(property)
+ return false unless property.parameters.length == 1
+
+ name, value = property.parameters.fetch(0)
+ name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
+ end
+
+ # Splits a submitted card into what gets stored and the birthday it
+ # carried. One BDAY line in a modeled spelling moves into the model
+ # and out of the card; any other BDAY — the vCard 4.0 forms, a
+ # foreign sentinel, more than one — is data this model cannot
+ # recompose and stays in the card byte for byte, with the
+ # model emptied so compose never adds a second BDAY beside it.
+ def self.subtract(card)
+ # Bytes that are not the UTF-8 the store's contract promises are
+ # never parsed — they pass through untouched to the bind that
+ # refuses them, which is where the contract is enforced.
+ unless card.valid_encoding?
+ return Split.new(card:, birthday: nil, bday_kept: false)
+ end
+
+ bday_groups, other_groups = line_groups(card).partition { it.first.match?(BDAY_LINE) }
+
+ birthday = nil
+ if bday_groups.length == 1
+ property = parse_line(bday_groups.fetch(0).join)
+ birthday = property && from_property(property)
+ end
+
+ extracted = !birthday.nil?
+ Split.new(
+ card: extracted ? other_groups.join : card,
+ birthday:,
+ bday_kept: !extracted && !bday_groups.empty?,
+ )
+ end
+
+ # Puts a birthday into a stored card, immediately before END:VCARD,
+ # as the inverse of subtract. A birthday with no wire form, or none
+ # at all, leaves the card exactly as it is. The inserted line takes
+ # the END line's own terminator so a card stays single-convention;
+ # with no END to anchor to there is no envelope worth respecting, and
+ # the line is appended with CRLF.
+ def self.compose(card, birthday)
+ line = birthday && birthday.to_line
+ return card if line.nil?
+
+ lines = physical_lines(card)
+ index = lines.rindex { it.match?(END_LINE) }
+ if index
+ lines.insert(index, line + terminator_of(lines.fetch(index)))
+ lines.join
+ else
+ card + line + "\r\n"
+ end
+ end
+
+ # One content line read as a property, or nil when it does not read
+ # as one. The parser unfolds what it is handed, so a folded BDAY
+ # arrives here as the single logical line it is.
+ def self.parse_line(text)
+ VCard::Parser.parse(text).fetch(0, nil)
+ rescue VCard::ParseError
+ nil
+ end
+
+ # Physical lines with their terminators attached, so surgery on them
+ # cannot lose or normalize a line break.
+ def self.physical_lines(card)
+ card.split(/(?<=\n)/, -1)
+ end
+
+ # Logical lines: a physical line and the continuations folded under
+ # it, kept together because a BDAY and its fold are one property.
+ # Slicing before each non-continuation, rather than chunking, is
+ # what attaches a continuation to the line above it.
+ def self.line_groups(card)
+ physical_lines(card).slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
+ end
+
+ # The line break a line ends with, for the line inserted beside it to
+ # match. CRLF when it has none, being the grammar's own.
+ def self.terminator_of(line)
+ line[/\r?\n\z/] || "\r\n"
+ end
+ end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 8b7366c..61acb5d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -3,6 +3,7 @@ require "digest"
require "pathname"
require "sequel"
+require "pro_tacts/birthday"
require "pro_tacts/contact"
require "pro_tacts/vcard/parser"
@@ -12,17 +13,26 @@ module ProTacts
# The transactional store: one SQLite database holding each contact's
# card exactly as it was submitted.
#
- # Three kinds of state live here and they are not equally precious.
+ # Four kinds of state live here and they are not equally precious.
# The cards are the truth about contact data, and the reason they are
# stored as the card rather than a parse of it is
# docs/plans/2026-08-24-vcard-storage-and-groups.md. The change log is
# the truth about history, and is why this is one database rather than
# a directory of files: "what changed since token X" cannot be
# recovered from current state, so a card, its etag, and its
- # change-log entry have to land or fail together. Everything else is
+ # change-log entry have to land or fail together. The birthdays are
+ # the third thing that cannot be rebuilt — a partial date has no
+ # vCard 3.0 spelling, so it lives beside its card rather than in it
+ # (docs/plans/2026-08-31-partial-birthdays.md). Everything else is
# an index derived from the cards, and #rebuild_index will make it
# again from nothing.
#
+ # Every Contact this store hands out is composed, never the stored
+ # card alone: a birthday is subtracted out of a card on the way in
+ # and composed back in on the way out, so the vcard and the etag a
+ # caller sees — and the etag the change log records — describe the
+ # card a client downloads, not the bytes on disk.
+ #
# Sequel's transactions join one already open rather than failing on
# SQLite's lack of nesting, which is what lets the group fan-out this
# design needs — one member's card rewriting its group and every other
@@ -100,7 +110,8 @@ module ProTacts
# what SQLite feels like returning.
#: () -> Array[Contact]
def contacts
- cards.order(:id).map { contact_from(it) }
+ birthdays = birthdays_by_id
+ cards.order(:id).map { contact_from(it, birthdays[it.fetch(:id).to_s]) }
end
# The collection's content tag: one value that changes when any card
@@ -129,7 +140,7 @@ module ProTacts
# nil.
#: (String id) -> Contact?
def contact(id)
- contact_from(cards.where(id:).sole)
+ contact_from(cards.where(id:).sole, birthday_of(id))
rescue Sequel::NoMatchingRow
nil
end
@@ -153,6 +164,19 @@ module ProTacts
# this card hashed to now, and the index rows read off the card. One
# transaction, because the log entry cannot be rebuilt from anything.
#
+ # What is stored is the card minus its birthday, which moves into
+ # the birthdays table — vCard 3.0 cannot carry a partial date, so
+ # the card never carries BDAY and every read composes it back in
+ # (docs/plans/2026-08-31-partial-birthdays.md). The Contact this
+ # returns is the composed one, and the logged etag is its hash, so
+ # a client's token describes the card it downloads. A submitted
+ # BDAY in a spelling the model does not recompose stays in the
+ # card byte for byte and empties the model, keeping RFC 6352
+ # section 6.3.2.2's promise; a submitted card with no BDAY at all
+ # deletes the birthday only when the model held a shape a client
+ # could have seen, because one it never served is not something
+ # the client could have removed.
+ #
# The strings are UTF-8 by contract, and the adapter holds the store
# to it: the sqlite3 gem encodes every bound value to UTF-8, so a
# binary-flagged byte above 7 bits raises at the bind, and bytes
@@ -161,13 +185,36 @@ module ProTacts
# where it is read, in write_card.
#: (String id, String vcard) -> Contact
def put(id, vcard)
- contact = Contact.for(id:, vcard:)
+ split = Birthday.subtract(vcard)
+ existing = birthday_of(id)
+ birthday =
+ if split.bday_kept
+ nil
+ elsif split.birthday
+ split.birthday
+ elsif existing && !existing.served?
+ existing
+ else
+ nil
+ end
+
+ contact = Contact.for(id:, vcard: Birthday.compose(split.card, birthday))
@database.transaction do
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
- .insert(id: contact.id, vcard: contact.vcard)
+ .insert(id: contact.id, vcard: split.card)
+ if birthday
+ birthdays
+ .insert_conflict(
+ target: :card_id,
+ update: {year: Sequel[:excluded][:year], month: Sequel[:excluded][:month], day: Sequel[:excluded][:day]},
+ )
+ .insert(card_id: contact.id, year: birthday.year, month: birthday.month, day: birthday.day)
+ else
+ birthdays.where(card_id: contact.id).delete
+ end
record(contact.id, "put", contact.etag)
- reindex(contact)
+ reindex(contact.id, split.card)
end
contact
end
@@ -196,13 +243,16 @@ module ProTacts
# Always safe to run: nothing is authoritative here, so if the
# projection ever disagrees with the cards, this is the repair.
# Returns the ids of cards that did not parse, which are served in
- # full but contribute nothing to the index.
+ # full but contribute nothing to the index. Raw rows rather than
+ # #contacts, which compose birthdays in: the index reflects what is
+ # stored, and no stored card carries a BDAY.
#: () -> Array[String]
def rebuild_index
@database.transaction do
card_properties.delete
- contacts.each.with_object([]) do |contact, unindexed|
- unindexed << contact.id unless reindex(contact)
+ cards.order(:id).all.each.with_object([]) do |row, unindexed|
+ id = row.fetch(:id).to_s
+ unindexed << id unless reindex(id, row.fetch(:vcard).to_s)
end
end
end
@@ -229,6 +279,11 @@ module ProTacts
@database[:card_parameters]
end
+ #: () -> Sequel::Dataset
+ def birthdays
+ @database[:birthdays]
+ end
+
#: (String card_id, String action, String? etag) -> void
def record(card_id, action, etag)
change_log.insert(card_id:, action:, etag:)
@@ -238,24 +293,26 @@ module ProTacts
# diffed because it is derived data and replacing it is the cheaper
# correct thing. Returns whether the card parsed: one that does not
# is still served, so refusing to index it must not fail the write.
- #: (Contact contact) -> bool
- def reindex(contact)
+ # Takes the stored card — with the birthday already subtracted — so
+ # the index never sees a BDAY no stored card carries.
+ #: (String id, String vcard) -> bool
+ def reindex(id, vcard)
# Before the parse, so that a card which has stopped parsing does
# not keep the rows from when it did.
- card_properties.where(card_id: contact.id).delete
- properties = properties_of(contact.vcard)
+ card_properties.where(card_id: id).delete
+ properties = properties_of(vcard)
return false if properties.nil?
properties.each.with_index do |property, position|
card_properties.insert(
- card_id: contact.id,
+ card_id: id,
position:,
property_group: property.group,
name: property.name,
value: property.value,
)
property.parameters.each do |name, value|
- card_parameters.insert(card_id: contact.id, position:, name:, value:)
+ card_parameters.insert(card_id: id, position:, name:, value:)
end
end
true
@@ -280,9 +337,40 @@ module ProTacts
# The etag comes back out of the card rather than out of a column, so
# a row can never carry one that disagrees with the bytes beside it.
- #: (Hash[Symbol, untyped] row) -> Contact
- def contact_from(row)
- Contact.for(id: row.fetch(:id).to_s, vcard: row.fetch(:vcard).to_s)
+ # The card is composed with its birthday first, so both halves
+ # describe what a client downloads.
+ #: (Hash[Symbol, untyped] row, Birthday? birthday) -> Contact
+ def contact_from(row, birthday)
+ Contact.for(
+ id: row.fetch(:id).to_s,
+ vcard: Birthday.compose(row.fetch(:vcard).to_s, birthday),
+ )
+ end
+
+ # Every birthday, keyed by card, for the listing reads that compose
+ # a whole collection in one pass.
+ #: () -> Hash[String, Birthday]
+ def birthdays_by_id
+ birthdays.all.to_h {
+ [it.fetch(:card_id).to_s, birthday_from(it)] #: [String, Birthday]
+ }
+ end
+
+ # One card's birthday, or nil for none. A plain `first` read rather
+ # than `sole`: card_id is the primary key, so there is no ambiguity
+ # for `sole` to catch and no row is the ordinary answer.
+ #: (String id) -> Birthday?
+ def birthday_of(id)
+ row = birthdays.where(card_id: id).first
+ row && birthday_from(row)
+ end
+
+ # A birthday row read as the model. The shape was validated on the
+ # way in, so a row that no longer parses is corruption to raise on
+ # rather than quietly drop.
+ #: (Hash[Symbol, untyped] row) -> Birthday
+ def birthday_from(row)
+ Birthday.new(year: row[:year], month: row[:month], day: row[:day])
end
#: (Hash[Symbol, untyped] row) -> Change
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index fb182e1..2da37bf 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -431,13 +431,15 @@ module ProTacts
stored = store.put(id, vcard)
response.status = existing ? 204 : 201
- # A strong ETag belongs on the answer because what was stored is
+ # A strong ETag belongs on the answer only when what was stored is
# the submitted bytes, octet for octet — the one case RFC 6352
- # section 6.3.2.3 says a client may rely on the tag it gets back.
- # Nothing subtracts from a card before storage yet; when a group
- # contributes properties at write time, that PUT must stop
- # sending one.
- response["ETag"] = stored.etag
+ # section 6.3.2.3 lets a client rely on the tag it gets back, and
+ # anywhere else it forbids one outright. A birthday is subtracted
+ # from the card before storage and composed back in on read, so a
+ # PUT that carried one stores a different card than it was handed
+ # and the client refetches; a card with nothing to subtract still
+ # stores octet for octet and still gets the tag.
+ response["ETag"] = stored.etag if stored.vcard == vcard
# A returned "" would land in the body and pin text/html and
# content-length onto the 204, which a bodyless status must not
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
new file mode 100644
index 0000000..f6b7e99
--- /dev/null
+++ b/sig/pro_tacts/birthday.rbs
@@ -0,0 +1,47 @@
+# Birthday and Birthday::Split are Data classes, whose members the
+# inline parser cannot read (see sig/pro_tacts/contact.rbs). Both are
+# marked `@rbs skip` in birthday.rb and signed here instead.
+module ProTacts
+ class Birthday
+ FULL_DATE: Regexp
+ OMIT_YEAR_DATE: Regexp
+ OMIT_YEAR: String
+ BDAY_LINE: Regexp
+ END_LINE: Regexp
+ CONTINUATION: Regexp
+
+ attr_reader year: Integer?
+ attr_reader month: Integer?
+ attr_reader day: Integer?
+
+ def self.new: (?year: Integer?, ?month: Integer?, ?day: Integer?) -> instance
+
+ def to_line: () -> String?
+
+ def served?: () -> bool
+
+ def self.from_property: (VCard::Property property) -> Birthday?
+
+ def self.apple_no_year?: (VCard::Property property) -> bool
+
+ def self.subtract: (String card) -> Split
+
+ def self.compose: (String card, Birthday? birthday) -> String
+
+ def self.parse_line: (String text) -> VCard::Property?
+
+ def self.physical_lines: (String card) -> Array[String]
+
+ def self.line_groups: (String card) -> Array[Array[String]]
+
+ def self.terminator_of: (String line) -> String
+
+ class Split
+ attr_reader card: String
+ attr_reader birthday: Birthday?
+ attr_reader bday_kept: bool
+
+ def self.new: (card: String, birthday: Birthday?, bday_kept: bool) -> instance
+ end
+ end
+end
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 924225b..42ef58d 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
<d:report><d:sync-collection/></d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</d:sync-token>
+ <cs:getctag>8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</d:sync-token>
<d:current-user-privilege-set>
<d:privilege><d:read/></d:privilege>
<d:privilege><d:write/></d:privilege>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 017bece..26e8c89 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -35,7 +35,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-complete.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"a4e6b4c955cfcebf3431ab4ab547c64a6bf1983ceba7953a77074ec64c1d6fbd"</d:getetag>
+ <d:getetag>"45d8178d8bb621243701ec282a386af13a226c9a6aa3f98bcd4d53a1deffefdc"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -44,7 +44,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-datetime.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"348c560bf1811fc48380fac606cf7198333ae0746c4f5ba8cec7269474b13162"</d:getetag>
+ <d:getetag>"16aae3a4d1cb495a0fa706932b411f87862c0d2111bab8b07efd3b5354b44847"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -71,7 +71,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"55f079c8ad3ab5fce768ce51eecc2447ab3be4d24dbf84967a2360b780b58f9d"</d:getetag>
+ <d:getetag>"a448d198e3c96acaf128f490b5ebdc1a961823ffb15f1dca480fde63f9e63902"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index 4544c7f..6da3896 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -34,7 +34,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-complete.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"a4e6b4c955cfcebf3431ab4ab547c64a6bf1983ceba7953a77074ec64c1d6fbd"</d:getetag>
+ <d:getetag>"45d8178d8bb621243701ec282a386af13a226c9a6aa3f98bcd4d53a1deffefdc"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -43,7 +43,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-datetime.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"348c560bf1811fc48380fac606cf7198333ae0746c4f5ba8cec7269474b13162"</d:getetag>
+ <d:getetag>"16aae3a4d1cb495a0fa706932b411f87862c0d2111bab8b07efd3b5354b44847"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -70,7 +70,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"55f079c8ad3ab5fce768ce51eecc2447ab3be4d24dbf84967a2360b780b58f9d"</d:getetag>
+ <d:getetag>"a448d198e3c96acaf128f490b5ebdc1a961823ffb15f1dca480fde63f9e63902"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 3d63a5f..4bae886 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
<d:report><d:sync-collection/></d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/ef8eddac0927936788c8997a6faedb70b86480fc67475851dcb15013ddc230c9</d:sync-token>
+ <cs:getctag>8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/8d49f12d5a21ce322b92458f141a911457556c0113fa89bbb6d647264744a0d9</d:sync-token>
<d:current-user-privilege-set>
<d:privilege><d:read/></d:privilege>
<d:privilege><d:write/></d:privilege>
@@ -60,7 +60,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-complete.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"a4e6b4c955cfcebf3431ab4ab547c64a6bf1983ceba7953a77074ec64c1d6fbd"</d:getetag>
+ <d:getetag>"45d8178d8bb621243701ec282a386af13a226c9a6aa3f98bcd4d53a1deffefdc"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -69,7 +69,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-datetime.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"348c560bf1811fc48380fac606cf7198333ae0746c4f5ba8cec7269474b13162"</d:getetag>
+ <d:getetag>"16aae3a4d1cb495a0fa706932b411f87862c0d2111bab8b07efd3b5354b44847"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -96,7 +96,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"55f079c8ad3ab5fce768ce51eecc2447ab3be4d24dbf84967a2360b780b58f9d"</d:getetag>
+ <d:getetag>"a448d198e3c96acaf128f490b5ebdc1a961823ffb15f1dca480fde63f9e63902"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/10-put-contact-edit/response b/test/fixtures/macos-exchange/10-put-contact-edit/response
index 0ca9ef3..e291fbe 100644
--- a/test/fixtures/macos-exchange/10-put-contact-edit/response
+++ b/test/fixtures/macos-exchange/10-put-contact-edit/response
@@ -1,3 +1,2 @@
204
-ETag: "07436a908594751e83c07cf99e4ed9b6a0fca38a14c492b90d977aba1780f63a"
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
new file mode 100644
index 0000000..964cd49
--- /dev/null
+++ b/test/pro_tacts/test_birthday.rb
@@ -0,0 +1,194 @@
+require_relative "../test_helper"
+
+require "pro_tacts/birthday"
+
+class BirthdayTest < Minitest::Test
+ # The six shapes RFC 6350 section 4.3.1 admits, one assertion per
+ # shape so a failure names the one that broke.
+ def test_every_admitted_shape_constructs
+ ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
+ ProTacts::Birthday.new(year: 1985, month: 4)
+ ProTacts::Birthday.new(year: 1985)
+ ProTacts::Birthday.new(month: 4, day: 12)
+ ProTacts::Birthday.new(month: 4)
+ ProTacts::Birthday.new(day: 12)
+ end
+
+ # The two the grammar refuses: year with day but no month has no form
+ # a date can take, and a birthday that is nothing at all is not one.
+ def test_the_shapes_the_grammar_refuses
+ error = assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, day: 12) }
+ assert_match "not a shape", error.message
+
+ assert_raises(ArgumentError) { ProTacts::Birthday.new }
+ end
+
+ def test_components_out_of_range_are_refused
+ assert_raises(ArgumentError) { ProTacts::Birthday.new(month: 13, day: 12) }
+ assert_raises(ArgumentError) { ProTacts::Birthday.new(month: 4, day: 32) }
+ assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 10_000, month: 4, day: 12) }
+ end
+
+ def test_the_wire_forms
+ assert_equal "BDAY:1985-04-12", ProTacts::Birthday.new(year: 1985, month: 4, day: 12).to_line
+ assert_equal "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12", ProTacts::Birthday.new(month: 4, day: 12).to_line
+ end
+
+ # The four shapes no client has been verified to accept reach no wire;
+ # this is the set the deletion rule treats as invisible.
+ def test_the_shapes_without_a_verified_spelling_serve_nothing
+ [ProTacts::Birthday.new(year: 1985, month: 4), ProTacts::Birthday.new(year: 1985),
+ ProTacts::Birthday.new(month: 4), ProTacts::Birthday.new(day: 12)].each do |birthday|
+ assert_nil birthday.to_line
+ refute birthday.served?
+ end
+ end
+
+ def test_the_served_shapes
+ [ProTacts::Birthday.new(year: 1985, month: 4, day: 12), ProTacts::Birthday.new(month: 4, day: 12)].each do |birthday|
+ assert birthday.served?
+ end
+ end
+
+ ## subtract
+
+ CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada\r\nUID:ada\r\nEND:VCARD\r\n"
+
+ def test_a_full_date_moves_into_the_model_and_out_of_the_card
+ split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\n"))
+
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
+ assert_equal CARD, split.card
+ refute split.bday_kept
+ end
+
+ # docs/macos-contacts.md, "Birthdays without a year": the verified
+ # Apple form, with 1604 standing in for the year in both halves.
+ def test_the_apple_no_year_form_moves_into_the_model
+ split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\n"))
+
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
+ assert_equal CARD, split.card
+ end
+
+ # RFC 2426 section 3.1.5 lets BDAY carry a date and time; the time is
+ # the one thing ingest deliberately loses, because a birthday is a
+ # date — the design record's decision, not an oversight.
+ def test_a_date_time_loses_its_time_on_the_way_in
+ ["BDAY:1985-04-12T23:10:00Z", "BDAY:1985-04-12T08:30:00-06:00"].each do |line|
+ split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n"))
+
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday, line
+ assert_equal CARD, split.card, line
+ end
+ end
+
+ def test_names_and_parameters_match_without_case
+ split = ProTacts::Birthday.subtract(CARD.sub("FN:Ada\r\n", "FN:Ada\r\nbday;x-apple-omit-year=1604:1604-04-12\r\n"))
+
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), split.birthday
+ end
+
+ def test_a_folded_bday_is_one_property
+ split = ProTacts::Birthday.subtract(CARD.sub("UID:ada\r\n", "BDAY:1985-04-\r\n 12\r\nUID:ada\r\n"))
+
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), split.birthday
+ assert_equal CARD, split.card
+ end
+
+ # Everything the model cannot recompose stays in the card byte for
+ # byte (RFC 6352 section 6.3.2.2), and the model is emptied so compose
+ # never adds a second BDAY beside it. The probe cards ride on this.
+ def test_the_unmodeled_spellings_stay_verbatim
+ unmodeled = [
+ "BDAY:--0412", # vCard 4.0 basic, month-day
+ "BDAY:--04-12", # vCard 4.0 extended, month-day
+ "BDAY:1985", # year alone
+ "BDAY:1985-04", # year and month
+ "BDAY:---12", # day alone
+ "BDAY:19850412", # undashed full date, legal 3.0
+ "BDAY:1985-4-12", # unpadded, not the grammar's shape
+ "BDAY;VALUE=DATE:1985-04-12",
+ "BDAY;X-APPLE-OMIT-YEAR=1605:1605-04-12", # a foreign sentinel
+ "BDAY;X-APPLE-OMIT-YEAR=1604;X-OTHER=1:1604-04-12", # parameters compose would drop
+ "item1.BDAY:1985-04-12", # a grouped property's label pairing belongs to the card
+ "BDAY:", # nothing to read
+ "BDAY:1985-13-40", # components out of range
+ ]
+
+ unmodeled.each do |line|
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\n#{line}\r\n")
+ split = ProTacts::Birthday.subtract(card)
+
+ assert_nil split.birthday, line
+ assert_equal card, split.card, line
+ assert split.bday_kept, line
+ end
+ end
+
+ def test_more_than_one_bday_stays_verbatim
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nBDAY:1985-04-12\r\nBDAY:1986-04-12\r\n")
+ split = ProTacts::Birthday.subtract(card)
+
+ assert_nil split.birthday
+ assert_equal card, split.card
+ assert split.bday_kept
+ end
+
+ def test_a_card_without_a_bday_is_untouched
+ split = ProTacts::Birthday.subtract(CARD)
+
+ assert_nil split.birthday
+ assert_equal CARD, split.card
+ refute split.bday_kept
+ end
+
+ # Bytes that are not the UTF-8 the store's contract promises are not
+ # this model's business: they pass through so the bind that enforces
+ # the contract is what refuses them.
+ def test_bytes_that_are_not_utf_8_pass_through_unparsed
+ invalid = "BDAY:\xFF\xFE\r\n".dup.force_encoding(Encoding::UTF_8)
+ split = ProTacts::Birthday.subtract(invalid)
+
+ assert_equal invalid, split.card
+ assert_nil split.birthday
+ refute split.bday_kept
+ end
+
+ ## compose
+
+ def test_compose_puts_the_birthday_before_the_end
+ composed = ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+
+ assert_equal CARD.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), composed
+ end
+
+ def test_compose_takes_the_end_lines_own_terminator
+ card = "BEGIN:VCARD\nUID:ada\nEND:VCARD\n"
+ composed = ProTacts::Birthday.compose(card, ProTacts::Birthday.new(month: 4, day: 12))
+
+ assert_equal "BEGIN:VCARD\nUID:ada\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\nEND:VCARD\n", composed
+ end
+
+ def test_compose_leaves_a_card_alone_when_there_is_nothing_to_say
+ assert_equal CARD, ProTacts::Birthday.compose(CARD, nil)
+ assert_equal CARD, ProTacts::Birthday.compose(CARD, ProTacts::Birthday.new(year: 1985))
+ end
+
+ def test_compose_appends_when_there_is_no_end_to_anchor_to
+ assert_equal "not a card\r\nBDAY:1985-04-12\r\n",
+ ProTacts::Birthday.compose("not a card\r\n", ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+ end
+
+ ## the pair of them
+
+ # subtract and compose are inverses on the model and on every line
+ # but the BDAY's own place in the card, which compose chooses.
+ def test_compose_undoes_subtract
+ birthday = ProTacts::Birthday.new(month: 4, day: 12)
+ split = ProTacts::Birthday.subtract(CARD.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n"))
+
+ assert_equal birthday, split.birthday
+ assert_equal birthday, ProTacts::Birthday.subtract(ProTacts::Birthday.compose(split.card, birthday)).birthday
+ end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 7b57536..5322a17 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -416,6 +416,147 @@ class StoreTest < Minitest::Test
end
end
+ ## Birthdays
+
+ AIDEN_BORN = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nBDAY:1985-04-12\r\n")
+
+ # The stored card carries no BDAY — a partial date has no vCard 3.0
+ # spelling — and every read composes the birthday back in, so the
+ # served card is the submitted one with the line back where compose
+ # puts it.
+ def test_a_birthday_is_stored_beside_the_card_and_served_within_it
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+ assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard
+ end
+ end
+
+ # The composed card is what an etag describes, on a read and in the
+ # change log alike, so a client's If-Match and its sync token both
+ # talk about the card it downloads.
+ def test_the_etag_and_the_log_describe_the_composed_card
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ composed = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
+
+ assert_equal ProTacts::Contact.etag_for(composed), store.contact("aiden").etag
+ assert_equal ProTacts::Contact.etag_for(composed), store.changes.last.etag
+ end
+ end
+
+ # The ctag follows the composed card too: a birthday moved is a
+ # change a client must see, even when the stored bytes did not move.
+ def test_the_ctag_moves_with_a_birthday_alone
+ with_store({"aiden" => AIDEN}) do |store|
+ before = store.ctag
+ store.put("aiden", AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"))
+
+ refute_equal before, store.ctag
+ end
+ end
+
+ def test_an_apple_no_year_birthday_round_trips
+ with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n")}) do |store|
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), birthday_row(store, "aiden")
+ end
+ end
+
+ # A submitted card with no BDAY deletes the birthday — the client
+ # was served one and sent its card back without it, which on a
+ # read-modify-write client is the user removing it.
+ def test_a_card_put_back_without_its_served_birthday_loses_it
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ store.put("aiden", AIDEN)
+
+ assert_nil birthday_row(store, "aiden")
+ assert_equal AIDEN, store.contact("aiden").vcard
+ end
+ end
+
+ # A birthday no client can see — year alone, month alone, day alone,
+ # year and month — survives a round trip the client never saw a
+ # birthday in. Planted directly, because no writer produces one yet.
+ def test_a_birthday_no_client_can_see_survives_a_card_without_one
+ with_store({"aiden" => AIDEN}) do |store|
+ database(store)[:birthdays].insert(card_id: "aiden", year: 1985)
+
+ store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+
+ assert_equal ProTacts::Birthday.new(year: 1985), birthday_row(store, "aiden")
+ assert_equal AIDEN.sub("FN:Aiden", "FN:Aiden Smith"), store.contact("aiden").vcard
+ end
+ end
+
+ # A BDAY the model cannot recompose stays in the card verbatim and
+ # empties the model: the card's own line speaks for itself, and
+ # compose must never add a second one beside it.
+ def test_an_unmodeled_bday_stays_in_the_card_and_empties_the_model
+ unmodeled = AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")
+
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ store.put("aiden", unmodeled)
+
+ assert_equal unmodeled, store.contact("aiden").vcard
+ assert_nil birthday_row(store, "aiden")
+ end
+ end
+
+ def test_a_stored_card_is_never_indexed_with_a_bday
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ assert_equal %w[BEGIN VERSION FN UID END], indexed_names(store, "aiden")
+ end
+ end
+
+ def test_deleting_a_card_takes_its_birthday_with_it
+ with_store({"aiden" => AIDEN_BORN}) do |store|
+ store.delete("aiden")
+
+ assert_empty database(store)[:birthdays].all
+ end
+ end
+
+ # The transaction the whole design turns on, now with a third piece:
+ # a birthday must not survive a write whose log entry failed.
+ def test_a_failed_birthday_write_leaves_no_birthday
+ Dir.mktmpdir do |dir|
+ path = Pathname.new(dir) / "contacts.db"
+
+ FailingLog.connect(path) do |store|
+ assert_raises(RuntimeError) { store.put("aiden", AIDEN_BORN) }
+ end
+
+ ProTacts::Store.connect(path) do |store|
+ assert_empty store.contacts
+ assert_empty database(store)[:birthdays].all
+ end
+ end
+ end
+
+ ## The migration
+
+ # A database at the old shape — BDAY in the card, no birthdays table
+ # — is carried over by the same subtraction a write makes: modeled
+ # forms move, unmodeled forms stay put.
+ def test_the_migration_moves_a_modeled_bday_out_of_the_cards
+ Dir.mktmpdir do |dir|
+ path = Pathname.new(dir) / "contacts.db"
+ Sequel.connect("sqlite://#{path}") do |db|
+ Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 1)
+ db[:cards].insert(id: "aiden", vcard: AIDEN_BORN)
+ db[:cards].insert(id: "znorth", vcard: ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"))
+ end
+
+ ProTacts::Store.connect(path) do |store|
+ assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
+ assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard
+
+ # Unmodeled: byte-identical, no birthday row beside it.
+ assert_equal ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"), store.contact("znorth").vcard
+ assert_nil birthday_row(store, "znorth")
+ end
+ end
+ end
+
private
# A store whose change-log write fails, after Store#put has already put
@@ -461,6 +602,13 @@ class StoreTest < Minitest::Test
]
end
+ # The birthday row read through the store's own model, so a test sees
+ # the shape it holds and not the columns it came from.
+ def birthday_row(store, id)
+ birthday = database(store)[:birthdays].where(card_id: id).first
+ birthday && ProTacts::Birthday.new(year: birthday[:year], month: birthday[:month], day: birthday[:day])
+ end
+
def wreck_the_index(store)
database(store)[:card_parameters].delete
database(store)[:card_properties].update(value: "wrong")
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 00f9345..85e933c 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -289,6 +289,45 @@ class WebTest < Minitest::Test
end
end
+ # RFC 6352 section 6.3.2.3: a strong ETag belongs on a PUT answer only
+ # when what was stored is the submission octet for octet. A birthday is
+ # subtracted before storage and composed back in on read, so a PUT that
+ # carried one elsewhere in the card than where compose puts it must not
+ # claim the tag — the client refetches instead. macOS writes BDAY
+ # mid-card, which is the shape here.
+ def test_a_put_whose_birthday_moves_goes_without_the_strong_etag
+ born = card("new", "New").sub("FN:New\r\n", "FN:New\r\nBDAY:1985-04-12\r\n")
+
+ with_contacts({}) do
+ put_request "new", born, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ assert_equal 201, last_response.status
+ assert_nil last_response["ETag"]
+
+ # What the client refetches carries the birthday, composed before
+ # the END, and that refetched tag is the one the next write matches.
+ get "/dav/addressbook/new.vcf"
+ assert_includes last_response.body, "BDAY:1985-04-12\r\nEND:VCARD"
+ etag = last_response["ETag"]
+
+ put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => etag
+ assert_equal 204, last_response.status
+ end
+ end
+
+ # The boundary of the same rule: a birthday already sitting where
+ # compose puts it stores octet for octet, and the tag is honest.
+ def test_a_put_whose_birthday_composes_in_place_keeps_the_strong_etag
+ born = card("new", "New").sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
+
+ with_contacts({}) do
+ put_request "new", born, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ assert_equal 201, last_response.status
+ assert_equal %("#{Digest::SHA256.hexdigest(born)}"), last_response["ETag"]
+ end
+ end
+
# The preconditions of RFC 6352 section 6.3.2.1, marshalled as 412s
# in the DAV:error form RFC 4918 section 16 defines.
def test_put_of_a_non_vcard_media_type_names_its_precondition