Replay the working macOS exchange as fixtures
The requests are verbatim from the 2026-08-14 debug log of the session
that first displayed the card, with identifying headers stripped;
responses snapshot current behavior and regenerate via rake fixtures.
Assisted-by: GLM 5.2 via pi
diff --git a/Rakefile b/Rakefile
index f549282..0946e9c 100644
--- a/Rakefile
+++ b/Rakefile
@@ -9,4 +9,12 @@ task :dev do
sh "fd -e rb . lib | entr -r rackup -o localhost"
end
+desc "Regenerate macOS exchange response fixtures from current responses"
+task :fixtures do
+ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+ require "pro_tacts/web"
+ require_relative "test/pro_tacts/exchange_fixtures"
+ ExchangeFixtures.record_responses(ProTacts::Web)
+end
+
task default: :test
diff --git a/test/fixtures/macos-exchange/01-propfind-root/request b/test/fixtures/macos-exchange/01-propfind-root/request
new file mode 100644
index 0000000..2c725d9
--- /dev/null
+++ b/test/fixtures/macos-exchange/01-propfind-root/request
@@ -0,0 +1,14 @@
+PROPFIND / HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 0
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+ <A:prop>
+ <A:current-user-principal/>
+ <A:principal-URL/>
+ <A:resourcetype/>
+ </A:prop>
+</A:propfind>
diff --git a/test/fixtures/macos-exchange/01-propfind-root/response b/test/fixtures/macos-exchange/01-propfind-root/response
new file mode 100644
index 0000000..f992bee
--- /dev/null
+++ b/test/fixtures/macos-exchange/01-propfind-root/response
@@ -0,0 +1,23 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:">
+ <d:response>
+ <d:href>/</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:current-user-principal>
+ <d:href>/dav/principal/</d:href>
+ </d:current-user-principal>
+ <d:principal-URL>
+ <d:href>/dav/principal/</d:href>
+ </d:principal-URL>
+ <d:resourcetype>
+ <d:collection/>
+ </d:resourcetype>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
diff --git a/test/fixtures/macos-exchange/02-propfind-well-known/request b/test/fixtures/macos-exchange/02-propfind-well-known/request
new file mode 100644
index 0000000..5cf9fae
--- /dev/null
+++ b/test/fixtures/macos-exchange/02-propfind-well-known/request
@@ -0,0 +1,14 @@
+PROPFIND /.well-known/carddav HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 0
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+ <A:prop>
+ <A:current-user-principal/>
+ <A:principal-URL/>
+ <A:resourcetype/>
+ </A:prop>
+</A:propfind>
diff --git a/test/fixtures/macos-exchange/02-propfind-well-known/response b/test/fixtures/macos-exchange/02-propfind-well-known/response
new file mode 100644
index 0000000..f696aa2
--- /dev/null
+++ b/test/fixtures/macos-exchange/02-propfind-well-known/response
@@ -0,0 +1,23 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:">
+ <d:response>
+ <d:href>/.well-known/carddav</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:current-user-principal>
+ <d:href>/dav/principal/</d:href>
+ </d:current-user-principal>
+ <d:principal-URL>
+ <d:href>/dav/principal/</d:href>
+ </d:principal-URL>
+ <d:resourcetype>
+ <d:collection/>
+ </d:resourcetype>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
diff --git a/test/fixtures/macos-exchange/03-options-principal/request b/test/fixtures/macos-exchange/03-options-principal/request
new file mode 100644
index 0000000..424fdf1
--- /dev/null
+++ b/test/fixtures/macos-exchange/03-options-principal/request
@@ -0,0 +1 @@
+OPTIONS /dav/principal/ HTTP/1.1
diff --git a/test/fixtures/macos-exchange/03-options-principal/response b/test/fixtures/macos-exchange/03-options-principal/response
new file mode 100644
index 0000000..b864f89
--- /dev/null
+++ b/test/fixtures/macos-exchange/03-options-principal/response
@@ -0,0 +1,5 @@
+200
+Content-Type: text/html
+DAV: 1, 3, access-control, addressbook
+Allow: OPTIONS, PROPFIND, REPORT
+
diff --git a/test/fixtures/macos-exchange/04-propfind-principal/request b/test/fixtures/macos-exchange/04-propfind-principal/request
new file mode 100644
index 0000000..7e5f9f5
--- /dev/null
+++ b/test/fixtures/macos-exchange/04-propfind-principal/request
@@ -0,0 +1,19 @@
+PROPFIND /dav/principal/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 0
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+ <A:prop>
+ <C:addressbook-home-set xmlns:C="urn:ietf:params:xml:ns:carddav"/>
+ <C:directory-gateway xmlns:C="urn:ietf:params:xml:ns:carddav"/>
+ <A:displayname/>
+ <D:email-address-set xmlns:D="http://calendarserver.org/ns/"/>
+ <A:principal-collection-set/>
+ <A:principal-URL/>
+ <A:resource-id/>
+ <A:supported-report-set/>
+ </A:prop>
+</A:propfind>
diff --git a/test/fixtures/macos-exchange/04-propfind-principal/response b/test/fixtures/macos-exchange/04-propfind-principal/response
new file mode 100644
index 0000000..0f4e547
--- /dev/null
+++ b/test/fixtures/macos-exchange/04-propfind-principal/response
@@ -0,0 +1,32 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <d:response>
+ <d:href>/dav/principal/</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:displayname>Default Principal</d:displayname>
+ <d:principal-URL>
+ <d:href>/dav/principal/</d:href>
+ </d:principal-URL>
+ <d:resourcetype>
+ <d:principal/>
+ </d:resourcetype>
+ <card:addressbook-home-set>
+ <d:href>/dav/addressbook/</d:href>
+ </card:addressbook-home-set>
+ <d:supported-report-set>
+ <d:supported-report>
+ <d:report><card:addressbook-multiget/></d:report>
+ </d:supported-report>
+ <d:supported-report>
+ <d:report><card:addressbook-query/></d:report>
+ </d:supported-report>
+ </d:supported-report-set>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/request b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/request
new file mode 100644
index 0000000..d12ad0c
--- /dev/null
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/request
@@ -0,0 +1,13 @@
+PROPFIND /dav/addressbook/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 0
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+ <A:prop>
+ <D:getctag xmlns:D="http://calendarserver.org/ns/"/>
+ <A:sync-token/>
+ </A:prop>
+</A:propfind>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
new file mode 100644
index 0000000..f664764
--- /dev/null
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -0,0 +1,57 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
+ <d:response>
+ <d:href>/dav/addressbook/</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:displayname>Contacts</d:displayname>
+<d:resourcetype>
+ <d:collection/>
+ <card:addressbook/>
+</d:resourcetype>
+<d:supported-report-set>
+ <d:supported-report>
+ <d:report><card:addressbook-multiget/></d:report>
+ </d:supported-report>
+ <d:supported-report>
+ <d:report><card:addressbook-query/></d:report>
+ </d:supported-report>
+ <d:supported-report>
+ <d:report><d:sync-collection/></d:report>
+ </d:supported-report>
+</d:supported-report-set>
+<d:sync-token>http://pro-tacts/sync/1</d:sync-token>
+<d:current-user-privilege-set>
+ <d:privilege><d:read/></d:privilege>
+ <d:privilege><d:write/></d:privilege>
+</d:current-user-privilege-set>
+<d:owner>
+ <d:href>/dav/principal/</d:href>
+</d:owner>
+<card:max-resource-size>102400</card:max-resource-size>
+<cs:getctag>ctag-1</cs:getctag>
+
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ <d:propstat>
+ <d:prop>
+ <d:add-member/>
+<d:quota-available-bytes/>
+<d:quota-used-bytes/>
+<d:resource-id/>
+<card:max-image-size/>
+<cs:me-card/>
+<cs:push-transports/>
+<cs:pushkey/>
+
+ </d:prop>
+ <d:status>HTTP/1.1 404 Not Found</d:status>
+ </d:propstat>
+</d:response>
+
+
+</d:multistatus>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/request b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/request
new file mode 100644
index 0000000..fce1618
--- /dev/null
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/request
@@ -0,0 +1,12 @@
+PROPFIND /dav/addressbook/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 1
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:propfind xmlns:A="DAV:">
+ <A:prop>
+ <A:getetag/>
+ </A:prop>
+</A:propfind>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
new file mode 100644
index 0000000..848e403
--- /dev/null
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -0,0 +1,26 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
+ <d:response>
+ <d:href>/dav/addressbook/</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"ctag-1"</d:getetag>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+</d:response>
+
+ <d:response>
+ <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+</d:response>
+
+</d:multistatus>
diff --git a/test/fixtures/macos-exchange/07-report-multiget/request b/test/fixtures/macos-exchange/07-report-multiget/request
new file mode 100644
index 0000000..8297a52
--- /dev/null
+++ b/test/fixtures/macos-exchange/07-report-multiget/request
@@ -0,0 +1,14 @@
+REPORT /dav/addressbook/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 1
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<C:addressbook-multiget xmlns:C="urn:ietf:params:xml:ns:carddav">
+ <A:prop xmlns:A="DAV:">
+ <A:getetag/>
+ <C:address-data/>
+ </A:prop>
+ <A:href xmlns:A="DAV:">/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</A:href>
+</C:addressbook-multiget>
diff --git a/test/fixtures/macos-exchange/07-report-multiget/response b/test/fixtures/macos-exchange/07-report-multiget/response
new file mode 100644
index 0000000..be34f5b
--- /dev/null
+++ b/test/fixtures/macos-exchange/07-report-multiget/response
@@ -0,0 +1,23 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <d:response>
+ <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ <card:address-data>BEGIN:VCARD
+VERSION:3.0
+PRODID:-//Apple Inc.//macOS 14.6.1//EN
+N:Contact;Test;;;
+FN:Test Contact
+REV:2026-01-14T00:00:00Z
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+END:VCARD</card:address-data>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/request b/test/fixtures/macos-exchange/08-report-sync-collection/request
new file mode 100644
index 0000000..ff5507c
--- /dev/null
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/request
@@ -0,0 +1,14 @@
+REPORT /dav/addressbook/ HTTP/1.1
+Brief: t
+Content-Type: text/xml
+Depth: 1
+Prefer: return=minimal
+
+<?xml version="1.0" encoding="UTF-8"?>
+<A:sync-collection xmlns:A="DAV:">
+ <A:sync-token>http://pro-tacts/sync/1</A:sync-token>
+ <A:sync-level>1</A:sync-level>
+ <A:prop>
+ <A:getetag/>
+ </A:prop>
+</A:sync-collection>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
new file mode 100644
index 0000000..be34f5b
--- /dev/null
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -0,0 +1,23 @@
+207
+Content-Type: text/xml
+
+<?xml version="1.0" encoding="UTF-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <d:response>
+ <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ <card:address-data>BEGIN:VCARD
+VERSION:3.0
+PRODID:-//Apple Inc.//macOS 14.6.1//EN
+N:Contact;Test;;;
+FN:Test Contact
+REV:2026-01-14T00:00:00Z
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+END:VCARD</card:address-data>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
new file mode 100644
index 0000000..7e10ad0
--- /dev/null
+++ b/test/fixtures/macos-exchange/README.md
@@ -0,0 +1,25 @@
+# macOS exchange fixtures
+
+Request-level recordings of the eight distinct requests macOS Contacts
+(26.5.1, AddressBookCore/2732.600.11) sent to pro-tacts on 2026-08-14, the
+day the one-card milestone landed — see
+`docs/plans/2026-08-12-one-card-on-macos.md`. That exchange is the only
+confirmed-working client session, so these requests are the baseline every
+later response change is measured against.
+
+Each step directory holds:
+
+- `request` — the request line, the headers that affect routing
+ (`Brief`, `Content-Type`, `Depth`, `Prefer`), a blank line, then the exact
+ body. `Authorization`, `Host`, `User-Agent`, and the `Tailscale-*` /
+ `X-Forwarded-*` / `X-Mme-Client-Info` headers were stripped: they identify
+ the tailnet and user, and the app ignores them.
+- `response` — the status line, the significant response headers
+ (`Content-Type`, `ETag`, `DAV`, `Allow`, `Location`), a blank line, then
+ the exact body the app returned.
+
+`request` files are evidence: edit them only when a new client session
+supersedes this one. `response` files are a snapshot of current app
+behavior: regenerate them with `rake fixtures` after changing responses
+deliberately, and review the diff — accidental drift is exactly what the
+comparison test exists to catch.
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
new file mode 100644
index 0000000..16277d4
--- /dev/null
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require "rack/test"
+
+# Loads and replays the recorded macOS Contacts exchange in
+# test/fixtures/macos-exchange. See that directory's README for provenance
+# and the request/response file format.
+module ExchangeFixtures
+ DIRECTORY = File.expand_path("../fixtures/macos-exchange", __dir__)
+
+ Step = Struct.new(:name, :method, :path, :headers, :body, keyword_init: true)
+ Response = Struct.new(:status, :headers, :body, keyword_init: true)
+
+ # Response headers worth snapshotting; everything else (Content-Length,
+ # Date, server) is plumbing.
+ RESPONSE_HEADERS = %w[Content-Type ETag DAV Allow Location].freeze
+
+ class << self
+ def steps
+ Dir.children(DIRECTORY)
+ .select { |child| File.directory?(File.join(DIRECTORY, child)) }
+ .sort
+ .map { |name| Step.new(name:, **parse_request(name)) }
+ end
+
+ def parse_request(name)
+ request_line, header_lines, body = split_message(read(name, "request"))
+ method, path, = request_line.split(" ")
+ { method:, path:, headers: parse_headers(header_lines), body: }
+ end
+
+ def recorded_response(step)
+ status_line, header_lines, body = split_message(read(step.name, "response"))
+ Response.new(status: status_line.to_i, headers: parse_headers(header_lines), body:)
+ end
+
+ # Rack env for the recorded headers: Content-Type and Depth are the only
+ # ones the app reads, but replaying all of them keeps the fidelity.
+ def env_for(step)
+ step.headers.to_h do |name, value|
+ key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
+ [key, value]
+ end
+ end
+
+ # Replays every recorded request against the app and rewrites the
+ # response fixtures. Rake task "fixtures"; run it after deliberately
+ # changing responses, and review the diff.
+ def record_responses(app)
+ session = Rack::Test::Session.new(Rack::MockSession.new(app))
+ steps.each do |step|
+ session.request(step.path, { method: step.method, input: step.body }.merge(env_for(step)))
+ write_response(step, session.last_response)
+ end
+ end
+
+ private
+
+ def read(name, file)
+ File.read(File.join(DIRECTORY, name, file))
+ end
+
+ def split_message(raw)
+ head, body = raw.split("\n\n", 2)
+ lines = head.split("\n")
+ [lines.first, lines.drop(1), body.to_s.chomp]
+ end
+
+ def parse_headers(lines)
+ lines.to_h { |line| line.split(": ", 2) }
+ end
+
+ def write_response(step, response)
+ headers = RESPONSE_HEADERS
+ .filter_map { |name| response[name] && "#{name}: #{response[name]}" }
+ content = ([response.status.to_s] + headers).join("\n") + "\n\n"
+ body = response.body.chomp
+ content = "#{content}#{body}\n" unless body.empty?
+ File.write(File.join(DIRECTORY, step.name, "response"), content)
+ end
+ end
+end
diff --git a/test/pro_tacts/test_macos_exchange.rb b/test/pro_tacts/test_macos_exchange.rb
new file mode 100644
index 0000000..6049584
--- /dev/null
+++ b/test/pro_tacts/test_macos_exchange.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require "minitest/autorun"
+require "rack/test"
+
+require_relative "exchange_fixtures"
+require "pro_tacts/web"
+
+# Replays each recorded macOS Contacts request against the app and compares
+# the response, byte for byte, with the recorded baseline. A failure means
+# the responses drifted from what the working client session saw; either fix
+# the drift or, if the change was deliberate, regenerate with `rake fixtures`.
+class MacosExchangeTest < Minitest::Test
+ include Rack::Test::Methods
+
+ def app
+ ProTacts::Web
+ end
+
+ ExchangeFixtures.steps.each do |step|
+ define_method(:"test_#{step.name}") do
+ request step.path, { method: step.method, input: step.body }.merge(ExchangeFixtures.env_for(step))
+
+ expected = ExchangeFixtures.recorded_response(step)
+
+ assert_equal expected.status, last_response.status
+ expected.headers.each do |name, value|
+ assert_equal value, last_response[name], name
+ end
+ assert_equal expected.body, last_response.body.chomp
+ end
+ end
+end