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
change zuwnwxumvkrlyowuozuwyutxwsuymrnt
commit be278bcd4a18e2d474783644d355f8417ec0c49b
author Alpha Chen <alpha@kejadlen.dev>
date
parent trwyqstt
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