test(seo): port social-unfurl tests to RSpec; add reverse-engineered spec

The MCP merge standardized the project on RSpec (rspec-rails, spec/). Port the Minitest suite added with the SEO work to RSpec request/model/service specs and remove test/, so there is one test framework. Specs use inline record creation to match the existing spec style. Also add .agents/specs/social-unfurls-and-seo.md: a reverse-engineered spec documenting the feature's routes, behavior, caching, privacy guards, acceptance criteria, and test map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 19:33 UTC c4b6a07959eeede20d6b63fabbc3353db75ea362
14 files changed +468 -283
.agents/specs/social-unfurls-and-seo.md new
+172
@@ -0,0 +1,172 @@
1 +# Spec: Social link unfurls and SEO for public repos
2 +
3 +Status: implemented (branch `feature/social-unfurls-seo`, merged to `development`).
4 +Reverse-engineered from the shipped code so the behavior is documented and
5 +testable. Scope is meta tags, Open Graph card images, and crawlability for
6 +public repositories.
7 +
8 +## Problem
9 +
10 +A pasted siGit repo link produced no preview card in Slack, X, LinkedIn, and
11 +Discord, and public repos were not cleanly indexable. Those are the channels
12 +where developer tools spread, so a shared link should render a preview card and
13 +leave a backlink.
14 +
15 +## Goals
16 +
17 +1. Every public repo page serves complete SEO and social meta tags in the
18 + initial server HTML, because crawlers do not run JavaScript.
19 +2. Each public repo has a generated 1200x630 preview image.
20 +3. Public repos are crawlable and listed in the sitemap; private and
21 + auth-gated pages are not, and leak nothing.
22 +
23 +## Non-goals
24 +
25 +Profile pages, stars/forks/explore/trending UI, full-text search, and
26 +analytics. This work is meta tags, card images, and crawlability only.
27 +
28 +## Rendering model
29 +
30 +The app is server-rendered Rails (ERB + Hotwire), so meta tags and a crawlable
31 +HTML body already appear in the initial response. No client-render workaround is
32 +needed.
33 +
34 +## Routes
35 +
36 +| Method | Path | Controller | Purpose |
37 +|--------|------|------------|---------|
38 +| GET | `/og.png` | `og_images#default` | Generic sitewide card (default `og:image`). |
39 +| GET | `/og/:username/:repository.png` | `og_images#show` | Per-repo card. Dotted names allowed (`Qwen2.5-GGUF`), `format: false`. |
40 +| GET | `/sitemap.xml` | `sitemaps#index` | Public repos plus their owners (pre-existing). |
41 +
42 +Both `/og` routes are declared before the `/:username` matcher so they are not
43 +read as profiles.
44 +
45 +## Behavior
46 +
47 +### Meta tags (all pages)
48 +
49 +Driven by `SeoHelper` and the `shared/_seo` partial. Defaults degrade so any
50 +page emits valid, non-empty tags. Pages override with `content_for`:
51 +`:title`, `:description`, `:og_image`, `:og_image_alt`, `:og_type`, `:robots`,
52 +`:structured_data`.
53 +
54 +Emitted: `<title>`, `meta description`, `link canonical` (path only, no query or
55 +fragment), Open Graph (`og:type`, `og:site_name` = `siGit`, `og:title`,
56 +`og:description`, `og:url`, `og:image`, `og:image:type`, `og:image:width` =
57 +1200, `og:image:height` = 630, `og:image:alt`, `og:locale`), Twitter
58 +(`summary_large_image`, title, description, image, image:alt), and JSON-LD
59 +(`Organization` + `WebSite` sitewide).
60 +
61 +### Public repo pages
62 +
63 +`repositories#show`, `#model`, `#tree`, `#cicd`, and `blobs#show` render
64 +`shared/_repository_social`, which sets the description, the per-repo
65 +`og:image` (`/og/:owner/:repo.png`), and the image alt text. The two canonical
66 +repo pages (`show`, `model`) also emit `SoftwareSourceCode` JSON-LD with name,
67 +description, repo URL, author, dates, image, and `programmingLanguage` when a
68 +primary language is detected.
69 +
70 +`Repository#seo_description` returns the description when present, otherwise a
71 +fallback that names the owner and the kind (code repo or open-weights model).
72 +This guarantees non-empty social text.
73 +
74 +### OG card images
75 +
76 +`OgImageService` builds an SVG from the brand template and rasterizes it to PNG
77 +with libvips. Card content: repo name, `@owner`, wrapped description (up to
78 +three lines), primary-language dot with color, star count when above zero, and
79 +the siGit wordmark. The generic card carries the tagline only.
80 +
81 +All user-controlled text is XML-escaped before going into the SVG.
82 +
83 +Primary language is guessed from file extensions on the default branch
84 +(`Repository#primary_language`), using a small Linguist-style color map. Model
85 +repos that carry weight files surface the weight format instead of a code
86 +language.
87 +
88 +### Caching and performance
89 +
90 +`OgImagesController` answers conditional GETs with `stale?(etag:, public:)`. The
91 +ETag is `OgImageService::TEMPLATE_VERSION` plus `Repository#og_version`, a hash
92 +of repo id, name, description, star count, default-branch tip commit, and
93 +`updated_at`. A repeat fetch with a matching `If-None-Match` returns 304. Bytes
94 +are cached in `Rails.cache` keyed by the same version, so the SVG is rasterized
95 +only on a cache miss, never on the hot path when a card exists. Responses set
96 +`Cache-Control: public` with a long max-age.
97 +
98 +The template version bumps invalidate every cached card. The content hash
99 +invalidates a single repo's card when its content changes.
100 +
101 +### Fallback
102 +
103 +If rasterization is unavailable (no libvips, no SVG or font support), the
104 +endpoint logs a warning and serves the static brand icon
105 +(`public/favicon/web-app-manifest-512x512.png`) with a short TTL, so a transient
106 +failure self-heals and the endpoint never returns 500.
107 +
108 +### Privacy guards
109 +
110 +- A private repo returns 404 on `/og/:owner/:repo.png` for everyone, including
111 + the owner, so no card can leak.
112 +- Visibility is decided server-side from the repo's `is_private` flag, not a
113 + client signal. A logged-out request to a private repo page renders the static
114 + 404 with no metadata.
115 +- Private repos and users who own only private repos are excluded from
116 + `sitemap.xml`.
117 +- Auth and transactional pages (`sessions`, `registrations`, `passwords`,
118 + `confirmations`, `billing`, `users#settings`, `repositories#new`) call
119 + `noindex!` in the controller, which makes `SeoHelper#meta_robots` emit
120 + `noindex, nofollow`.
121 +
122 +### robots.txt
123 +
124 +`public/robots.txt` allows content, disallows `/auth`, `/settings`, `/billing`,
125 +`/new`, `/api/`, and `/*/raw/`, and points at `/sitemap.xml`.
126 +
127 +## Acceptance criteria
128 +
129 +1. A public repo URL pasted into Slack, X, LinkedIn, or Discord shows a
130 + `summary_large_image` card with the correct title, description, and image.
131 +2. `view-source` on a public repo page shows the OG, Twitter, and canonical
132 + tags in the initial HTML.
133 +3. `/og/:owner/:repo.png` returns a 1200x630 PNG, is a 304 on the second hit
134 + with a matching ETag, and has a fallback for missing metadata.
135 +4. A private repo URL returns no preview, carries `noindex`, and is absent from
136 + `sitemap.xml`.
137 +5. `robots.txt` and `sitemap.xml` validate; public repos appear in the sitemap,
138 + auth and non-content routes do not.
139 +
140 +## Production dependency
141 +
142 +The runtime image installs `libvips`, `librsvg2-2`, and `fonts-dejavu-core` so
143 +libvips can rasterize SVG text. macOS dev hosts usually lack libvips, so the
144 +endpoint returns the static fallback locally; the card renders in CI and
145 +production.
146 +
147 +## Tests
148 +
149 +RSpec, the project framework.
150 +
151 +- `spec/requests/repository_seo_spec.rb`: meta output for public vs private,
152 + JSON-LD, indexable flag, empty-description fallback, noindex on auth pages.
153 +- `spec/requests/og_images_spec.rb`: PNG content type, cache headers, 304 on
154 + conditional GET, dotted names, private 404, unknown 404, default card.
155 +- `spec/requests/sitemap_robots_spec.rb`: sitemap inclusion and exclusion,
156 + robots directives.
157 +- `spec/models/repository_seo_spec.rb`: `seo_description`, `og_version`,
158 + `primary_language`.
159 +- `spec/services/og_image_service_spec.rb`: SVG structure, XML-escaping, default
160 + card, render-or-typed-error.
161 +
162 +## Key files
163 +
164 +- `app/services/og_image_service.rb`, `app/controllers/og_images_controller.rb`
165 +- `app/helpers/seo_helper.rb`, `app/views/shared/_seo.html.erb`,
166 + `app/views/shared/_repository_social.html.erb`
167 +- `app/models/repository.rb` (`seo_description`, `primary_language`,
168 + `og_version`), `app/services/git_repository_service.rb` (`head_sha`,
169 + `tree_filenames`)
170 +- `app/controllers/application_controller.rb` (`noindex!`), `config/routes.rb`,
171 + `public/robots.txt`, `Dockerfile`
172 +- `docs/seo-and-social-unfurls.md` (how to verify)
spec/models/repository_seo_spec.rb new
+47
@@ -0,0 +1,47 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe Repository, type: :model do
6 + let(:user) { User.create!(smbcloud_id: 8001, email: "alice@example.com", username: "alice") }
7 +
8 + def build_repo(**attrs)
9 + user.repositories.create!(
10 + { name: "widget", kind: "code", default_branch: "main",
11 + disk_path: "/nonexistent/alice/widget.git" }.merge(attrs)
12 + )
13 + end
14 +
15 + describe "#seo_description" do
16 + it "uses the description when present" do
17 + repo = build_repo(description: "A tiny widget library.")
18 + expect(repo.seo_description).to eq("A tiny widget library.")
19 + end
20 +
21 + it "falls back for a code repo without a description" do
22 + repo = build_repo(description: nil)
23 + expect(repo.seo_description).to include("@alice")
24 + expect(repo.seo_description).to include("Git hosting built for AI workflows")
25 + end
26 +
27 + it "falls back for a model repo without a description" do
28 + repo = build_repo(name: "Qwen2.5-GGUF", kind: "model", description: nil)
29 + expect(repo.seo_description).to include("open-weights model")
30 + end
31 + end
32 +
33 + describe "#og_version" do
34 + it "changes when a share-card input changes" do
35 + repo = build_repo(stars_count: 1)
36 + before = repo.og_version
37 + repo.stars_count = 2
38 + expect(repo.og_version).not_to eq(before)
39 + end
40 + end
41 +
42 + describe "#primary_language" do
43 + it "is nil for an uninitialized repo" do
44 + expect(build_repo.primary_language).to be_nil
45 + end
46 + end
47 +end
spec/requests/og_images_spec.rb new
+74
@@ -0,0 +1,74 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +# The OG endpoint must always answer crawlers with a cacheable PNG (a generated
6 +# card where libvips is available, a static fallback otherwise) and must never
7 +# render a card for a private repo.
8 +RSpec.describe "OG share-card images", type: :request do
9 + let(:owner) { User.create!(smbcloud_id: 6001, email: "alice@example.com", username: "alice") }
10 + let(:other) { User.create!(smbcloud_id: 6002, email: "bob@example.com", username: "bob") }
11 +
12 + let!(:public_repo) do
13 + owner.repositories.create!(
14 + name: "widget", description: "A tiny widget library.",
15 + kind: "code", default_branch: "main",
16 + disk_path: "/nonexistent/alice/widget.git", is_private: false, stars_count: 7
17 + )
18 + end
19 +
20 + let!(:dotted_repo) do
21 + owner.repositories.create!(
22 + name: "Qwen2.5-GGUF", description: "Quantized weights.",
23 + kind: "model", default_branch: "main",
24 + disk_path: "/nonexistent/alice/qwen.git", is_private: false
25 + )
26 + end
27 +
28 + let!(:private_repo) do
29 + other.repositories.create!(
30 + name: "secret-internal-tool", description: "private",
31 + kind: "code", default_branch: "main",
32 + disk_path: "/nonexistent/bob/secret.git", is_private: true
33 + )
34 + end
35 +
36 + it "returns a cacheable PNG for a public repo" do
37 + get "/og/alice/widget.png"
38 + expect(response).to have_http_status(:ok)
39 + expect(response.media_type).to eq("image/png")
40 + expect(response.headers["Cache-Control"]).to include("public")
41 + expect(response.headers["ETag"]).to be_present
42 + end
43 +
44 + it "routes dotted repo names correctly" do
45 + get "/og/alice/Qwen2.5-GGUF.png"
46 + expect(response).to have_http_status(:ok)
47 + expect(response.media_type).to eq("image/png")
48 + end
49 +
50 + it "serves a 304 on a conditional GET with a matching ETag" do
51 + get "/og/alice/widget.png"
52 + etag = response.headers["ETag"]
53 + expect(etag).to be_present
54 +
55 + get "/og/alice/widget.png", headers: { "If-None-Match" => etag }
56 + expect(response).to have_http_status(:not_modified)
57 + end
58 +
59 + it "renders the generic sitewide card" do
60 + get "/og.png"
61 + expect(response).to have_http_status(:ok)
62 + expect(response.media_type).to eq("image/png")
63 + end
64 +
65 + it "404s for a private repo (no card, even though it exists)" do
66 + get "/og/bob/secret-internal-tool.png"
67 + expect(response).to have_http_status(:not_found)
68 + end
69 +
70 + it "404s for an unknown repo" do
71 + get "/og/alice/does-not-exist.png"
72 + expect(response).to have_http_status(:not_found)
73 + end
74 +end
spec/requests/repository_seo_spec.rb new
+79
@@ -0,0 +1,79 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +# The SEO/social meta tags must be in the initial server HTML — crawlers don't
6 +# run JS — and must never expose anything about a private repo.
7 +RSpec.describe "Repository SEO meta tags", type: :request do
8 + let(:owner) { User.create!(smbcloud_id: 5001, email: "alice@example.com", username: "alice") }
9 + let(:other) { User.create!(smbcloud_id: 5002, email: "bob@example.com", username: "bob") }
10 +
11 + let!(:public_repo) do
12 + owner.repositories.create!(
13 + name: "widget", description: "A tiny widget library for building UIs fast.",
14 + kind: "code", default_branch: "main",
15 + disk_path: "/nonexistent/alice/widget.git", is_private: false, stars_count: 7
16 + )
17 + end
18 +
19 + let!(:private_repo) do
20 + other.repositories.create!(
21 + name: "secret-internal-tool", description: "TOPSECRETDESCRIPTION should never leak to crawlers.",
22 + kind: "code", default_branch: "main",
23 + disk_path: "/nonexistent/bob/secret.git", is_private: true
24 + )
25 + end
26 +
27 + describe "a public repo page" do
28 + before { get "/alice/widget" }
29 +
30 + it "responds 200" do
31 + expect(response).to have_http_status(:ok)
32 + end
33 +
34 + it "emits Open Graph, Twitter, and canonical tags" do
35 + expect(response.body).to include('property="og:title" content="alice/widget"')
36 + expect(response.body).to include('property="og:site_name" content="siGit"')
37 + expect(response.body).to include('property="og:image" content="http://www.example.com/og/alice/widget.png"')
38 + expect(response.body).to include('property="og:image:width" content="1200"')
39 + expect(response.body).to include('property="og:image:height" content="630"')
40 + expect(response.body).to include('name="twitter:card" content="summary_large_image"')
41 + expect(response.body).to include('rel="canonical" href="http://www.example.com/alice/widget"')
42 + expect(response.body).to include("A tiny widget library")
43 + end
44 +
45 + it "emits SoftwareSourceCode JSON-LD" do
46 + expect(response.body).to include('"@type":"SoftwareSourceCode"')
47 + expect(response.body).to include('"codeRepository":"http://www.example.com/alice/widget"')
48 + end
49 +
50 + it "is indexable" do
51 + expect(response.body).to include('name="robots" content="index, follow"')
52 + end
53 + end
54 +
55 + it "still emits a non-empty description when the repo has none" do
56 + public_repo.update_column(:description, nil)
57 + get "/alice/widget"
58 + expect(response).to have_http_status(:ok)
59 + expect(response.body).to include("Git hosting built for AI workflows")
60 + end
61 +
62 + describe "a private repo" do
63 + it "404s and leaks no metadata to a logged-out crawler" do
64 + get "/bob/secret-internal-tool"
65 + expect(response).to have_http_status(:not_found)
66 + expect(response.body).not_to include("TOPSECRETDESCRIPTION")
67 + expect(response.body).not_to include("secret-internal-tool")
68 + expect(response.body).not_to include("og/bob")
69 + end
70 + end
71 +
72 + describe "auth pages" do
73 + it "are marked noindex" do
74 + get "/auth"
75 + expect(response).to have_http_status(:ok)
76 + expect(response.body).to include('name="robots" content="noindex, nofollow"')
77 + end
78 + end
79 +end
spec/requests/sitemap_robots_spec.rb new
+50
@@ -0,0 +1,50 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +# robots.txt and sitemap.xml must expose public content and exclude anything
6 +# private or behind auth.
7 +RSpec.describe "Sitemap and robots", type: :request do
8 + let(:alice) { User.create!(smbcloud_id: 7001, email: "alice@example.com", username: "alice") }
9 + let(:bob) { User.create!(smbcloud_id: 7002, email: "bob@example.com", username: "bob") }
10 +
11 + before do
12 + alice.repositories.create!(name: "widget", kind: "code", default_branch: "main",
13 + disk_path: "/nonexistent/alice/widget.git", is_private: false)
14 + alice.repositories.create!(name: "Qwen2.5-GGUF", kind: "model", default_branch: "main",
15 + disk_path: "/nonexistent/alice/qwen.git", is_private: false)
16 + bob.repositories.create!(name: "secret-internal-tool", kind: "code", default_branch: "main",
17 + disk_path: "/nonexistent/bob/secret.git", is_private: true)
18 + end
19 +
20 + describe "sitemap.xml" do
21 + before { get "/sitemap.xml" }
22 +
23 + it "lists public repos and their owners" do
24 + expect(response).to have_http_status(:ok)
25 + expect(response.media_type).to eq("application/xml")
26 + expect(response.body).to include("http://www.example.com/alice/widget")
27 + expect(response.body).to include("http://www.example.com/alice/Qwen2.5-GGUF")
28 + expect(response.body).to include("http://www.example.com/alice")
29 + end
30 +
31 + it "excludes private repos and private-only users" do
32 + expect(response.body).not_to include("secret-internal-tool")
33 + expect(response.body).not_to include("http://www.example.com/bob")
34 + end
35 + end
36 +
37 + describe "robots.txt" do
38 + it "allows content and disallows auth/transactional routes" do
39 + get "/robots.txt"
40 + expect(response).to have_http_status(:ok)
41 + body = response.body
42 + expect(body).to include("Sitemap: https://sigit.si/sitemap.xml")
43 + expect(body).to include("Disallow: /settings")
44 + expect(body).to include("Disallow: /billing")
45 + expect(body).to include("Disallow: /auth")
46 + expect(body).to include("Disallow: /new")
47 + expect(body).to include("Disallow: /api/")
48 + end
49 + end
50 +end
spec/services/og_image_service_spec.rb new
+46
@@ -0,0 +1,46 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe OgImageService do
6 + let(:user) { User.create!(smbcloud_id: 9001, email: "alice@example.com", username: "alice") }
7 + let(:repo) do
8 + user.repositories.create!(
9 + name: "widget", description: "A tiny widget library.",
10 + kind: "code", default_branch: "main",
11 + disk_path: "/nonexistent/alice/widget.git", stars_count: 7
12 + )
13 + end
14 +
15 + it "builds a well-formed SVG with the repo name and owner" do
16 + svg = described_class.send(:repository_svg, repo)
17 + expect(svg).to start_with("<svg")
18 + expect(svg).to include("@alice /")
19 + expect(svg).to include("widget")
20 + expect(svg).to include('width="1200"')
21 + expect(svg).to include('height="630"')
22 + end
23 +
24 + it "XML-escapes user-controlled text to prevent SVG injection" do
25 + repo.name = %q{x"><script>alert(1)</script>}
26 + repo.description = "evil & <b>markup</b>"
27 + svg = described_class.send(:repository_svg, repo)
28 + expect(svg).not_to include("<script>")
29 + expect(svg).to include("&lt;script&gt;")
30 + expect(svg).to include("&amp;")
31 + end
32 +
33 + it "builds the default card without a repository" do
34 + svg = described_class.send(:default_svg)
35 + expect(svg).to start_with("<svg")
36 + expect(svg).to include("Git hosting for the AI era")
37 + end
38 +
39 + it "renders PNG bytes, or raises a typed (catchable) error without libvips" do
40 + png = described_class.render_default
41 + expect(png).to start_with("\x89PNG".b)
42 + rescue OgImageService::Error
43 + # Graceful, controller-catchable failure on hosts without libvips/SVG support.
44 + expect(true).to be(true)
45 + end
46 +end
test/controllers/og_images_controller_test.rb deleted
-54
@@ -1,54 +0,0 @@
1 -# frozen_string_literal: true
2 -
3 -require "test_helper"
4 -
5 -# The OG endpoint must always answer crawlers with a PNG (a generated card where
6 -# libvips is available, a static fallback otherwise), cache aggressively, and
7 -# never render a card for a private repo.
8 -class OgImagesControllerTest < ActionDispatch::IntegrationTest
9 - setup do
10 - @public = repositories(:public_code)
11 - @model = repositories(:public_model)
12 - @private = repositories(:private_code)
13 - end
14 -
15 - test "public repo OG image returns a cacheable PNG" do
16 - get "/og/#{@public.user.username}/#{@public.name}.png"
17 - assert_response :success
18 - assert_equal "image/png", response.media_type
19 - assert_includes response.headers["Cache-Control"], "public"
20 - assert response.headers["ETag"].present?, "expected an ETag for conditional GETs"
21 - end
22 -
23 - test "dotted repo names route correctly" do
24 - get "/og/#{@model.user.username}/#{@model.name}.png"
25 - assert_response :success
26 - assert_equal "image/png", response.media_type
27 - end
28 -
29 - test "second hit with matching ETag is served from cache as 304" do
30 - get "/og/#{@public.user.username}/#{@public.name}.png"
31 - etag = response.headers["ETag"]
32 - assert etag.present?
33 -
34 - get "/og/#{@public.user.username}/#{@public.name}.png",
35 - headers: { "If-None-Match" => etag }
36 - assert_response :not_modified
37 - end
38 -
39 - test "default sitewide card returns a PNG" do
40 - get "/og.png"
41 - assert_response :success
42 - assert_equal "image/png", response.media_type
43 - end
44 -
45 - test "private repo OG image is not rendered (404)" do
46 - get "/og/#{@private.user.username}/#{@private.name}.png"
47 - assert_response :not_found
48 - end
49 -
50 - test "unknown repo OG image is 404" do
51 - get "/og/alice/does-not-exist.png"
52 - assert_response :not_found
53 - end
54 -end
test/fixtures/repositories.yml deleted
-29
@@ -1,29 +0,0 @@
1 -public_code:
2 - user: alice
3 - name: widget
4 - description: A tiny widget library for building UIs fast.
5 - default_branch: main
6 - kind: code
7 - disk_path: /nonexistent/alice/widget.git
8 - is_private: false
9 - stars_count: 7
10 -
11 -public_model:
12 - user: alice
13 - name: Qwen2.5-GGUF
14 - description: Quantized GGUF weights for fast local inference.
15 - default_branch: main
16 - kind: model
17 - disk_path: /nonexistent/alice/qwen.git
18 - is_private: false
19 - stars_count: 0
20 -
21 -private_code:
22 - user: bob
23 - name: secret-internal-tool
24 - description: TOPSECRETDESCRIPTION should never leak to crawlers.
25 - default_branch: main
26 - kind: code
27 - disk_path: /nonexistent/bob/secret.git
28 - is_private: true
29 - stars_count: 3
test/fixtures/users.yml deleted
-11
@@ -1,11 +0,0 @@
1 -alice:
2 - username: alice
3 - email: alice@example.com
4 - smbcloud_id: 1001
5 - display_name: Alice
6 -
7 -bob:
8 - username: bob
9 - email: bob@example.com
10 - smbcloud_id: 1002
11 - display_name: Bob
test/integration/repository_seo_test.rb deleted
-60
@@ -1,60 +0,0 @@
1 -# frozen_string_literal: true
2 -
3 -require "test_helper"
4 -
5 -# Verifies the server-rendered SEO/social meta tags for public vs private repos.
6 -# Crawlers don't run JS, so everything asserted here must be in the initial HTML.
7 -class RepositorySeoTest < ActionDispatch::IntegrationTest
8 - setup do
9 - @public = repositories(:public_code)
10 - @private = repositories(:private_code)
11 - end
12 -
13 - test "public repo page emits Open Graph, Twitter, and canonical tags server-side" do
14 - get "/#{@public.user.username}/#{@public.name}"
15 - assert_response :success
16 -
17 - assert_includes response.body, %(property="og:title" content="alice/widget")
18 - assert_includes response.body, %(property="og:site_name" content="siGit")
19 - assert_includes response.body, %(property="og:image" content="http://www.example.com/og/alice/widget.png")
20 - assert_includes response.body, %(property="og:image:width" content="1200")
21 - assert_includes response.body, %(property="og:image:height" content="630")
22 - assert_includes response.body, %(name="twitter:card" content="summary_large_image")
23 - assert_includes response.body, %(rel="canonical" href="http://www.example.com/alice/widget")
24 - assert_includes response.body, "A tiny widget library"
25 - end
26 -
27 - test "public repo page emits SoftwareSourceCode JSON-LD" do
28 - get "/#{@public.user.username}/#{@public.name}"
29 - assert_response :success
30 - assert_includes response.body, %("@type":"SoftwareSourceCode")
31 - assert_includes response.body, %("codeRepository":"http://www.example.com/alice/widget")
32 - end
33 -
34 - test "public repo page is indexable" do
35 - get "/#{@public.user.username}/#{@public.name}"
36 - assert_includes response.body, %(name="robots" content="index, follow")
37 - end
38 -
39 - test "private repo returns 404 and leaks no metadata to a logged-out crawler" do
40 - get "/#{@private.user.username}/#{@private.name}"
41 - assert_response :not_found
42 - refute_includes response.body, "TOPSECRETDESCRIPTION"
43 - refute_includes response.body, "secret-internal-tool"
44 - refute_includes response.body, "og/bob"
45 - end
46 -
47 - test "auth pages are marked noindex" do
48 - get "/auth"
49 - assert_response :success
50 - assert_includes response.body, %(name="robots" content="noindex, nofollow")
51 - end
52 -
53 - test "empty-description repo still gets a non-empty description tag" do
54 - @public.update_column(:description, nil)
55 - get "/#{@public.user.username}/#{@public.name}"
56 - assert_response :success
57 - # Falls back to the generated seo_description rather than an empty tag.
58 - assert_includes response.body, "Git hosting built for AI workflows"
59 - end
60 -end
test/integration/sitemap_robots_test.rb deleted
-35
@@ -1,35 +0,0 @@
1 -# frozen_string_literal: true
2 -
3 -require "test_helper"
4 -
5 -# robots.txt and sitemap.xml must expose public content and exclude anything
6 -# private or behind auth.
7 -class SitemapRobotsTest < ActionDispatch::IntegrationTest
8 - test "sitemap lists public repos and their owners" do
9 - get "/sitemap.xml"
10 - assert_response :success
11 - assert_equal "application/xml", response.media_type
12 -
13 - assert_includes response.body, "http://www.example.com/alice/widget"
14 - assert_includes response.body, "http://www.example.com/alice/Qwen2.5-GGUF"
15 - assert_includes response.body, "http://www.example.com/alice"
16 - end
17 -
18 - test "sitemap excludes private repos and users who only have private repos" do
19 - get "/sitemap.xml"
20 - refute_includes response.body, "secret-internal-tool"
21 - refute_includes response.body, "http://www.example.com/bob"
22 - end
23 -
24 - test "robots.txt allows content and disallows auth/transactional routes" do
25 - get "/robots.txt"
26 - assert_response :success
27 -
28 - body = response.body
29 - assert_includes body, "Sitemap: https://sigit.si/sitemap.xml"
30 - assert_includes body, "Disallow: /settings"
31 - assert_includes body, "Disallow: /billing"
32 - assert_includes body, "Disallow: /auth"
33 - assert_includes body, "Disallow: /api/"
34 - end
35 -end
test/models/repository_seo_test.rb deleted
-35
@@ -1,35 +0,0 @@
1 -# frozen_string_literal: true
2 -
3 -require "test_helper"
4 -
5 -class RepositoryModelSeoTest < ActiveSupport::TestCase
6 - test "seo_description uses the description when present" do
7 - repo = repositories(:public_code)
8 - assert_equal "A tiny widget library for building UIs fast.", repo.seo_description
9 - end
10 -
11 - test "seo_description falls back for a code repo without a description" do
12 - repo = repositories(:public_code)
13 - repo.description = nil
14 - assert_includes repo.seo_description, "@alice"
15 - assert_includes repo.seo_description, "Git hosting built for AI workflows"
16 - end
17 -
18 - test "seo_description falls back for a model repo without a description" do
19 - repo = repositories(:public_model)
20 - repo.description = nil
21 - assert_includes repo.seo_description, "open-weights model"
22 - end
23 -
24 - test "og_version changes when share-card inputs change" do
25 - repo = repositories(:public_code)
26 - before = repo.og_version
27 - repo.stars_count = repo.stars_count + 1
28 - refute_equal before, repo.og_version
29 - end
30 -
31 - test "primary_language is nil for an uninitialized repo" do
32 - # Fixture disk_path does not exist on disk.
33 - assert_nil repositories(:public_code).primary_language
34 - end
35 -end
test/services/og_image_service_test.rb deleted
-44
@@ -1,44 +0,0 @@
1 -# frozen_string_literal: true
2 -
3 -require "test_helper"
4 -
5 -class OgImageServiceTest < ActiveSupport::TestCase
6 - test "repository SVG includes the repo name, owner, and is well-formed" do
7 - repo = repositories(:public_code)
8 - svg = OgImageService.send(:repository_svg, repo)
9 -
10 - assert svg.start_with?("<svg")
11 - assert_includes svg, "@alice /"
12 - assert_includes svg, "widget"
13 - assert_includes svg, %(width="1200")
14 - assert_includes svg, %(height="630")
15 - end
16 -
17 - test "user-controlled text is XML-escaped to prevent SVG injection" do
18 - repo = repositories(:public_code)
19 - repo.name = %q{x"><script>alert(1)</script>}
20 - repo.description = "evil & <b>markup</b>"
21 - svg = OgImageService.send(:repository_svg, repo)
22 -
23 - refute_includes svg, "<script>"
24 - assert_includes svg, "&lt;script&gt;"
25 - assert_includes svg, "&amp;"
26 - end
27 -
28 - test "default card is generated without a repository" do
29 - svg = OgImageService.send(:default_svg)
30 - assert svg.start_with?("<svg")
31 - assert_includes svg, "Git hosting for the AI era"
32 - end
33 -
34 - test "render raises a typed error when libvips/SVG support is unavailable" do
35 - # In CI/local without libvips this exercises the fallback path the
36 - # controller relies on; where libvips exists it returns PNG bytes instead.
37 - begin
38 - png = OgImageService.render_default
39 - assert png.start_with?("\x89PNG".b), "expected PNG magic bytes"
40 - rescue OgImageService::Error
41 - assert true # graceful, controller-catchable failure
42 - end
43 - end
44 -end
test/test_helper.rb deleted
-15
@@ -1,15 +0,0 @@
1 -# frozen_string_literal: true
2 -
3 -ENV["RAILS_ENV"] ||= "test"
4 -require_relative "../config/environment"
5 -require "rails/test_help"
6 -
7 -module ActiveSupport
8 - class TestCase
9 - # Run tests in parallel with specified workers
10 - parallelize(workers: :number_of_processors)
11 -
12 - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
13 - fixtures :all
14 - end
15 -end