Browse Source

Change member and profile from showing a projection to using aggregate

feature/tags
Bèr Kessels 10 months ago
parent
commit
15dec708d1

+ 31
- 0
app/aggregates/member.rb View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative 'mixins/attributes'
require 'lib/aggregate_equality'

module Aggregates
##
@@ -9,9 +10,17 @@ module Aggregates
# and can interact with other members on this and other +Instances+
class Member
include EventSourcery::AggregateRoot
include AggregateEquality
include Attributes

apply MemberAdded do |event|
username = event.body['username']
write_attributes(
added: true,
handle: Handle.new(username),
email: event.body['email'],
name: event.body['name']
)
end

apply MemberInvited do |event|
@@ -51,6 +60,12 @@ module Aggregates
self
end

attr_reader :id

def member_id
id
end

def bio
attributes[:bio]
end
@@ -59,8 +74,24 @@ module Aggregates
attributes[:name]
end

def active?
attributes.fetch(:added, false)
end

def handle
attributes[:handle]
end

def email
attributes[:email]
end

def invitation_token
id
end

def null?
false
end
end
end

+ 5
- 1
app/aggregates/mixins/attributes.rb View File

@@ -9,7 +9,11 @@ module Aggregates
end

def attributes
@attributes ||= Hash.new('')
@attributes ||= Hash.new('').merge(aggregate_id: id)
end

def to_h
attributes
end
end
end

+ 2
- 2
app/commands/contact/add.rb View File

@@ -25,7 +25,7 @@ module Commands

def validate
REQUIRED_PARAMS.each do |param|
if (payload[param] || '').empty?
if (payload[param] || '').to_s.empty?
raise BadRequest, "#{param} is blank"
end
end
@@ -34,7 +34,7 @@ module Commands
private

def aggregate_id_name
payload.fetch('handle', '') + payload.fetch('owner_id', '')
"#{payload['handle']}#{payload['owner_id']}"
end

def aggregate_id_namespace

+ 24
- 1
app/projections/contacts/projector.rb View File

@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative '../members/query'

module Projections
module Contacts
##
@@ -11,15 +13,36 @@ module Projections

table :contacts do
column :owner_id, 'UUID NOT NULL'
column :contact_id, 'UUID NOT NULL'
column :handle, :text
column :name, :text
column :bio, :text
column :updated_at, DateTime
end

project ContactAdded do |event|
aggregate_id = Members::Query.aggregate_id_for(event.body['handle'])
contact = Roost.repository.load(Aggregates::Member, aggregate_id)

table.insert(
owner_id: event.body['owner_id'],
handle: event.body['handle']
contact_id: aggregate_id,
handle: contact.handle.to_s,
name: contact.name,
bio: contact.bio,
updated_at: Time.now
)
end

project MemberBioUpdated do |event|
table.where(contact_id: event.aggregate_id)
.update(bio: event.body['bio'])
end

project MemberNameUpdated do |event|
table.where(contact_id: event.aggregate_id)
.update(name: event.body['name'])
end
end
end
end

+ 1
- 16
app/projections/members/projector.rb View File

@@ -14,9 +14,6 @@ module Projections
column :handle, :text, null: false
column :username, :text
column :password, :text
column :name, :text
column :bio, :text
column :email, :text
end

project MemberAdded do |event|
@@ -26,21 +23,9 @@ module Projections
member_id: event.aggregate_id,
handle: Handle.new(username).to_s,
username: username,
password: event.body['password'],
name: event.body['name'],
email: event.body['email']
password: event.body['password']
)
end

project MemberBioUpdated do |event|
table.where(member_id: event.aggregate_id)
.update(bio: event.body['bio'])
end

project MemberNameUpdated do |event|
table.where(member_id: event.aggregate_id)
.update(name: event.body['name'])
end
end
end
end

+ 4
- 0
app/projections/members/query.rb View File

@@ -13,6 +13,10 @@ module Projections
collection[member_id: id]
end

def self.aggregate_id_for(handle)
(collection.first(handle: handle.to_s) || {}).fetch(:member_id, nil)
end

def self.collection
@collection ||= Roost.projections_database[:members]
end

+ 18
- 22
app/projections/updates/projector.rb View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative '../members/query'
require 'app/aggregates/member'

module Projections
module Updates
@@ -50,14 +51,14 @@ module Projections
end

project MemberBioUpdated do |event|
author = AuthorRecord.new(Members::Query.find(event.aggregate_id))
author = Roost.repository.load(Aggregates::Member, event.aggregate_id)
update = BioUpdateRecord.new(event.body.merge(author: author))

# Insert a record for each local member.
Members::Query.collection.select(:member_id).each do |attrs|
table.insert(
for: attrs[:member_id],
author: author.handle,
author: author.handle.to_s,
posted_at: DateTime.now,
text: update.text
)
@@ -65,15 +66,15 @@ module Projections
end

project ContactAdded do |event|
author = AuthorRecord.new(Members::Query.find(event.body['owner_id']))
author = Roost.repository.load(
Aggregates::Member, event.body['owner_id']
)
update = AddedContact.new(event.body.merge(author: author))

handle = Handle.parse(event.body['handle'])
recepient = Members::Query.find_by(username: handle.username)
recipient_id = Members::Query.aggregate_id_for(event.body['handle'])

table.insert(
for: recepient[:member_id],
author: author.handle,
for: recipient_id,
author: author.handle.to_s,
posted_at: DateTime.now,
text: update.text
)
@@ -86,13 +87,20 @@ module Projections
def text
''
end

def author_name
return '' unless author

name = author.name.to_s
name.empty? ? author.handle : name
end
end

##
# Represents an update to someones profile bio that can be projected
class BioUpdateRecord < UpdateRecord
def text
"#{author.name} updated their bio to #{bio}"
"#{author_name} updated their bio to #{bio}"
end
end

@@ -100,19 +108,7 @@ module Projections
# Represents an update to someones profile bio that can be projected
class AddedContact < UpdateRecord
def text
"#{author.name} added you to their contacts"
end
end

##
# Represents the author of an update
class AuthorRecord < OpenStruct
def name
super || handle
end

def handle
Handle.new(username).to_s
"#{author_name} added you to their contacts"
end
end
end

+ 1
- 1
app/web/controllers/api/api_controller.rb View File

@@ -9,7 +9,7 @@ module Api
class ApiController < ::ApplicationController
# Find authentication details
get '/api/session' do
body current_member.to_h.slice(:member_id, :username, :name, :email)
body current_member.to_h.slice(:aggregate_id, :handle, :name, :email)
.to_json
status(200)
end

+ 4
- 4
app/web/controllers/application_controller.rb View File

@@ -22,7 +22,7 @@ class ApplicationController < Sinatra::Base
protected

def requires_authorization
authorize { !current_member.null? }
authorize { current_member.active? }
end

def authorize(&block)
@@ -30,8 +30,8 @@ class ApplicationController < Sinatra::Base
end

def current_member
@current_member ||= ViewModels::Profile.new(
Projections::Members::Query.find(member_id)
)
return OpenStruct.new(active?: false) unless member_id
@current_member ||= Roost.repository.load(Aggregates::Member, member_id)
end
end

+ 2
- 1
app/web/controllers/web/contacts_controller.rb View File

@@ -31,7 +31,8 @@ module Web
private

def contact
OpenStruct.new(handle: handle.to_s)
aggregate_id = Projections::Members::Query.aggregate_id_for(handle)
Roost.repository.load(Aggregates::Member, aggregate_id)
end

def handle

+ 9
- 6
app/web/controllers/web/profiles_controller.rb View File

@@ -5,11 +5,12 @@ module Web
# Handles profile views
class ProfilesController < WebController
include PolicyHelpers
include LoadHelpers

# TODO: /@handle should redirect to /@handle@example.org when we are
# on example.org
# TODO: /handle should redirect to /@handle@example.org as well
get '/m/@:handle' do
get '/m/:handle' do
raise NotFound, 'Member with that handle not found' if profile.null?

erb(:profile, layout: :layout_member, locals: { profile: profile })
@@ -18,12 +19,14 @@ module Web
private

def profile
return @profile if @profile

member = Projections::Members::Query.find_by(
username: Handle.parse(params[:handle]).username
@profile ||= decorate(
load(
Aggregates::Member,
Projections::Members::Query.aggregate_id_for(params[:handle])
),
ViewModels::Profile,
ViewModels::Profile::NullProfile
)
@profile = ViewModels::Profile.new(member)
end
alias contact profile
end

+ 17
- 0
app/web/helpers/load_helpers.rb View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true

##
# Loads aggregates from repo with fallbacks to nullobjects
module LoadHelpers
def load(aggregate_class, aggregate_id)
return unless aggregate_id

Roost.repository.load(aggregate_class, aggregate_id)
end

def decorate(object, decorator_class, decorator_null_class)
return decorator_null_class.new unless object

decorator_class.new(object)
end
end

+ 33
- 8
app/web/view_models/profile.rb View File

@@ -1,15 +1,26 @@
# frozen_string_literal: true

require 'lib/aggregate_equality'

module ViewModels
##
# A Member Profile view model
class Profile < OpenStruct
class Profile < SimpleDelegator
include AggregateEquality

def self.from_collection(collection)
collection.map { |attrs| new(attrs) }
collection.map { |obj| build(obj) }
end

def handle
Handle.new(username)
def self.build(obj = nil)
case obj
when NilClass
NullProfile.new if obj.nil?
when Hash
new(OpenStruct.new(obj))
else
new(obj)
end
end

def updated_on
@@ -21,13 +32,27 @@ module ViewModels
end

def null?
member_id.nil?
false
end

def ==(other)
return false if null?
##
# Standin for empty profile
class NullProfile < NullObject
def name
placeholder
end

def handle
Handle.new(placeholder)
end

def updated_on
updated_at.to_date
end

handle.to_s == other.handle.to_s
def updated_at
NullDateTime.new('never')
end
end
end
end

+ 8
- 0
app/web/views/profile.erb View File

@@ -30,4 +30,12 @@
</p>
</div>
<p class="bio"><%= profile.bio %></p>

<section class="tags">
<a class="button" href="/m/<%= profile.handle %>/tags">
<span class="icon is-small">
<i class="mdi mdi-plus"></i>
</span>
</a>
</section>
</div>

+ 12
- 0
lib/aggregate_equality.rb View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true

##
# Allows aggregates and aggregate-alikes (like decorators) to match
# the objects
module AggregateEquality
def ==(other)
return false if id.nil?

id == other.id
end
end

+ 5
- 1
lib/null_date.rb View File

@@ -4,13 +4,17 @@
# Generic Null object
class NullObject
attr_reader :placeholder
def initialize(placeholder)
def initialize(placeholder = '')
@placeholder = placeholder
end

def to_s
placeholder
end

def null?
true
end
end

##

+ 24
- 1
test/aggregates/member_test.rb View File

@@ -10,11 +10,34 @@ module Aggregates
class MemberTest < Minitest::Spec
include AttributeBehaviour

let(:id) { fake_uuid(Aggregates::Member, 1) }

subject do
Aggregates::Member.new(fake_uuid(Aggregates::Member, 1), [])
Aggregates::Member.new(id, [])
end

it_behaves_as_attribute_setter(:update_bio, 'bio', MemberBioUpdated)
it_behaves_as_attribute_setter(:update_name, 'name', MemberNameUpdated)

it_sets_attribute(:add_member, 'email')
it_sets_attribute(:add_member, 'name')
it 'add_member sets handle from username' do
subject.add_member('username' => 'harry')
assert_equal(subject.handle, Handle.new('harry'))
end

it 'MemberAdded sets added attribute to true' do
assert(Aggregates::Member.new(id, [MemberAdded.new]).attributes[:added])
end

it 'to_h returns attributes' do
assert_equal({ aggregate_id: id }, subject.to_h)
end

describe 'active?' do
it 'is true when added is true' do
assert(Aggregates::Member.new(id, [MemberAdded.new]).active?)
end
end
end
end

+ 7
- 2
test/aggregates/registration_test.rb View File

@@ -6,8 +6,10 @@ module Aggregates
##
# Unit test for the more complex logic in Registration Aggregate
class RegistrationTest < Minitest::Spec
let(:id) { fake_uuid(Aggregates::Registration, 1) }

subject do
Aggregates::Registration.new(fake_uuid(Aggregates::Registration, 1), [])
Aggregates::Registration.new(id, [])
end

let(:payload) do
@@ -49,7 +51,10 @@ module Aggregates
end

it 'adds username email and password to event' do
assert_equal(subject.changes.last.body, payload.transform_keys(&:to_s))
assert_equal(
payload.transform_keys(&:to_s).merge('aggregate_id' => id),
subject.changes.last.body
)
end
end
end

+ 9
- 6
test/integration/api/member_authenticates_test.rb View File

@@ -29,25 +29,28 @@ class MemberAuthenticatesTest < Minitest::ApiSpec
get '/api/session'
assert_status(200)
assert_equal(
parsed_response,
{
member_id: workflow.aggregate_id,
aggregate_id: workflow.aggregate_id,
name: workflow.member_name,
email: workflow.member_email,
username: nil
}
handle: '@@example.com'
},
parsed_response
)
end
end

describe 'with invalid aggregate_id in token' do
it 'returns an empty member body' do
authentication_payload[:sub] = SecureRandom.uuid
authentication_payload[:sub] = fake_uuid(Aggregates::Member, 1)
token = jwt.encode(authentication_payload, secret, 'HS256')
header 'Authorization', "Bearer #{token}"
get '/api/session'
assert_status(200)
assert_equal(parsed_response, {})
assert_equal(
parsed_response,
{ aggregate_id: fake_uuid(Aggregates::Member, 1) }
)
end
end


+ 1
- 4
test/integration/web/manage_profile_test.rb View File

@@ -51,7 +51,7 @@ class MemberManagesProfileTest < Minitest::WebSpec
main_menu('Updates').click
assert_content "hpotter@example.com #{Date.today}"
# Until harry has changed their name, we render their handle
assert_content "hpotter@example.com updated their bio to #{bio}"
assert_content "@hpotter@example.com updated their bio to #{bio}"
end

it 'does not notify other members on the instance of name update' do
@@ -62,7 +62,4 @@ class MemberManagesProfileTest < Minitest::WebSpec
main_menu('Updates').click
refute_selector('.update')
end

## TODO implement followers first
# it 'notifies all remote contacts'
end

+ 34
- 0
test/lib/aggregate_equality_test.rb View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true

require 'test_helper'

##
# Test Mock
class TestFakeAggregate
include AggregateEquality
attr_reader :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

##
# Test the AggregateEquality module
class AggregateEqualityTest < Minitest::Spec
it 'is equal when ids are equal' do
id = fake_uuid(Aggregates::Member, 1)
assert_equal(
TestFakeAggregate.new(id, 'harry'),
TestFakeAggregate.new(id, 'ron')
)
end

it 'it not equal when both ids are nil' do
refute_equal(
TestFakeAggregate.new(nil, 'harry'),
TestFakeAggregate.new(nil, 'ron')
)
end
end

+ 4
- 0
test/support/workflows.rb View File

@@ -23,6 +23,10 @@ module Workflows
factory(MemberRegisters, form_attributes)
end

def tags_member(form_attributes = {})
factory(TagsMember, form_attributes)
end

def discover_member(form_attributes = {})
factory(DiscoversMember, form_attributes)
end

+ 72
- 0
test/web/helpers/load_helpers_test.rb View File

@@ -0,0 +1,72 @@
# frozen_string_literal: true

require 'test_helper'

require 'app/web/helpers/load_helpers'

# Test Mock
class TestFakeController
include LoadHelpers
end

# Test Mock for a View Model
class TestFakeViewModel
def initialize(_obj); end
end
# Test Mock for a Null View Model
class TestFakeViewNullModel; end

##
# Test the Load Helpers Mixin
class LoadHelpersTest < Minitest::Spec
let(:aggregate_id) { fake_uuid(Aggregates::Member, 1) }
let(:fake_respository) { Minitest::Mock.new }
let(:subject) { TestFakeController.new }
let(:aggregate) { OpenStruct.new }

before do
@repo = Roost.repository
Roost.instance_variable_set(:@repository, fake_respository)

fake_respository.expect(
:load,
aggregate,
[Aggregates::Member, aggregate_id]
)
end

after do
Roost.instance_variable_set(:@repository, @repo)
end

it '#load loads from the Aggregate repository' do
subject.load(Aggregates::Member, aggregate_id)
fake_respository.verify
end

it '#load does not load when aggregate_id is nil' do
assert_nil(subject.load(Aggregates::Member, nil))
end

it '#decorate decorates the object' do
assert_kind_of(
TestFakeViewModel,
subject.decorate(
Aggregates::Member.new(aggregate_id, []),
TestFakeViewModel,
TestFakeViewNullModel
)
)
end

it '#decorate decorates object with null view when object is null' do
assert_kind_of(
TestFakeViewNullModel,
subject.decorate(
nil,
TestFakeViewModel,
TestFakeViewNullModel
)
)
end
end

+ 29
- 0
test/web/view_models/profile_test.rb View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true

require 'test_helper'
require 'app/web/view_models/profile'

##
# Test Generic decorator spec
# TODO: move into own lib superclass when appropriate
class ViewModelTest < Minitest::Spec
it 'handles build with nil as null?' do
assert(ViewModels::Profile.build(nil).null?)
end

it 'handles build with attributes hash' do
view_model = ViewModels::Profile.build({ name: 'Harry' })
assert_equal('Harry', view_model.name)
end

it 'handles build with aggregate on aggregate_id' do
id = fake_uuid(Aggregates::Member, 1)
view_model = ViewModels::Profile.build(OpenStruct.new(aggregate_id: id))
assert_equal(view_model.aggregate_id, id)
end
end

##
# Test Profile view model implementation
class ViewModelProfileTest < Minitest::Spec
end

Loading…
Cancel
Save