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