global state model

This commit is contained in:
Dylan Knutson
2024-12-30 01:19:00 +00:00
parent 553d0bea8d
commit 32173b50d8
24 changed files with 721 additions and 69 deletions

View File

@@ -2,3 +2,4 @@
- [ ] Add bookmarking feature for posts across different domains
- [ ] Add search feature to search FA descriptions, tags, E621 descriptions, tags
- [ ] Get inkbunny index scan job working

View File

@@ -0,0 +1,56 @@
class GlobalStatesController < ApplicationController
before_action :set_global_state, only: %i[edit update destroy]
after_action :verify_authorized
def index
authorize GlobalState
@global_states = policy_scope(GlobalState).order(:key)
end
def new
@global_state = GlobalState.new
authorize @global_state
end
def create
@global_state = GlobalState.new(global_state_params)
authorize @global_state
if @global_state.save
redirect_to global_states_path,
notice: "Global state was successfully created."
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize @global_state
end
def update
authorize @global_state
if @global_state.update(global_state_params)
redirect_to global_states_path,
notice: "Global state was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @global_state
@global_state.destroy
redirect_to global_states_path,
notice: "Global state was successfully deleted."
end
private
def set_global_state
@global_state = GlobalState.find(params[:id])
end
def global_state_params
params.require(:global_state).permit(:key, :value, :value_type)
end
end

View File

@@ -7,6 +7,7 @@ interface UserMenuProps {
editProfilePath: string;
signOutPath: string;
csrfToken: string;
globalStatesPath: string;
}
export const UserMenu: React.FC<UserMenuProps> = ({
@@ -15,6 +16,7 @@ export const UserMenu: React.FC<UserMenuProps> = ({
editProfilePath,
signOutPath,
csrfToken,
globalStatesPath,
}) => {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -82,6 +84,16 @@ export const UserMenu: React.FC<UserMenuProps> = ({
)}
</div>
{userRole === 'admin' && (
<a
href={globalStatesPath}
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
>
<i className="fas fa-cogs mr-2 w-5" />
<span>Global State</span>
</a>
)}
<a
href={editProfilePath}
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"

View File

@@ -84,8 +84,7 @@ class Scraper::ClientFactory
end
def self._get_ib_client_sid(client)
sid_key = "sid-#{Rails.application.config.x.proxy}"
sid = Domain::Inkbunny::GlobalState.find_or_create_by!(key: sid_key)
sid = GlobalState.find_or_create_by!(key: "inkbunny-sid")
sid.with_lock("FOR UPDATE") do
if sid.value.blank?
sid.value = _ib_login(client)

View File

@@ -38,7 +38,7 @@ class Scraper::HttpClient
unless allowed_domain?(uri.host)
raise InvalidURLError.new(
"domain not permitted: #{uri.host} - allowed: #{config.allowed_domains}"
"domain not permitted: #{uri.host} - allowed: #{config.allowed_domains}",
)
end
@@ -63,8 +63,8 @@ class Scraper::HttpClient
request_headers = {
"cookie" =>
HTTP::Cookie.cookie_value(
@cookie_jar.cookies(Addressable::URI.encode url)
)
@cookie_jar.cookies(Addressable::URI.encode url),
),
}
requested_at = Time.now
response = @http_performer.do_request(method, url, request_headers)
@@ -85,7 +85,7 @@ class Scraper::HttpClient
response_blob_entry =
BlobEntryP.find_or_build(
content_type: content_type,
contents: response_body
contents: response_body,
)
scrubbed_uri = @config.scrub_stored_uri(uri)
@@ -104,8 +104,8 @@ class Scraper::HttpClient
response_time_ms: response_time_ms,
requested_at: requested_at,
caused_by_entry: caused_by_entry,
performed_by: @http_performer.name
}
performed_by: "direct",
},
)
# double write blob_file while migrating
@@ -140,8 +140,8 @@ class Scraper::HttpClient
"GET #{response_code_colorized} /",
"#{HexUtil.humansize(response_blob_entry.bytes_stored).bold} / #{HexUtil.humansize(response_blob_entry.size).bold}]",
"[#{response_time_ms.to_s.bold} ms / #{total_time_ms.to_s.bold} ms]",
scrubbed_uri.to_s.black
].reject(&:nil?).join(" ")
scrubbed_uri.to_s.black,
].reject(&:nil?).join(" "),
)
if response_code == 524 || response_code == 502 || response_code == 503 ||
@@ -177,7 +177,7 @@ class Scraper::HttpClient
name: cookie_key_conf[:name],
value: cookie_key_conf[:value],
path: cookie_key_conf[:path] || "",
for_domain: true
for_domain: true,
}
@cookie_jar.add(HTTP::Cookie.new(conf))
end

View File

@@ -3,9 +3,9 @@ class Scraper::InkbunnyHttpClientConfig < Scraper::HttpClientConfig
def do_login(performer)
sid_model =
Domain::Inkbunny::GlobalState.find_or_create_by(
key: "#{performer.name}-sid"
)
GlobalState.find_or_create_by(key: "inkbunny-sid") do |gs|
gs.value_type = :string
end
sid_model.with_lock do
if sid_model.value.blank?
sid_model.value = do_ib_login(performer)
@@ -56,13 +56,11 @@ class Scraper::InkbunnyHttpClientConfig < Scraper::HttpClientConfig
def do_ib_login(performer)
username =
Domain::Inkbunny::GlobalState.find_by(
key: "#{performer.name}-username"
)&.value || raise("missing inkbunny username in global state")
GlobalState.find_by(key: "inkbunny-username")&.value ||
raise("missing inkbunny username in global state")
password =
Domain::Inkbunny::GlobalState.find_by(
key: "#{performer.name}-password"
)&.value || raise("missing inkbunny password in global state")
GlobalState.find_by(key: "inkbunny-password")&.value ||
raise("missing inkbunny password in global state")
uri =
"https://inkbunny.net/api_login.php?username=#{username}&password=#{password}"
logger.info("logging in to inkbunny as #{username}...")
@@ -76,7 +74,7 @@ class Scraper::InkbunnyHttpClientConfig < Scraper::HttpClientConfig
sid =
JSON.parse(response.body)["sid"] ||
raise(
"inkbunny login failed: no sid in response (#{response.body[0..1000]})"
"inkbunny login failed: no sid in response (#{response.body[0..1000]})",
)
logger.info("logged in to inkbunny as #{username}: #{sid}")
sid

View File

@@ -1,5 +0,0 @@
class Domain::Inkbunny::GlobalState < ReduxApplicationRecord
self.table_name = "domain_inkbunny_global_states"
# key: string (unique)
# value: string
end

View File

@@ -0,0 +1,51 @@
class GlobalState < ReduxApplicationRecord
validates :key, presence: true, uniqueness: true
validates :value, presence: true
validates :value_type, presence: true
enum :value_type,
{ string: 0, counter: 1, duration: 2, password: 3 },
prefix: :value_type
validate :validate_value_format
def self.get(key)
find_by(key: key)&.value
end
def self.set(key, value)
record = find_or_initialize_by(key: key)
record.value = value
record.save!
value
end
def display_value
case value_type.to_sym
when :password
"••••••••"
else
value
end
end
private
def validate_value_format
return unless value_type.present?
case value_type.to_sym
when :counter
unless value.match?(/\A-?\d+\z/)
errors.add(:value, "must be a valid integer for counter type")
end
when :duration
unless value.match?(/\A\d+[smhd]\z/)
errors.add(
:value,
"must be a valid duration (e.g., '30s', '5m', '2h', '1d')",
)
end
end
end
end

View File

@@ -0,0 +1,35 @@
class GlobalStatePolicy < ApplicationPolicy
def index?
user.admin?
end
def show?
user.admin?
end
def create?
user.admin?
end
def update?
user.admin?
end
def destroy?
user.admin?
end
def new?
create?
end
def edit?
update?
end
class Scope < Scope
def resolve
user.admin? ? scope.all : scope.none
end
end
end

View File

@@ -0,0 +1,67 @@
<%= form_with(model: global_state, class: "space-y-4") do |form| %>
<% if global_state.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(global_state.errors.count, "error") %> prohibited this global state from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc space-y-1 pl-5">
<% global_state.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div>
<%= form.label :key, class: "block text-sm font-medium text-slate-700" %>
<div class="mt-1">
<%= form.text_field :key, class: "block w-full rounded-md border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm" %>
</div>
<p class="mt-2 text-sm text-slate-500">A unique identifier for this global state.</p>
</div>
<div>
<%= form.label :value_type, class: "block text-sm font-medium text-slate-700" %>
<div class="mt-1">
<%= form.select(
:value_type,
GlobalState.value_types.keys.map { |t| [t.titleize, t] },
{},
class: "block w-full rounded-md border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm",
) %>
</div>
<p class="mt-2 text-sm text-slate-500">
The type of value:
<ul class="list-disc pl-5">
<li><strong>String:</strong> Any text value</li>
<li><strong>Count:</strong> A whole number (e.g., "42")</li>
<li><strong>Duration:</strong> Time duration with unit (e.g., "30s", "5m", "2h", "1d")</li>
<li><strong>Password:</strong> Sensitive data that will be masked in the UI</li>
</ul>
</p>
</div>
<div>
<%= form.label :value, class: "block text-sm font-medium text-slate-700" %>
<div class="mt-1">
<%= form.text_field :value, class: "block w-full rounded-md border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm" %>
</div>
<p class="mt-2 text-sm text-slate-500">The value to store, format depends on the selected type.</p>
</div>
<div class="pt-5">
<div class="flex justify-end space-x-3">
<%= link_to "Cancel", global_states_path, class: "rounded-md border border-slate-300 bg-white py-2 px-4 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2" %>
<%= form.submit class: "inline-flex justify-center rounded-md border border-transparent bg-sky-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2" %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,25 @@
<div class="container mx-auto px-4 py-8">
<div class="mx-auto max-w-3xl">
<div class="mb-6 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1">
<h2
class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl"
>
Edit Global State: <%= @global_state.key %>
</h2>
</div>
<div class="mt-4 flex md:ml-4 md:mt-0">
<%= link_to "Back to Global States",
global_states_path,
class:
"inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= render "form", global_state: @global_state %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,113 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-slate-900">Global States</h1>
<p class="mt-2 text-sm text-slate-700">
A list of all global states in the application.
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<%= link_to new_global_state_path,
class:
"inline-flex items-center justify-center rounded-md border border-transparent bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 sm:w-auto" do %>
<i class="fas fa-plus mr-2"></i>
New Global State
<% end %>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div
class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg"
>
<table class="min-w-full divide-y divide-slate-300">
<thead class="bg-slate-50">
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-slate-900 sm:pl-6"
>
Key
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-slate-900"
>
Type
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-slate-900"
>
Value
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-slate-900"
>
Last Updated
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 bg-white">
<% @global_states.each do |global_state| %>
<tr>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-slate-900 sm:pl-6"
>
<%= global_state.key %>
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-slate-500"
>
<span
class="<%= if global_state.value_type_password?
"bg-red-100 text-red-800"
else
"bg-sky-100 text-sky-800"
end %> inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
>
<%= global_state.value_type.titleize %>
</span>
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-slate-500"
>
<%= global_state.display_value %>
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-slate-500"
>
<%= time_ago_in_words(global_state.updated_at) %> ago
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<%= link_to "Edit",
edit_global_state_path(global_state),
class: "text-sky-600 hover:text-sky-900 mr-3" %>
<%= button_to "Delete",
global_state_path(global_state),
method: :delete,
class: "text-red-600 hover:text-red-900 inline",
form: {
class: "inline",
data: {
turbo_confirm: "Are you sure?",
},
} %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,25 @@
<div class="container mx-auto px-4 py-8">
<div class="mx-auto max-w-3xl">
<div class="mb-6 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1">
<h2
class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl"
>
New Global State
</h2>
</div>
<div class="mt-4 flex md:ml-4 md:mt-0">
<%= link_to "Back to Global States",
global_states_path,
class:
"inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= render "form", global_state: @global_state %>
</div>
</div>
</div>
</div>

View File

@@ -33,7 +33,8 @@
userRole: current_user.role,
editProfilePath: edit_user_registration_path,
signOutPath: destroy_user_session_path,
csrfToken: form_authenticity_token
csrfToken: form_authenticity_token,
globalStatesPath: global_states_path
}) %>
<% else %>
<%= link_to new_user_session_path, class: "text-slate-600 hover:text-slate-900" do %>

View File

@@ -88,4 +88,6 @@ Rails.application.routes.draw do
}
end
end
resources :global_states, path: "state"
end

View File

@@ -0,0 +1,14 @@
class CreateGlobalStates < ActiveRecord::Migration[7.2]
def change
drop_table :domain_inkbunny_global_states
create_table :global_states do |t|
t.string :key, null: false
t.string :value
t.index :key, unique: true
t.timestamps
end
end
end

View File

@@ -0,0 +1,5 @@
class AddValueTypeToGlobalStates < ActiveRecord::Migration[7.1]
def change
add_column :global_states, :value_type, :integer, null: false, default: 0
end
end

18
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_12_29_001358) do
ActiveRecord::Schema[7.2].define(version: 2024_12_30_005956) do
# These are extensions that must be enabled in order to support this database
enable_extension "hstore"
enable_extension "intarray"
@@ -1591,13 +1591,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_29_001358) do
t.index ["follower_id"], name: "index_domain_inkbunny_follows_on_follower_id"
end
create_table "domain_inkbunny_global_states", force: :cascade do |t|
t.string "key", null: false
t.string "value"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "domain_inkbunny_pool_joins", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "pool_id", null: false
@@ -1841,6 +1834,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_29_001358) do
t.index ["key"], name: "index_flat_sst_entries_on_key", unique: true
end
create_table "global_states", force: :cascade do |t|
t.string "key", null: false
t.string "value"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "value_type", default: 0, null: false
t.index ["key"], name: "index_global_states_on_key", unique: true
end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false

View File

@@ -19,12 +19,12 @@ namespace :ib do
password = $stdin.gets.chomp
end
Domain::Inkbunny::GlobalState.find_or_create_by(
key: "direct-username"
).update!(value: username)
Domain::Inkbunny::GlobalState.find_or_create_by(
key: "direct-password"
).update!(value: password)
GlobalState
.find_or_create_by(key: "inkbunny-username")
.update!(value: username) { |gs| gs.value_type = :string }
GlobalState
.find_or_create_by(key: "inkbunny-password")
.update!(value: password) { |gs| gs.value_type = :password }
puts "auth credentials set to #{username} / #{password}"
end

View File

@@ -0,0 +1,106 @@
require "rails_helper"
RSpec.describe GlobalStatesController, type: :controller do
let(:admin_user) { create(:user, role: :admin) }
let(:valid_attributes) do
{ key: "test_key", value: "test_value", value_type: "string" }
end
let(:invalid_attributes) { { key: "", value: "", value_type: "" } }
let(:global_state) { create(:global_state, valid_attributes) }
before { sign_in admin_user }
describe "GET #index" do
it "returns a success response" do
get :index
expect(response).to be_successful
end
end
describe "GET #new" do
it "returns a success response" do
get :new
expect(response).to be_successful
end
end
describe "POST #create" do
context "with valid params" do
it "creates a new GlobalState" do
expect {
post :create, params: { global_state: valid_attributes }
}.to change(GlobalState, :count).by(1)
end
it "redirects to the global states list" do
post :create, params: { global_state: valid_attributes }
expect(response).to redirect_to(global_states_path)
end
end
context "with invalid params" do
it "returns unprocessable entity status" do
post :create, params: { global_state: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "GET #edit" do
it "returns a success response" do
get :edit, params: { id: global_state.to_param }
expect(response).to be_successful
end
end
describe "PUT #update" do
context "with valid params" do
let(:new_attributes) { { value: "new_value" } }
it "updates the requested global_state" do
put :update,
params: {
id: global_state.to_param,
global_state: new_attributes,
}
global_state.reload
expect(global_state.value).to eq("new_value")
end
it "redirects to the global states list" do
put :update,
params: {
id: global_state.to_param,
global_state: new_attributes,
}
expect(response).to redirect_to(global_states_path)
end
end
context "with invalid params" do
it "returns unprocessable entity status" do
put :update,
params: {
id: global_state.to_param,
global_state: invalid_attributes,
}
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "DELETE #destroy" do
let!(:global_state) { create(:global_state) }
it "destroys the requested global_state" do
expect {
delete :destroy, params: { id: global_state.to_param }
}.to change(GlobalState, :count).by(-1)
end
it "redirects to the global_states list" do
delete :destroy, params: { id: global_state.to_param }
expect(response).to redirect_to(global_states_path)
end
end
end

View File

@@ -0,0 +1,7 @@
FactoryBot.define do
factory :global_state do
sequence(:key) { |n| "key_#{n}" }
value { "test_value" }
value_type { :string }
end
end

View File

@@ -4,42 +4,45 @@ describe Scraper::InkbunnyHttpClientConfig do
subject { described_class.new }
let!(:username_model) do
Domain::Inkbunny::GlobalState.create!(
key: "test-username",
value: "the-user"
create(
:global_state,
key: "inkbunny-username",
value: "the-user",
value_type: :string,
)
end
let!(:password_model) do
Domain::Inkbunny::GlobalState.create!(
key: "test-password",
value: "the-password"
create(
:global_state,
key: "inkbunny-password",
value: "the-password",
value_type: :password,
)
end
let(:performer) do
SpecUtil.mock_http_performer(
:post,
"https://inkbunny.net/api_login.php?username=the-user&password=the-password",
performer_name: "test",
response_headers: {
"content-type" => "application/json"
"content-type" => "application/json",
},
response_body: '{"sid": "the-sid"}'
response_body: '{"sid": "the-sid"}',
)
end
describe "#do_login" do
context "sid not stored" do
it "logs in the user and stores sid" do
expect(Domain::Inkbunny::GlobalState.find_by(key: "test-sid")).to be_nil
expect(GlobalState.find_by(key: "inkbunny-sid")).to be_nil
subject.do_login(performer)
sid = Domain::Inkbunny::GlobalState.find_by(key: "test-sid")
sid = GlobalState.find_by(key: "inkbunny-sid")
expect(sid.value).to eq("the-sid")
end
end
context "sid stored" do
it "does not log in the user" do
Domain::Inkbunny::GlobalState.create!(key: "test-sid", value: "the-sid")
create(:global_state, key: "inkbunny-sid", value: "the-sid")
subject.do_login(performer)
expect(performer).to_not have_received(:do_request)
end
@@ -47,16 +50,14 @@ describe Scraper::InkbunnyHttpClientConfig do
end
describe "#map_uri" do
before do
Domain::Inkbunny::GlobalState.create!(key: "test-sid", value: "the-sid")
end
before { create(:global_state, key: "inkbunny-sid", value: "the-sid") }
it "raises if not logged in yet" do
expect do
subject.map_uri(
URI.parse(
"https://inkbunny.net/api_search.php?orderby=create_datetime"
)
"https://inkbunny.net/api_search.php?orderby=create_datetime",
),
)
end.to raise_error("no sid")
end
@@ -66,13 +67,13 @@ describe Scraper::InkbunnyHttpClientConfig do
uri =
subject.map_uri(
URI.parse(
"https://inkbunny.net/api_search.php?orderby=create_datetime"
)
"https://inkbunny.net/api_search.php?orderby=create_datetime",
),
)
expect(uri).to eq(
URI.parse(
"https://inkbunny.net/api_search.php?orderby=create_datetime&sid=the-sid"
)
"https://inkbunny.net/api_search.php?orderby=create_datetime&sid=the-sid",
),
)
end
end
@@ -82,13 +83,13 @@ describe Scraper::InkbunnyHttpClientConfig do
uri =
subject.scrub_stored_uri(
URI.parse(
"https://inkbunny.net/api_login.php?username=foo&password=bar"
)
"https://inkbunny.net/api_login.php?username=foo&password=bar",
),
)
expect(uri).to eq(
URI.parse(
"https://inkbunny.net/api_login.php?username=*****&password=*****"
)
"https://inkbunny.net/api_login.php?username=*****&password=*****",
),
)
end
end

View File

@@ -0,0 +1,139 @@
require "rails_helper"
RSpec.describe GlobalState, type: :model do
describe "validations" do
subject do
build(
:global_state,
key: "test_key",
value: "test_value",
value_type: "string",
)
end
it { should validate_presence_of(:key) }
it { should validate_uniqueness_of(:key) }
it { should validate_presence_of(:value) }
it { should validate_presence_of(:value_type) }
it do
should define_enum_for(:value_type).with_values(
string: 0,
counter: 1,
duration: 2,
password: 3,
).with_prefix(:value_type)
end
context "when value_type is counter" do
subject { build(:global_state, value_type: :counter) }
it "is valid with a positive numeric value" do
subject.value = "123"
expect(subject).to be_valid
end
it "is valid with a negative numeric value" do
subject.value = "-42"
expect(subject).to be_valid
end
it "is valid with zero" do
subject.value = "0"
expect(subject).to be_valid
end
it "is invalid with a non-numeric value" do
subject.value = "abc"
expect(subject).not_to be_valid
expect(subject.errors[:value]).to include(
"must be a valid integer for counter type",
)
end
end
context "when value_type is duration" do
subject { build(:global_state, value_type: :duration) }
it "is valid with a proper duration format" do
%w[30s 5m 2h 1d].each do |duration|
subject.value = duration
expect(subject).to be_valid
end
end
it "is invalid with an improper duration format" do
subject.value = "invalid"
expect(subject).not_to be_valid
expect(subject.errors[:value]).to include(
"must be a valid duration (e.g., '30s', '5m', '2h', '1d')",
)
end
end
end
describe "enum methods" do
it "generates predicate methods with prefix" do
state = build(:global_state, value_type: :password)
expect(state.value_type_password?).to be true
expect(state.value_type_string?).to be false
expect(state.value_type_counter?).to be false
expect(state.value_type_duration?).to be false
end
end
describe "#display_value" do
it "masks password values" do
state =
build(
:global_state,
key: "key_3",
value: "secret123",
value_type: :password,
)
expect(state.display_value).to eq("••••••••")
end
it "shows regular values for non-password types" do
%i[string counter duration].each do |type|
state =
build(:global_state, key: "key_4", value: "test123", value_type: type)
expect(state.display_value).to eq("test123")
end
end
end
describe ".get" do
it "returns the value for the given key" do
create(:global_state, key: "test_key", value: "test_value")
expect(GlobalState.get("test_key")).to eq("test_value")
end
it "returns nil for non-existent key" do
expect(GlobalState.get("non_existent")).to be_nil
end
end
describe ".set" do
it "creates a new record if key does not exist" do
expect { GlobalState.set("new_key", "new_value") }.to change(
GlobalState,
:count,
).by(1)
state = GlobalState.find_by(key: "new_key")
expect(state.value).to eq("new_value")
end
it "updates existing record if key exists" do
state = create(:global_state, key: "test_key", value: "old_value")
expect { GlobalState.set("test_key", "new_value") }.not_to change(
GlobalState,
:count,
)
expect(state.reload.value).to eq("new_value")
end
end
end

View File

@@ -14,12 +14,10 @@ class SpecUtil
response_code: 200,
response_time_ms: 15,
response_headers: { "content-type" => "text/plain" },
response_body: "http body",
performer_name: "direct"
response_body: "http body"
)
mock = instance_double("Scraper::CurlHttpPerformer")
allow(mock).to receive(:is_a?).with(String).and_return(false)
allow(mock).to receive(:name).and_return(performer_name)
allow(mock).to receive(:do_request).with(
expected_method,
expected_url,