Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: snapshot testing

pdewey.com 3ad8ad2e 79c94877

verified
+1979
+2
go.mod
··· 21 21 github.com/gorilla/websocket v1.5.3 // indirect 22 22 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 23 23 github.com/klauspost/compress v1.18.3 // indirect 24 + github.com/kortschak/utter v1.7.0 // indirect 24 25 github.com/mattn/go-colorable v0.1.13 // indirect 25 26 github.com/mattn/go-isatty v0.0.20 // indirect 26 27 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect ··· 30 31 github.com/prometheus/client_model v0.5.0 // indirect 31 32 github.com/prometheus/common v0.45.0 // indirect 32 33 github.com/prometheus/procfs v0.12.0 // indirect 34 + github.com/ptdewey/shutter v0.1.4 // indirect 33 35 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 34 36 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 35 37 golang.org/x/crypto v0.21.0 // indirect
+4
go.sum
··· 27 27 github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 28 28 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 29 29 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 30 + github.com/kortschak/utter v1.7.0 h1:6NKMynvGUyqfeMTawfah4zyInlrgwzjkDAHrT+skx/w= 31 + github.com/kortschak/utter v1.7.0/go.mod h1:vSmSjbyrlKjjsL71193LmzBOKgwePk9DH6uFaWHIInc= 30 32 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 31 33 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 32 34 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= ··· 64 66 github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 65 67 github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 66 68 github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 69 + github.com/ptdewey/shutter v0.1.4 h1:tMTNMTxCpA1F0REyi+taztoHVe9EpB5sSKhaIBzYu1c= 70 + github.com/ptdewey/shutter v0.1.4/go.mod h1:teeIXF4LdgsE9E4kjHk9nGzDxl2cjdbVb1qbdzAHSR4= 67 71 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 68 72 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 69 73 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+7
internal/bff/__snapshots__/all_used.snap
··· 1 + --- 2 + title: all used 3 + test_name: TestIterateRemaining_Snapshot/all_used 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int(nil)
+7
internal/bff/__snapshots__/avatar_urls.snap
··· 1 + --- 2 + title: avatar_urls 3 + test_name: TestSafeURL_Snapshot/avatar_URLs 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []string{"", "/static/icon-placeholder.svg", "https://cdn.bsky.app/avatar.jpg", "https://av-cdn.bsky.app/img/avatar/plain/did:plc:test/abc@jpeg", "", "", "", ""}
+7
internal/bff/__snapshots__/bean_with_missing_optional_fields.snap
··· 1 + --- 2 + title: bean with missing optional fields 3 + test_name: TestProfileContent_BeansTab_Snapshot/bean_with_missing_optional_fields 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"1\" \n data-roasters=\"0\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg mb-4 font-medium\">No brews yet! Start tracking your coffee journey.</p>\n <a href=\"/brews/new\"\n class=\"inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium\">\n Add Your First Brew\n </a>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Coffee Beans</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">☕ Roaster</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📍 Origin</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🔥 Roast</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🌱 Process</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n Mystery Bean\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n <span class=\"text-brown-400 not-italic\">-</span>\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editBean('', '', '', '', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Bean</span>\n </button>\n </div>\n \n </div>\n \n\n \n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/bean_with_only_origin.snap
··· 1 + --- 2 + title: bean with only origin 3 + test_name: TestTemplateRendering_BeanCard_Snapshot/bean_with_only_origin 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"bean-card\">\n <h3></h3>\n <p>Origin: Colombia</p>\n \n</div>\n"
+7
internal/bff/__snapshots__/bean_with_roaster.snap
··· 1 + --- 2 + title: bean with roaster 3 + test_name: TestFeedTemplate_BeanItem_Snapshot/bean_with_roaster 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/roaster.pro\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/roaster.pro\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Pro Roaster</a>\n \n <a href=\"/profile/roaster.pro\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@roaster.pro</a>\n </div>\n <span class=\"text-brown-500 text-sm\">5 minutes ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🫘 added a new bean\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">\n Kenya AA\n </span>\n \n <span class=\"text-brown-700\"> from Onyx Coffee Lab</span>\n \n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Origin:</span> Kenya</div>\n \n \n <div><span class=\"text-brown-600\">Roast:</span> Medium</div>\n \n \n <div><span class=\"text-brown-600\">Process:</span> Natural</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Sweet and fruity with notes of blueberry\"</div>\n \n </div>\n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/bean_without_roaster.snap
··· 1 + --- 2 + title: bean without roaster 3 + test_name: TestFeedTemplate_BeanItem_Snapshot/bean_without_roaster 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/homebrewer\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/homebrewer\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Home Brewer</a>\n \n <a href=\"/profile/homebrewer\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@homebrewer</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1 day ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🫘 added a new bean\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">\n Colombian Supremo\n </span>\n \n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Origin:</span> Colombia</div>\n \n \n <div><span class=\"text-brown-600\">Roast:</span> Medium</div>\n \n \n <div><span class=\"text-brown-600\">Process:</span> Natural</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Sweet and fruity with notes of blueberry\"</div>\n \n </div>\n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/brew_with_minimal_data.snap
··· 1 + --- 2 + title: brew with minimal data 3 + test_name: TestFeedTemplate_BrewItem_Snapshot/brew_with_minimal_data 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/newbie\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/newbie\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@newbie</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1 minute ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n House Blend\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n \n \n \n \n </div>\n \n </div>\n \n </div>\n \n \n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n \n \n \n </div>\n\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/brew_with_unicode_bean_name.snap
··· 1 + --- 2 + title: brew with unicode bean name 3 + test_name: TestFeedTemplate_BrewItem_Snapshot/brew_with_unicode_bean_name 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/japan.coffee\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/japan.coffee\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">日本のコーヒー</a>\n \n <a href=\"/profile/japan.coffee\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@japan.coffee</a>\n </div>\n <span class=\"text-brown-500 text-sm\">3 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n コーヒー豆\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n <span class=\"inline-flex items-center gap-0.5\">📍 日本</span>\n \n \n \n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 8/10\n </span>\n \n </div>\n \n \n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n \n \n \n </div>\n\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/brew_with_zero_rating.snap
··· 1 + --- 2 + title: brew with zero rating 3 + test_name: TestTemplateRendering_BrewCard_Snapshot/brew_with_zero_rating 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"brew-card\">\n <div class=\"date\">Jan 15, 2024</div>\n \n <div class=\"bean\">House Blend</div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/brewer_item.snap
··· 1 + --- 2 + title: brewer item 3 + test_name: TestFeedTemplate_BrewerItem_Snapshot 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/pourover.fan\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/pourover.fan\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Pour Over Fan</a>\n \n <a href=\"/profile/pourover.fan\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@pourover.fan</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 days ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brewer\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Kalita Wave 185</span>\n </div>\n \n <div class=\"text-sm text-brown-800 italic\">\"Flat-bottom dripper with wave filters\"</div>\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/celsius_temp.snap
··· 1 + --- 2 + title: celsius temp 3 + test_name: TestFormatTempValue_Snapshot/celsius_temp 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "93.0"
+7
internal/bff/__snapshots__/complete_brew.snap
··· 1 + --- 2 + title: complete brew 3 + test_name: TestTemplateRendering_BrewCard_Snapshot/complete_brew 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"brew-card\">\n <div class=\"date\">Jan 15, 2024</div>\n \n <div class=\"bean\">Ethiopian Yirgacheffe</div>\n \n \n <div class=\"rating\">9/10</div>\n \n \n <div class=\"notes\">Bright citrus notes with floral aroma</div>\n \n</div>\n"
+7
internal/bff/__snapshots__/complete_brew_with_all_fields.snap
··· 1 + --- 2 + title: complete brew with all fields 3 + test_name: TestFeedTemplate_BrewItem_Snapshot/complete_brew_with_all_fields 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/coffee.lover\" class=\"flex-shrink-0\">\n \n \n \n <img src=\"https://cdn.bsky.app/avatar.jpg\" alt=\"\" class=\"w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition\" />\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/coffee.lover\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Coffee Enthusiast</a>\n \n <a href=\"/profile/coffee.lover\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@coffee.lover</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n Ethiopian Yirgacheffe\n </div>\n \n <div class=\"text-sm text-brown-700 mt-0.5\">\n <span class=\"font-medium\">🏪 Onyx Coffee Lab</span>\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n <span class=\"inline-flex items-center gap-0.5\">📍 Ethiopia</span>\n <span class=\"inline-flex items-center gap-0.5\">🔥 Light</span>\n <span class=\"inline-flex items-center gap-0.5\">🌱 Washed</span>\n <span class=\"inline-flex items-center gap-0.5\">⚖️ 16g</span>\n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 9/10\n </span>\n \n </div>\n \n \n \n <div class=\"mb-2\">\n <span class=\"text-xs text-brown-600\">Brewer:</span>\n <span class=\"text-sm font-semibold text-brown-900\">\n Hario V60\n </span>\n </div>\n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n <div>\n <span class=\"text-brown-600\">Grinder:</span> 1Zpresso JX-Pro (Medium-fine)\n </div>\n \n \n <div class=\"col-span-2\">\n <span class=\"text-brown-600\">Pours:</span>\n \n <div class=\"pl-2 text-brown-600\">• 50g @ 30s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 45s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 1m</div>\n \n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Temp:</span> 93.0°C\n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Time:</span> 3m\n </div>\n \n </div>\n\n \n <div class=\"mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2\">\n \"Bright citrus notes with floral aroma\"\n </div>\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/decimal_celsius.snap
··· 1 + --- 2 + title: decimal celsius 3 + test_name: TestFormatTempValue_Snapshot/decimal_celsius 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "92.5"
+7
internal/bff/__snapshots__/decimal_fahrenheit.snap
··· 1 + --- 2 + title: decimal fahrenheit 3 + test_name: TestFormatTempValue_Snapshot/decimal_fahrenheit 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "201.8"
+7
internal/bff/__snapshots__/different_values.snap
··· 1 + --- 2 + title: different values 3 + test_name: TestPtrEquals_Snapshot/different_values 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + false
+8
internal/bff/__snapshots__/empty_dict.snap
··· 1 + --- 2 + title: empty dict 3 + test_name: TestDict_Snapshot/empty_dict 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + }
+7
internal/bff/__snapshots__/empty_feed_authenticated.snap
··· 1 + --- 2 + title: empty feed authenticated 3 + test_name: TestFeedTemplate_EmptyFeed_Snapshot/empty_feed_authenticated 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n <div class=\"bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200\">\n <p class=\"mb-2 font-medium\">No activity in the feed yet.</p>\n <p class=\"text-sm\">Be the first to add something!</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/empty_feed_unauthenticated.snap
··· 1 + --- 2 + title: empty feed unauthenticated 3 + test_name: TestFeedTemplate_EmptyFeed_Snapshot/empty_feed_unauthenticated 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n <div class=\"bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200\">\n <p class=\"mb-2 font-medium\">No activity in the feed yet.</p>\n <p class=\"text-sm\">Be the first to add something!</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/empty_pours.snap
··· 1 + --- 2 + title: empty pours 3 + test_name: TestPoursToJSON_Snapshot/empty_pours 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "[]"
+7
internal/bff/__snapshots__/equal_values.snap
··· 1 + --- 2 + title: equal values 3 + test_name: TestPtrEquals_Snapshot/equal_values 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + true
+7
internal/bff/__snapshots__/escape_js.snap
··· 1 + --- 2 + title: escape_js 3 + test_name: TestEscapeJS_Snapshot 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []string{"simple string", "string with \\'single quotes\\'", "string with \\\"double quotes\\\"", "line1\\nline2", "tab\\there", "backslash\\\\test", "mixed: \\'quotes\\', \\\"quotes\\\", \\n newlines \\t tabs", ""}
+7
internal/bff/__snapshots__/fahrenheit_temp.snap
··· 1 + --- 2 + title: fahrenheit temp 3 + test_name: TestFormatTempValue_Snapshot/fahrenheit_temp 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "205.0"
+7
internal/bff/__snapshots__/five_iterations.snap
··· 1 + --- 2 + title: five iterations 3 + test_name: TestIterate_Snapshot/five_iterations 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int{0, 1, 2, 3, 4}
+7
internal/bff/__snapshots__/full_bean_data.snap
··· 1 + --- 2 + title: full bean data 3 + test_name: TestTemplateRendering_BeanCard_Snapshot/full_bean_data 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"bean-card\">\n <h3>Ethiopian Yirgacheffe</h3>\n <p>Origin: Ethiopia</p>\n <p>Roast: Light</p>\n</div>\n"
+7
internal/bff/__snapshots__/full_grinder_data.snap
··· 1 + --- 2 + title: full grinder data 3 + test_name: TestTemplateRendering_GearCards_Snapshot/full_grinder_data 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"grinder-card\">\n <h3>1Zpresso JX-Pro</h3>\n <p>Type: Hand</p>\n <p>Burr: Conical</p>\n</div>\n"
+7
internal/bff/__snapshots__/grinder_item.snap
··· 1 + --- 2 + title: grinder item 3 + test_name: TestFeedTemplate_GrinderItem_Snapshot 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/gearhead\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/gearhead\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Coffee Gear Head</a>\n \n <a href=\"/profile/gearhead\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@gearhead</a>\n </div>\n <span class=\"text-brown-500 text-sm\">30 minutes ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ⚙️ added a new grinder\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Comandante C40</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Type:</span> Hand</div>\n \n \n <div><span class=\"text-brown-600\">Burr:</span> Conical</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Excellent for pour over\"</div>\n \n </div>\n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/half_used.snap
··· 1 + --- 2 + title: half used 3 + test_name: TestIterateRemaining_Snapshot/half_used 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int(nil)
+7
internal/bff/__snapshots__/large_value.snap
··· 1 + --- 2 + title: large value 3 + test_name: TestHasValue_Snapshot/large_value 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + true
+7
internal/bff/__snapshots__/minimal_bean_data.snap
··· 1 + --- 2 + title: minimal bean data 3 + test_name: TestTemplateRendering_BeanCard_Snapshot/minimal_bean_data 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"bean-card\">\n <h3>House Blend</h3>\n \n \n</div>\n"
+7
internal/bff/__snapshots__/minimal_brew.snap
··· 1 + --- 2 + title: minimal brew 3 + test_name: TestTemplateRendering_BrewCard_Snapshot/minimal_brew 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"brew-card\">\n <div class=\"date\">Jan 15, 2024</div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/minimal_grinder_data.snap
··· 1 + --- 2 + title: minimal grinder data 3 + test_name: TestTemplateRendering_GearCards_Snapshot/minimal_grinder_data 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"grinder-card\">\n <h3>Generic Grinder</h3>\n \n \n</div>\n"
+7
internal/bff/__snapshots__/mixed_feed_all_types.snap
··· 1 + --- 2 + title: mixed feed all types 3 + test_name: TestFeedTemplate_MixedFeed_Snapshot 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user1\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user1\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User One</a>\n \n <a href=\"/profile/user1\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user1</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1 hour ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n Ethiopian Yirgacheffe\n </div>\n \n <div class=\"text-sm text-brown-700 mt-0.5\">\n <span class=\"font-medium\">🏪 Onyx</span>\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n <span class=\"inline-flex items-center gap-0.5\">📍 Ethiopia</span>\n <span class=\"inline-flex items-center gap-0.5\">🔥 Light</span>\n <span class=\"inline-flex items-center gap-0.5\">🌱 Washed</span>\n <span class=\"inline-flex items-center gap-0.5\">⚖️ 16g</span>\n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 9/10\n </span>\n \n </div>\n \n \n \n <div class=\"mb-2\">\n <span class=\"text-xs text-brown-600\">Brewer:</span>\n <span class=\"text-sm font-semibold text-brown-900\">\n Hario V60\n </span>\n </div>\n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n <div>\n <span class=\"text-brown-600\">Grinder:</span> 1Zpresso JX-Pro (Medium-fine)\n </div>\n \n \n <div class=\"col-span-2\">\n <span class=\"text-brown-600\">Pours:</span>\n \n <div class=\"pl-2 text-brown-600\">• 50g @ 30s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 45s</div>\n \n <div class=\"pl-2 text-brown-600\">• 100g @ 1m</div>\n \n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Temp:</span> 93.0°C\n </div>\n \n \n <div>\n <span class=\"text-brown-600\">Time:</span> 3m\n </div>\n \n </div>\n\n \n <div class=\"mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2\">\n \"Bright citrus notes with floral aroma\"\n </div>\n \n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user2\" class=\"flex-shrink-0\">\n \n \n \n <img src=\"https://cdn.bsky.app/avatar.jpg\" alt=\"\" class=\"w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition\" />\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user2\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Two</a>\n \n <a href=\"/profile/user2\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user2</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1.5 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🫘 added a new bean\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">\n Kenya AA\n </span>\n \n <span class=\"text-brown-700\"> from Onyx Coffee Lab</span>\n \n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Origin:</span> Kenya</div>\n \n \n <div><span class=\"text-brown-600\">Roast:</span> Medium</div>\n \n \n <div><span class=\"text-brown-600\">Process:</span> Natural</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Sweet and fruity with notes of blueberry\"</div>\n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user3\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user3\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Three</a>\n \n <a href=\"/profile/user3\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user3</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🏪 added a new roaster\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Heart Coffee Roasters</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Location:</span> Portland, OR</div>\n \n \n \n \n <div><span class=\"text-brown-600\">Website:</span> <a href=\"https://heartroasters.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-brown-800 hover:underline\">https://heartroasters.com</a></div>\n \n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user4\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user4\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user4</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2.5 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ⚙️ added a new grinder\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Comandante C40</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Type:</span> Hand</div>\n \n \n <div><span class=\"text-brown-600\">Burr:</span> Conical</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Excellent for pour over\"</div>\n \n </div>\n </div>\n \n </div>\n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/user5\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/user5\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User Five</a>\n \n <a href=\"/profile/user5\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@user5</a>\n </div>\n <span class=\"text-brown-500 text-sm\">3 hours ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brewer\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Kalita Wave 185</span>\n </div>\n \n <div class=\"text-sm text-brown-800 italic\">\"Flat-bottom dripper with wave filters\"</div>\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/mixed_valid_and_invalid.snap
··· 1 + --- 2 + title: mixed valid and invalid 3 + test_name: TestDict_ErrorCases_Snapshot/mixed_valid_and_invalid 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "dict keys must be strings"
+11
internal/bff/__snapshots__/multiple_key-values.snap
··· 1 + --- 2 + title: multiple key-values 3 + test_name: TestDict_Snapshot/multiple_key-values 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + "name": "Ethiopian", 9 + "rating": 9, 10 + "roast": "Light", 11 + }
+7
internal/bff/__snapshots__/multiple_pours.snap
··· 1 + --- 2 + title: multiple pours 3 + test_name: TestPoursToJSON_Snapshot/multiple_pours 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "[{\"water\":50,\"time\":30},{\"water\":100,\"time\":45},{\"water\":80,\"time\":60}]"
+7
internal/bff/__snapshots__/negative_temperature.snap
··· 1 + --- 2 + title: negative temperature 3 + test_name: TestHasTemp_Snapshot/negative_temperature 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + false
+7
internal/bff/__snapshots__/negative_value.snap
··· 1 + --- 2 + title: negative value 3 + test_name: TestHasValue_Snapshot/negative_value 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + false
+16
internal/bff/__snapshots__/nested_values.snap
··· 1 + --- 2 + title: nested values 3 + test_name: TestDict_Snapshot/nested_values 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + "bean": map[string]interface{}{ 9 + "name": "Ethiopian", 10 + "origin": "Yirgacheffe", 11 + }, 12 + "brew": map[string]interface{}{ 13 + "method": "V60", 14 + "temp": 93.0, 15 + }, 16 + }
+7
internal/bff/__snapshots__/nil_feed.snap
··· 1 + --- 2 + title: nil feed 3 + test_name: TestFeedTemplate_EmptyFeed_Snapshot/nil_feed 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n <div class=\"bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200\">\n <p class=\"mb-2 font-medium\">No activity in the feed yet.</p>\n <p class=\"text-sm\">Be the first to add something!</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/nil_int_pointer.snap
··· 1 + --- 2 + title: nil int pointer 3 + test_name: TestPtrValue_Snapshot/nil_int_pointer 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + 0
+7
internal/bff/__snapshots__/nil_pointer.snap
··· 1 + --- 2 + title: nil pointer 3 + test_name: TestPtrEquals_Snapshot/nil_pointer 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + false
+7
internal/bff/__snapshots__/nil_pours.snap
··· 1 + --- 2 + title: nil pours 3 + test_name: TestPoursToJSON_Snapshot/nil_pours 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "[]"
+7
internal/bff/__snapshots__/nil_string_pointer.snap
··· 1 + --- 2 + title: nil string pointer 3 + test_name: TestPtrValue_Snapshot/nil_string_pointer 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + ""
+7
internal/bff/__snapshots__/non-string_key.snap
··· 1 + --- 2 + title: non-string key 3 + test_name: TestDict_ErrorCases_Snapshot/non-string_key 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "dict keys must be strings"
+7
internal/bff/__snapshots__/none_used.snap
··· 1 + --- 2 + title: none used 3 + test_name: TestIterateRemaining_Snapshot/none_used 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int(nil)
+7
internal/bff/__snapshots__/odd_number_of_arguments.snap
··· 1 + --- 2 + title: odd number of arguments 3 + test_name: TestDict_ErrorCases_Snapshot/odd_number_of_arguments 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "dict requires an even number of arguments"
+7
internal/bff/__snapshots__/pointer_to_empty_vs_empty.snap
··· 1 + --- 2 + title: pointer to empty vs empty 3 + test_name: TestPtrEquals_Snapshot/pointer_to_empty_vs_empty 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + true
+7
internal/bff/__snapshots__/positive_temperature.snap
··· 1 + --- 2 + title: positive temperature 3 + test_name: TestHasTemp_Snapshot/positive_temperature 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + true
+7
internal/bff/__snapshots__/positive_value.snap
··· 1 + --- 2 + title: positive value 3 + test_name: TestHasValue_Snapshot/positive_value 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + true
+7
internal/bff/__snapshots__/profile_roaster_with_invalid_url_protocol.snap
··· 1 + --- 2 + title: profile roaster with invalid URL protocol 3 + test_name: TestProfileContent_URLSecurity_Snapshot/profile_roaster_with_invalid_URL_protocol 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">FTP Roaster</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <span class=\"text-brown-400\">-</span>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/profile_roaster_with_unsafe_website_url.snap
··· 1 + --- 2 + title: profile roaster with unsafe website URL 3 + test_name: TestProfileContent_URLSecurity_Snapshot/profile_roaster_with_unsafe_website_URL 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Sketchy Roaster</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Unknown\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <span class=\"text-brown-400\">-</span>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/profile_with_empty_beans.snap
··· 1 + --- 2 + title: profile with empty beans 3 + test_name: TestProfileContent_BeansTab_Snapshot/profile_with_empty_beans 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"0\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No beans or roasters yet.</p>\n </div>\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/profile_with_gear_collection.snap
··· 1 + --- 2 + title: profile with gear collection 3 + test_name: TestProfileContent_GearTabs_Snapshot 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"0\" \n data-roasters=\"1\" \n data-grinders=\"2\" \n data-brewers=\"1\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg mb-4 font-medium\">No brews yet! Start tracking your coffee journey.</p>\n <a href=\"/brews/new\"\n class=\"inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium\">\n Add Your First Brew\n </a>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Heart Coffee</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Portland, OR\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n \n \n <a href=\"https://heartroasters.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-brown-700 hover:underline font-medium\">Visit Site</a>\n \n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editRoaster('', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Roaster</span>\n </button>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">⚙️ Grinders</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🔧 Type</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">💎 Burrs</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📝 Notes</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Comandante C40</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Hand\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Conical\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Perfect for pour over\n </td>\n </tr>\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Niche Zero</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Electric\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Conical\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n <span class=\"text-brown-400 not-italic\">-</span>\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editGrinder('', '', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Grinder</span>\n </button>\n </div>\n \n </div>\n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Brewers</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🔧 Type</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Hario V60</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Pour Over\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Classic pour over cone\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editBrewer('', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Brewer</span>\n </button>\n </div>\n \n </div>\n \n\n \n</div>\n"
+7
internal/bff/__snapshots__/profile_with_multiple_beans.snap
··· 1 + --- 2 + title: profile with multiple beans 3 + test_name: TestProfileContent_BeansTab_Snapshot/profile_with_multiple_beans 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"2\" \n data-roasters=\"0\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg mb-4 font-medium\">No brews yet! Start tracking your coffee journey.</p>\n <a href=\"/brews/new\"\n class=\"inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium\">\n Add Your First Brew\n </a>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Coffee Beans</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">☕ Roaster</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📍 Origin</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🔥 Roast</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🌱 Process</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n Ethiopian Yirgacheffe\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n Onyx Coffee Lab\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Ethiopia\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Light\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Washed\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Bright and floral with citrus notes\n </td>\n </tr>\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n Colombia Supremo\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Colombia\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Medium\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Natural\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n <span class=\"text-brown-400 not-italic\">-</span>\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editBean('', '', '', '', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Bean</span>\n </button>\n </div>\n \n </div>\n \n\n \n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/profile_with_special_characters.snap
··· 1 + --- 2 + title: profile with special characters 3 + test_name: TestProfileContent_SpecialCharacters_Snapshot 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"1\" \n data-roasters=\"0\" \n data-grinders=\"1\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg mb-4 font-medium\">No brews yet! Start tracking your coffee journey.</p>\n <a href=\"/brews/new\"\n class=\"inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium\">\n Add Your First Brew\n </a>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Coffee Beans</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">☕ Roaster</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📍 Origin</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🔥 Roast</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🌱 Process</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n Bean with &lt;html&gt; &amp; &#34;quotes&#34;\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Colombia &amp; Peru\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Description with &#39;single&#39; and &#34;double&#34; quotes\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editBean('', '', '', '', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Bean</span>\n </button>\n </div>\n \n </div>\n \n\n \n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">⚙️ Grinders</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🔧 Type</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">💎 Burrs</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📝 Notes</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Grinder &amp; Co.</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Notes with &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n <div class=\"mt-3 text-center\">\n <button @click=\"editGrinder('', '', '', '', '')\" class=\"inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium\">\n <span>+</span>\n <span>Add New Grinder</span>\n </button>\n </div>\n \n </div>\n \n\n \n \n\n \n</div>\n"
+7
internal/bff/__snapshots__/profile_with_unicode_content.snap
··· 1 + --- 2 + title: profile with unicode content 3 + test_name: TestProfileContent_Unicode_Snapshot 4 + file_name: profile_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n\n<div id=\"profile-stats-data\" \n data-brews=\"0\" \n data-beans=\"2\" \n data-roasters=\"1\" \n data-grinders=\"0\" \n data-brewers=\"0\"\n style=\"display: none;\"></div>\n\n\n<div x-show=\"activeTab === 'brews'\">\n \n\n<div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300\">\n \n <p class=\"text-brown-800 text-lg font-medium\">No brews yet.</p>\n \n</div>\n\n\n</div>\n\n\n<div x-show=\"activeTab === 'beans'\" x-cloak class=\"space-y-6\">\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">☕ Coffee Beans</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">☕ Roaster</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📍 Origin</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🔥 Roast</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">🌱 Process</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap\">📝 Description</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n エチオピア イルガチェフェ\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n 日本\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n 明るく花のような香り\n </td>\n </tr>\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">\n Café de Colombia\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Bogotá\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n <span class=\"text-brown-400\">-</span>\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-700 italic max-w-xs\">\n Suave y aromático\n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n \n <div>\n <h3 class=\"text-lg font-semibold text-brown-900 mb-3\">🏪 Favorite Roasters</h3>\n <div class=\"overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300\">\n <table class=\"min-w-full divide-y divide-brown-300\">\n <thead class=\"bg-brown-200/80\">\n <tr>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">Name</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">📍 Location</th>\n <th class=\"px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider\">🌐 Website</th>\n </tr>\n </thead>\n <tbody class=\"bg-brown-50/60 divide-y divide-brown-200\">\n \n <tr class=\"hover:bg-brown-100/60 transition-colors\">\n <td class=\"px-6 py-4 text-sm font-bold text-brown-900\">Кофейня Москва</td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n Москва, Россия\n </td>\n <td class=\"px-6 py-4 text-sm text-brown-900\">\n \n <span class=\"text-brown-400\">-</span>\n \n </td>\n </tr>\n \n </tbody>\n </table>\n </div>\n \n </div>\n \n\n \n</div>\n\n\n<div x-show=\"activeTab === 'gear'\" x-cloak class=\"space-y-6\">\n \n \n\n \n \n\n \n <div class=\"bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300\">\n <p class=\"font-medium\">No gear added yet.</p>\n </div>\n \n</div>\n"
+7
internal/bff/__snapshots__/profile_with_unsafe_avatar_url.snap
··· 1 + --- 2 + title: profile with unsafe avatar URL 3 + test_name: TestFeedTemplate_SecurityURLs_Snapshot/profile_with_unsafe_avatar_URL 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/badavatar\" class=\"flex-shrink-0\">\n \n \n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/badavatar\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Bad Avatar</a>\n \n <a href=\"/profile/badavatar\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@badavatar</a>\n </div>\n <span class=\"text-brown-500 text-sm\">2 minutes ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🫘 added a new bean\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">\n Test Bean\n </span>\n \n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Origin:</span> Test Origin</div>\n \n \n <div><span class=\"text-brown-600\">Roast:</span> Medium</div>\n \n \n <div><span class=\"text-brown-600\">Process:</span> Natural</div>\n \n \n <div class=\"mt-2 text-brown-800 italic\">\"Sweet and fruity with notes of blueberry\"</div>\n \n </div>\n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/rating_3_out_of_10.snap
··· 1 + --- 2 + title: rating 3 out of 10 3 + test_name: TestIterateRemaining_Snapshot/rating_3_out_of_10 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int(nil)
+7
internal/bff/__snapshots__/rating_7_out_of_10.snap
··· 1 + --- 2 + title: rating 7 out of 10 3 + test_name: TestIterateRemaining_Snapshot/rating_7_out_of_10 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int(nil)
+7
internal/bff/__snapshots__/rating_formatting.snap
··· 1 + --- 2 + title: rating_formatting 3 + test_name: TestFormatHelpers_Snapshot/rating_formatting 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []string{"N/A", "1/10", "5/10", "7/10", "10/10"}
+7
internal/bff/__snapshots__/roaster_item.snap
··· 1 + --- 2 + title: roaster item 3 + test_name: TestFeedTemplate_RoasterItem_Snapshot 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/roastmaster\" class=\"flex-shrink-0\">\n \n \n \n <img src=\"https://cdn.bsky.app/avatar2.jpg\" alt=\"\" class=\"w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition\" />\n \n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/roastmaster\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Roast Master</a>\n \n <a href=\"/profile/roastmaster\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@roastmaster</a>\n </div>\n <span class=\"text-brown-500 text-sm\">10 minutes ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🏪 added a new roaster\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Heart Coffee Roasters</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n <div><span class=\"text-brown-600\">Location:</span> Portland, OR</div>\n \n \n \n \n <div><span class=\"text-brown-600\">Website:</span> <a href=\"https://heartroasters.com\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-brown-800 hover:underline\">https://heartroasters.com</a></div>\n \n \n </div>\n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/roaster_with_unsafe_website_url.snap
··· 1 + --- 2 + title: roaster with unsafe website URL 3 + test_name: TestFeedTemplate_SecurityURLs_Snapshot/roaster_with_unsafe_website_URL 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/hacker\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/hacker\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">Hacker</a>\n \n <a href=\"/profile/hacker\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@hacker</a>\n </div>\n <span class=\"text-brown-500 text-sm\">1 minute ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n 🏪 added a new roaster\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200\">\n <div class=\"text-base mb-2\">\n <span class=\"font-bold text-brown-900\">Sketchy Roaster</span>\n </div>\n <div class=\"text-sm text-brown-700 space-y-1\">\n \n \n \n \n \n </div>\n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/single_iteration.snap
··· 1 + --- 2 + title: single iteration 3 + test_name: TestIterate_Snapshot/single_iteration 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int{0}
+9
internal/bff/__snapshots__/single_key-value.snap
··· 1 + --- 2 + title: single key-value 3 + test_name: TestDict_Snapshot/single_key-value 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + map[string]interface{}{ 8 + "key1": "value1", 9 + }
+7
internal/bff/__snapshots__/single_pour.snap
··· 1 + --- 2 + title: single pour 3 + test_name: TestPoursToJSON_Snapshot/single_pour 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "[{\"water\":50,\"time\":30}]"
+7
internal/bff/__snapshots__/small_positive.snap
··· 1 + --- 2 + title: small positive 3 + test_name: TestHasTemp_Snapshot/small_positive 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + true
+7
internal/bff/__snapshots__/special_characters_in_content.snap
··· 1 + --- 2 + title: special characters in content 3 + test_name: TestFeedTemplate_SpecialCharacters_Snapshot 4 + file_name: feed_template_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "\n<div class=\"space-y-4\">\n \n \n <div class=\"bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow\">\n \n <div class=\"flex items-center gap-3 mb-3\">\n <a href=\"/profile/special.chars\" class=\"flex-shrink-0\">\n \n <div class=\"w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition\">\n <span class=\"text-brown-600 text-sm\">?</span>\n </div>\n \n </a>\n <div class=\"flex-1 min-w-0\">\n <div class=\"flex items-center gap-2\">\n \n <a href=\"/profile/special.chars\" class=\"font-medium text-brown-900 truncate hover:text-brown-700 hover:underline\">User &amp; Co.</a>\n \n <a href=\"/profile/special.chars\" class=\"text-brown-600 text-sm truncate hover:text-brown-700 hover:underline\">@special.chars</a>\n </div>\n <span class=\"text-brown-500 text-sm\">5 seconds ago</span>\n </div>\n </div>\n\n \n <div class=\"mb-2 text-sm text-brown-700\">\n ☕ added a new brew\n </div>\n\n \n \n \n <div class=\"bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200\">\n \n <div class=\"flex items-start justify-between gap-3 mb-3\">\n <div class=\"flex-1 min-w-0\">\n \n <div class=\"font-bold text-brown-900 text-base\">\n Bean with &amp; ampersand\n </div>\n \n <div class=\"text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5\">\n \n \n \n \n </div>\n \n </div>\n \n <span class=\"inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0\">\n ⭐ 8/10\n </span>\n \n </div>\n \n \n \n \n \n <div class=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700\">\n \n \n \n \n </div>\n\n \n <div class=\"mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2\">\n \"Notes with &#34;quotes&#34; and &lt;html&gt;tags&lt;/html&gt; and &#39;single quotes&#39;\"\n </div>\n \n </div>\n \n </div>\n \n \n \n</div>\n"
+7
internal/bff/__snapshots__/temperature_formatting.snap
··· 1 + --- 2 + title: temperature_formatting 3 + test_name: TestFormatHelpers_Snapshot/temperature_formatting 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []string{"N/A", "20.5°C", "93.0°C", "100.0°C", "200.5°F", "212.0°F"}
+7
internal/bff/__snapshots__/ten_iterations.snap
··· 1 + --- 2 + title: ten iterations 3 + test_name: TestIterate_Snapshot/ten_iterations 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
+7
internal/bff/__snapshots__/time_formatting.snap
··· 1 + --- 2 + title: time_formatting 3 + test_name: TestFormatHelpers_Snapshot/time_formatting 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []string{"N/A", "15s", "1m", "1m 30s", "3m", "4m 5s"}
+7
internal/bff/__snapshots__/valid_int_pointer.snap
··· 1 + --- 2 + title: valid int pointer 3 + test_name: TestPtrValue_Snapshot/valid_int_pointer 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + 42
+7
internal/bff/__snapshots__/valid_string_pointer.snap
··· 1 + --- 2 + title: valid string pointer 3 + test_name: TestPtrValue_Snapshot/valid_string_pointer 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "test value"
+7
internal/bff/__snapshots__/website_urls.snap
··· 1 + --- 2 + title: website_urls 3 + test_name: TestSafeURL_Snapshot/website_URLs 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []string{"", "https://example.com", "http://example.com", "https://roastery.coffee/beans", "", "", "", ""}
+8
internal/bff/__snapshots__/zero_count.snap
··· 1 + --- 2 + title: zero count 3 + test_name: TestIterate_Snapshot/zero_count 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + []int{ 8 + }
+7
internal/bff/__snapshots__/zero_temp.snap
··· 1 + --- 2 + title: zero temp 3 + test_name: TestFormatTempValue_Snapshot/zero_temp 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + "0.0"
+7
internal/bff/__snapshots__/zero_temperature.snap
··· 1 + --- 2 + title: zero temperature 3 + test_name: TestHasTemp_Snapshot/zero_temperature 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + false
+7
internal/bff/__snapshots__/zero_value.snap
··· 1 + --- 2 + title: zero value 3 + test_name: TestHasValue_Snapshot/zero_value 4 + file_name: render_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + false
+496
internal/bff/feed_template_snapshot_test.go
··· 1 + package bff 2 + 3 + import ( 4 + "bytes" 5 + "html/template" 6 + "testing" 7 + "time" 8 + 9 + "github.com/ptdewey/shutter" 10 + 11 + "arabica/internal/atproto" 12 + "arabica/internal/feed" 13 + "arabica/internal/models" 14 + ) 15 + 16 + // Helper functions for creating test data 17 + 18 + func mockProfile(handle string, displayName string, avatar string) *atproto.Profile { 19 + var dn *string 20 + if displayName != "" { 21 + dn = &displayName 22 + } 23 + var av *string 24 + if avatar != "" { 25 + av = &avatar 26 + } 27 + return &atproto.Profile{ 28 + DID: "did:plc:" + handle, 29 + Handle: handle, 30 + DisplayName: dn, 31 + Avatar: av, 32 + } 33 + } 34 + 35 + func mockBrew(beanName string, roasterName string, rating int) *models.Brew { 36 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 37 + brew := &models.Brew{ 38 + RKey: "brew123", 39 + BeanRKey: "bean123", 40 + CreatedAt: testTime, 41 + Rating: rating, 42 + Temperature: 93.0, 43 + WaterAmount: 250, 44 + CoffeeAmount: 16, 45 + TimeSeconds: 180, 46 + GrindSize: "Medium-fine", 47 + Method: "V60", 48 + TastingNotes: "Bright citrus notes with floral aroma", 49 + } 50 + 51 + if beanName != "" { 52 + brew.Bean = &models.Bean{ 53 + RKey: "bean123", 54 + Name: beanName, 55 + Origin: "Ethiopia", 56 + RoastLevel: "Light", 57 + Process: "Washed", 58 + CreatedAt: testTime, 59 + } 60 + if roasterName != "" { 61 + brew.Bean.Roaster = &models.Roaster{ 62 + RKey: "roaster123", 63 + Name: roasterName, 64 + Location: "Portland, OR", 65 + Website: "https://example.com", 66 + CreatedAt: testTime, 67 + } 68 + } 69 + } 70 + 71 + brew.GrinderObj = &models.Grinder{ 72 + RKey: "grinder123", 73 + Name: "1Zpresso JX-Pro", 74 + GrinderType: "Hand", 75 + BurrType: "Conical", 76 + CreatedAt: testTime, 77 + } 78 + 79 + brew.BrewerObj = &models.Brewer{ 80 + RKey: "brewer123", 81 + Name: "Hario V60", 82 + BrewerType: "Pour Over", 83 + Description: "Ceramic dripper", 84 + CreatedAt: testTime, 85 + } 86 + 87 + brew.Pours = []*models.Pour{ 88 + {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30, CreatedAt: testTime}, 89 + {PourNumber: 2, WaterAmount: 100, TimeSeconds: 45, CreatedAt: testTime}, 90 + {PourNumber: 3, WaterAmount: 100, TimeSeconds: 60, CreatedAt: testTime}, 91 + } 92 + 93 + return brew 94 + } 95 + 96 + func mockBean(name string, origin string, hasRoaster bool) *models.Bean { 97 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 98 + bean := &models.Bean{ 99 + RKey: "bean456", 100 + Name: name, 101 + Origin: origin, 102 + RoastLevel: "Medium", 103 + Process: "Natural", 104 + Description: "Sweet and fruity with notes of blueberry", 105 + CreatedAt: testTime, 106 + } 107 + 108 + if hasRoaster { 109 + bean.Roaster = &models.Roaster{ 110 + RKey: "roaster456", 111 + Name: "Onyx Coffee Lab", 112 + Location: "Bentonville, AR", 113 + Website: "https://onyxcoffeelab.com", 114 + CreatedAt: testTime, 115 + } 116 + } 117 + 118 + return bean 119 + } 120 + 121 + func mockRoaster() *models.Roaster { 122 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 123 + return &models.Roaster{ 124 + RKey: "roaster789", 125 + Name: "Heart Coffee Roasters", 126 + Location: "Portland, OR", 127 + Website: "https://heartroasters.com", 128 + CreatedAt: testTime, 129 + } 130 + } 131 + 132 + func mockGrinder() *models.Grinder { 133 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 134 + return &models.Grinder{ 135 + RKey: "grinder789", 136 + Name: "Comandante C40", 137 + GrinderType: "Hand", 138 + BurrType: "Conical", 139 + Notes: "Excellent for pour over", 140 + CreatedAt: testTime, 141 + } 142 + } 143 + 144 + func mockBrewer() *models.Brewer { 145 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 146 + return &models.Brewer{ 147 + RKey: "brewer789", 148 + Name: "Kalita Wave 185", 149 + BrewerType: "Pour Over", 150 + Description: "Flat-bottom dripper with wave filters", 151 + CreatedAt: testTime, 152 + } 153 + } 154 + 155 + // Template execution helper 156 + func execFeedTemplate(feedItems []*feed.FeedItem, isAuthenticated bool) (string, error) { 157 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 158 + tmpl, err := tmpl.ParseFiles("../../templates/partials/feed.tmpl") 159 + if err != nil { 160 + return "", err 161 + } 162 + 163 + data := map[string]interface{}{ 164 + "FeedItems": feedItems, 165 + "IsAuthenticated": isAuthenticated, 166 + } 167 + 168 + var buf bytes.Buffer 169 + err = tmpl.ExecuteTemplate(&buf, "feed", data) 170 + if err != nil { 171 + return "", err 172 + } 173 + 174 + return buf.String(), nil 175 + } 176 + 177 + // Test individual record types 178 + 179 + func TestFeedTemplate_BrewItem_Snapshot(t *testing.T) { 180 + tests := []struct { 181 + name string 182 + feedItem *feed.FeedItem 183 + }{ 184 + { 185 + name: "complete brew with all fields", 186 + feedItem: &feed.FeedItem{ 187 + RecordType: "brew", 188 + Action: "☕ added a new brew", 189 + Brew: mockBrew("Ethiopian Yirgacheffe", "Onyx Coffee Lab", 9), 190 + Author: mockProfile("coffee.lover", "Coffee Enthusiast", "https://cdn.bsky.app/avatar.jpg"), 191 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 192 + TimeAgo: "2 hours ago", 193 + }, 194 + }, 195 + { 196 + name: "brew with minimal data", 197 + feedItem: &feed.FeedItem{ 198 + RecordType: "brew", 199 + Action: "☕ added a new brew", 200 + Brew: &models.Brew{ 201 + RKey: "brew456", 202 + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 203 + Rating: 0, // no rating 204 + Bean: &models.Bean{ 205 + Name: "House Blend", 206 + }, 207 + }, 208 + Author: mockProfile("newbie", "", ""), 209 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 210 + TimeAgo: "1 minute ago", 211 + }, 212 + }, 213 + { 214 + name: "brew with unicode bean name", 215 + feedItem: &feed.FeedItem{ 216 + RecordType: "brew", 217 + Action: "☕ added a new brew", 218 + Brew: &models.Brew{ 219 + RKey: "brew789", 220 + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 221 + Rating: 8, 222 + Bean: &models.Bean{ 223 + Name: "コーヒー豆", 224 + Origin: "日本", 225 + }, 226 + }, 227 + Author: mockProfile("japan.coffee", "日本のコーヒー", ""), 228 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 229 + TimeAgo: "3 hours ago", 230 + }, 231 + }, 232 + } 233 + 234 + for _, tt := range tests { 235 + t.Run(tt.name, func(t *testing.T) { 236 + result, err := execFeedTemplate([]*feed.FeedItem{tt.feedItem}, true) 237 + if err != nil { 238 + t.Fatalf("Failed to execute template: %v", err) 239 + } 240 + shutter.Snap(t, tt.name, result) 241 + }) 242 + } 243 + } 244 + 245 + func TestFeedTemplate_BeanItem_Snapshot(t *testing.T) { 246 + tests := []struct { 247 + name string 248 + feedItem *feed.FeedItem 249 + }{ 250 + { 251 + name: "bean with roaster", 252 + feedItem: &feed.FeedItem{ 253 + RecordType: "bean", 254 + Action: "🫘 added a new bean", 255 + Bean: mockBean("Kenya AA", "Kenya", true), 256 + Author: mockProfile("roaster.pro", "Pro Roaster", ""), 257 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 258 + TimeAgo: "5 minutes ago", 259 + }, 260 + }, 261 + { 262 + name: "bean without roaster", 263 + feedItem: &feed.FeedItem{ 264 + RecordType: "bean", 265 + Action: "🫘 added a new bean", 266 + Bean: mockBean("Colombian Supremo", "Colombia", false), 267 + Author: mockProfile("homebrewer", "Home Brewer", ""), 268 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 269 + TimeAgo: "1 day ago", 270 + }, 271 + }, 272 + } 273 + 274 + for _, tt := range tests { 275 + t.Run(tt.name, func(t *testing.T) { 276 + result, err := execFeedTemplate([]*feed.FeedItem{tt.feedItem}, true) 277 + if err != nil { 278 + t.Fatalf("Failed to execute template: %v", err) 279 + } 280 + shutter.Snap(t, tt.name, result) 281 + }) 282 + } 283 + } 284 + 285 + func TestFeedTemplate_RoasterItem_Snapshot(t *testing.T) { 286 + feedItem := &feed.FeedItem{ 287 + RecordType: "roaster", 288 + Action: "🏪 added a new roaster", 289 + Roaster: mockRoaster(), 290 + Author: mockProfile("roastmaster", "Roast Master", "https://cdn.bsky.app/avatar2.jpg"), 291 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 292 + TimeAgo: "10 minutes ago", 293 + } 294 + 295 + result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 296 + if err != nil { 297 + t.Fatalf("Failed to execute template: %v", err) 298 + } 299 + shutter.Snap(t, "roaster item", result) 300 + } 301 + 302 + func TestFeedTemplate_GrinderItem_Snapshot(t *testing.T) { 303 + feedItem := &feed.FeedItem{ 304 + RecordType: "grinder", 305 + Action: "⚙️ added a new grinder", 306 + Grinder: mockGrinder(), 307 + Author: mockProfile("gearhead", "Coffee Gear Head", ""), 308 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 309 + TimeAgo: "30 minutes ago", 310 + } 311 + 312 + result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 313 + if err != nil { 314 + t.Fatalf("Failed to execute template: %v", err) 315 + } 316 + shutter.Snap(t, "grinder item", result) 317 + } 318 + 319 + func TestFeedTemplate_BrewerItem_Snapshot(t *testing.T) { 320 + feedItem := &feed.FeedItem{ 321 + RecordType: "brewer", 322 + Action: "☕ added a new brewer", 323 + Brewer: mockBrewer(), 324 + Author: mockProfile("pourover.fan", "Pour Over Fan", ""), 325 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 326 + TimeAgo: "2 days ago", 327 + } 328 + 329 + result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 330 + if err != nil { 331 + t.Fatalf("Failed to execute template: %v", err) 332 + } 333 + shutter.Snap(t, "brewer item", result) 334 + } 335 + 336 + // Test mixed feeds and edge cases 337 + 338 + func TestFeedTemplate_MixedFeed_Snapshot(t *testing.T) { 339 + feedItems := []*feed.FeedItem{ 340 + { 341 + RecordType: "brew", 342 + Action: "☕ added a new brew", 343 + Brew: mockBrew("Ethiopian Yirgacheffe", "Onyx", 9), 344 + Author: mockProfile("user1", "User One", ""), 345 + Timestamp: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), 346 + TimeAgo: "1 hour ago", 347 + }, 348 + { 349 + RecordType: "bean", 350 + Action: "🫘 added a new bean", 351 + Bean: mockBean("Kenya AA", "Kenya", true), 352 + Author: mockProfile("user2", "User Two", "https://cdn.bsky.app/avatar.jpg"), 353 + Timestamp: time.Date(2024, 1, 15, 11, 30, 0, 0, time.UTC), 354 + TimeAgo: "1.5 hours ago", 355 + }, 356 + { 357 + RecordType: "roaster", 358 + Action: "🏪 added a new roaster", 359 + Roaster: mockRoaster(), 360 + Author: mockProfile("user3", "User Three", ""), 361 + Timestamp: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), 362 + TimeAgo: "2 hours ago", 363 + }, 364 + { 365 + RecordType: "grinder", 366 + Action: "⚙️ added a new grinder", 367 + Grinder: mockGrinder(), 368 + Author: mockProfile("user4", "", ""), 369 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 370 + TimeAgo: "2.5 hours ago", 371 + }, 372 + { 373 + RecordType: "brewer", 374 + Action: "☕ added a new brewer", 375 + Brewer: mockBrewer(), 376 + Author: mockProfile("user5", "User Five", ""), 377 + Timestamp: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), 378 + TimeAgo: "3 hours ago", 379 + }, 380 + } 381 + 382 + result, err := execFeedTemplate(feedItems, true) 383 + if err != nil { 384 + t.Fatalf("Failed to execute template: %v", err) 385 + } 386 + shutter.Snap(t, "mixed feed all types", result) 387 + } 388 + 389 + func TestFeedTemplate_EmptyFeed_Snapshot(t *testing.T) { 390 + tests := []struct { 391 + name string 392 + feedItems []*feed.FeedItem 393 + isAuthenticated bool 394 + }{ 395 + { 396 + name: "empty feed authenticated", 397 + feedItems: []*feed.FeedItem{}, 398 + isAuthenticated: true, 399 + }, 400 + { 401 + name: "empty feed unauthenticated", 402 + feedItems: []*feed.FeedItem{}, 403 + isAuthenticated: false, 404 + }, 405 + { 406 + name: "nil feed", 407 + feedItems: nil, 408 + isAuthenticated: true, 409 + }, 410 + } 411 + 412 + for _, tt := range tests { 413 + t.Run(tt.name, func(t *testing.T) { 414 + result, err := execFeedTemplate(tt.feedItems, tt.isAuthenticated) 415 + if err != nil { 416 + t.Fatalf("Failed to execute template: %v", err) 417 + } 418 + shutter.Snap(t, tt.name, result) 419 + }) 420 + } 421 + } 422 + 423 + // Test security (URL sanitization) 424 + 425 + func TestFeedTemplate_SecurityURLs_Snapshot(t *testing.T) { 426 + tests := []struct { 427 + name string 428 + feedItem *feed.FeedItem 429 + }{ 430 + { 431 + name: "roaster with unsafe website URL", 432 + feedItem: &feed.FeedItem{ 433 + RecordType: "roaster", 434 + Action: "🏪 added a new roaster", 435 + Roaster: &models.Roaster{ 436 + RKey: "roaster999", 437 + Name: "Sketchy Roaster", 438 + Website: "javascript:alert('xss')", // Should be sanitized 439 + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 440 + }, 441 + Author: mockProfile("hacker", "Hacker", ""), 442 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 443 + TimeAgo: "1 minute ago", 444 + }, 445 + }, 446 + { 447 + name: "profile with unsafe avatar URL", 448 + feedItem: &feed.FeedItem{ 449 + RecordType: "bean", 450 + Action: "🫘 added a new bean", 451 + Bean: mockBean("Test Bean", "Test Origin", false), 452 + Author: mockProfile("badavatar", "Bad Avatar", "javascript:alert('xss')"), // Should be sanitized 453 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 454 + TimeAgo: "2 minutes ago", 455 + }, 456 + }, 457 + } 458 + 459 + for _, tt := range tests { 460 + t.Run(tt.name, func(t *testing.T) { 461 + result, err := execFeedTemplate([]*feed.FeedItem{tt.feedItem}, true) 462 + if err != nil { 463 + t.Fatalf("Failed to execute template: %v", err) 464 + } 465 + shutter.Snap(t, tt.name, result) 466 + }) 467 + } 468 + } 469 + 470 + // Test special characters 471 + 472 + func TestFeedTemplate_SpecialCharacters_Snapshot(t *testing.T) { 473 + feedItem := &feed.FeedItem{ 474 + RecordType: "brew", 475 + Action: "☕ added a new brew", 476 + Brew: &models.Brew{ 477 + RKey: "brew999", 478 + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 479 + Rating: 8, 480 + TastingNotes: "Notes with \"quotes\" and <html>tags</html> and 'single quotes'", 481 + Bean: &models.Bean{ 482 + Name: "Bean with & ampersand", 483 + Description: "Description with <script>alert('xss')</script>", 484 + }, 485 + }, 486 + Author: mockProfile("special.chars", "User & Co.", ""), 487 + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 488 + TimeAgo: "5 seconds ago", 489 + } 490 + 491 + result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 492 + if err != nil { 493 + t.Fatalf("Failed to execute template: %v", err) 494 + } 495 + shutter.Snap(t, "special characters in content", result) 496 + }
+18
internal/bff/helpers.go
··· 246 246 s = strings.ReplaceAll(s, "\t", "\\t") 247 247 return s 248 248 } 249 + 250 + // Dict creates a map from alternating key-value arguments. 251 + // Useful for passing multiple parameters to sub-templates in Go templates. 252 + // Example: {{template "foo" dict "Key1" .Value1 "Key2" .Value2}} 253 + func Dict(values ...interface{}) (map[string]interface{}, error) { 254 + if len(values)%2 != 0 { 255 + return nil, fmt.Errorf("dict requires an even number of arguments") 256 + } 257 + dict := make(map[string]interface{}, len(values)/2) 258 + for i := 0; i < len(values); i += 2 { 259 + key, ok := values[i].(string) 260 + if !ok { 261 + return nil, fmt.Errorf("dict keys must be strings") 262 + } 263 + dict[key] = values[i+1] 264 + } 265 + return dict, nil 266 + }
+331
internal/bff/profile_template_snapshot_test.go
··· 1 + package bff 2 + 3 + import ( 4 + "bytes" 5 + "html/template" 6 + "testing" 7 + "time" 8 + 9 + "github.com/ptdewey/shutter" 10 + 11 + "arabica/internal/models" 12 + ) 13 + 14 + // Test profile content partial rendering 15 + 16 + func TestProfileContent_BeansTab_Snapshot(t *testing.T) { 17 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 18 + tmpl, err := tmpl.ParseFiles( 19 + "../../templates/partials/profile_content.tmpl", 20 + "../../templates/partials/brew_list_content.tmpl", 21 + ) 22 + if err != nil { 23 + t.Fatalf("Failed to parse template: %v", err) 24 + } 25 + 26 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 27 + 28 + tests := []struct { 29 + name string 30 + data map[string]interface{} 31 + }{ 32 + { 33 + name: "profile with multiple beans", 34 + data: map[string]interface{}{ 35 + "Beans": []*models.Bean{ 36 + { 37 + RKey: "bean1", 38 + Name: "Ethiopian Yirgacheffe", 39 + Origin: "Ethiopia", 40 + RoastLevel: "Light", 41 + Process: "Washed", 42 + Description: "Bright and floral with citrus notes", 43 + Roaster: &models.Roaster{ 44 + RKey: "roaster1", 45 + Name: "Onyx Coffee Lab", 46 + Location: "Arkansas", 47 + Website: "https://onyxcoffeelab.com", 48 + }, 49 + CreatedAt: testTime, 50 + }, 51 + { 52 + RKey: "bean2", 53 + Name: "Colombia Supremo", 54 + Origin: "Colombia", 55 + RoastLevel: "Medium", 56 + Process: "Natural", 57 + Description: "", 58 + CreatedAt: testTime, 59 + }, 60 + }, 61 + "Roasters": []*models.Roaster{}, 62 + "Grinders": []*models.Grinder{}, 63 + "Brewers": []*models.Brewer{}, 64 + "Brews": []*models.Brew{}, 65 + "IsOwnProfile": true, 66 + }, 67 + }, 68 + { 69 + name: "profile with empty beans", 70 + data: map[string]interface{}{ 71 + "Beans": []*models.Bean{}, 72 + "Roasters": []*models.Roaster{}, 73 + "Grinders": []*models.Grinder{}, 74 + "Brewers": []*models.Brewer{}, 75 + "Brews": []*models.Brew{}, 76 + "IsOwnProfile": false, 77 + }, 78 + }, 79 + { 80 + name: "bean with missing optional fields", 81 + data: map[string]interface{}{ 82 + "Beans": []*models.Bean{ 83 + { 84 + RKey: "bean3", 85 + Name: "Mystery Bean", 86 + CreatedAt: testTime, 87 + }, 88 + }, 89 + "Roasters": []*models.Roaster{}, 90 + "Grinders": []*models.Grinder{}, 91 + "Brewers": []*models.Brewer{}, 92 + "Brews": []*models.Brew{}, 93 + "IsOwnProfile": true, 94 + }, 95 + }, 96 + } 97 + 98 + for _, tt := range tests { 99 + t.Run(tt.name, func(t *testing.T) { 100 + var buf bytes.Buffer 101 + err := tmpl.ExecuteTemplate(&buf, "profile_content", tt.data) 102 + if err != nil { 103 + t.Fatalf("Failed to execute template: %v", err) 104 + } 105 + shutter.Snap(t, tt.name, buf.String()) 106 + }) 107 + } 108 + } 109 + 110 + func TestProfileContent_GearTabs_Snapshot(t *testing.T) { 111 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 112 + tmpl, err := tmpl.ParseFiles( 113 + "../../templates/partials/profile_content.tmpl", 114 + "../../templates/partials/brew_list_content.tmpl", 115 + ) 116 + if err != nil { 117 + t.Fatalf("Failed to parse template: %v", err) 118 + } 119 + 120 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 121 + 122 + data := map[string]interface{}{ 123 + "Beans": []*models.Bean{}, 124 + "Roasters": []*models.Roaster{ 125 + { 126 + RKey: "roaster1", 127 + Name: "Heart Coffee", 128 + Location: "Portland, OR", 129 + Website: "https://heartroasters.com", 130 + CreatedAt: testTime, 131 + }, 132 + }, 133 + "Grinders": []*models.Grinder{ 134 + { 135 + RKey: "grinder1", 136 + Name: "Comandante C40", 137 + GrinderType: "Hand", 138 + BurrType: "Conical", 139 + Notes: "Perfect for pour over", 140 + CreatedAt: testTime, 141 + }, 142 + { 143 + RKey: "grinder2", 144 + Name: "Niche Zero", 145 + GrinderType: "Electric", 146 + BurrType: "Conical", 147 + CreatedAt: testTime, 148 + }, 149 + }, 150 + "Brewers": []*models.Brewer{ 151 + { 152 + RKey: "brewer1", 153 + Name: "Hario V60", 154 + BrewerType: "Pour Over", 155 + Description: "Classic pour over cone", 156 + CreatedAt: testTime, 157 + }, 158 + }, 159 + "Brews": []*models.Brew{}, 160 + "IsOwnProfile": true, 161 + } 162 + 163 + var buf bytes.Buffer 164 + err = tmpl.ExecuteTemplate(&buf, "profile_content", data) 165 + if err != nil { 166 + t.Fatalf("Failed to execute template: %v", err) 167 + } 168 + shutter.Snap(t, "profile with gear collection", buf.String()) 169 + } 170 + 171 + func TestProfileContent_URLSecurity_Snapshot(t *testing.T) { 172 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 173 + tmpl, err := tmpl.ParseFiles( 174 + "../../templates/partials/profile_content.tmpl", 175 + "../../templates/partials/brew_list_content.tmpl", 176 + ) 177 + if err != nil { 178 + t.Fatalf("Failed to parse template: %v", err) 179 + } 180 + 181 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 182 + 183 + tests := []struct { 184 + name string 185 + data map[string]interface{} 186 + }{ 187 + { 188 + name: "profile roaster with unsafe website URL", 189 + data: map[string]interface{}{ 190 + "Beans": []*models.Bean{}, 191 + "Roasters": []*models.Roaster{ 192 + { 193 + RKey: "roaster1", 194 + Name: "Sketchy Roaster", 195 + Location: "Unknown", 196 + Website: "javascript:alert('xss')", // Should be sanitized 197 + CreatedAt: testTime, 198 + }, 199 + }, 200 + "Grinders": []*models.Grinder{}, 201 + "Brewers": []*models.Brewer{}, 202 + "Brews": []*models.Brew{}, 203 + "IsOwnProfile": false, 204 + }, 205 + }, 206 + { 207 + name: "profile roaster with invalid URL protocol", 208 + data: map[string]interface{}{ 209 + "Beans": []*models.Bean{}, 210 + "Roasters": []*models.Roaster{ 211 + { 212 + RKey: "roaster2", 213 + Name: "FTP Roaster", 214 + Website: "ftp://example.com", // Should be rejected 215 + CreatedAt: testTime, 216 + }, 217 + }, 218 + "Grinders": []*models.Grinder{}, 219 + "Brewers": []*models.Brewer{}, 220 + "Brews": []*models.Brew{}, 221 + "IsOwnProfile": false, 222 + }, 223 + }, 224 + } 225 + 226 + for _, tt := range tests { 227 + t.Run(tt.name, func(t *testing.T) { 228 + var buf bytes.Buffer 229 + err := tmpl.ExecuteTemplate(&buf, "profile_content", tt.data) 230 + if err != nil { 231 + t.Fatalf("Failed to execute template: %v", err) 232 + } 233 + shutter.Snap(t, tt.name, buf.String()) 234 + }) 235 + } 236 + } 237 + 238 + func TestProfileContent_SpecialCharacters_Snapshot(t *testing.T) { 239 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 240 + tmpl, err := tmpl.ParseFiles( 241 + "../../templates/partials/profile_content.tmpl", 242 + "../../templates/partials/brew_list_content.tmpl", 243 + ) 244 + if err != nil { 245 + t.Fatalf("Failed to parse template: %v", err) 246 + } 247 + 248 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 249 + 250 + data := map[string]interface{}{ 251 + "Beans": []*models.Bean{ 252 + { 253 + RKey: "bean1", 254 + Name: "Bean with <html> & \"quotes\"", 255 + Origin: "Colombia & Peru", 256 + Description: "Description with 'single' and \"double\" quotes", 257 + CreatedAt: testTime, 258 + }, 259 + }, 260 + "Roasters": []*models.Roaster{}, 261 + "Grinders": []*models.Grinder{ 262 + { 263 + RKey: "grinder1", 264 + Name: "Grinder & Co.", 265 + Notes: "Notes with <script>alert('xss')</script>", 266 + CreatedAt: testTime, 267 + }, 268 + }, 269 + "Brewers": []*models.Brewer{}, 270 + "Brews": []*models.Brew{}, 271 + "IsOwnProfile": true, 272 + } 273 + 274 + var buf bytes.Buffer 275 + err = tmpl.ExecuteTemplate(&buf, "profile_content", data) 276 + if err != nil { 277 + t.Fatalf("Failed to execute template: %v", err) 278 + } 279 + shutter.Snap(t, "profile with special characters", buf.String()) 280 + } 281 + 282 + func TestProfileContent_Unicode_Snapshot(t *testing.T) { 283 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 284 + tmpl, err := tmpl.ParseFiles( 285 + "../../templates/partials/profile_content.tmpl", 286 + "../../templates/partials/brew_list_content.tmpl", 287 + ) 288 + if err != nil { 289 + t.Fatalf("Failed to parse template: %v", err) 290 + } 291 + 292 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 293 + 294 + data := map[string]interface{}{ 295 + "Beans": []*models.Bean{ 296 + { 297 + RKey: "bean1", 298 + Name: "エチオピア イルガチェフェ", // Japanese 299 + Origin: "日本", 300 + Description: "明るく花のような香り", 301 + CreatedAt: testTime, 302 + }, 303 + { 304 + RKey: "bean2", 305 + Name: "Café de Colombia", 306 + Origin: "Bogotá", 307 + Description: "Suave y aromático", 308 + CreatedAt: testTime, 309 + }, 310 + }, 311 + "Roasters": []*models.Roaster{ 312 + { 313 + RKey: "roaster1", 314 + Name: "Кофейня Москва", // Russian 315 + Location: "Москва, Россия", 316 + CreatedAt: testTime, 317 + }, 318 + }, 319 + "Grinders": []*models.Grinder{}, 320 + "Brewers": []*models.Brewer{}, 321 + "Brews": []*models.Brew{}, 322 + "IsOwnProfile": false, 323 + } 324 + 325 + var buf bytes.Buffer 326 + err = tmpl.ExecuteTemplate(&buf, "profile_content", data) 327 + if err != nil { 328 + t.Fatalf("Failed to execute template: %v", err) 329 + } 330 + shutter.Snap(t, "profile with unicode content", buf.String()) 331 + }
+13
internal/bff/render.go
··· 38 38 "safeAvatarURL": SafeAvatarURL, 39 39 "safeWebsiteURL": SafeWebsiteURL, 40 40 "escapeJS": EscapeJS, 41 + "dict": Dict, 41 42 } 42 43 }) 43 44 return templateFuncs ··· 79 80 return nil, err 80 81 } 81 82 83 + // Parse card templates 84 + t, err = t.ParseGlob(dir + "/partials/cards/*.tmpl") 85 + if err != nil { 86 + return nil, err 87 + } 88 + 82 89 // Parse the specific page template 83 90 t, err = t.ParseFiles(dir + "/" + pageName) 84 91 if err != nil { ··· 95 102 96 103 // Parse all partials 97 104 t, err := t.ParseGlob(dir + "/partials/*.tmpl") 105 + if err != nil { 106 + return nil, err 107 + } 108 + 109 + // Parse card templates 110 + t, err = t.ParseGlob(dir + "/partials/cards/*.tmpl") 98 111 if err != nil { 99 112 return nil, err 100 113 }
+552
internal/bff/render_snapshot_test.go
··· 1 + package bff 2 + 3 + import ( 4 + "bytes" 5 + "html/template" 6 + "testing" 7 + "time" 8 + 9 + "github.com/ptdewey/shutter" 10 + 11 + "arabica/internal/models" 12 + ) 13 + 14 + func TestDict_Snapshot(t *testing.T) { 15 + tests := []struct { 16 + name string 17 + args []interface{} 18 + }{ 19 + { 20 + name: "empty dict", 21 + args: []interface{}{}, 22 + }, 23 + { 24 + name: "single key-value", 25 + args: []interface{}{"key1", "value1"}, 26 + }, 27 + { 28 + name: "multiple key-values", 29 + args: []interface{}{"name", "Ethiopian", "roast", "Light", "rating", 9}, 30 + }, 31 + { 32 + name: "nested values", 33 + args: []interface{}{ 34 + "bean", map[string]interface{}{"name": "Ethiopian", "origin": "Yirgacheffe"}, 35 + "brew", map[string]interface{}{"method": "V60", "temp": 93.0}, 36 + }, 37 + }, 38 + } 39 + 40 + for _, tt := range tests { 41 + t.Run(tt.name, func(t *testing.T) { 42 + result, err := Dict(tt.args...) 43 + if err != nil { 44 + shutter.Snap(t, tt.name+"_error", err.Error()) 45 + } else { 46 + shutter.Snap(t, tt.name, result) 47 + } 48 + }) 49 + } 50 + } 51 + 52 + func TestDict_ErrorCases_Snapshot(t *testing.T) { 53 + tests := []struct { 54 + name string 55 + args []interface{} 56 + }{ 57 + { 58 + name: "odd number of arguments", 59 + args: []interface{}{"key1", "value1", "key2"}, 60 + }, 61 + { 62 + name: "non-string key", 63 + args: []interface{}{123, "value1"}, 64 + }, 65 + { 66 + name: "mixed valid and invalid", 67 + args: []interface{}{"key1", "value1", 456, "value2"}, 68 + }, 69 + } 70 + 71 + for _, tt := range tests { 72 + t.Run(tt.name, func(t *testing.T) { 73 + _, err := Dict(tt.args...) 74 + if err != nil { 75 + shutter.Snap(t, tt.name, err.Error()) 76 + } else { 77 + shutter.Snap(t, tt.name, "no error") 78 + } 79 + }) 80 + } 81 + } 82 + 83 + func TestTemplateRendering_BeanCard_Snapshot(t *testing.T) { 84 + // Create a minimal template with the bean_card template 85 + tmplStr := `{{define "bean_card"}} 86 + <div class="bean-card"> 87 + <h3>{{.Bean.Name}}</h3> 88 + {{if .Bean.Origin}}<p>Origin: {{.Bean.Origin}}</p>{{end}} 89 + {{if .Bean.RoastLevel}}<p>Roast: {{.Bean.RoastLevel}}</p>{{end}} 90 + </div> 91 + {{end}}` 92 + 93 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 94 + tmpl, err := tmpl.Parse(tmplStr) 95 + if err != nil { 96 + t.Fatalf("Failed to parse template: %v", err) 97 + } 98 + 99 + tests := []struct { 100 + name string 101 + data map[string]interface{} 102 + }{ 103 + { 104 + name: "full bean data", 105 + data: map[string]interface{}{ 106 + "Bean": &models.Bean{ 107 + Name: "Ethiopian Yirgacheffe", 108 + Origin: "Ethiopia", 109 + RoastLevel: "Light", 110 + Process: "Washed", 111 + }, 112 + "IsOwnProfile": true, 113 + }, 114 + }, 115 + { 116 + name: "minimal bean data", 117 + data: map[string]interface{}{ 118 + "Bean": &models.Bean{ 119 + Name: "House Blend", 120 + }, 121 + "IsOwnProfile": false, 122 + }, 123 + }, 124 + { 125 + name: "bean with only origin", 126 + data: map[string]interface{}{ 127 + "Bean": &models.Bean{ 128 + Origin: "Colombia", 129 + }, 130 + "IsOwnProfile": true, 131 + }, 132 + }, 133 + } 134 + 135 + for _, tt := range tests { 136 + t.Run(tt.name, func(t *testing.T) { 137 + var buf bytes.Buffer 138 + err := tmpl.ExecuteTemplate(&buf, "bean_card", tt.data) 139 + if err != nil { 140 + t.Fatalf("Failed to execute template: %v", err) 141 + } 142 + shutter.Snap(t, tt.name, buf.String()) 143 + }) 144 + } 145 + } 146 + 147 + func TestTemplateRendering_BrewCard_Snapshot(t *testing.T) { 148 + // Simplified brew card template for testing 149 + tmplStr := `{{define "brew_card"}} 150 + <div class="brew-card"> 151 + <div class="date">{{.Brew.CreatedAt.Format "Jan 2, 2006"}}</div> 152 + {{if .Brew.Bean}} 153 + <div class="bean">{{.Brew.Bean.Name}}</div> 154 + {{end}} 155 + {{if hasValue .Brew.Rating}} 156 + <div class="rating">{{formatRating .Brew.Rating}}</div> 157 + {{end}} 158 + {{if .Brew.TastingNotes}} 159 + <div class="notes">{{.Brew.TastingNotes}}</div> 160 + {{end}} 161 + </div> 162 + {{end}}` 163 + 164 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 165 + tmpl, err := tmpl.Parse(tmplStr) 166 + if err != nil { 167 + t.Fatalf("Failed to parse template: %v", err) 168 + } 169 + 170 + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 171 + 172 + tests := []struct { 173 + name string 174 + data map[string]interface{} 175 + }{ 176 + { 177 + name: "complete brew", 178 + data: map[string]interface{}{ 179 + "Brew": &models.Brew{ 180 + CreatedAt: testTime, 181 + Bean: &models.Bean{ 182 + Name: "Ethiopian Yirgacheffe", 183 + Origin: "Ethiopia", 184 + }, 185 + Rating: 9, 186 + TastingNotes: "Bright citrus notes with floral aroma", 187 + }, 188 + "IsOwnProfile": true, 189 + }, 190 + }, 191 + { 192 + name: "minimal brew", 193 + data: map[string]interface{}{ 194 + "Brew": &models.Brew{ 195 + CreatedAt: testTime, 196 + }, 197 + "IsOwnProfile": false, 198 + }, 199 + }, 200 + { 201 + name: "brew with zero rating", 202 + data: map[string]interface{}{ 203 + "Brew": &models.Brew{ 204 + CreatedAt: testTime, 205 + Bean: &models.Bean{ 206 + Name: "House Blend", 207 + }, 208 + Rating: 0, 209 + }, 210 + "IsOwnProfile": true, 211 + }, 212 + }, 213 + } 214 + 215 + for _, tt := range tests { 216 + t.Run(tt.name, func(t *testing.T) { 217 + var buf bytes.Buffer 218 + err := tmpl.ExecuteTemplate(&buf, "brew_card", tt.data) 219 + if err != nil { 220 + t.Fatalf("Failed to execute template: %v", err) 221 + } 222 + shutter.Snap(t, tt.name, buf.String()) 223 + }) 224 + } 225 + } 226 + 227 + func TestTemplateRendering_GearCards_Snapshot(t *testing.T) { 228 + // Simplified grinder card template 229 + tmplStr := `{{define "grinder_card"}} 230 + <div class="grinder-card"> 231 + <h3>{{.Grinder.Name}}</h3> 232 + {{if .Grinder.GrinderType}}<p>Type: {{.Grinder.GrinderType}}</p>{{end}} 233 + {{if .Grinder.BurrType}}<p>Burr: {{.Grinder.BurrType}}</p>{{end}} 234 + </div> 235 + {{end}}` 236 + 237 + tmpl := template.New("test").Funcs(getTemplateFuncs()) 238 + tmpl, err := tmpl.Parse(tmplStr) 239 + if err != nil { 240 + t.Fatalf("Failed to parse template: %v", err) 241 + } 242 + 243 + tests := []struct { 244 + name string 245 + data map[string]interface{} 246 + }{ 247 + { 248 + name: "full grinder data", 249 + data: map[string]interface{}{ 250 + "Grinder": &models.Grinder{ 251 + Name: "1Zpresso JX-Pro", 252 + GrinderType: "Hand", 253 + BurrType: "Conical", 254 + }, 255 + "IsOwnProfile": true, 256 + }, 257 + }, 258 + { 259 + name: "minimal grinder data", 260 + data: map[string]interface{}{ 261 + "Grinder": &models.Grinder{ 262 + Name: "Generic Grinder", 263 + }, 264 + "IsOwnProfile": false, 265 + }, 266 + }, 267 + } 268 + 269 + for _, tt := range tests { 270 + t.Run(tt.name, func(t *testing.T) { 271 + var buf bytes.Buffer 272 + err := tmpl.ExecuteTemplate(&buf, "grinder_card", tt.data) 273 + if err != nil { 274 + t.Fatalf("Failed to execute template: %v", err) 275 + } 276 + shutter.Snap(t, tt.name, buf.String()) 277 + }) 278 + } 279 + } 280 + 281 + func TestFormatHelpers_Snapshot(t *testing.T) { 282 + t.Run("temperature formatting", func(t *testing.T) { 283 + temps := []float64{0, 20.5, 93.0, 100.0, 200.5, 212.0} 284 + results := make([]string, len(temps)) 285 + for i, temp := range temps { 286 + results[i] = FormatTemp(temp) 287 + } 288 + shutter.Snap(t, "temperature_formatting", results) 289 + }) 290 + 291 + t.Run("time formatting", func(t *testing.T) { 292 + times := []int{0, 15, 60, 90, 180, 245} 293 + results := make([]string, len(times)) 294 + for i, sec := range times { 295 + results[i] = FormatTime(sec) 296 + } 297 + shutter.Snap(t, "time_formatting", results) 298 + }) 299 + 300 + t.Run("rating formatting", func(t *testing.T) { 301 + ratings := []int{0, 1, 5, 7, 10} 302 + results := make([]string, len(ratings)) 303 + for i, rating := range ratings { 304 + results[i] = FormatRating(rating) 305 + } 306 + shutter.Snap(t, "rating_formatting", results) 307 + }) 308 + } 309 + 310 + func TestSafeURL_Snapshot(t *testing.T) { 311 + t.Run("avatar URLs", func(t *testing.T) { 312 + urls := []string{ 313 + "", 314 + "/static/icon-placeholder.svg", 315 + "https://cdn.bsky.app/avatar.jpg", 316 + "https://av-cdn.bsky.app/img/avatar/plain/did:plc:test/abc@jpeg", 317 + "http://cdn.bsky.app/avatar.jpg", 318 + "https://evil.com/xss.jpg", 319 + "/../../etc/passwd", 320 + "javascript:alert('xss')", 321 + } 322 + results := make([]string, len(urls)) 323 + for i, url := range urls { 324 + results[i] = SafeAvatarURL(url) 325 + } 326 + shutter.Snap(t, "avatar_urls", results) 327 + }) 328 + 329 + t.Run("website URLs", func(t *testing.T) { 330 + urls := []string{ 331 + "", 332 + "https://example.com", 333 + "http://example.com", 334 + "https://roastery.coffee/beans", 335 + "javascript:alert('xss')", 336 + "ftp://example.com", 337 + "https://", 338 + "example.com", 339 + } 340 + results := make([]string, len(urls)) 341 + for i, url := range urls { 342 + results[i] = SafeWebsiteURL(url) 343 + } 344 + shutter.Snap(t, "website_urls", results) 345 + }) 346 + } 347 + 348 + func TestEscapeJS_Snapshot(t *testing.T) { 349 + inputs := []string{ 350 + "simple string", 351 + "string with 'single quotes'", 352 + "string with \"double quotes\"", 353 + "line1\nline2", 354 + "tab\there", 355 + "backslash\\test", 356 + "mixed: 'quotes', \"quotes\", \n newlines \t tabs", 357 + "", 358 + } 359 + 360 + results := make([]string, len(inputs)) 361 + for i, input := range inputs { 362 + results[i] = EscapeJS(input) 363 + } 364 + 365 + shutter.Snap(t, "escape_js", results) 366 + } 367 + 368 + func TestPoursToJSON_Snapshot(t *testing.T) { 369 + tests := []struct { 370 + name string 371 + pours []*models.Pour 372 + }{ 373 + { 374 + name: "empty pours", 375 + pours: []*models.Pour{}, 376 + }, 377 + { 378 + name: "single pour", 379 + pours: []*models.Pour{ 380 + {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30}, 381 + }, 382 + }, 383 + { 384 + name: "multiple pours", 385 + pours: []*models.Pour{ 386 + {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30}, 387 + {PourNumber: 2, WaterAmount: 100, TimeSeconds: 45}, 388 + {PourNumber: 3, WaterAmount: 80, TimeSeconds: 60}, 389 + }, 390 + }, 391 + { 392 + name: "nil pours", 393 + pours: nil, 394 + }, 395 + } 396 + 397 + for _, tt := range tests { 398 + t.Run(tt.name, func(t *testing.T) { 399 + result := PoursToJSON(tt.pours) 400 + shutter.Snap(t, tt.name, result) 401 + }) 402 + } 403 + } 404 + 405 + func TestIterate_Snapshot(t *testing.T) { 406 + tests := []struct { 407 + name string 408 + count int 409 + }{ 410 + {name: "zero count", count: 0}, 411 + {name: "single iteration", count: 1}, 412 + {name: "five iterations", count: 5}, 413 + {name: "ten iterations", count: 10}, 414 + } 415 + 416 + for _, tt := range tests { 417 + t.Run(tt.name, func(t *testing.T) { 418 + result := Iterate(tt.count) 419 + shutter.Snap(t, tt.name, result) 420 + }) 421 + } 422 + } 423 + 424 + func TestIterateRemaining_Snapshot(t *testing.T) { 425 + tests := []struct { 426 + name string 427 + total int 428 + used int 429 + }{ 430 + {name: "all used", total: 10, used: 10}, 431 + {name: "none used", total: 10, used: 0}, 432 + {name: "half used", total: 10, used: 5}, 433 + {name: "rating 7 out of 10", total: 10, used: 7}, 434 + {name: "rating 3 out of 10", total: 10, used: 3}, 435 + } 436 + 437 + for _, tt := range tests { 438 + t.Run(tt.name, func(t *testing.T) { 439 + result := IterateRemaining(tt.total, tt.used) 440 + shutter.Snap(t, tt.name, result) 441 + }) 442 + } 443 + } 444 + 445 + func TestHasTemp_Snapshot(t *testing.T) { 446 + tests := []struct { 447 + name string 448 + temp float64 449 + }{ 450 + {name: "zero temperature", temp: 0}, 451 + {name: "positive temperature", temp: 93.0}, 452 + {name: "negative temperature", temp: -5.0}, 453 + {name: "small positive", temp: 0.1}, 454 + } 455 + 456 + for _, tt := range tests { 457 + t.Run(tt.name, func(t *testing.T) { 458 + result := HasTemp(tt.temp) 459 + shutter.Snap(t, tt.name, result) 460 + }) 461 + } 462 + } 463 + 464 + func TestHasValue_Snapshot(t *testing.T) { 465 + tests := []struct { 466 + name string 467 + value int 468 + }{ 469 + {name: "zero value", value: 0}, 470 + {name: "positive value", value: 5}, 471 + {name: "negative value", value: -3}, 472 + {name: "large value", value: 1000}, 473 + } 474 + 475 + for _, tt := range tests { 476 + t.Run(tt.name, func(t *testing.T) { 477 + result := HasValue(tt.value) 478 + shutter.Snap(t, tt.name, result) 479 + }) 480 + } 481 + } 482 + 483 + func TestPtrEquals_Snapshot(t *testing.T) { 484 + str1 := "test" 485 + str2 := "different" 486 + 487 + tests := []struct { 488 + name string 489 + ptr *string 490 + val string 491 + }{ 492 + {name: "nil pointer", ptr: nil, val: "test"}, 493 + {name: "equal values", ptr: &str1, val: "test"}, 494 + {name: "different values", ptr: &str1, val: str2}, 495 + {name: "pointer to empty vs empty", ptr: &([]string{""}[0]), val: ""}, 496 + } 497 + 498 + for _, tt := range tests { 499 + t.Run(tt.name, func(t *testing.T) { 500 + result := PtrEquals(tt.ptr, tt.val) 501 + shutter.Snap(t, tt.name, result) 502 + }) 503 + } 504 + } 505 + 506 + func TestPtrValue_Snapshot(t *testing.T) { 507 + str1 := "test value" 508 + num1 := 42 509 + 510 + tests := []struct { 511 + name string 512 + ptr interface{} 513 + }{ 514 + {name: "nil string pointer", ptr: (*string)(nil)}, 515 + {name: "valid string pointer", ptr: &str1}, 516 + {name: "nil int pointer", ptr: (*int)(nil)}, 517 + {name: "valid int pointer", ptr: &num1}, 518 + } 519 + 520 + for _, tt := range tests { 521 + t.Run(tt.name, func(t *testing.T) { 522 + var result interface{} 523 + switch v := tt.ptr.(type) { 524 + case *string: 525 + result = PtrValue(v) 526 + case *int: 527 + result = PtrValue(v) 528 + } 529 + shutter.Snap(t, tt.name, result) 530 + }) 531 + } 532 + } 533 + 534 + func TestFormatTempValue_Snapshot(t *testing.T) { 535 + tests := []struct { 536 + name string 537 + temp float64 538 + }{ 539 + {name: "zero temp", temp: 0}, 540 + {name: "celsius temp", temp: 93.0}, 541 + {name: "fahrenheit temp", temp: 205.0}, 542 + {name: "decimal celsius", temp: 92.5}, 543 + {name: "decimal fahrenheit", temp: 201.8}, 544 + } 545 + 546 + for _, tt := range tests { 547 + t.Run(tt.name, func(t *testing.T) { 548 + result := FormatTempValue(tt.temp) 549 + shutter.Snap(t, tt.name, result) 550 + }) 551 + } 552 + }
web/static/arabica-org.png

This is a binary file and will not be displayed.