Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e5b2bf3
Replace Her in SplitRegistry
rzane Jan 22, 2025
d916189
Implement SplitConfig
rzane Jan 22, 2025
478505b
Implement IdentifierType
rzane Jan 22, 2025
7c5086c
Remove unused methods
rzane Jan 22, 2025
b6c2599
Reimplement Identifier
rzane Jan 22, 2025
f2eebb7
Ignore unknown attributes
rzane Jan 22, 2025
42dba0b
Clean up Identifier#save
rzane Jan 22, 2025
6f42ea2
Implement Visitor
rzane Jan 22, 2025
1b02044
Allow connection switching
rzane Jan 22, 2025
5750615
Avoid potential confustion with `faraday_middleware`
rzane Jan 22, 2025
f91a368
Replace VisitorDetail
rzane Jan 22, 2025
297e350
Replace AssignmentDetail
rzane Jan 22, 2025
b2b4d4e
Convert Remote::Assignment
rzane Jan 22, 2025
b4f9692
Convert SplitDetail
rzane Jan 22, 2025
8a31499
Convert AssignmentEvent
rzane Jan 22, 2025
06794e4
Update FakeServer.reset
rzane Jan 22, 2025
1cc8c9f
Remove RemoteModel
rzane Jan 22, 2025
9a1250e
Remove TestTrackApi
rzane Jan 22, 2025
e872e45
Remove vendored gems
rzane Jan 22, 2025
9874de4
Remove Her
rzane Jan 22, 2025
1a642d1
Provide `#request` helper
rzane Jan 23, 2025
597073d
Make sure fake responses are consistent with real responses
rzane Jan 23, 2025
2cb115c
Remove unused dependencies
rzane Jan 23, 2025
60c6d25
Tidy up #visitor=
rzane Jan 23, 2025
1224a75
Fix RuboCop
rzane Jan 23, 2025
effd79e
Separate models from HTTP
rzane Jan 23, 2025
d7bca1d
Always use `.attribute`
rzane Jan 23, 2025
55b0c65
Use `ActiveModel::Model`
rzane Jan 23, 2025
847b139
Add spec for `TestTrack::Resource`
rzane Jan 23, 2025
3de6cee
Add specs for `TestTrack::Client`
rzane Jan 23, 2025
36ec075
Create a persistence concern
rzane Jan 23, 2025
9539979
Move `UnrecoverableConnectivityError`
rzane Jan 23, 2025
170a663
Raise the correct exceptions
rzane Jan 23, 2025
41aece8
Move Client to lib
rzane Jan 23, 2025
57938f0
Add convenience method for building a connection
rzane Jan 23, 2025
bf68235
Add specs for persistence
rzane Jan 23, 2025
bfe9cab
Clear changes information after initialize
rzane Jan 23, 2025
e415238
Ensure that middleware is in the correct order
rzane Jan 23, 2025
0bde9fe
Remove unnecessary require
rzane Jan 23, 2025
3f3f1d9
Avoid stubbing the test subject
rzane Jan 24, 2025
f39f96c
Add a test for timeout
rzane Jan 24, 2025
86780f6
Fix RuboCop violations
rzane Jan 24, 2025
acc66ff
Bump version
rzane Jan 24, 2025
94e8388
Clarify error message
rzane Jan 24, 2025
7f4c8ef
Rename to visitor_attributes
rzane Jan 29, 2025
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
9 changes: 2 additions & 7 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
PATH
remote: .
specs:
test_track_rails_client (8.0.0)
test_track_rails_client (9.0.0)
activejob (>= 7.0, < 8.1)
activemodel (>= 7.0, < 8.1)
faraday (>= 0.8)
faraday_middleware
faraday (~> 1.10)
mixpanel-ruby (~> 1.4)
multi_json (~> 1.7)
public_suffix (>= 2.0.0)
railties (>= 7.0, < 8.1)
request_store (~> 1.3)
Expand Down Expand Up @@ -103,8 +101,6 @@ GEM
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
globalid (1.2.1)
activesupport (>= 6.1)
hashdiff (1.1.2)
Expand All @@ -127,7 +123,6 @@ GEM
method_source (1.1.0)
minitest (5.25.4)
mixpanel-ruby (1.7.0)
multi_json (1.15.0)
multipart-post (2.4.1)
nokogiri (1.18.2-arm64-darwin)
racc (~> 1.4)
Expand Down
29 changes: 29 additions & 0 deletions app/models/concerns/test_track/persistence.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module TestTrack::Persistence
extend ActiveSupport::Concern

module ClassMethods
def create!(attributes)
new(attributes).tap(&:save!)
end
end

def save
return false unless valid?

persist!
true
rescue Faraday::UnprocessableEntityError
errors.add(:base, 'The HTTP request failed with a 422 status code')
false
end
Comment on lines +10 to +18
Copy link
Contributor Author

@rzane rzane Jan 24, 2025

Choose a reason for hiding this comment

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

Note: The 422 response contains the errors that occurred, and Her would normally assign that to the record. I don't think we're relying on that functionality, so I did the bare minimum here.


def save!
save or raise(ActiveModel::ValidationError, self)
Copy link
Member

Choose a reason for hiding this comment

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

rare or spotted in the wild! but is it worth using it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ha, this is one of those rare cases where you're supposed to use or. I restrict my usage to or raise and or return.

end

private

def persist!
raise NotImplementedError
end
end
14 changes: 0 additions & 14 deletions app/models/concerns/test_track/remote_model.rb

This file was deleted.

14 changes: 14 additions & 0 deletions app/models/concerns/test_track/resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module TestTrack::Resource
extend ActiveSupport::Concern

include ActiveModel::Model
include ActiveModel::Attributes

private

def _assign_attribute(name, value)
super
rescue ActiveModel::UnknownAttributeError
# Don't raise when we encounter an unknown attribute.
Copy link
Member

Choose a reason for hiding this comment

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

Why not?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because there was an existing test for this behavior, but also because it makes sense with the way APIs are versioned.

Also, if we don't allow unknown fields, any non-breaking change (e.g. new field is added) to the API will break the client.

Copy link
Member

Choose a reason for hiding this comment

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

We do have an API versioning strategy, so the addition of a new field could (and probably should) fall under a new version release. But I can understand not introducing additional strictness as part of this PR.

end
end
13 changes: 11 additions & 2 deletions app/models/test_track/remote/assignment.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
class TestTrack::Remote::Assignment
include TestTrack::RemoteModel
include TestTrack::Resource
include ActiveModel::Dirty

attributes :split_name, :variant, :context, :unsynced
attribute :split_name
attribute :variant
attribute :context
attribute :unsynced, :boolean

validates :split_name, :variant, :mixpanel_result, presence: true

def initialize(...)
super
clear_changes_information
end
Comment on lines +12 to +15
Copy link
Contributor Author

@rzane rzane Jan 24, 2025

Choose a reason for hiding this comment

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

This resource uses ActiveModel::Dirty to implement variant_changed?. It'd probably be better to not rely on AM::Dirty, but I wanted to maintain the current behavior as much as possible.


def unsynced?
unsynced || variant_changed?
end
Expand Down
17 changes: 6 additions & 11 deletions app/models/test_track/remote/assignment_detail.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
class TestTrack::Remote::AssignmentDetail
include TestTrack::RemoteModel
include TestTrack::Resource

attributes :split_location, :split_name, :variant_name, :variant_description, :assigned_at

def assigned_at
original = super
if original.blank? || !original.respond_to?(:in_time_zone)
nil
else
original.in_time_zone rescue nil # rubocop:disable Style/RescueModifier
end
end
Comment on lines -6 to -13
Copy link
Member

Choose a reason for hiding this comment

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

I assume this is more or less what :datetime buys us here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup, exactly. There's a test that covers this, too.

attribute :split_location
attribute :split_name
attribute :variant_name
attribute :variant_description
attribute :assigned_at, :datetime

def self.fake_instance_attributes(_)
{
Expand Down
22 changes: 16 additions & 6 deletions app/models/test_track/remote/assignment_event.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
class TestTrack::Remote::AssignmentEvent
include TestTrack::RemoteModel
include TestTrack::Resource
include TestTrack::Persistence

collection_path 'api/v1/assignment_event'

attributes :visitor_id, :split_name, :unsynced
attribute :visitor_id
attribute :split_name
attribute :mixpanel_result
attribute :context
attribute :unsynced, :boolean

validates :visitor_id, :split_name, :mixpanel_result, presence: true

alias unsynced? unsynced

def fake_save_response_attributes
nil # :no_content is the expected response type
private

def persist!
TestTrack::Client.request(
method: :post,
path: 'api/v1/assignment_event',
body: { context:, visitor_id:, split_name:, mixpanel_result: },
fake: nil
)
end
end
4 changes: 1 addition & 3 deletions app/models/test_track/remote/fake_server.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
class TestTrack::Remote::FakeServer
include TestTrack::RemoteModel

def self.reset!(seed)
raise('Cannot reset FakeServer if TestTrack is enabled.') if TestTrack.enabled?

put('api/v1/reset', seed:)
TestTrack::Client.connection.put('api/v1/reset', seed:)
end
end
30 changes: 17 additions & 13 deletions app/models/test_track/remote/identifier.rb
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
class TestTrack::Remote::Identifier
include TestTrack::RemoteModel
include TestTrack::Resource
include TestTrack::Persistence

collection_path 'api/v1/identifier'

has_one :remote_visitor, data_key: :visitor, class_name: "TestTrack::Remote::Visitor"

attributes :identifier_type, :visitor_id, :value
attribute :identifier_type
attribute :visitor_id
attribute :value

validates :identifier_type, :visitor_id, :value, presence: true

def fake_save_response_attributes
{ visitor: { id: visitor_id, assignments: [] } }
def visitor
@visitor or raise('Visitor data unavailable until you save this identifier.')
end

def visitor
@visitor ||= TestTrack::Visitor.new(visitor_opts!)
def visitor=(visitor_attributes)
@visitor = TestTrack::Remote::Visitor.new(visitor_attributes).to_visitor
end

private

def visitor_opts!
raise("Visitor data unavailable until you save this identifier.") unless attributes[:remote_visitor]
def persist!
result = TestTrack::Client.request(
method: :post,
path: 'api/v1/identifier',
body: { identifier_type:, visitor_id:, value: },
fake: { visitor: { id: visitor_id, assignments: [] } }
)

{ id: remote_visitor.id, assignments: remote_visitor.assignments }
assign_attributes(result)
end
end
18 changes: 12 additions & 6 deletions app/models/test_track/remote/identifier_type.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
class TestTrack::Remote::IdentifierType
include TestTrack::RemoteModel
include TestTrack::Resource
include TestTrack::Persistence

collection_path 'api/v1/identifier_type'

attributes :name
attribute :name

validates :name, presence: true

def fake_save_response_attributes
nil # :no_content is the expected response type
private

def persist!
TestTrack::Client.request(
method: :post,
path: 'api/v1/identifier_type',
body: { name: },
fake: nil
)
end
end
29 changes: 23 additions & 6 deletions app/models/test_track/remote/split_config.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
class TestTrack::Remote::SplitConfig
include TestTrack::RemoteModel
include TestTrack::Resource
include TestTrack::Persistence

collection_path 'api/v1/split_configs'

attributes :name, :weighting_registry
attribute :name
attribute :weighting_registry

validates :name, :weighting_registry, presence: true

def fake_save_response_attributes
nil # :no_content is the expected response type
def self.destroy_existing(id)
TestTrack::Client.request(
method: :delete,
path: "api/v1/split_configs/#{id}",
fake: nil
)

nil
end
Comment on lines +10 to +18
Copy link
Member

Choose a reason for hiding this comment

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

What calls this method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

def drop_split(name)
TestTrack::Remote::SplitConfig.destroy_existing(name)
splits.except!(name.to_s)
persist_schema!
end


private

def persist!
TestTrack::Client.request(
method: :post,
path: 'api/v1/split_configs',
body: { name:, weighting_registry: },
fake: nil
)
end
end
30 changes: 20 additions & 10 deletions app/models/test_track/remote/split_detail.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
class TestTrack::Remote::SplitDetail
include TestTrack::RemoteModel
include TestTrack::Resource

collection_path 'api/v1/split_details'

attributes :name, :hypothesis, :assignment_criteria, :description, :owner, :location, :platform, :variant_details
attribute :name
attribute :hypothesis
attribute :assignment_criteria
attribute :description
attribute :owner
attribute :location
attribute :platform
attribute :variant_details

def self.from_name(name)
# TODO: FakeableHer needs to make this faking a feature of `get`
if faked?
new(fake_instance_attributes(name))
else
get("api/v1/split_details/#{name}")
end
result = TestTrack::Client.request(
method: :get,
path: "api/v1/split_details/#{name}",
fake: fake_instance_attributes(name)
)

new(result)
end

def self.fake_instance_attributes(name)
Expand Down Expand Up @@ -39,4 +45,8 @@ def self.fake_variant_details
}
]
end

def variant_details=(values)
super(values.map(&:symbolize_keys))
Copy link
Member

Choose a reason for hiding this comment

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

Should a symbolize_keys or deep_symbolize_keys be baked into the way that TestTrack::Client.request returns results?

Copy link
Contributor Author

@rzane rzane Jan 29, 2025

Choose a reason for hiding this comment

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

I looked into that. The Faraday::Response::Json middleware does allow you to specify parser_options: { symbolize_names: true }.

This project is been pretty inconsistent with usage of symbol versus string keys. Fakes mostly have symbol keys. Some of our stubs mix string/symbol keys. Splits have string keys (deep). Variant details use symbol keys (shallow).

Using symbolize_names actually caused more problems than it solved, for whatever reason.

end
end
20 changes: 11 additions & 9 deletions app/models/test_track/remote/split_registry.rb
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
class TestTrack::Remote::SplitRegistry
include TestTrack::RemoteModel
include TestTrack::Resource

CACHE_KEY = 'test_track_split_registry'.freeze

collection_path 'api/v3/builds/:build_timestamp/split_registry'
attribute :splits
attribute :experience_sampling_weight

class << self
def fake_instance_attributes(_)
::TestTrack::Fake::SplitRegistry.instance.to_h
end

def instance
# TODO: FakeableHer needs to make this faking a feature of `get`
if faked?
new(fake_instance_attributes(nil))
else
get("api/v3/builds/#{TestTrack.build_timestamp}/split_registry")
end
result = TestTrack::Client.request(
method: :get,
path: "api/v3/builds/#{TestTrack.build_timestamp}/split_registry",
fake: fake_instance_attributes(nil)
)

new(result)
end

def reset
Rails.cache.delete(CACHE_KEY)
end

def to_hash
if faked?
if TestTrack::Client.fake?
instance.attributes.freeze
else
fetch_cache { instance.attributes }.freeze
Expand Down
Loading
Loading