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("<script>")
30
+ expect(svg).to include("&")
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, "<script>"
25
- assert_includes svg, "&"
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