A store can be listed or not

Soft deletion already said "this store is gone". What was missing is "not yet" —
a catalog still being set up, or one pulled from the picker for a while without
losing the row and its history. `GET /api/stores` now answers with the active
ones only.

The client is deliberately told nothing about the flag. It has no state for
"there but switched off", and giving it one would mean every client release
having an opinion about it; an inactive store is simply absent, which is a case
the client already handles because it is the same as never having existed. The
payload stays two fields, and a spec holds it there.

Default true, so the migration lists every store that exists today. One that
silently emptied the registry would be a client with nothing to install from.
No index: a handful of rows, read once per client on first run.

In the admin the flag is what the page is *for*, so it is not just a checkbox on
the form: Active is the default scope, the index shows listed/hidden as a status
tag with a one-click toggle beside Edit, and the two batch actions do it in bulk.
A request spec covers all of it, because none of it is reachable from a model
spec — the pipelines resource shipped without `permit_params` and every edit
raised, which is the same layer and the same lesson. The last example toggles in
the admin and then reads /api/stores, since a change here that the registry does
not reflect is the only failure that actually matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:23:20 +02:00
co-authored by Claude Opus 5
parent 23f4c43da8
commit bcb95424c3
8 changed files with 184 additions and 6 deletions
@@ -32,5 +32,25 @@ RSpec.describe Api::StoresController, type: :request do
expect(JSON.parse(response.body)).to be_empty
end
# Two different "not listed", and a client can tell neither apart from the store
# never having existed — which is the point. It has no state for "there but
# switched off", so an inactive store is simply absent.
it "leaves out inactive stores" do
create(:store, name: "Listed")
create(:store, name: "Not yet", catalog_url: "https://soon.example", active: false)
get "/api/stores"
expect(JSON.parse(response.body).map { |s| s["name"] }).to eq([ "Listed" ])
end
it "still says nothing about the flag itself" do
create(:store)
get "/api/stores"
expect(JSON.parse(response.body).first.keys).to contain_exactly("name", "catalogUrl")
end
end
end
+4
View File
@@ -2,5 +2,9 @@ FactoryBot.define do
factory :store do
name { "Teletype Games" }
catalog_url { "https://teletypegames.org" }
trait :inactive do
active { false }
end
end
end
@@ -0,0 +1,91 @@
require "rails_helper"
require "warden/test/helpers"
# The admin is where the flag is actually used, and none of it is reachable from a model
# spec: the scopes, the toggle action and the batch actions are ActiveAdmin plumbing that
# only exists once the host's admin is running. The pipelines resource shipped with a
# missing `permit_params` and every edit raised — same layer, same lesson.
RSpec.describe "Admin stores", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "stores-spec@example.org", password: "password123") }
let!(:listed) { create(:store, name: "Listed", catalog_url: "https://listed.example") }
let!(:hidden) { create(:store, :inactive, name: "Hidden", catalog_url: "https://hidden.example") }
before do
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after { Warden.test_reset! }
# Same reasoning as the pipelines spec: a request spec has no rendered form to take a
# CSRF token from, and the token is not what is under test.
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
it "opens with the active ones" do
get "/admin/stores"
expect(response).to have_http_status(:ok)
expect(response.body).to include("Listed")
expect(response.body).not_to include("Hidden")
end
it "shows the hidden ones under their own scope" do
get "/admin/stores", params: { scope: "inactive" }
expect(response.body).to include("Hidden")
end
it "renders a store's page" do
get "/admin/stores/#{hidden.id}"
expect(response).to have_http_status(:ok)
end
# The one this page exists for: the form saves the flag rather than dropping it as an
# unpermitted attribute, which fails silently — the page redirects and nothing changes.
it "saves the flag from the form" do
put "/admin/stores/#{listed.id}", params: { store: { active: "0" } }
expect(response).to have_http_status(:found)
expect(listed.reload).not_to be_active
end
it "toggles from the index in one click" do
put "/admin/stores/#{listed.id}/toggle"
expect(response).to have_http_status(:found)
expect(listed.reload).not_to be_active
put "/admin/stores/#{listed.id}/toggle"
expect(listed.reload).to be_active
end
it "lists and hides in bulk" do
post "/admin/stores/batch_action",
params: { batch_action: "list", collection_selection: [ hidden.id ] }
expect(hidden.reload).to be_active
post "/admin/stores/batch_action",
params: { batch_action: "hide", collection_selection: [ hidden.id ] }
expect(hidden.reload).not_to be_active
end
# The registry is the whole point of the flag, so the two are checked together: a
# change made here has to be what the client sees.
it "is what /api/stores answers with" do
put "/admin/stores/#{listed.id}/toggle"
get "/api/stores"
expect(JSON.parse(response.body)).to be_empty
end
end