Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/controllers/admin/sponsors_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,14 @@ def audit_contact_subscription(contact)
return unless contact.previous_changes.key?(:mailing_list_consent)

contact_audit_key = contact.mailing_list_consent ? 'subscribe' : 'unsubscribe'
Auditor::Audit.new(@sponsor, "sponsor.admin_contact_#{contact_audit_key}", current_user, contact)
.log_with_note(contact.email)

MemberActivityRecorder.record(
actor: current_user,
key: "sponsor.admin_contact_#{contact_audit_key}",
trackable: @sponsor,
recipient: contact,
contact_email: contact.email
)
end

def filter_search_params
Expand Down
8 changes: 6 additions & 2 deletions app/controllers/contact_preferences_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ def subscription_preference(contact)
end

def audit_contact_subscription(contact)
Auditor::Audit.new(contact.sponsor, subscription_preference(contact), contact)
.log_with_note(contact.email)
MemberActivityRecorder.record(
actor: contact,
key: subscription_preference(contact),
trackable: contact.sponsor,
contact_email: contact.email
)
end
end
10 changes: 8 additions & 2 deletions app/services/member_activity_recorder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,16 @@ class MemberActivityRecorder
# historical activity is ever backfilled.
TRACKING_STARTED_ON = Date.new(2026, 9, 8).freeze

def self.record(actor:, key:, trackable: nil, recipient: nil)
def self.record(actor:, key:, trackable: nil, recipient: nil, contact_email: nil)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is now 11 lines against Metrics/MethodLength Max 10, so the lint job fails. Pulling the hash into a local keeps the create! call on one line:

params = contact_email ? { note: contact_email } : {}
PublicActivity::Activity.create!(owner: actor, key:, trackable:, recipient:, parameters: params)

Rails.logger.warn("MemberActivityRecorder: unregistered key '#{key}'") unless KEYS.include?(key)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both migrated call sites pass keys that KEYS does not list: sponsor.admin_contact_subscribe, sponsor.admin_contact_unsubscribe, sponsor.contact_subscribe and sponsor.contact_unsubscribe. Every consent toggle writes unregistered key here. That warning is the one signal for a genuinely unregistered key.

Adding the four keys follows the comment on KEYS: "extend when adding sites".

trackable ||= actor
PublicActivity::Activity.create!(owner: actor, key:, trackable:, recipient:)
PublicActivity::Activity.create!(
owner: actor,
key:,
trackable:,
recipient:,
parameters: contact_email ? { note: contact_email } : {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing in spec/services/member_activity_recorder_spec.rb touches parameters, so this branch is only exercised through the controller. Two examples there would pin both sides: one with contact_email: expecting { note: email }, one without expecting {}.

)
rescue StandardError => e
Rails.logger.warn("MemberActivityRecorder failed (#{key}): #{e.class}: #{e.message}")
end
Expand Down
82 changes: 82 additions & 0 deletions spec/controllers/contact_preferences_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
require 'rails_helper'

RSpec.describe ContactPreferencesController do
let(:contact) { Fabricate(:contact, mailing_list_consent: false) }

before do
allow(ContactMailingListService).to receive(:sync)
end

describe 'GET #show' do
it 'sets the contact for a valid token' do
get :show, params: { token: contact.token }

expect(response).to have_http_status(:success)
expect(controller.instance_variable_get(:@contact)).to eq(contact)
expect(flash[:notice]).to be_nil
end

it 'shows an invalid-session notice for an unknown token' do
get :show, params: { token: 'unknown-token' }

expect(response).to have_http_status(:success)
expect(flash[:notice]).to eq(I18n.t('messages.invalid_session'))
end
end

describe 'PATCH #update' do
it 'passes the contact and email to the activity recorder' do
expect(MemberActivityRecorder).to receive(:record).with(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with this example.

expect(...).to receive trips RSpec/MessageSpies, so the lint job fails. It also restates what the DB-backed examples below already prove through the real recorder (owner, trackable, parameters[:note]), while coupling the test to the exact keyword list, so a harmless new optional kwarg would break it.

Dropping it is enough. If the intent is to pin that no recipient: is passed, assert activity.recipient.nil? in the subscribe example below instead.

actor: contact,
key: 'sponsor.contact_subscribe',
trackable: contact.sponsor,
contact_email: contact.email
)

patch :update, params: {
contact: { token: contact.token, mailing_list_consent: '1' }
}
end

it 'subscribes the contact and records the consent activity' do
patch :update, params: {
contact: { token: contact.token, mailing_list_consent: '1' }
}

expect(contact.reload.mailing_list_consent).to be(true)
expect(ContactMailingListService).to have_received(:sync).with(contact)
expect(response).to redirect_to(
contact_preferences_path(token: contact.token, notice: I18n.t('messages.contact_preferences.updated'))
)

activity = PublicActivity::Activity.find_by!(key: 'sponsor.contact_subscribe')
expect(activity.owner).to eq(contact)
expect(activity.trackable).to eq(contact.sponsor)
expect(activity.parameters).to include(note: contact.email)
end

it 'unsubscribes the contact and records the consent activity' do
contact.update!(mailing_list_consent: true)

patch :update, params: {
contact: { token: contact.token, mailing_list_consent: '0' }
}

expect(contact.reload.mailing_list_consent).to be(false)
expect(ContactMailingListService).to have_received(:sync).with(contact)

activity = PublicActivity::Activity.find_by!(key: 'sponsor.contact_unsubscribe')
expect(activity.owner).to eq(contact)
expect(activity.trackable).to eq(contact.sponsor)
expect(activity.parameters).to include(note: contact.email)
end

it 'returns not found when the submitted token is unknown' do
patch :update, params: {
contact: { token: 'unknown-token', mailing_list_consent: '1' }
}

expect(response).to have_http_status(:not_found)
end
end
end
22 changes: 18 additions & 4 deletions spec/features/admin/sponsor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,15 @@
context 'with activities' do
scenario 'when there are activities' do
contact = sponsor.contacts.first
audit = Auditor::Audit.new(sponsor, 'sponsor.admin_contact_subscribe', manager, contact)
audit.log_with_note(contact.email)
contact_audit_key = contact.mailing_list_consent ? 'subscribe' : 'unsubscribe'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mirrors the controller's own ternary, so a key-derivation regression there would still satisfy the rendering assertions below. Hardcode it here (sponsor.admin_contact_subscribe, matching the fabricator default) and leave derivation to the form-driven scenarios. Same for the deleted-contact setup further down.


MemberActivityRecorder.record(
actor: manager,
key: "sponsor.admin_contact_#{contact_audit_key}",
trackable: sponsor,
recipient: contact,
contact_email: contact.email
)

visit admin_sponsor_path(sponsor)

Expand All @@ -173,8 +180,15 @@

scenario 'when an activity is associated with a deleted contact' do
contact = sponsor.contacts.first
audit = Auditor::Audit.new(sponsor, 'sponsor.admin_contact_subscribe', manager, contact)
audit.log_with_note(contact.email)
contact_audit_key = contact.mailing_list_consent ? 'subscribe' : 'unsubscribe'

MemberActivityRecorder.record(
actor: manager,
key: "sponsor.admin_contact_#{contact_audit_key}",
trackable: sponsor,
recipient: contact,
contact_email: contact.email
)

contact.delete

Expand Down
Loading