improve cache busting based on policy

This commit is contained in:
Dylan Knutson
2025-03-02 07:45:07 +00:00
parent 23188f948f
commit 9256d78bf5
31 changed files with 2518 additions and 65 deletions

View File

@@ -32,5 +32,6 @@
- [ ] Make unified Static file job
- [ ] Make unified Avatar file job
- [ ] ko-fi domain icon
- [ ] tumblr domain icon
- [ ] Do PCA on user factors table to display a 2D plot of users
- [ ] Use links found in descriptions to indicate re-scanning a post? (e.g. for comic next/prev links)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -212,11 +212,15 @@ module Domain::DescriptionsHelper
)
end
def props_for_user_hover_preview(user, visual_style, icon_size)
cache_key = [
user,
policy(user),
"popover_inline_link_domain_user",
icon_size,
]
Rails
.cache
.fetch(
[user, "popover_inline_link_domain_user", visual_style, icon_size],
) do
.fetch(cache_key) do
num_posts =
user.has_created_posts? ? user.user_post_creations.count : nil
registered_at = domain_user_registered_at_string_for_view(user)
@@ -227,7 +231,6 @@ module Domain::DescriptionsHelper
avatar_thumb_size = icon_size == "large" ? "64-avatar" : "32-avatar"
{
visualStyle: visual_style,
iconSize: icon_size,
linkText: user.name_for_view,
userId: user.to_param,
@@ -247,6 +250,10 @@ module Domain::DescriptionsHelper
userNumFollowed: num_followed,
}
end
.then do |props|
props[:visualStyle] = visual_style
props
end
end
sig do
@@ -255,11 +262,11 @@ module Domain::DescriptionsHelper
)
end
def props_for_post_hover_preview(post, link_text, visual_style)
cache_key = [post, policy(post), "popover_inline_link_domain_post"]
Rails
.cache
.fetch([post, "popover_inline_link_domain_post", visual_style]) do
props = {
visualStyle: visual_style,
.fetch(cache_key) do
{
linkText: link_text,
postId: post.to_param,
postTitle: post.title,
@@ -267,13 +274,16 @@ module Domain::DescriptionsHelper
postThumbnailPath: thumbnail_for_post_path(post),
postThumbnailAlt: "View on #{domain_name_for_model(post)}",
postDomainIcon: domain_model_icon_path(post),
}
if creator = post.primary_creator_for_view
props[:creatorName] = creator.name_for_view
props[:creatorAvatarPath] = user_avatar_path_for_view(creator)
}.then do |props|
if creator = post.primary_creator_for_view
props[:creatorName] = creator.name_for_view
props[:creatorAvatarPath] = user_avatar_path_for_view(creator)
end
props
end
end
.then do |props|
props[:visualStyle] = visual_style
props
end
end

View File

@@ -60,6 +60,8 @@ module Domain::DomainsHelper
"spreadshirt.de" => "spreadshirt.png",
"spreadshirt.com" => "spreadshirt.png",
"boosty.to" => "boosty.png",
"tumblr.com" => "tumblr.png",
"ko-fi.com" => "ko-fi.png",
}.freeze,
T::Hash[String, String],
)

View File

@@ -3,6 +3,7 @@ module Domain::ModelHelper
extend T::Sig
extend T::Helpers
include HelpersInterface
include Pundit::Authorization
abstract!
sig do
@@ -15,7 +16,7 @@ module Domain::ModelHelper
).returns(T.nilable(String))
end
def render_for_model(model, partial, as:, expires_in: 1.hour, cache_key: nil)
cache_key ||= [model, partial]
cache_key ||= [model, policy(model), partial]
Rails
.cache
.fetch(cache_key, expires_in:) do

View File

@@ -3,6 +3,7 @@ module Domain::UsersHelper
extend T::Sig
extend T::Helpers
include HelpersInterface
include Pundit::Authorization
abstract!
sig { params(user: Domain::User).returns(T.nilable(String)) }

View File

@@ -1,6 +1,9 @@
import * as React from 'react';
import PostHoverPreview from './PostHoverPreview';
import { anchorClassNamesForVisualStyle } from '../utils/hoverPreviewUtils';
import {
anchorClassNamesForVisualStyle,
iconClassNamesForSize,
} from '../utils/hoverPreviewUtils';
interface PostHoverPreviewWrapperProps {
visualStyle: 'sky-link' | 'description-section-link';
@@ -38,10 +41,14 @@ export const PostHoverPreviewWrapper: React.FC<
>
<a
href={postPath}
className={anchorClassNamesForVisualStyle(visualStyle)}
className={anchorClassNamesForVisualStyle(visualStyle, true)}
>
{visualStyle === 'description-section-link' && (
<i className="fa-regular fa-image h-4 w-4 flex-shrink-0"></i>
<img
src={postDomainIcon}
alt={postTitle}
className={iconClassNamesForSize('small')}
/>
)}
{linkText}
</a>

View File

@@ -1,6 +1,9 @@
import * as React from 'react';
import { UserHoverPreview } from './UserHoverPreview';
import { anchorClassNamesForVisualStyle } from '../utils/hoverPreviewUtils';
import {
anchorClassNamesForVisualStyle,
iconClassNamesForSize,
} from '../utils/hoverPreviewUtils';
type VisualStyle = 'sky-link' | 'description-section-link';
type IconSize = 'small' | 'large';
@@ -22,15 +25,6 @@ interface UserHoverPreviewWrapperProps {
userNumFollowed?: number;
}
function iconClassNamesForSize(size: IconSize) {
switch (size) {
case 'large':
return 'h-8 w-8 flex-shrink-0 rounded-md';
case 'small':
return 'h-4 w-4 flex-shrink-0 rounded-md';
}
}
export const UserHoverPreviewWrapper: React.FC<
UserHoverPreviewWrapperProps
> = ({

View File

@@ -11,6 +11,16 @@ import {
* Utility functions for hover preview styling
*/
export type IconSize = 'large' | 'small';
export function iconClassNamesForSize(size: IconSize) {
switch (size) {
case 'large':
return 'h-8 w-8 flex-shrink-0 rounded-md';
case 'small':
return 'h-5 w-5 flex-shrink-0 rounded-sm';
}
}
// Base preview container styling
export const getPreviewContainerClassName = (
maxWidth: string,

View File

@@ -387,6 +387,8 @@ class Domain::Fa::Job::Base < Scraper::JobBase
user.num_comments_given = user_page.num_comments_given
user.num_journals = user_page.num_journals
user.num_favorites = user_page.num_favorites
user.num_watched_by = user_page.num_watched_by
user.num_watching = user_page.num_watching
user.profile_html =
user_page.profile_html.encode("UTF-8", invalid: :replace, undef: :replace)
if url = user_page.profile_thumb_url

View File

@@ -113,6 +113,25 @@ class Domain::Fa::Job::UserPageJob < Domain::Fa::Job::Base
unique_by: %i[from_id to_id],
)
user.scanned_follows_at = Time.current
elsif recent_watching.any?
# if there are watchers, find the ones we've already recorded. if
# all of them are known, then we can assume watched users are up to date.
num_recent_watching = recent_watching.count
num_recent_watching_known =
user
.followed_users
.where(url_name: recent_watching.map(&:url_name))
.count
if (num_recent_watching == num_recent_watching_known) &&
(user.followed_users.count == user_page.num_watching)
logger.info(
format_tags(
"skipping UserFollowsJob, all watched users already known",
),
)
user.scanned_follows_at = Time.current
end
end
end
@@ -148,6 +167,36 @@ class Domain::Fa::Job::UserPageJob < Domain::Fa::Job::Base
unique_by: %i[from_id to_id],
)
user.scanned_followed_by_at = Time.current
elsif recent_watchers.any?
# if there are watchers, find the ones we've already recorded. if
# all of them are known, then we can skip scanning favs the next time.
known_watchers =
Domain::User::FaUser.where(url_name: recent_watchers.map(&:url_name))
if known_watchers.count == recent_watchers.count
logger.info(
format_tags("skipping followed by scan, all watchers already known"),
)
user.scanned_followed_by_at = Time.current
end
elsif recent_watchers.any?
# if there are watchers, find the ones we've already recorded. if
# all of them are known, then we can assume watched users are up to date.
num_recent_watchers = recent_watchers.count
num_recent_watchers_known =
user
.followed_by_users
.where(url_name: recent_watchers.map(&:url_name))
.count
if (num_recent_watchers == num_recent_watchers_known) &&
(user.followed_by_users.count == user_page.num_watched_by)
logger.info(
format_tags(
"skipping UserFollowsJob, all watched users already known",
),
)
user.scanned_followed_by_at = Time.current
end
end
end
end

View File

@@ -27,6 +27,8 @@ class Domain::Fa::Parser::UserPageHelper < Domain::Fa::Parser::Base
@recent_watching = T.let(nil, T.nilable(T::Array[RecentUser]))
@statistics = T.let(nil, T.nilable(Nokogiri::XML::Element))
@main_about = T.let(nil, T.nilable(Nokogiri::XML::Element))
@num_watched_by = T.let(nil, T.nilable(Integer))
@num_watching = T.let(nil, T.nilable(Integer))
@elem = elem
@page_version = page_version
@@ -261,6 +263,34 @@ class Domain::Fa::Parser::UserPageHelper < Domain::Fa::Parser::Base
@recent_watching ||= recent_users_for_section("Recently Watched")
end
sig { returns(Integer) }
def num_watched_by
@num_watched_by ||=
begin
watchers_text =
recent_users_section("Recent Watchers")
.parent
.css(".floatright")
.first
.text
watchers_text.match(/Watched by (\d+)/)[1].to_i
end
end
sig { returns(Integer) }
def num_watching
@num_watching ||=
begin
watchers_text =
recent_users_section("Recently Watched")
.parent
.css(".floatright")
.first
.text
watchers_text.match(/Watching (\d+)/)[1].to_i
end
end
private
sig { params(section_name: String).returns(T::Array[RecentUser]) }
@@ -289,6 +319,20 @@ class Domain::Fa::Parser::UserPageHelper < Domain::Fa::Parser::Base
end
end
sig { params(section_name: String).returns(Nokogiri::XML::Element) }
def recent_users_section(section_name)
case @page_version
when VERSION_2
@elem
.css(".userpage-section-left")
.find do |elem|
elem.css(".section-header h2")&.first&.text&.strip == section_name
end
else
unimplemented_version!
end
end
sig { params(legacy_name: Symbol, redux_idx: Integer).returns(Integer) }
def stat_value(legacy_name, redux_idx)
legacy_map = # if false # old mode?

View File

@@ -8,12 +8,15 @@ class Domain::User::FaUser < Domain::User
attr_json :artist_type, :string
attr_json :mood, :string
attr_json :profile_html, :string
# num_* are those indicated on the user page
attr_json :num_pageviews, :integer
attr_json :num_submissions, :integer
attr_json :num_comments_recieved, :integer
attr_json :num_comments_given, :integer
attr_json :num_journals, :integer
attr_json :num_favorites, :integer
attr_json :num_watched_by, :integer
attr_json :num_watching, :integer
attr_json_due_timestamp :scanned_gallery_at, 3.years
attr_json_due_timestamp :scanned_page_at, 3.months
attr_json_due_timestamp :scanned_follows_at, 3.months

View File

@@ -1,54 +1,107 @@
# typed: true
# typed: strict
# frozen_string_literal: true
class ApplicationPolicy
attr_reader :user, :record
extend T::Sig
sig { returns(T.nilable(User)) }
attr_reader :user
sig do
returns(
T.nilable(
T.any(ReduxApplicationRecord, T.class_of(ReduxApplicationRecord)),
),
)
end
attr_reader :record
sig { returns(String) }
def cache_key
# find all the policy methods that the subclass defines up to ApplicationPolicy
klass = T.let(self.class, T.class_of(ApplicationPolicy))
methods = []
while klass < ApplicationPolicy
methods.concat(klass.instance_methods(false))
klass = T.cast(klass.superclass, T.class_of(ApplicationPolicy))
end
methods.uniq!
methods.sort!
method_values_string =
methods.map { |method| "#{method}:#{send(method)}" }.join("/")
"#{self.class.name}::#{Digest::SHA256.hexdigest(method_values_string)[0..16]}"
end
sig do
params(
user: T.nilable(User),
record:
T.nilable(
T.any(ReduxApplicationRecord, T.class_of(ReduxApplicationRecord)),
),
).void
end
def initialize(user, record)
@user = user
@record = record
end
sig { returns(T::Boolean) }
def index?
false
end
sig { returns(T::Boolean) }
def show?
false
end
sig { returns(T::Boolean) }
def create?
false
end
sig { returns(T::Boolean) }
def new?
create?
end
sig { returns(T::Boolean) }
def update?
false
end
sig { returns(T::Boolean) }
def edit?
update?
end
sig { returns(T::Boolean) }
def destroy?
false
end
class Scope
extend T::Sig
sig { params(user: T.nilable(User), scope: T.untyped).void }
def initialize(user, scope)
@user = user
@scope = scope
end
sig { returns(T.untyped) }
def resolve
raise NoMethodError, "You must define #resolve in #{self.class}"
end
private
attr_reader :user, :scope
sig { returns(T.nilable(User)) }
attr_reader :user
sig { returns(T.untyped) }
attr_reader :scope
end
end

View File

@@ -1,50 +1,66 @@
# typed: true
# typed: strict
class GlobalStatePolicy < ApplicationPolicy
extend T::Sig
sig { returns(T::Boolean) }
def index?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def show?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def create?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def update?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def destroy?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def fa_cookies?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def edit_fa_cookies?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def update_fa_cookies?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def ib_cookies?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def edit_ib_cookies?
user.admin?
user&.admin? || false
end
sig { returns(T::Boolean) }
def update_ib_cookies?
user.admin?
user&.admin? || false
end
class Scope < ApplicationPolicy::Scope
extend T::Sig
sig { returns(T.untyped) }
def resolve
scope.all
end

View File

@@ -1,7 +1,7 @@
<%# Display post information with associated details %>
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2">
<%= link_to domain_post_path(post),
<%= link_to Rails.application.routes.url_helpers.domain_post_path(post),
class: "badge bg-primary",
target: "_blank" do %>
<i class="fa-solid fa-image me-1"></i><%= post.class.name %> #<%= post.id %>

View File

@@ -2,7 +2,7 @@
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2">
<% attr = user.class.param_prefix_and_attribute[1] %>
<%= link_to domain_user_path(user),
<%= link_to Rails.application.routes.url_helpers.domain_user_path(user),
class: "badge bg-primary",
target: "_blank" do %>
<i class="fa-solid fa-user me-1"></i><%= user.class.name %> #<%= user.id %>

View File

@@ -74,6 +74,7 @@ ActiveSupport.on_load(:good_job_application_controller) do
helper Domain::PostsHelper
helper Domain::UsersHelper
helper Domain::PostGroupsHelper
helper Domain::DomainModelHelper
helper GoodJobHelper
end
end

View File

@@ -2,17 +2,21 @@
START_PROMETHEUS_EXPORTER =
T.let(
# do not start in test or console mode
(
!Rails.env.test? && !Rails.const_defined?("Console") &&
Rails.const_defined?("Server")
) ||
# always start in sever mode
Rails.const_defined?("Server") ||
# always start in worker mode
(Rails.env == "worker") ||
# ran with args when in server mode, but no top level task
(ARGV.any? && Rake.application.top_level_tasks.empty?),
# never start in test mode
!Rails.env.test? &&
(
(
# and do not start in console mode
!Rails.env.test? && !Rails.const_defined?("Console") &&
Rails.const_defined?("Server")
) ||
# always start in sever mode
Rails.const_defined?("Server") ||
# always start in worker mode
(Rails.env == "worker") ||
# ran with args when in server mode, but no top level task
(ARGV.any? && Rake.application.top_level_tasks.empty?)
),
T::Boolean,
)

View File

@@ -34,9 +34,10 @@ class ApplicationController
include ::Domain::UsersHelper
include ::PathsHelper
include ::Domain::DomainsHelper
include ::Domain::DomainModelHelper
include ::Pundit::Authorization
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper

View File

@@ -31,9 +31,10 @@ class DeviseController
include ::Domain::UsersHelper
include ::PathsHelper
include ::Domain::DomainsHelper
include ::Domain::DomainModelHelper
include ::Pundit::Authorization
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper

View File

@@ -1816,6 +1816,96 @@ class Domain::User::FaUser
sig { void }
def num_submissions_will_change!; end
sig { returns(T.nilable(::Integer)) }
def num_watched_by; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def num_watched_by=(value); end
sig { returns(T::Boolean) }
def num_watched_by?; end
sig { returns(T.nilable(::Integer)) }
def num_watched_by_before_last_save; end
sig { returns(T.untyped) }
def num_watched_by_before_type_cast; end
sig { returns(T::Boolean) }
def num_watched_by_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def num_watched_by_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def num_watched_by_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def num_watched_by_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def num_watched_by_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def num_watched_by_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def num_watched_by_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def num_watched_by_previously_was; end
sig { returns(T.nilable(::Integer)) }
def num_watched_by_was; end
sig { void }
def num_watched_by_will_change!; end
sig { returns(T.nilable(::Integer)) }
def num_watching; end
sig { params(value: T.nilable(::Integer)).returns(T.nilable(::Integer)) }
def num_watching=(value); end
sig { returns(T::Boolean) }
def num_watching?; end
sig { returns(T.nilable(::Integer)) }
def num_watching_before_last_save; end
sig { returns(T.untyped) }
def num_watching_before_type_cast; end
sig { returns(T::Boolean) }
def num_watching_came_from_user?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def num_watching_change; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def num_watching_change_to_be_saved; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def num_watching_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def num_watching_in_database; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def num_watching_previous_change; end
sig { params(from: T.nilable(::Integer), to: T.nilable(::Integer)).returns(T::Boolean) }
def num_watching_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::Integer)) }
def num_watching_previously_was; end
sig { returns(T.nilable(::Integer)) }
def num_watching_was; end
sig { void }
def num_watching_will_change!; end
sig { returns(T.nilable(::String)) }
def page_scan_error; end
@@ -2021,6 +2111,12 @@ class Domain::User::FaUser
sig { void }
def restore_num_submissions!; end
sig { void }
def restore_num_watched_by!; end
sig { void }
def restore_num_watching!; end
sig { void }
def restore_page_scan_error!; end
@@ -2180,6 +2276,18 @@ class Domain::User::FaUser
sig { returns(T::Boolean) }
def saved_change_to_num_submissions?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_num_watched_by; end
sig { returns(T::Boolean) }
def saved_change_to_num_watched_by?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_num_watching; end
sig { returns(T::Boolean) }
def saved_change_to_num_watching?; end
sig { returns(T.nilable([T.nilable(::String), T.nilable(::String)])) }
def saved_change_to_page_scan_error; end
@@ -2848,6 +2956,12 @@ class Domain::User::FaUser
sig { returns(T::Boolean) }
def will_save_change_to_num_submissions?; end
sig { returns(T::Boolean) }
def will_save_change_to_num_watched_by?; end
sig { returns(T::Boolean) }
def will_save_change_to_num_watching?; end
sig { returns(T::Boolean) }
def will_save_change_to_page_scan_error?; end

View File

@@ -30,6 +30,8 @@ class GoodJob::JobsController
include ::Domain::UsersHelper
include ::PathsHelper
include ::Domain::DomainsHelper
include ::Domain::DomainModelHelper
include ::Pundit::Authorization
include ::Domain::PostsHelper
include ::Domain::PostGroupsHelper
include ::GoodJobHelper

View File

@@ -34,9 +34,10 @@ class Rails::ApplicationController
include ::Domain::UsersHelper
include ::PathsHelper
include ::Domain::DomainsHelper
include ::Domain::DomainModelHelper
include ::Pundit::Authorization
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper

View File

@@ -34,9 +34,10 @@ class Rails::Conductor::BaseController
include ::Domain::UsersHelper
include ::PathsHelper
include ::Domain::DomainsHelper
include ::Domain::DomainModelHelper
include ::Pundit::Authorization
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper

View File

@@ -34,9 +34,10 @@ class Rails::HealthController
include ::Domain::UsersHelper
include ::PathsHelper
include ::Domain::DomainsHelper
include ::Domain::DomainModelHelper
include ::Pundit::Authorization
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper

View File

@@ -39,10 +39,6 @@ module HelpersInterface
def number_to_human_size(size)
end
sig { params(user: Domain::User).returns(Domain::UserPolicy) }
def policy(user)
end
sig do
params(
model: T.any(Domain::Post, Domain::User),

View File

@@ -254,6 +254,162 @@ describe Domain::Fa::Job::UserPageJob do
end
end
context "the user has more than threshold watched users and they are all known" do
let(:client_mock_config) do
[
{
uri: "https://www.furaffinity.net/user/koul/",
status_code: 200,
content_type: "text/html",
contents:
SpecUtil.read_fixture_file(
# this user page indicates:
# - 14 watched
# - 25 watchers
"domain/fa/user_page/user_page_koul_over_threshold_watchers.html",
),
},
]
end
let(:user) { create(:domain_user_fa_user, url_name: "koul") }
let(:recent_watched_user_url_names) do
%w[
spiritinchoco
syrrvya
knyazkolosok
waspsalad
moth-sprout
suzamuri
2d10
lemithecat
fr95
floki.midnight
dizzy.
kilobytefox
]
end
it "does not mark scanned_follows_at if no followed users are known" do
expect do
perform_now({ user: })
user.reload
end.not_to change { user.scanned_follows_at }
expect(
SpecUtil.enqueued_job_args(Domain::Fa::Job::UserFollowsJob),
).to match([hash_including(user:, caused_by_entry: @log_entries[0])])
end
it "does not mark scanned_follows_at if page indicates more followed than are recorded" do
# all in recent are marked as followed
recent_watched_user_url_names.each do |url_name|
user.followed_users << create(
:domain_user_fa_user,
url_name:,
name: url_name,
)
end
expect(user.followed_users.count).to eq(12)
# but the page indicates user watching 14
expect do
perform_now({ user: })
user.reload
end.not_to change { user.scanned_follows_at }
expect(user.followed_users.count).to eq(12)
expect(
SpecUtil.enqueued_job_args(Domain::Fa::Job::UserFollowsJob),
).to match([hash_including(user:, caused_by_entry: @log_entries[0])])
end
it "marks scanned_follows_at as recent if all followed users are known and number of followed matches page" do
recent_watched_user_url_names.each do |url_name|
user.followed_users << create(:domain_user_fa_user, url_name:)
end
user.followed_users << create(:domain_user_fa_user, url_name: "test1")
user.followed_users << create(:domain_user_fa_user, url_name: "test2")
expect(user.followed_users.count).to eq(14)
expect do
perform_now({ user: })
user.reload
end.to change { user.scanned_follows_at }.to be_within(3.seconds).of(
Time.current,
)
expect(
SpecUtil.enqueued_job_args(Domain::Fa::Job::UserFollowsJob),
).to be_empty
end
end
context "the user is watched by more users than the threshold" do
let(:client_mock_config) do
[
{
uri: "https://www.furaffinity.net/user/koul/",
status_code: 200,
content_type: "text/html",
contents:
SpecUtil.read_fixture_file(
"domain/fa/user_page/user_page_koul_over_threshold_watchers.html",
),
},
]
end
let(:watched_by_user_url_names) do
%w[
skifmutt
k92
erri49
spaghetti779
nonuri
mkyosh
mystpaww
retrorinx
aurorethedire
rasssss
coyotesolo
commissarisador
]
end
let(:user) { create(:domain_user_fa_user, url_name: "koul") }
it "does not mark scanned_followed_by_at as recent if over threshold" do
expect do
perform_now({ user: })
user.reload
end.not_to change { user.scanned_followed_by_at }
end
it "marks scanned_followed_by_at as recent if all watched by users are known and number of watched by matches page" do
watched_by_user_url_names.each do |url_name|
user.followed_by_users << create(:domain_user_fa_user, url_name:)
end
# 25 recorded, so make 25-12 more
(25 - 12).times do |idx|
user.followed_by_users << create(
:domain_user_fa_user,
url_name: "test#{idx}",
)
end
expect do
perform_now({ user: })
user.reload
end.to change { user.scanned_followed_by_at }.to be_within(3.seconds).of(
Time.current,
)
end
end
context "the user has no recent favories" do
let(:client_mock_config) do
[

View File

@@ -500,8 +500,26 @@ describe Domain::Fa::Parser::Page do
assert_equal "Nothing special, just practice.", sub.description_html.strip
end
it "parses num_watched_by and num_watching" do
parser =
get_parser_at(
Rails.root.join(
"test/fixtures/files/domain/fa/user_page/user_page_koul_over_threshold_watchers.html",
),
)
assert_page_type parser, :probably_user_page?
up = parser.user_page
assert_equal 25, up.num_watched_by
assert_equal 14, up.num_watching
end
def get_parser(file, require_logged_in: true)
path = File.join("domain/fa/parser/redux", file)
get_parser_at(path, require_logged_in:)
end
def get_parser_at(path, require_logged_in: true)
path = path.to_s
contents =
SpecUtil.read_fixture_file(path) || raise("Couldn't open #{path}")
parser =
@@ -511,7 +529,7 @@ describe Domain::Fa::Parser::Page do
)
assert_equal Domain::Fa::Parser::Page::VERSION_2,
parser.page_version,
"parser version mismatch for file #{file}"
"parser version mismatch for file #{path}"
parser
end
end

File diff suppressed because one or more lines are too long