0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 10:47:18 +08:00
discourse/spec/jobs/export_csv_file_spec.rb
Alan Guo Xiang Tan 395077a745
PERF: Use daily rollups for DAU/MAU reports (#41958)
DAU/MAU reports previously calculated every requested date from the full
user visit history, so dashboard requests became slower as that history
grew.

Persist the daily DAU and trailing-30-day MAU values and have the shared
report read only those rollups. The scheduled job builds all existing
history on its first successful run, then refreshes yesterday and today
every three hours. Replacements are atomic, so a failed calculation
leaves the previous report data intact.
2026-07-24 09:52:40 +08:00

555 lines
18 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe Jobs::ExportCsvFile do
describe "#execute" do
let(:other_user) { Fabricate(:user) }
let(:admin) { Fabricate(:admin) }
let(:action_log) { StaffActionLogger.new(admin).log_revoke_moderation(other_user) }
it "raises an error when the entity is missing" do
expect { Jobs::ExportCsvFile.new.execute(user_id: admin.id) }.to raise_error(
Discourse::InvalidParameters,
)
end
context "when re-validating permissions at execution time" do
it "exports the user list for an admin" do
expect do
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "user_list")
end.to change { Upload.count }.by(1)
ensure
admin.uploads.each(&:destroy!)
end
it "raises an error when the admin was demoted after enqueueing" do
admin.revoke_admin!
expect do
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "user_list")
end.to raise_error(Discourse::InvalidAccess)
expect(UserExport.where(user_id: admin.id)).to be_empty
end
it "raises an error when a regular user attempts a privileged export" do
user = Fabricate(:user)
expect do
Jobs::ExportCsvFile.new.execute(user_id: user.id, entity: "user_list")
end.to raise_error(Discourse::InvalidAccess)
expect(UserExport.where(user_id: user.id)).to be_empty
end
it "raises an error when a moderator attempts an admin-only export" do
moderator = Fabricate(:moderator)
expect do
Jobs::ExportCsvFile.new.execute(user_id: moderator.id, entity: "user_list")
end.to raise_error(Discourse::InvalidAccess)
expect(UserExport.where(user_id: moderator.id)).to be_empty
end
end
it "works" do
action_log
begin
expect do
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
end.to change { Upload.count }.by(1)
system_message = admin.topics_allowed.last
expect(system_message.title).to eq(
I18n.t(
"system_messages.csv_export_succeeded.subject_template",
export_title: "Staff Action",
),
)
upload = system_message.first_post.uploads.first
expect(system_message.first_post.raw).to eq(
I18n.t(
"system_messages.csv_export_succeeded.text_body_template",
download_link: UploadMarkdown.new(upload).attachment_markdown,
).chomp,
)
expect(system_message.id).to eq(UserExport.last.topic_id)
expect(system_message.closed).to eq(true)
files = []
Zip::File.open(Discourse.store.path_for(upload)) do |zip_file|
zip_file.each { |entry| files << entry.name }
end
expect(files.size).to eq(1)
ensure
admin.uploads.each(&:destroy!)
end
end
it "generates csv export failed message if upload is too large" do
action_log
SiteSetting.max_export_file_size_kb = 0
begin
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
system_message = admin.topics_allowed.last
expect(system_message.title).to eq(
I18n.t(
"system_messages.csv_export_failed.subject_template",
export_title: "Data export failed",
),
)
ensure
admin.uploads.each(&:destroy!)
end
end
context "when exporting staff action" do
it "exports staff action logs with date filters" do
freeze_time
(1..10).each do |i|
Fabricate(
:user_history,
action: UserHistory.actions[:suspend_user],
created_at: i.days.ago,
)
end
expect do
Jobs::ExportCsvFile.new.execute(
user_id: admin.id,
entity: "staff_action",
args: {
# Fine-tuning for 1 minute to ensure we capture the correct range
start_date: (5.days.ago - 1.minute).iso8601,
end_date: (2.days.ago + 1.minute).iso8601,
},
)
end.to change { Upload.count }.by(1)
Zip::File.open(Discourse.store.path_for(Upload.last)) do |zip_file|
zip_file.each do |entry|
content = zip_file.read(entry)
expect(CSV.parse(content).size).to eq(5) # [header, 2, 3, 4, 5]
end
end
end
it "delegates to UserHistory.staff_action_records" do
Fabricate(:user_history, action: UserHistory.actions[:suspend_user])
Fabricate(:user_history, action: UserHistory.actions[:change_site_setting])
Fabricate(:user_history, action: UserHistory.actions[:delete_theme])
res =
UserHistory.staff_action_records(
admin,
action_id: UserHistory.actions[:suspend_user].to_s,
)
UserHistory
.expects(:staff_action_records)
.with(
admin,
ActiveSupport::HashWithIndifferentAccess.new(
"action_id" => UserHistory.actions[:suspend_user].to_s,
),
)
.returns(res)
Jobs::ExportCsvFile.new.execute(
user_id: admin.id,
entity: "staff_action",
args: {
"action_id" => UserHistory.actions[:suspend_user].to_s,
},
)
Zip::File.open(Discourse.store.path_for(Upload.last)) do |zip_file|
zip_file.each do |entry|
content = zip_file.read(entry)
expect(CSV.parse(content).size).to eq(2)
end
end
end
it "redacts details and context for moderators who cannot see the log content" do
category = Fabricate(:private_category, group: Fabricate(:group))
topic = Fabricate(:topic, category: category)
post = Fabricate(:post, topic: topic)
moderator = Fabricate(:moderator)
action_log =
StaffActionLogger.new(admin).log_post_edit(
post,
old_raw: "#{post.raw} old",
context: "secret context",
)
Jobs::ExportCsvFile.new.execute(user_id: moderator.id, entity: "staff_action")
row = parse_staff_action_rows(Upload.last).find { |r| r["action"] == "post_edit" }
expect(row).to be_present
expect(row["details"]).to eq(I18n.t("staff_action_logs.redacted"))
expect(row["context"]).to be_blank
action_log.destroy!
end
it "does not redact details and context for admins" do
post = Fabricate(:post)
StaffActionLogger.new(admin).log_post_edit(
post,
old_raw: "#{post.raw} old",
context: "visible context",
)
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
row = parse_staff_action_rows(Upload.last).find { |r| r["action"] == "post_edit" }
expect(row).to be_present
expect(row["details"]).to include("---")
expect(row["context"]).to include("visible context")
end
end
end
describe ".report_export" do
let(:user) { Fabricate(:admin) }
let(:exporter) do
exporter = Jobs::ExportCsvFile.new
exporter.entity = "report"
exporter.extra =
ActiveSupport::HashWithIndifferentAccess.new(
start_date: "2010-01-01",
end_date: "2011-01-01",
)
exporter.current_user = User.find_by(id: user.id)
exporter
end
it "does not throw an error when the dates are invalid" do
Jobs::ExportCsvFile.new.execute(
entity: "report",
user_id: user.id,
args: {
start_date: "asdfasdf",
end_date: "not-a-date",
name: "dau_by_mau",
},
)
end
it "works with single-column reports" do
Fabricate(:user_visit_daily_rollup, date: Date.new(2010, 1, 1))
Fabricate(:user_visit_daily_rollup, date: Date.new(2010, 1, 3), mau: 2)
exporter.extra["name"] = "dau_by_mau"
report = export_report
expect(report.first).to contain_exactly("Day", "Percent")
expect(report.second).to contain_exactly("2010-01-01", "100.0")
expect(report.third).to contain_exactly("2010-01-03", "50.0")
end
it "works with filters" do
user.user_visits.create!(visited_at: "2010-01-01", posts_read: 42)
group = Fabricate(:group)
user1 = Fabricate(:user)
Fabricate(:group_user, group: group, user: user1)
user1.user_visits.create!(visited_at: "2010-01-03", posts_read: 420)
exporter.extra["name"] = "visits"
exporter.extra["group"] = group.id
report = export_report
expect(report.length).to eq(2)
expect(report.first).to contain_exactly("Day", "Desktop", "Mobile")
expect(report.second).to contain_exactly("2010-01-03", "1", "")
end
it "works with single-column reports with default label" do
user.user_visits.create!(visited_at: "2010-01-01", mobile: true)
Fabricate(:user).user_visits.create!(visited_at: "2010-01-03", mobile: true)
exporter.extra["name"] = "mobile_visits"
report = export_report
expect(report.first).to contain_exactly("Day", "Count")
expect(report.second).to contain_exactly("2010-01-01", "1")
expect(report.third).to contain_exactly("2010-01-03", "1")
end
it "works with multi-columns reports" do
DiscourseIpInfo.stubs(:get).with("1.1.1.1").returns(location: "Earth")
user.user_auth_token_logs.create!(
action: "login",
client_ip: "1.1.1.1",
created_at: "2010-01-01",
)
exporter.extra["name"] = "admin_logins"
report = export_report
expect(report.first).to contain_exactly("User", "Location", "Login at")
expect(report.second).to contain_exactly(user.username, "Earth", "2010-01-01 00:00:00 UTC")
end
it "exports suspicious login IP details when allowed" do
DiscourseIpInfo.stubs(:get).with("1.1.1.1").returns(location: "Earth")
user.user_auth_token_logs.create!(
action: "suspicious",
client_ip: "1.1.1.1",
user_agent: "Mozilla/5.0",
created_at: "2010-01-01 12:00:00 UTC",
)
exporter.extra["name"] = "suspicious_logins"
report = export_report
expect(report.second[0, 3]).to eq([user.username, "1.1.1.1", "Earth"])
end
context "when the current user cannot view IPs" do
fab!(:moderator_without_ip_access, :moderator)
let(:user) { moderator_without_ip_access }
it "redacts suspicious login IP address while retaining location" do
SiteSetting.moderators_view_ips = false
DiscourseIpInfo.stubs(:get).returns(location: "Earth")
moderator_without_ip_access.user_auth_token_logs.create!(
action: "suspicious",
client_ip: "1.1.1.1",
user_agent: "Mozilla/5.0",
created_at: "2010-01-01 12:00:00 UTC",
)
exporter.extra["name"] = "suspicious_logins"
report = export_report
expect(report.second[0]).to eq(moderator_without_ip_access.username)
expect(report.second[1]).to eq("")
expect(report.second[2]).to eq("Earth")
end
end
it "works with topic reports" do
freeze_time DateTime.parse("2010-01-01 6:00")
exporter.extra["name"] = "top_referred_topics"
post1 = Fabricate(:post)
Fabricate(:post)
IncomingLink.add(
host: "a.com",
referer: "http://twitter.com",
post_id: post1.id,
ip_address: "1.1.1.1",
)
report = export_report
expect(report.first).to contain_exactly("Topic", "Clicks")
expect(report.second).to contain_exactly(post1.topic.id.to_s, "1")
end
it "works with stacked_chart reports" do
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_logged_in", count: 1)
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_logged_in", count: 2)
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_logged_in", count: 3)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_anon", count: 4)
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_anon", count: 5)
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_anon", count: 6)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_crawler", count: 7)
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_crawler", count: 8)
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_crawler", count: 9)
exporter.extra["name"] = "consolidated_page_views"
report = export_report
expect(report[0]).to contain_exactly("Day", "Logged in users", "Anonymous users", "Crawlers")
expect(report[1]).to contain_exactly("2010-01-01", "1", "4", "7")
expect(report[2]).to contain_exactly("2010-01-02", "2", "5", "8")
expect(report[3]).to contain_exactly("2010-01-03", "3", "6", "9")
end
it "works with stacked_chart reports and hidden_labels" do
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_logged_in", count: 1)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_anon", count: 4)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_crawler", count: 7)
exporter.extra["name"] = "consolidated_page_views"
exporter.extra["hidden_labels"] = "page_view_crawler"
report = export_report
expect(report[0]).to contain_exactly("Day", "Logged in users", "Anonymous users")
expect(report[1]).to contain_exactly("2010-01-01", "1", "4")
end
it "works with posts reports and filters" do
category = Fabricate(:category)
subcategory = Fabricate(:category, parent_category: category)
Fabricate(
:post,
topic: Fabricate(:topic, category: category),
created_at: "2010-01-01 12:00:00 UTC",
)
Fabricate(
:post,
topic: Fabricate(:topic, category: subcategory),
created_at: "2010-01-01 12:00:00 UTC",
)
exporter.extra["name"] = "posts"
exporter.extra["category"] = category.id
report = export_report
expect(report[0]).to contain_exactly("Count", "Day")
expect(report[1]).to contain_exactly("1", "2010-01-01")
exporter.extra["include_subcategories"] = true
report = export_report
expect(report[0]).to contain_exactly("Count", "Day")
expect(report[1]).to contain_exactly("2", "2010-01-01")
end
def export_report
report = []
exporter.report_export { |entry| report << entry }
report
end
end
let(:user_list_header) do
%w[
id
name
username
email
title
created_at
last_seen_at
last_posted_at
last_emailed_at
trust_level
approved
suspended_at
suspended_till
blocked
active
admin
moderator
ip_address
staged
secondary_emails
topics_entered
posts_read_count
time_read
topic_count
post_count
likes_given
likes_received
location
website
views
]
end
let(:user_list_export) do
exported_data = []
Jobs::ExportCsvFile.new.user_list_export { |entry| exported_data << entry }
exported_data
end
def to_hash(row)
Hash[*user_list_header.zip(row).flatten]
end
def parse_staff_action_rows(upload)
rows = []
Zip::File.open(Discourse.store.path_for(upload)) do |zip_file|
zip_file.each do |entry|
csv_rows = CSV.parse(zip_file.read(entry), headers: true)
rows.concat(csv_rows.map(&:to_h))
end
end
rows
end
it "exports secondary emails" do
user = Fabricate(:user)
Fabricate(:secondary_email, user: user, primary: false)
secondary_emails = user.secondary_emails
user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
expect(user["secondary_emails"].split(";")).to match_array(secondary_emails)
end
it "exports sso data" do
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.discourse_connect_secret = "x" * 10
SiteSetting.enable_discourse_connect = true
user = Fabricate(:user)
user.user_profile.update_column(:location, "La,La Land")
user.create_single_sign_on_record(
external_id: "123",
last_payload: "xxx",
external_email: "test@test.com",
)
user_list_header.push(
"external_id",
"external_email",
"external_username",
"external_name",
"external_avatar_url",
)
user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
expect(user["location"]).to eq('"La,La Land"')
expect(user["external_id"]).to eq("123")
expect(user["external_email"]).to eq("test@test.com")
end
it "exports user fields" do
user_field_1 = Fabricate(:user_field, name: "custom field 1")
user_field_2 = Fabricate(:user_field, name: "custom field 2", field_type: "confirm")
user_field_3 = Fabricate(:user_field, name: "custom field 3", field_type: "confirm")
user = Fabricate(:user)
user.set_user_field(user_field_1.id, "Answer custom 1")
user.set_user_field(user_field_2.id, true)
user.set_user_field(user_field_3.id, false)
user.save!
user_list_header.push(
"custom field 1 (custom user field)",
"custom field 2 (custom user field)",
"custom field 3 (custom user field)",
)
export_user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
expect(export_user["custom field 1 (custom user field)"]).to eq("Answer custom 1")
expect(export_user["custom field 2 (custom user field)"]).to eq("true")
expect(export_user["custom field 3 (custom user field)"]).to eq("false")
end
end