opt_out 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ *.gem
2
+ *.rbc
3
+ *.db
4
+ .bundle
5
+ .config
6
+ .yardoc
7
+ .env
8
+ Gemfile.lock
9
+ _yardoc
10
+ doc/
11
+ pkg
12
+ rdoc
13
+ tmp
14
+ vendor/gems
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in html-pipeline.gemspec
4
+ gemspec
5
+
6
+ group :development do
7
+ gem 'bundler'
8
+ gem 'rake'
9
+ gem 'dotenv'
10
+ end
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Jerry Cheung
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # OptOut
2
+
3
+ OptOut is a rubygem for tracking unsubscriptions to newsletters.
4
+
5
+ ## Usage
6
+
7
+ ```ruby
8
+ OptOut.unsubscribe('newsletters', '5') # unsubscribe user id '5' from 'newsletters'
9
+ OptOut.subscribed?('newsletters', '5')
10
+ => false
11
+
12
+ OptOut.subscribe('newsletters', '5') # re-subscribe a user to 'newsletters'
13
+ OptOut.subscribed?('newsletters', '5')
14
+ => true
15
+
16
+ OptOut.unsubscribed?('newsletters', '5') # another way to query
17
+ => false
18
+
19
+ OptOut.subscribed('newsletters', '8') # users are subscribed by default unless explicitly unsubscribed
20
+ => true
21
+
22
+ ['1', '2', '3'].each {|user_id| OptOut.unsubscribe('newsletters', user_id)}
23
+ OptOut.unsubscribers # returns a list of unsubscribed user ids
24
+ => ['1', '2', '3']
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ The persistence backend can be configured to be one of:
30
+
31
+ * [MemoryAdapter](lib/opt_out/adapters/memory_adapter.rb)
32
+ * [RedisAdapter](lib/opt_out/adapters/redis_adapter.rb)
33
+ * [ActiveRecordAdapter](lib/opt_out/adapters/active_record_adapter.rb)
34
+
35
+ For example, to configure OptOut to store unsubscriptions in Redis:
36
+
37
+ ```ruby
38
+ OptOut.configure do |c|
39
+ c.adapter = OptOut::Adapters::RedisAdapter
40
+ c.options = {
41
+ :url => 'redis://localhost:6379'
42
+ }
43
+ end
44
+ ```
45
+
46
+ See individual adapter classes for setup and configuration options. To write a
47
+ custom adapter, take a look at [AbstractAdapter](lib/opt_out/adapters/abstract_adapter.rb)
48
+
49
+
50
+ ## Development
51
+
52
+ To run tests, you will need a running redis instance. Add a `.env` file to the
53
+ project root to configure where redis lives:
54
+
55
+ ```
56
+ REDIS_URL=redis://localhost:6379
57
+ ```
58
+
59
+ To run tests:
60
+
61
+ ```sh
62
+ $ rake
63
+ ```
64
+
65
+ ## Contributing
66
+
67
+ 1. [Fork it](https://help.github.com/articles/fork-a-repo)
68
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
69
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
70
+ 4. Push to the branch (`git push origin my-new-feature`)
71
+ 5. Create new [Pull Request](https://help.github.com/articles/using-pull-requests)
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << "test"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ t.verbose = true
9
+ end
10
+
11
+ task :default => :test
data/lib/opt_out.rb ADDED
@@ -0,0 +1,58 @@
1
+ # Track user unsubscriptions by list.
2
+ #
3
+ # OptOut.unsubscribe('newsletters', '5') # unsubscribe user id '5' from 'newsletters'
4
+ # OptOut.subscribed?('newsletters', '5')
5
+ # => false
6
+ #
7
+ # OptOut.subscribe('newsletters', '5') # re-subscribe a user to 'newsletters'
8
+ # OptOut.subscribed?('newsletters', '5')
9
+ # => true
10
+ #
11
+ # OptOut.unsubscribed?('newsletters', '5') # another way to query
12
+ # => false
13
+ #
14
+ # OptOut.subscribed('newsletters', '8') # users are subscribed by default unless explicitly unsubscribed
15
+ # => true
16
+ #
17
+ # ['1', '2', '3'].each {|user_id| OptOut.unsubscribe('newsletters', user_id)}
18
+ # OptOut.unsubscribers # returns a list of unsubscribed user ids
19
+ # => ['1', '2', '3']
20
+ require 'forwardable'
21
+ require 'opt_out/adapters'
22
+
23
+ module OptOut
24
+ # Options:
25
+ # :adapter - subclass of OptOut::Adapters::AbstractAdapter
26
+ # :options - instantiation options to pass to `adapter`
27
+ class Configuration < Struct.new(:adapter, :options)
28
+ end
29
+
30
+ class << self
31
+ extend Forwardable
32
+ delegate [:subscribe, :subscribed?, :unsubscribe, :unsubscribed?, :unsubscribers, :reset] => :adapter
33
+
34
+ # Private: returns a memoized instance of adapter to use
35
+ def adapter
36
+ @adapter ||= config.adapter.new(config.options)
37
+ end
38
+
39
+ # Public: Configure OptOut. Returns Configuration.
40
+ #
41
+ # Example:
42
+ #
43
+ # OptOut.configure do |c|
44
+ # c.adapter = OptOut::Adapters::RedisAdapter
45
+ # c.options = {:host => 'localhost', :port => '6379', :password => ''}
46
+ # end
47
+ def configure(&blk)
48
+ blk.call(config)
49
+ @adapter = nil # invalidate adapter on reconfiguration
50
+ config
51
+ end
52
+
53
+ # Public: Returns Configuration
54
+ def config
55
+ @config ||= Configuration.new
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,8 @@
1
+ module OptOut
2
+ module Adapters
3
+ autoload :AbstractAdapter, 'opt_out/adapters/abstract_adapter'
4
+ autoload :MemoryAdapter, 'opt_out/adapters/memory_adapter'
5
+ autoload :RedisAdapter, 'opt_out/adapters/redis_adapter'
6
+ autoload :ActiveRecordAdapter, 'opt_out/adapters/active_record_adapter'
7
+ end
8
+ end
@@ -0,0 +1,52 @@
1
+ module OptOut
2
+ module Adapters
3
+ # An adapter is responsible for tracking (un/re)subscriptions, and
4
+ # unsubscribers.
5
+ class AbstractAdapter
6
+ def initialize(options = nil)
7
+ @options = options || {}
8
+ end
9
+
10
+ # Public: `user_id` is subscribed? to `list_id` iff it's unsubscribed.
11
+ #
12
+ # Returns boolean.
13
+ def subscribed?(list_id, user_id)
14
+ !unsubscribed?(list_id, user_id)
15
+ end
16
+
17
+ # Public: Resubscribe `user_id` to `list_id`. Note that adapters should
18
+ # only keep track of unsubscriptions. Even if subscribe has never been
19
+ # called before, a user is unsubscribed only if `#unsubscribe` is
20
+ # called.
21
+ #
22
+ # Returns nothing.
23
+ def subscribe(list_id, user_id)
24
+ raise NotImplementedError.new
25
+ end
26
+
27
+ # Public: unsubscribe `user_id` from `list_id`
28
+ #
29
+ # Returns nothing.
30
+ def unsubscribe(list_id, user_id)
31
+ raise NotImplementedError.new
32
+ end
33
+
34
+ # Public: is `user_id` unsubscribed from `list_id`?
35
+ #
36
+ # Returns boolean.
37
+ def unsubscribed?(list_id, user_id)
38
+ raise NotImplementedError.new
39
+ end
40
+
41
+ # Public: returns an array of unsubscribers for `list_id`
42
+ def unsubscribers(list_id)
43
+ raise NotImplementedError.new
44
+ end
45
+
46
+ # Private: reset internal data store for testing
47
+ def reset
48
+ raise NotImplementedError.new
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ require 'active_record'
2
+
3
+ module OptOut
4
+ module Adapters
5
+ # Adapter that stores persists data through ActiveRecord.
6
+ # It requires the following table:
7
+ #
8
+ # :list_id string
9
+ # :user_id string
10
+ # composite index on (list_id, user_id)
11
+ #
12
+ # Options
13
+ # :table_name - name of storage table. Defaults to 'opt_outs'
14
+ class ActiveRecordAdapter < AbstractAdapter
15
+
16
+ def subscribe(list_id, user_id)
17
+ return if [list_id, user_id].any? {|s| s.nil? || s == ''}
18
+ store.where(:list_id => list_id.to_s, :user_id => user_id.to_s).delete_all
19
+ nil
20
+ end
21
+
22
+ # TODO: would prefer opt_outs table to not have a primary key `id`, but
23
+ # that's not working right now
24
+ def unsubscribe(list_id, user_id)
25
+ store.create(:list_id => list_id.to_s, :user_id => user_id.to_s)
26
+ rescue ActiveRecord::RecordNotUnique
27
+ # already unsubscribed
28
+ ensure
29
+ return nil
30
+ end
31
+
32
+ def unsubscribed?(list_id, user_id)
33
+ store.exists?(:list_id => list_id.to_s, :user_id => user_id.to_s)
34
+ end
35
+
36
+ def unsubscribers(list_id)
37
+ store.where(:list_id => list_id.to_s).map(&:user_id).to_a
38
+ end
39
+
40
+ def reset
41
+ store.delete_all
42
+ end
43
+
44
+ private
45
+
46
+ def store
47
+ return @store if @store
48
+
49
+ table_name = @options[:table_name]
50
+ @store = Class.new(ActiveRecord::Base) do
51
+ self.table_name = table_name
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,40 @@
1
+ require 'set'
2
+
3
+ module OptOut
4
+ module Adapters
5
+ # Adapter that stores persists data in memory in a hash.
6
+ #
7
+ # Options
8
+ # :store - optional Hash instance to store unsubscriptions
9
+ class MemoryAdapter < AbstractAdapter
10
+ # Subscribe `user_id` to `list_id`. Returns nothing.
11
+ def subscribe(list_id, user_id)
12
+ store[list_id].delete(user_id) and return
13
+ end
14
+
15
+ def unsubscribe(list_id, user_id)
16
+ store[list_id] ||= Set.new
17
+ store[list_id] << user_id
18
+ nil
19
+ end
20
+
21
+ def unsubscribed?(list_id, user_id)
22
+ (store[list_id] || []).include?(user_id)
23
+ end
24
+
25
+ def unsubscribers(list_id)
26
+ store[list_id].to_a
27
+ end
28
+
29
+ def reset
30
+ store.clear
31
+ end
32
+
33
+ private
34
+
35
+ def store
36
+ @store ||= @options[:store] || Hash.new
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ require 'redis'
2
+ require 'uri'
3
+
4
+ module OptOut
5
+ module Adapters
6
+ # Adapter that persists data in a Redis set.
7
+ #
8
+ # Options
9
+ # :redis - redis connection object OR
10
+ # :url - redis connection url OR
11
+ # :host
12
+ # :port
13
+ # :password
14
+ # :key_format - format string for redis key. list_id is interpolated into this option.
15
+ # Default is "opt_out:%s"
16
+ class RedisAdapter < AbstractAdapter
17
+ def subscribe(list_id, user_id)
18
+ redis.srem(key(list_id), user_id) and return
19
+ end
20
+
21
+ def unsubscribe(list_id, user_id)
22
+ redis.sadd(key(list_id), user_id) and return
23
+ end
24
+
25
+ def unsubscribed?(list_id, user_id)
26
+ redis.sismember(key(list_id), user_id)
27
+ end
28
+
29
+ def unsubscribers(list_id)
30
+ redis.smembers(key(list_id))
31
+ end
32
+
33
+ def reset
34
+ redis.flushdb
35
+ end
36
+
37
+ def key_format
38
+ @key_format || @options[:key_format] || "opt_out:%s"
39
+ end
40
+ attr_writer :key_format
41
+
42
+ # Private: returns redis client for this adapter
43
+ def redis
44
+ return @redis if @redis
45
+
46
+ @redis = if @options[:redis]
47
+ @options[:redis]
48
+ elsif @options[:url]
49
+ uri = URI.parse(@options[:url])
50
+ Redis.new(:host => uri.host, :port => uri.port, :password => uri.password)
51
+ else
52
+ Redis.new(:host => @options[:host], :port => @options[:port], :password => @options[:password])
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Returns key to use for redis set add from `:key_format` option
59
+ def key(list_id)
60
+ key_format % list_id
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,3 @@
1
+ module OptOut
2
+ VERSION = '1.0.0'
3
+ end
data/opt_out.gemspec ADDED
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path("../lib/opt_out/version", __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.name = "opt_out"
6
+ gem.version = OptOut::VERSION
7
+ gem.license = "MIT"
8
+ gem.authors = ["Jerry Cheung"]
9
+ gem.email = ["jch@whatcodecraves.com"]
10
+ gem.description = %q{Track newsletter unsubscriptions}
11
+ gem.summary = %q{Utilities for managing user unsubscribes from lists}
12
+ gem.homepage = "https://github.com/jch/opt_out"
13
+
14
+ gem.files = `git ls-files`.split $/
15
+ gem.test_files = gem.files.grep(%r{^test})
16
+ gem.require_paths = ["lib"]
17
+
18
+ gem.add_development_dependency "redis"
19
+ gem.add_development_dependency "activerecord"
20
+ gem.add_development_dependency "sqlite3"
21
+ end
@@ -0,0 +1,20 @@
1
+ require 'adapter_tests'
2
+ require 'sqlite3'
3
+
4
+ class OptOut::ActiveRecordAdapterTest < Test::Unit::TestCase
5
+ include AdapterTests
6
+ test_adapter OptOut::Adapters::ActiveRecordAdapter, :table_name => 'opt_outs' do
7
+ ActiveRecord::Base.establish_connection({
8
+ :adapter => 'sqlite3',
9
+ :database => './test.db'
10
+ })
11
+ conn = ActiveRecord::Base.connection
12
+
13
+ unless conn.table_exists?(:opt_outs)
14
+ conn.create_table(:opt_outs) do |t|
15
+ t.string "list_id"
16
+ t.string "user_id"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,79 @@
1
+ require 'opt_out'
2
+ require 'test/unit'
3
+ require 'dotenv'
4
+
5
+ Dotenv.load
6
+
7
+ # All adapters must pass these tests. To setup a new adapter test:
8
+ #
9
+ # class MyAdapterTest < Test::Unit::TestCase
10
+ # include AdapterTests
11
+ # test_adapter(MyAdapter, {:some => 'options'}) do
12
+ # # optional test setup block
13
+ # end
14
+ # end
15
+ module AdapterTests
16
+ def self.included(base)
17
+ base.extend Macros
18
+ end
19
+
20
+ module Macros
21
+ attr_accessor :original_config, :test_config
22
+
23
+ def test_adapter(adapter, options = {}, &blk)
24
+ self.original_config = OptOut.config.dup
25
+ self.test_config = {
26
+ :adapter => adapter,
27
+ :options => options,
28
+ :setup => blk
29
+ }
30
+ end
31
+ end
32
+
33
+ def setup
34
+ if custom_setup = self.class.test_config[:setup]
35
+ custom_setup.call
36
+ end
37
+ OptOut.configure do |c|
38
+ c.adapter = self.class.test_config[:adapter]
39
+ c.options = self.class.test_config[:options]
40
+ end
41
+ OptOut.reset
42
+ end
43
+
44
+ def teardown
45
+ OptOut.config.adapter = self.class.original_config[:adapter]
46
+ OptOut.config.options = self.class.original_config[:options]
47
+ OptOut.reset
48
+ end
49
+
50
+ def test_auto_subscribed
51
+ assert OptOut.subscribed?('newsletters', '5')
52
+ end
53
+
54
+ def test_resubscribe
55
+ OptOut.unsubscribe('newsletters', '5')
56
+ OptOut.subscribe('newsletters', '5')
57
+ assert OptOut.subscribed?('newsletters', '5')
58
+ assert !OptOut.unsubscribed?('newsletters', '5')
59
+ end
60
+
61
+ def test_unsubscribe
62
+ OptOut.unsubscribe('newsletters', '5')
63
+ assert !OptOut.subscribed?('newsletters', '5')
64
+ assert OptOut.unsubscribed?('newsletters', '5')
65
+ end
66
+
67
+ def test_multi_unsubscribe
68
+ OptOut.unsubscribe('newsletters', '5')
69
+ OptOut.unsubscribe('newsletters', '5')
70
+ assert !OptOut.subscribed?('newsletters', '5')
71
+ assert OptOut.unsubscribed?('newsletters', '5')
72
+ end
73
+
74
+ def test_unsubscribers
75
+ OptOut.unsubscribe('newsletters', '5')
76
+ OptOut.unsubscribe('newsletters', '6')
77
+ assert_equal ['5', '6'], OptOut.unsubscribers('newsletters').sort
78
+ end
79
+ end
@@ -0,0 +1,6 @@
1
+ require 'adapter_tests'
2
+
3
+ class OptOut::MemoryAdapterTest < Test::Unit::TestCase
4
+ include AdapterTests
5
+ test_adapter OptOut::Adapters::MemoryAdapter, :store => {}
6
+ end
@@ -0,0 +1,17 @@
1
+ require 'adapter_tests'
2
+
3
+ class OptOut::RedisAdapterTest < Test::Unit::TestCase
4
+ include AdapterTests
5
+ test_adapter OptOut::Adapters::RedisAdapter, :url => ENV['REDIS_URL']
6
+
7
+ def test_default_key_format
8
+ OptOut.unsubscribe('releases', '9')
9
+ assert_equal ['9'], OptOut.adapter.redis.smembers("opt_out:releases")
10
+ end
11
+
12
+ def test_custom_key_format
13
+ OptOut.adapter.key_format = "notifications:%s:subscribe"
14
+ OptOut.unsubscribe('releases', '9')
15
+ assert_equal ['9'], OptOut.adapter.redis.smembers("notifications:releases:subscribe")
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: opt_out
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jerry Cheung
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-08-09 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: redis
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: activerecord
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: sqlite3
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ description: Track newsletter unsubscriptions
63
+ email:
64
+ - jch@whatcodecraves.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - .gitignore
70
+ - Gemfile
71
+ - LICENSE
72
+ - README.md
73
+ - Rakefile
74
+ - lib/opt_out.rb
75
+ - lib/opt_out/adapters.rb
76
+ - lib/opt_out/adapters/abstract_adapter.rb
77
+ - lib/opt_out/adapters/active_record_adapter.rb
78
+ - lib/opt_out/adapters/memory_adapter.rb
79
+ - lib/opt_out/adapters/redis_adapter.rb
80
+ - lib/opt_out/version.rb
81
+ - opt_out.gemspec
82
+ - test/active_record_adapter_test.rb
83
+ - test/adapter_tests.rb
84
+ - test/memory_adapter_test.rb
85
+ - test/redis_adapter_test.rb
86
+ homepage: https://github.com/jch/opt_out
87
+ licenses:
88
+ - MIT
89
+ post_install_message:
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ none: false
95
+ requirements:
96
+ - - ! '>='
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ! '>='
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubyforge_project:
107
+ rubygems_version: 1.8.23
108
+ signing_key:
109
+ specification_version: 3
110
+ summary: Utilities for managing user unsubscribes from lists
111
+ test_files:
112
+ - test/active_record_adapter_test.rb
113
+ - test/adapter_tests.rb
114
+ - test/memory_adapter_test.rb
115
+ - test/redis_adapter_test.rb