~srushe/ho-tan

c01ca8972c7d2d1ee0317575e23d578fc183de18 — Stephen Rushe 2 years ago
Snapshot of working version
A  => .env.test +4 -0
@@ 1,4 @@
APP_ENV=test
MEDIA_ENDPOINT=http://media.example.com/
DESTINATION_CONFIG=../../config/destinations.test.yml
SYNDICATION_TARGET_CONFIG=../../config/syndication_targets.test.yml

A  => .gitignore +6 -0
@@ 1,6 @@
.env
config/*.yml
!config/*.test.yml
coverage/*
spec/examples.txt
tmp/*

A  => .rspec +1 -0
@@ 1,1 @@
--require spec_helper

A  => .ruby-gemset +1 -0
@@ 1,1 @@
ho-tan

A  => .ruby-version +1 -0
@@ 1,1 @@
ruby-2.5.3

A  => CODE_OF_CONDUCT.md +74 -0
@@ 1,74 @@
# Contributor Covenant Code of Conduct

## Our Pledge

In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.

## Our Standards

Examples of behavior that contributes to creating a positive environment
include:

* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members

Examples of unacceptable behavior by participants include:

* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
  address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
  professional setting

## Our Responsibilities

Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.

Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.

## Scope

This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at coc+hotan@deeden.co.uk. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]

[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

A  => Gemfile +24 -0
@@ 1,24 @@
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "git@github.com:#{repo_name}" }

gem 'sinatra'
gem 'sinatra-contrib'
gem 'dotenv'
gem 'addressable'
gem 'babosa', github: 'norman/babosa'
gem 'rack-contrib'
gem 'indieauth-token-verification'
gem 'indieweb-post_types'
gem 'indieweb-post_types-identifier-bookmark'
gem 'indieweb-post_types-identifier-read'
gem 'indieweb-post_types-identifier-scrobble', github: 'srushe/indieweb-post_types-identifier-scrobble'

group :test, :development do
  gem 'rack-test'
  gem 'rspec'
  gem 'simplecov', require: false
  gem 'timecop'
end

A  => Gemfile.lock +92 -0
@@ 1,92 @@
GIT
  remote: git@github.com:norman/babosa
  revision: 29f6057dac9016171e7e82c51d85e2fb5e6e5fbe
  specs:
    babosa (1.0.2)

GIT
  remote: git@github.com:srushe/indieweb-post_types-identifier-scrobble
  revision: 255053fbc98d2826408fbc09756eccfdf8bf26d4
  specs:
    indieweb-post_types-identifier-scrobble (0.1.0)

GEM
  remote: https://rubygems.org/
  specs:
    addressable (2.6.0)
      public_suffix (>= 2.0.2, < 4.0)
    backports (3.11.4)
    diff-lcs (1.3)
    docile (1.3.1)
    dotenv (2.6.0)
    indieauth-token-verification (0.2.0)
    indieweb-post_types (0.3.1)
    indieweb-post_types-identifier-bookmark (1.0.0)
      indieweb-post_types
    indieweb-post_types-identifier-read (0.1.0)
    json (2.1.0)
    multi_json (1.13.1)
    mustermann (1.0.3)
    public_suffix (3.0.3)
    rack (2.0.6)
    rack-contrib (2.1.0)
      rack (~> 2.0)
    rack-protection (2.0.5)
      rack
    rack-test (1.1.0)
      rack (>= 1.0, < 3)
    rspec (3.8.0)
      rspec-core (~> 3.8.0)
      rspec-expectations (~> 3.8.0)
      rspec-mocks (~> 3.8.0)
    rspec-core (3.8.0)
      rspec-support (~> 3.8.0)
    rspec-expectations (3.8.2)
      diff-lcs (>= 1.2.0, < 2.0)
      rspec-support (~> 3.8.0)
    rspec-mocks (3.8.0)
      diff-lcs (>= 1.2.0, < 2.0)
      rspec-support (~> 3.8.0)
    rspec-support (3.8.0)
    simplecov (0.16.1)
      docile (~> 1.1)
      json (>= 1.8, < 3)
      simplecov-html (~> 0.10.0)
    simplecov-html (0.10.2)
    sinatra (2.0.5)
      mustermann (~> 1.0)
      rack (~> 2.0)
      rack-protection (= 2.0.5)
      tilt (~> 2.0)
    sinatra-contrib (2.0.5)
      backports (>= 2.8.2)
      multi_json
      mustermann (~> 1.0)
      rack-protection (= 2.0.5)
      sinatra (= 2.0.5)
      tilt (>= 1.3, < 3)
    tilt (2.0.9)
    timecop (0.9.1)

PLATFORMS
  ruby

DEPENDENCIES
  addressable
  babosa!
  dotenv
  indieauth-token-verification
  indieweb-post_types
  indieweb-post_types-identifier-bookmark
  indieweb-post_types-identifier-read
  indieweb-post_types-identifier-scrobble!
  rack-contrib
  rack-test
  rspec
  simplecov
  sinatra
  sinatra-contrib
  timecop

BUNDLED WITH
   2.0.1

A  => LICENSE.txt +21 -0
@@ 1,21 @@
The MIT License (MIT)

Copyright (c) 2019 Stephen Rushe

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

A  => README.md +106 -0
@@ 1,106 @@
# Ho-Tan - A Ruby Micropub Endpoint

Ho-Tan is a [Micropub](http://micropub.rocks/) endpoint. It is written in Ruby, as a [Sinatra](http://sinatrarb.com/) application, and supports IndieAuth authorisation, Micropub create, update, delete, and undelete commands, as well as multiple [destinations](https://indieweb.org/destination).

Ho-Tan stores all posts as JSON files, and requires no database to run. It was initially inspired by Barry Frost's [Transformative](https://github.com/barryf/transformative), but informed by my desire to separate the storage of the data from the display on a website. Ho-Tan lets me deal with posts directly in files, while allowing me to change how I process them on the back-end for display.

## Requirements

* Ruby - the latest version (or one version back).

## Installation

Ho-Tan powers my personal website (https://deeden.co.uk/) and, as such, is configured to work for me. How it will work for someone else is an interesting question, and one I'd love to hear more about! For my purposes I run Ho-Tan through [Passenger](https://www.phusionpassenger.com/) on a [Dreamhost VPS](https://www.dreamhost.com/hosting/vps/).

## Configuration

### Environment variables

Use of the application **requires** a number of environment variables to be specified, while others are optional. It does support [dotenv](https://github.com/bkeepers/dotenv) if that floats your boat.

#### Required

| Variable | Format | Purpose |
| -------- | ------ | ------- |
| SYNDICATION_TARGET_CONFIG | Path to a YAML file | Specifies the syndication options the endpoint supports |
| DESTINATION_CONFIG | Path to a YAML file | Specifies the destinations the endpoint supports |
| TOKEN_ENDPOINT | URL | The token endpoint used to verify any IndieAuth token |
| DOMAIN | URL | The domain any IndieAuth token will be verified to be valid for |

##### Syndication targets

The Micropub specification includes the concept of [syndication targets](https://www.w3.org/TR/micropub/#h-syndication-targets), a way of specifying other sites to which posts could be syndicated. Ho-Tan supports this by allowing you to specify `SYNDICATION_TARGET_CONFIG` which should point to a file containing YAML detailing the syndication targets supported by the endpoint. Ho-Tan simply reads the details from the YAML file (if provided) and uses them for any syndication target responses, meaning that it can support extra fields (such as `service` and `user`) that may be added to the specification.

A simple example syndication targets file could look something like:

```yaml
syndication_targets:
  -
    uid: https://twitter.com/
    name: Twitter
  -
    uid: https://facebook.com/
    name: Facebook
```

##### Destinations

Although not mentioned in the Micropub specification there is an experimental concept of "[multi-site indieweb](https://indieweb.org/multi-site_indieweb)" which allows a single endpoint to support multiple posting destinations. For example I use this to support posting most post types to my main website while posting scrobbles to a separate site. Ho-Tan supports this by allowing destinations to be configured in the destinations YAML file (specified by `DESTINATION_CONFIG`).

At least 1 destination **must** be configured (presumably your website), but you can add as many as are required. The first site mentioned will be regarded as the "default" destination (and used when no `mp-destination` is provided in a request), however a different destination can be marked as the default be setting `default: true` on the entry in the file.

Each entry within the destinations YAML file **must** contain values for `uid` (a unique identifier for the destination, which will be used in requests), `name` (which will be sent to clients and displayed in their interfaces), `directory` (a directory path indicating where data files for this destination should be written to and read from), and `base_url` (the start of the url for the destination, used to identify the correct destination when we receive a URL in a request). Only the `uid` and `name` will be used directly in requests, the other fields are purely for internal use.

A simple example destination file could look something like:

```yaml
destinations:
  -
    uid: something_else
    name: Some other site
    directory: content/something_else
    base_url: https://something-else.deeden.co.uk/
  -
    uid: deeden
    name: Me
    directory: content/deeden
    base_url: https://deeden.co.uk/
    default: true
```

##### Token Verification environment variables

Ho-Tan uses the [IndieAuth::TokenVerification](https://github.com/srushe/indieauth-token-verification/) ruby gem to verify an IndieAuth access token against a token endpoint, and the `TOKEN_ENDPOINT` and `DOMAIN` environment variables are required by that gem.

`TOKEN_ENDPOINT` specifies the token endpoint to be used to validate the access token. Failure to specify `TOKEN_ENDPOINT` will result in a `IndieAuth::TokenVerification::MissingTokenEndpointError` error being raised.

`DOMAIN` specifies the domain we expect to see in the response from the validated token. It should match that specified when the token was first generated (presumably your website URL). Failure to specify `DOMAIN` will result in a `IndieAuth::TokenVerification::MissingDomainError` error being raised.

#### Optional

| Variable | Format | Purpose |
| -------- | ------ | ------- |
| MEDIA_ENDPOINT | URL | The URL of the media endpoint for the site, if one exists |
| APP_ENV | String | Specifies the environment the application should run as |

Ho-Tan **does not** support media uploads directly, but instead expects you to have a separate media endpoint (if you so desire). Specifying the URL for that media endpoint (as `MEDIA_ENDPOINT`) will simply result in that URL being included when a [configuration query](https://www.w3.org/TR/micropub/#configuration) is made to the endpoint. If you don't have a media endpoint don't set the variable and the application will still work.

Finally, you can also use `APP_ENV` to tell Sinatra which environment it should be running. For example I use `production` for my live endpoint. You may, or may not, need it. Try it and find out.

## Supported Post Types

Ho-Tan supports all of the standard post types identified by the [Indieweb::PostTypes](https://github.com/srushe/indieweb-post_types) ruby gem, specifically `rsvp`, `reply`, `repost`, `like`, `video`, `photo`, `article`, and `note`. It also supports some non-standard types, specifically `bookmark`, `read`, and `scrobble` (as I wanted them).

## Contributing

While Ho-Tan is written with a view to "[self-dogfooding](https://indieweb.org/selfdogfood)" I'm still happy for other people to use and contribute to the project. Bug reports and pull requests are welcome on GitHub at https://github.com/srushe/ho-tan. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.

## License

This application is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).

## Ho-Tan?

![Ho-Tan in the Elders chamber](static/ho-tan.jpeg)

Ho-Tan is one of the Elders of [Yonderland](https://en.wikipedia.org/wiki/Yonderland), and serves as the scribe/record keeper. He was played, in the TV series, by [Laurence Rickard](https://twitter.com/Lazbotron). I love Yonderland.

A  => config.ru +10 -0
@@ 1,10 @@
# frozen_string_literal: true

require 'dotenv/load'
require 'rack/contrib'
require './lib/ho_tan/application'

# Parse JSON in the body when the content-type is application/json.
use Rack::PostBodyContentTypeParser

run HoTan::Application

A  => config/destinations.test.yml +17 -0
@@ 1,17 @@
destinations:
  -
    uid: https://first-site.example.com/
    name: Site 1
    directory: content/site_1
    base_url: https://first-site.example.com/
  -
    uid: https://second-site.example.com/
    name: Site 2
    directory: content/site_2
    base_url: https://second-site.example.com/
    default: true
  -
    uid: https://third-site.example.com/
    name: Site 3
    directory: content/site_3
    base_url: https://third-site.example.com/

A  => config/syndication_targets.test.yml +8 -0
@@ 1,8 @@
syndication_targets:
  -
    uid: https://twitter.com/
    name: Twitter
  -
    uid: https://facebook.com/
    name: Facebook


A  => lib/ho_tan/application.rb +201 -0
@@ 1,201 @@
# frozen_string_literal: true

require 'sinatra'
require 'sinatra/config_file'
require 'sinatra/reloader' if development?
require 'indie_auth/token_verification'
require 'indieweb/post_types'
require 'indieweb/post_types/identifier/bookmark'
require 'indieweb/post_types/identifier/read'
require 'indieweb/post_types/identifier/scrobble'
require_relative 'destinations'
require_relative 'post_factory'

module HoTan
  class Application < Sinatra::Application
    set :public_folder, ::File.dirname(__FILE__) + '/../../static'

    set :syndication_targets, []
    set :destinations, []
    config_file ENV['SYNDICATION_TARGET_CONFIG'] if ENV.key?('SYNDICATION_TARGET_CONFIG')
    config_file ENV['DESTINATION_CONFIG'] if ENV.key?('DESTINATION_CONFIG')

    before do
      halt 503, 'Destinations must be configured' if settings.destinations.empty?
    end

    Indieweb::PostTypes.configure do |config|
      config.insert_identifier(klass: Indieweb::PostTypes::Identifier::Bookmark,
                               before: Indieweb::PostTypes::Identifier::Article)
      config.insert_identifier(klass: Indieweb::PostTypes::Identifier::Read,
                               before: Indieweb::PostTypes::Identifier::Article)
      config.insert_identifier(klass: Indieweb::PostTypes::Identifier::Scrobble,
                               before: Indieweb::PostTypes::Identifier::Article)
    end

    get '/' do
      send_file ::File.join(settings.public_folder, 'index.html') unless params.key?('q')

      verify_token

      content_type :json

      unless %w[config source syndicate-to destination].include?(params['q'])
        send_error(description: "'#{params['q']}' is not a valid value for 'q'")
      end

      return render_source if params['q'] == 'source'

      response = {}
      if params['q'] == 'config'
        response['media-endpoint'] = ENV['MEDIA_ENDPOINT']
      end
      if %w[config syndicate-to].include?(params['q'])
        response['syndicate-to'] = settings.syndication_targets
      end
      if %w[config destination].include?(params['q'])
        response['destination'] = destinations.to_config
      end

      response.compact.to_json
    end

    # Create/Edit post
    post '/' do
      scope = params.fetch('action', 'create')
      verify_token(scope)

      create_post unless params.key?(:action)

      if params.key?(:action)
        begin
          post = post_factory.from(params[:url])
        rescue HoTan::PostFactory::UnrecognisedDestinationError
          send_error(description: 'An unrecognised destination was provided')
        rescue HoTan::Post::DataFile::NotFoundError
          send_error(status: 400, error: 'invalid_request', description: 'Post not found for provided URL')
        end

        case params[:action]
        when 'delete'
          delete_post(post)
        when 'undelete'
          undelete_post(post)
        when 'update'
          update_post(post)
        end
      end
    end

    private

    def verify_token(scope = nil)
      access_token = request.env['HTTP_AUTHORIZATION'] || params['access_token'] || ''
      IndieAuth::TokenVerification.new(access_token).verify(scope)
    rescue IndieAuth::TokenVerification::AccessTokenMissingError
      send_error(status: 401, error: 'unauthorized', description: 'Access token missing or empty')
    rescue IndieAuth::TokenVerification::MissingDomainError
      send_error(status: 400, error: 'invalid_request', description: 'DOMAIN is not specified')
    rescue IndieAuth::TokenVerification::MissingTokenEndpointError
      send_error(status: 400, error: 'invalid_request', description: 'TOKEN_ENDPOINT is not specified')
    rescue IndieAuth::TokenVerification::ForbiddenUserError
      send_error(status: 403, error: 'forbidden', description: 'User does not have permission')
    rescue IndieAuth::TokenVerification::IncorrectMeError
      send_error(status: 401, error: 'insufficient_scope', description: 'The "me" value does not match the expected DOMAIN')
    rescue IndieAuth::TokenVerification::InsufficentScopeError
      send_error(status: 401, error: 'insufficient_scope', description: 'The scope of this token does not meet the requirements for this request')
    end

    def send_error(status: 400, error: 'invalid_request', description:)
      json = {
        error: error,
        error_description: description
      }.to_json

      halt(status, { 'Content-Type' => 'application/json' }, json)
    end

    def destinations
      @destinations ||= HoTan::Destinations.from(settings.destinations)
    end

    def post_factory
      @post_factory ||= HoTan::PostFactory.new(destinations: destinations)
    end

    def render_source
      if params.fetch(:url, '').strip.empty?
        send_error(description: "'url' must be provided to retrieve 'source'")
      end

      post = post_factory.from(params[:url])
      data = post.instance.data

      # Extract selected properties.
      source_data = if params.key?('properties')
                      {
                        'properties' => data['properties'].select do |k, _v|
                                          params['properties'].include?(k)
                                        end
                      }
                    else
                      data.select { |k, _v| %w[properties type].include?(k) }
                    end

      # Remove entry_type from properties, if there.
      source_data['properties'].delete('entry_type')

      source_data.to_json
    rescue HoTan::PostFactory::UnrecognisedDestinationError
      send_error(description: 'An unrecognised destination was provided')
    rescue HoTan::Post::InvalidPathError
      send_error(description: "'url' not recognised")
    end

    def create_post
      post = post_factory.create(params)

      status 202
      headers 'Location' => post.absolute_url
    rescue HoTan::Post::Normalize::InvalidHError
      send_error(description: "'h' must be provided")
    rescue HoTan::Post::Normalize::InvalidTypeError
      send_error(description: 'A type must be provided')
    rescue HoTan::Post::Normalize::InvalidCreateError
      send_error(description: 'No recognisable parameters for entry creation')
    rescue HoTan::PostFactory::UnrecognisedDestinationError
      send_error(description: 'An unrecognised destination was provided')
    rescue HoTan::Post::UnrecognisedTypeError => e
      send_error(description: e.message)
    rescue HoTan::Post::DuplicateCreateError
      send_error(description: 'Failed to create due to an already existing entry')
    end

    def update_post(post)
      post.update!(params)

      if post.updated_url?
        status 201
        headers 'Location' => post.absolute_url
      else
        status 204
      end
    rescue HoTan::Post::InvalidUpdateError
      send_error(description: 'Invalid update parameters provided')
    rescue HoTan::PostFactory::UnrecognisedDestinationError
      send_error(description: 'An unrecognised destination was provided')
    rescue HoTan::Post::UnrecognisedTypeError => e
      send_error(description: e.message)
    end

    def delete_post(post)
      post.delete!
      status 204
    end

    def undelete_post(post)
      post.undelete!
      status 204
    end
  end
end

A  => lib/ho_tan/destination.rb +23 -0
@@ 1,23 @@
# frozen_string_literal: true

module HoTan
  class Destination
    attr_reader :uid, :name, :directory, :base_url, :default

    def initialize(data)
      @uid = data['uid']
      @name = data['name']
      @directory = data['directory']
      @base_url = data['base_url']
      @default = !!data['default']
    end

    def default?
      @default == true
    end

    def to_config
      { 'uid' => @uid, 'name' => @name }
    end
  end
end

A  => lib/ho_tan/destinations.rb +35 -0
@@ 1,35 @@
# frozen_string_literal: true

require_relative 'destination'

module HoTan
  class Destinations
    attr_reader :destinations

    def self.from(data)
      new(data)
    end

    def all
      destinations
    end

    def to_config
      destinations.collect(&:to_config)
    end

    def default
      return @default_destination if defined? @default_destination

      @default_destination = destinations.find(&:default?) || destinations[0]
    end

    private

    def initialize(data)
      @destinations = data.collect do |destination_data|
        HoTan::Destination.new(destination_data)
      end
    end
  end
end

A  => lib/ho_tan/post.rb +134 -0
@@ 1,134 @@
# frozen_string_literal: true

require_relative 'post/data_file'
require_relative 'post/location'
require_relative 'post/type/article'
require_relative 'post/type/bookmark'
require_relative 'post/type/checkin'
require_relative 'post/type/note'
require_relative 'post/type/photo'
require_relative 'post/type/read'
require_relative 'post/type/reply'
require_relative 'post/type/repost'
require_relative 'post/type/scrobble'

module HoTan
  class Post
    class DuplicateCreateError < StandardError; end
    class InvalidPathError < StandardError; end
    class InvalidUpdateError < StandardError; end
    class UnrecognisedTypeError < StandardError; end

    attr_reader :instance, :destination, :original_url

    def self.create!(data, destination)
      post = new(instance_from(data), destination)
      raise DuplicateCreateError if File.exist?(post.save_location)
      post.tap(&:save!)
      # new(instance_from(data), destination).tap(&:save!)
    end

    def self.retrieve(path, url, destination)
      data = HoTan::Post::DataFile.new(path).read
      new(instance_from(data), destination, original_url: url)
    rescue HoTan::Post::DataFile::NotFoundError
      raise InvalidPathError
    end

    def update!(data)
      if data.key?('replace')
        raise InvalidUpdateError unless data['replace'].is_a?(Hash)
        raise InvalidUpdateError unless data['replace'].values.all? { |v| v.is_a?(Array) }

        instance.properties.merge!(data['replace'])
      end
      if data.key?('add')
        raise InvalidUpdateError unless data['add'].is_a?(Hash)
        raise InvalidUpdateError unless data['add'].values.all? { |v| v.is_a?(Array) }

        data['add'].each_pair do |k, additions|
          instance.properties[k] ||= []
          instance.properties[k] += additions
        end
      end
      if data.key?('delete')
        raise InvalidUpdateError unless data['delete'].is_a?(Array) || data['delete'].is_a?(Hash)
        raise InvalidUpdateError if data['delete'].is_a?(Hash) && !data['delete'].values.all? { |v| v.is_a?(Array) }

        if data['delete'].is_a?(Array)
          data['delete'].each { |k| instance.properties.delete(k) }
        else
          data['delete'].each_pair do |k, removals|
            removals.each { |value| instance.properties[k].delete(value) }
            instance.properties.delete(k) if instance.properties[k].empty?
          end
        end
      end

      instance.properties['updated_at'] = [Time.now.utc.iso8601]

      save!

      delete_original if updated_url?
    end

    def delete!
      instance.properties['deleted_at'] = [Time.now.utc.iso8601]
      save!
    end

    def undelete!
      instance.properties.delete('deleted_at')
      save!
    end

    # Where should this post be saved?
    def save_location
      @save_location ||= location.path_for_instance(instance)
    end

    def absolute_url
      @absolute_url ||= location.url_for_instance(instance)
    end

    def updated_url?
      return false if original_url.nil?

      original_url != absolute_url
    end

    def save!
      HoTan::Post::DataFile.new(save_location).save(instance.data)
    end

    private

    def initialize(instance, destination, original_url: nil)
      @instance = instance
      @destination = destination
      @original_url = original_url
    end

    def location
      @location ||= HoTan::Post::Location.new(destination)
    end

    def delete_original
      path_for_original_url = location.path_for_url(original_url)
      HoTan::Post::DataFile.new(path_for_original_url).delete!
    end

    def self.instance_from(data)
      post_type = Indieweb::PostTypes.type_from(data)
      klass_for(post_type).new(data)
    end
    private_class_method :instance_from

    def self.klass_for(post_type)
      Object.const_get("HoTan::Post::Type::#{post_type.capitalize}")
    rescue NameError
      raise UnrecognisedTypeError, "The type '#{post_type}' is not recognised"
    end
    private_class_method :klass_for
  end
end

A  => lib/ho_tan/post/data_file.rb +32 -0
@@ 1,32 @@
# frozen_string_literal: true

require 'fileutils'

module HoTan
  class Post
    class DataFile
      class NotFoundError < StandardError; end

      attr_reader :path

      def initialize(path)
        @path = path
      end

      def read
        raise NotFoundError unless File.exist?(path)

        JSON.parse(File.read(path))
      end

      def save(data)
        FileUtils.mkdir_p(File.dirname(path))
        File.write(path, JSON.pretty_generate(data))
      end

      def delete!
        FileUtils.rm path
      end
    end
  end
end

A  => lib/ho_tan/post/location.rb +38 -0
@@ 1,38 @@
# frozen_string_literal: true

require 'addressable'

module HoTan
  class Post
    class Location
      attr_reader :destination

      def initialize(destination)
        @destination = destination
      end

      def url_for_instance(instance)
        Addressable::URI.join(destination.base_url, "#{instance.path}/", instance.slug).to_s
      end

      def path_for_instance(instance)
        File.join(destination.directory, instance.path, "#{instance.slug}.json")
      end

      def path_for_url(url)
        uri = URI(url)
        File.join(destination.directory, path_from(uri), file_from(uri))
      end

      private

      def path_from(uri)
        Pathname.new(uri.path.to_s).dirname.to_s
      end

      def file_from(uri)
        "#{File.basename(uri.path, '.*')}.json"
      end
    end
  end
end

A  => lib/ho_tan/post/normalize.rb +59 -0
@@ 1,59 @@
# frozen_string_literal: true

module HoTan
  class Post
    module Normalize
      class InvalidHError < StandardError; end
      class InvalidTypeError < StandardError; end
      class InvalidCreateError < StandardError; end

      class << self
        def for_create(params)
          if url_encoded_create?(params)
            url_encoded_create_data_from(params)
          elsif json_create?(params)
            json_create_data_from(params)
          else
            raise InvalidCreateError
          end
        end

        private

        def url_encoded_create?(data)
          return false unless data.key?('h')
          return true if data['h'] == 'entry'

          raise InvalidHError
        end

        def json_create?(data)
          return false unless data.key?('properties')
          return true if data.key?('type') && data['type'].is_a?(Array) && data['type'][0] == 'h-entry'

          raise InvalidTypeError
        end

        def url_encoded_create_data_from(data)
          create_data = {
            'type' => ["h-#{data['h']}"],
            'properties' => Hash[data.reject { |k, _v| k == 'h' }.map { |k, v| [k.to_s, Array(v)] }]
          }

          create_data['properties']['published'] ||= [Time.now.utc.iso8601]
          create_data
        end

        def json_create_data_from(data)
          create_data = {
            'type' => data['type'],
            'properties' => data['properties']
          }

          create_data['properties']['published'] ||= [Time.now.utc.iso8601]
          create_data
        end
      end
    end
  end
end

A  => lib/ho_tan/post/type/article.rb +19 -0
@@ 1,19 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Article < Base
        private

        def type_slug
          return unless properties.key?('name') && properties['name'].is_a?(Array)

          properties['name'][0].to_slug.normalize.to_s
        end
      end
    end
  end
end

A  => lib/ho_tan/post/type/base.rb +62 -0
@@ 1,62 @@
# frozen_string_literal: true

require 'babosa'
require 'time'

module HoTan
  class Post
    module Type
      class Base
        attr_reader :data

        def initialize(data)
          @data = data
          @data['properties']['entry_type'] = [entry_type]
        end

        def path
          @path ||= File.join(type_directory, Time.parse(data['properties']['published'][0]).strftime('%Y/%m/%d'))
        end

        def slug
          @slug ||= mp_slug || type_slug || default_slug
        end

        def properties
          data['properties']
        end

        protected

        def entry_type
          self.class.to_s.split('::').last.downcase
        end

        def type_directory
          "#{entry_type}s"
        end

        def mp_slug
          return nil unless properties.key?('mp-slug') && properties['mp-slug'].is_a?(Array)

          slug = properties['mp-slug'][0].to_s.to_slug.normalize.to_s
          slug.empty? ? nil : slug
        end

        def type_slug
          nil
        end

        def default_slug
          datetime = if properties.key?('published') && properties['published'].is_a?(Array)
                       Time.parse(properties['published'][0])
                     else
                       Time.now.utc
                     end

          datetime.strftime('%H%M%S')
        end
      end
    end
  end
end

A  => lib/ho_tan/post/type/bookmark.rb +12 -0
@@ 1,12 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Bookmark < Base
      end
    end
  end
end

A  => lib/ho_tan/post/type/checkin.rb +12 -0
@@ 1,12 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Checkin < Base
      end
    end
  end
end

A  => lib/ho_tan/post/type/note.rb +12 -0
@@ 1,12 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Note < Base
      end
    end
  end
end

A  => lib/ho_tan/post/type/photo.rb +12 -0
@@ 1,12 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Photo < Base
      end
    end
  end
end

A  => lib/ho_tan/post/type/read.rb +17 -0
@@ 1,17 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Read < Base
        private

        def type_directory
          'reading'
        end
      end
    end
  end
end

A  => lib/ho_tan/post/type/reply.rb +17 -0
@@ 1,17 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Reply < Base
        private

        def type_directory
          'replies'
        end
      end
    end
  end
end

A  => lib/ho_tan/post/type/repost.rb +12 -0
@@ 1,12 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Repost < Base
      end
    end
  end
end

A  => lib/ho_tan/post/type/scrobble.rb +20 -0
@@ 1,20 @@
# frozen_string_literal: true

require_relative 'base'

module HoTan
  class Post
    module Type
      class Scrobble < Base
        private

        def type_slug
          [
            properties['scrobble-of'][0]['properties'].fetch_values('artist', 'title'),
            Time.parse(properties['published'][0]).strftime('%H%M%S')
          ].flatten.join('-').to_slug.normalize.to_s
        end
      end
    end
  end
end

A  => lib/ho_tan/post_factory.rb +55 -0
@@ 1,55 @@
# frozen_string_literal: true

require_relative 'post'
require_relative 'post/location'
require_relative 'post/normalize'

module HoTan
  class PostFactory
    class InvalidPathError < StandardError; end
    class UnrecognisedDestinationError < StandardError; end

    attr_reader :destinations

    def initialize(destinations:)
      @destinations = destinations
    end

    def create(data)
      create_data = HoTan::Post::Normalize.for_create(data)
      destination = destination_from_data(create_data)
      HoTan::Post.create!(create_data, destination)
    end

    def from(url)
      destination = destination_from_url(url)
      path = HoTan::Post::Location.new(destination).path_for_url(url)
      HoTan::Post.retrieve(path, url, destination)
    rescue HoTan::Post::InvalidPathError
      raise InvalidPathError
    end

    private

    def destination_from_data(data)
      return destinations.default unless data['properties'].key?('mp-destination')

      destination = destinations.all.find do |d|
        d.uid == data['properties']['mp-destination'][0]
      end
      raise UnrecognisedDestinationError if destination.nil?

      data['properties'].delete('mp-destination')
      destination
    end

    def destination_from_url(url)
      destination = destinations.all.find do |d|
        url.start_with?(d.base_url)
      end
      raise UnrecognisedDestinationError if destination.nil?

      destination
    end
  end
end

A  => spec/lib/ho_tan/application_spec.rb +749 -0
@@ 1,749 @@
# frozen_string_literal: true

require_relative '../../../lib/ho_tan/application'
require 'json'
require 'rspec'
require 'rack/test'

RSpec.shared_examples_for 'an error response' do
  let(:response_json) { JSON.parse(last_response.body) }

  it 'responds correctly' do
    aggregate_failures do
      expect(last_response.status).to eq(expected_status)
      expect(last_response.content_type).to eq('application/json')
      expect(response_json).to eq('error' => expected_error_type, 'error_description' => expected_error_description)
    end
  end
end

RSpec.shared_examples_for 'a request with missing destinations' do
  before do
    @original_destinations = described_class.settings.destinations
    described_class.set :destinations, []
    make_request
  end

  after do
    described_class.set :destinations, @original_destinations
  end

  it 'responds correctly' do
    aggregate_failures do
      expect(last_response.status).to eq(503)
      expect(last_response.content_type).to eq('text/html;charset=utf-8')
      expect(last_response.body).to eq('Destinations must be configured')
    end
  end
end

RSpec.shared_examples_for 'an endpoint requiring verification' do
  before do
    allow(IndieAuth::TokenVerification).to receive(:new).and_raise(error)

    make_request
  end

  context 'when verification raises AccessTokenMissingError' do
    let(:error) { IndieAuth::TokenVerification::AccessTokenMissingError }
    let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:unauthorized] }
    let(:expected_error_type) { 'unauthorized' }
    let(:expected_error_description) { 'Access token missing or empty' }

    it_behaves_like 'an error response'
  end

  context 'when verification raises MissingDomainError' do
    let(:error) { IndieAuth::TokenVerification::MissingDomainError }
    let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:bad_request] }
    let(:expected_error_type) { 'invalid_request' }
    let(:expected_error_description) { 'DOMAIN is not specified' }

    it_behaves_like 'an error response'
  end

  context 'when verification raises MissingTokenEndpointError' do
    let(:error) { IndieAuth::TokenVerification::MissingTokenEndpointError }
    let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:bad_request] }
    let(:expected_error_type) { 'invalid_request' }
    let(:expected_error_description) { 'TOKEN_ENDPOINT is not specified' }

    it_behaves_like 'an error response'
  end

  context 'when verification raises ForbiddenUserError' do
    let(:error) { IndieAuth::TokenVerification::ForbiddenUserError }
    let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:forbidden] }
    let(:expected_error_type) { 'forbidden' }
    let(:expected_error_description) { 'User does not have permission' }

    it_behaves_like 'an error response'
  end

  context 'when verification raises IncorrectMeError' do
    let(:error) { IndieAuth::TokenVerification::IncorrectMeError }
    let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:unauthorized] }
    let(:expected_error_type) { 'insufficient_scope' }
    let(:expected_error_description) { 'The "me" value does not match the expected DOMAIN' }

    it_behaves_like 'an error response'
  end

  context 'when verification raises InsufficentScopeError' do
    let(:error) { IndieAuth::TokenVerification::InsufficentScopeError }
    let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:unauthorized] }
    let(:expected_error_type) { 'insufficient_scope' }
    let(:expected_error_description) { 'The scope of this token does not meet the requirements for this request' }

    it_behaves_like 'an error response'
  end
end

RSpec.shared_examples_for 'an invalid create request' do
  let(:post_factory) { instance_double(HoTan::PostFactory) }
  let(:error_message) { nil }
  let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:bad_request] }
  let(:expected_error_type) { 'invalid_request' }

  before do
    allow(HoTan::PostFactory).to receive(:new) { post_factory }
    allow(post_factory).to receive(:create).and_raise(error, error_message)

    make_request
  end

  context 'when creation raises HoTan::Post::Normalize::InvalidHError' do
    let(:error) { HoTan::Post::Normalize::InvalidHError }
    let(:expected_error_description) { "'h' must be provided" }

    it_behaves_like 'an error response'
  end

  context 'when creation raises HoTan::Post::Normalize::InvalidTypeError' do
    let(:error) { HoTan::Post::Normalize::InvalidTypeError }
    let(:expected_error_description) { 'A type must be provided' }

    it_behaves_like 'an error response'
  end

  context 'when creation raises HoTan::Post::Normalize::InvalidCreateError' do
    let(:error) { HoTan::Post::Normalize::InvalidCreateError }
    let(:expected_error_description) { 'No recognisable parameters for entry creation' }

    it_behaves_like 'an error response'
  end

  context 'when creation raises HoTan::Post::UnrecognisedTypeError' do
    let(:error) { HoTan::Post::UnrecognisedTypeError }
    let(:error_message) { "The type 'wibble' is not recognised" }
    let(:expected_error_description) { error_message }

    it_behaves_like 'an error response'
  end

  context 'when creation raises HoTan::PostFactory::UnrecognisedDestinationError' do
    let(:error) { HoTan::PostFactory::UnrecognisedDestinationError }
    let(:expected_error_description) { 'An unrecognised destination was provided' }

    it_behaves_like 'an error response'
  end

  context 'when creation raises HoTan::Post::DuplicateCreateError' do
    let(:error) { HoTan::Post::DuplicateCreateError }
    let(:expected_error_description) { 'Failed to create due to an already existing entry' }

    it_behaves_like 'an error response'
  end
end

RSpec.shared_examples_for 'an invalid update request' do
  let(:post_factory) { instance_double(HoTan::PostFactory) }
  let(:error_message) { nil }
  let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:bad_request] }
  let(:expected_error_type) { 'invalid_request' }

  before do
    allow(HoTan::PostFactory).to receive(:new) { post_factory }
  end

  context 'when an error occurs while retrieving the post' do
    before do
      allow(post_factory).to receive(:from).and_raise(error, error_message)
      make_request
    end

    context 'when the request raises HoTan::Post::DataFile::NotFoundError' do
      let(:error) { HoTan::Post::DataFile::NotFoundError }
      let(:expected_error_description) { 'Post not found for provided URL' }

      it_behaves_like 'an error response'
    end

    context 'when the request raises HoTan::PostFactory::UnrecognisedDestinationError' do
      let(:error) { HoTan::PostFactory::UnrecognisedDestinationError }
      let(:expected_error_description) { 'An unrecognised destination was provided' }

      it_behaves_like 'an error response'
    end
  end

  context 'when the error occurs when updating the post' do
    let(:existing_post) { double(:post) }

    before do
      allow(post_factory).to receive(:from) { existing_post }
      allow(existing_post).to receive(:update!).and_raise(error, error_message)
      make_request
    end

    context 'when the request raises HoTan::Post::InvalidUpdateError' do
      let(:error) { HoTan::Post::InvalidUpdateError }
      let(:expected_error_description) { 'Invalid update parameters provided' }

      it_behaves_like 'an error response'
    end

    context 'when the request raises HoTan::PostFactory::UnrecognisedDestinationError' do
      let(:error) { HoTan::PostFactory::UnrecognisedDestinationError }
      let(:expected_error_description) { 'An unrecognised destination was provided' }

      it_behaves_like 'an error response'
    end

    context 'when the request raises HoTan::Post::UnrecognisedTypeError' do
      let(:error) { HoTan::Post::UnrecognisedTypeError }
      let(:expected_error_description) { "The type 'wibble' is not recognised" }
      let(:error_message) { "The type 'wibble' is not recognised" }

      it_behaves_like 'an error response'
    end
  end
end

RSpec.shared_examples_for 'an invalid delete or undelete request' do
  let(:post_factory) { instance_double(HoTan::PostFactory) }
  let(:error_message) { nil }
  let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:bad_request] }
  let(:expected_error_type) { 'invalid_request' }

  before do
    allow(HoTan::PostFactory).to receive(:new) { post_factory }
    allow(post_factory).to receive(:from).and_raise(error, error_message)

    make_request
  end

  context 'when the request raises HoTan::Post::DataFile::NotFoundError' do
    let(:error) { HoTan::Post::DataFile::NotFoundError }
    let(:expected_error_description) { 'Post not found for provided URL' }

    it_behaves_like 'an error response'
  end

  context 'when the request raises HoTan::PostFactory::UnrecognisedDestinationError' do
    let(:error) { HoTan::PostFactory::UnrecognisedDestinationError }
    let(:expected_error_description) { 'An unrecognised destination was provided' }

    it_behaves_like 'an error response'
  end
end

RSpec.describe HoTan::Application do
  include Rack::Test::Methods

  def app
    HoTan::Application
  end

  let(:should_verify) { false }
  let(:token_verifier) { instance_double(IndieAuth::TokenVerification) }

  before do
    allow(IndieAuth::TokenVerification).to receive(:new) { token_verifier }
    allow(token_verifier).to receive(:verify) { should_verify }
  end

  context 'GET' do
    context 'when the index is requested' do
      let(:make_request) { get '/' }

      it_behaves_like 'a request with missing destinations'

      it 'does not attempt to verify a token' do
        make_request
        expect(IndieAuth::TokenVerification).not_to have_received(:new)
      end

      it 'returns the default index page' do
        make_request
        aggregate_failures do
          expect(last_response).to be_ok
          expect(last_response.body).to match('<h1>Ho-Tan - A Micropub Endpoint</h1>')
        end
      end
    end

    context 'when an unrecognised "q" parameter is provided' do
      let(:make_request) { get '/?q=whowiththewhatnow' }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'

      context 'when verification succeeds' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:bad_request] }
        let(:expected_error_type) { 'invalid_request' }
        let(:expected_error_description) { "'whowiththewhatnow' is not a valid value for 'q'" }

        before { make_request }

        it_behaves_like 'an error response'
      end
    end

    context 'when q=destination is provided' do
      let(:make_request) { get '/?q=destination' }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'

      context 'when verification succeeds' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok] }
        let(:response_json) { JSON.parse(last_response.body) }
        let(:expected_response) do
          {
            'destination' => [
              { 'uid' => 'https://first-site.example.com/', 'name' => 'Site 1' },
              { 'uid' => 'https://second-site.example.com/', 'name' => 'Site 2' },
              { 'uid' => 'https://third-site.example.com/', 'name' => 'Site 3' }
            ]
          }
        end

        before { make_request }

        it 'responds correctly' do
          aggregate_failures do
            expect(last_response.status).to eq(expected_status)
            expect(last_response.content_type).to eq('application/json')
            expect(response_json).to eq(expected_response)
          end
        end
      end
    end

    context 'when q=syndicate-to is provided' do
      let(:make_request) { get '/?q=syndicate-to' }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'

      context 'when verification succeeds' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok] }
        let(:response_json) { JSON.parse(last_response.body) }
        let(:expected_response) do
          {
            'syndicate-to' => [
              { 'uid' => 'https://twitter.com/', 'name' => 'Twitter' },
              { 'uid' => 'https://facebook.com/', 'name' => 'Facebook' }
            ]
          }
        end

        before { make_request }

        it 'responds correctly' do
          aggregate_failures do
            expect(last_response.status).to eq(expected_status)
            expect(last_response.content_type).to eq('application/json')
            expect(response_json).to eq(expected_response)
          end
        end
      end
    end

    context 'when q=config is provided' do
      let(:make_request) { get '/?q=config' }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'

      context 'when verification succeeds' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok] }
        let(:response_json) { JSON.parse(last_response.body) }
        let(:expected_response) do
          {
            'media-endpoint' => expected_media_endpoint,
            'syndicate-to' => [
              { 'uid' => 'https://twitter.com/', 'name' => 'Twitter' },
              { 'uid' => 'https://facebook.com/', 'name' => 'Facebook' }
            ],
            'destination' => [
              { 'uid' => 'https://first-site.example.com/', 'name' => 'Site 1' },
              { 'uid' => 'https://second-site.example.com/', 'name' => 'Site 2' },
              { 'uid' => 'https://third-site.example.com/', 'name' => 'Site 3' }
            ]
          }.compact
        end

        before do
          ENV['MEDIA_ENDPOINT'] = expected_media_endpoint

          make_request
        end

        context 'when the media endpoint is not set' do
          let(:expected_media_endpoint) { nil }

          it 'responds correctly' do
            aggregate_failures do
              expect(last_response.status).to eq(expected_status)
              expect(last_response.content_type).to eq('application/json')
              expect(response_json).to eq(expected_response)
            end
          end
        end

        context 'when the media endpoint is set' do
          let(:expected_media_endpoint) { 'http://media.example.com/' }

          it 'responds correctly' do
            aggregate_failures do
              expect(last_response.status).to eq(expected_status)
              expect(last_response.content_type).to eq('application/json')
              expect(response_json).to eq(expected_response)
            end
          end
        end
      end
    end

    context 'when q=source is provided' do
      let(:make_request) { get '/?q=source' }
      it_behaves_like 'a request with missing destinations'

      context 'when a error occurs' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:bad_request] }
        let(:expected_error_type) { 'invalid_request' }

        context 'due to the url being missing' do
          let(:expected_error_description) { "'url' must be provided to retrieve 'source'" }

          before { make_request }

          context 'due to not being provided' do
            let(:make_request) { get '/?q=source' }

            it_behaves_like 'an error response'
          end

          context 'due to being empty' do
            let(:make_request) { get '/?q=source&url=' }

            it_behaves_like 'an error response'
          end
        end

        context 'due to the url not matching the supported destinations' do
          let(:make_request) { get "/?q=source&url=#{url}" }
          let(:url) { 'https://totally-real-url.example.com/' }
          let(:post_factory) { instance_double(HoTan::PostFactory) }
          let(:expected_error_description) { 'An unrecognised destination was provided' }

          before do
            allow(HoTan::PostFactory).to receive(:new) { post_factory }
            allow(post_factory).to receive(:from).and_raise(HoTan::PostFactory::UnrecognisedDestinationError)
            make_request
          end

          it_behaves_like 'an error response'
        end

        context 'due to the url not being recognised' do
          let(:make_request) { get "/?q=source&url=#{url}" }
          let(:url) { 'https://totally-real-url.example.com/' }
          let(:post_factory) { instance_double(HoTan::PostFactory) }
          let(:expected_error_description) { "'url' not recognised" }

          before do
            allow(HoTan::PostFactory).to receive(:new) { post_factory }
            allow(post_factory).to receive(:from).and_raise(HoTan::Post::InvalidPathError)
            make_request
          end

          it_behaves_like 'an error response'
        end
      end

      context 'when no error occurs' do
        let(:post_factory) { instance_double(HoTan::PostFactory) }
        let(:existing_post) { double(:post, instance: post_instance) }
        let(:post_instance) { double(:post_instance, data: post_data) }
        let(:post_data) do
          {
            'type' => ['h-entry'],
            'properties' => {
              'content' => ['Some content'],
              'title' => ['A title'],
              'tags' => %w[a b],
              'entry_type' => ['the_type']
            }
          }
        end

        before do
          allow(HoTan::PostFactory).to receive(:new) { post_factory }
          allow(post_factory).to receive(:from) { existing_post }
          make_request
        end

        context 'when no properties are specified' do
          let(:make_request) { get '/?q=source&url=http://example.com/foo' }
          let(:body) { JSON.parse(last_response.body) }
          let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok] }
          let(:expected_data) do
            {
              'type' => ['h-entry'],
              'properties' => {
                'content' => ['Some content'],
                'title' => ['A title'],
                'tags' => %w[a b]
              }
            }
          end

          it 'returns the correct response' do
            aggregate_failures do
              expect(body).to eq(expected_data)
              expect(last_response.status).to eq(expected_status)
              expect(last_response.content_type).to eq('application/json')
            end
          end
        end

        context 'when properties are specified' do
          context 'when only one property is specified' do
            let(:make_request) { get '/?q=source&properties=title&url=http://example.com/foo' }
            let(:body) { JSON.parse(last_response.body) }
            let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok] }
            let(:expected_data) do
              {
                'properties' => {
                  'title' => ['A title']
                }
              }
            end

            it 'returns the correct response' do
              aggregate_failures do
                expect(body).to eq(expected_data)
                expect(last_response.status).to eq(expected_status)
                expect(last_response.content_type).to eq('application/json')
              end
            end
          end

          context 'when more than one property is specified' do
            let(:make_request) { get '/?q=source&properties[]=tags&properties[]=content&url=http://example.com/foo' }
            let(:body) { JSON.parse(last_response.body) }
            let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok] }
            let(:expected_data) do
              {
                'properties' => {
                  'content' => ['Some content'],
                  'tags' => %w[a b]
                }
              }
            end

            it 'returns the correct response' do
              aggregate_failures do
                expect(body).to eq(expected_data)
                expect(last_response.status).to eq(expected_status)
                expect(last_response.content_type).to eq('application/json')
              end
            end
          end
        end
      end
    end
  end

  context 'POST' do
    context 'creating a post' do
      let(:create_params) { {} }
      let(:make_request) { post '/', create_params }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'


      context 'attempts to verify with a scope of "create"' do
        before do
          allow(token_verifier).to receive(:verify).and_raise(IndieAuth::TokenVerification::InsufficentScopeError)
          make_request
        end

        it { expect(token_verifier).to have_received(:verify).with('create') }
      end

      context 'when an error occurs' do
        it_behaves_like 'an invalid create request'
      end

      context 'when successful' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:accepted] }
        let(:post_factory) { instance_double(HoTan::PostFactory) }
        let(:created_post) do
          instance_double(HoTan::Post, absolute_url: 'http://example.com/some/post')
        end

        before do
          allow(HoTan::PostFactory).to receive(:new) { post_factory }
          allow(post_factory).to receive(:create) { created_post }

          make_request
        end

        it { expect(last_response.status).to eq(expected_status) }
        it { expect(last_response['Location']).to eq(created_post.absolute_url) }
      end
    end

    context 'updating a post' do
      let(:url) { 'https://example.com/foo/bar' }
      let(:update_params) { { 'action' => 'update', 'add' => { 'foo' => 'bar' } } }
      let(:make_request) { post '/', update_params }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'

      context 'attempts to verify with a scope of "update"' do
        before do
          allow(token_verifier).to receive(:verify).and_raise(IndieAuth::TokenVerification::InsufficentScopeError)
          make_request
        end

        it { expect(token_verifier).to have_received(:verify).with('update') }
      end

      context 'when an error occurs' do
        it_behaves_like 'an invalid update request'
      end

      context 'when successful' do
        let(:post_factory) { instance_double(HoTan::PostFactory) }
        let(:existing_post) do
          instance_double(HoTan::Post, absolute_url: url, updated_url?: updated_url)
        end

        before do
          allow(HoTan::PostFactory).to receive(:new) { post_factory }
          allow(post_factory).to receive(:from) { existing_post }
          allow(existing_post).to receive(:update!)

          make_request
        end

        context 'when the url for the post is updated' do
          let(:updated_url) { true }
          let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:created] }

          it { expect(existing_post).to have_received(:update!).with(update_params) }
          it { expect(last_response.status).to eq(expected_status) }
          it { expect(last_response['Location']).to eq(existing_post.absolute_url) }
        end

        context 'when the url for the post is not updated' do
          let(:updated_url) { false }
          let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:no_content] }

          it { expect(existing_post).to have_received(:update!).with(update_params) }
          it { expect(last_response.status).to eq(expected_status) }
          it { expect(last_response['Location']).to be_nil }
        end
      end
    end

    context 'deleting a post' do
      let(:url) { 'https://example.com/foo/bar' }
      let(:delete_params) { { 'url' => url } }
      let(:make_request) { post '/?action=delete', delete_params }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'

      context 'attempts to verify with a scope of "delete"' do
        before do
          allow(token_verifier).to receive(:verify).and_raise(IndieAuth::TokenVerification::InsufficentScopeError)
          make_request
        end

        it { expect(token_verifier).to have_received(:verify).with('delete') }
      end

      context 'when an error occurs' do
        it_behaves_like 'an invalid delete or undelete request'
      end

      context 'when successful' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:no_content] }
        let(:post_factory) { instance_double(HoTan::PostFactory) }
        let(:created_post) do
          instance_double(HoTan::Post, absolute_url: url)
        end

        before do
          allow(HoTan::PostFactory).to receive(:new) { post_factory }
          allow(post_factory).to receive(:from) { created_post }
          allow(created_post).to receive(:delete!) { true }

          make_request
        end

        it { expect(created_post).to have_received(:delete!) }
        it { expect(last_response.status).to eq(expected_status) }
      end
    end

    context 'undeleting a post' do
      let(:url) { 'https://example.com/foo/bar' }
      let(:undelete_params) { { 'url' => url } }
      let(:make_request) { post '/?action=undelete', undelete_params }

      it_behaves_like 'a request with missing destinations'
      it_behaves_like 'an endpoint requiring verification'

      context 'attempts to verify with a scope of "undelete"' do
        before do
          allow(token_verifier).to receive(:verify).and_raise(IndieAuth::TokenVerification::InsufficentScopeError)
          make_request
        end

        it { expect(token_verifier).to have_received(:verify).with('undelete') }
      end

      context 'when an error occurs' do
        it_behaves_like 'an invalid delete or undelete request'
      end

      context 'when successful' do
        let(:expected_status) { Rack::Utils::SYMBOL_TO_STATUS_CODE[:no_content] }
        let(:post_factory) { instance_double(HoTan::PostFactory) }
        let(:created_post) do
          instance_double(HoTan::Post, absolute_url: url)
        end

        before do
          allow(HoTan::PostFactory).to receive(:new) { post_factory }
          allow(post_factory).to receive(:from) { created_post }
          allow(created_post).to receive(:undelete!) { true }

          make_request
        end

        it { expect(created_post).to have_received(:undelete!) }
        it { expect(last_response.status).to eq(expected_status) }
      end
    end
  end
end

A  => spec/lib/ho_tan/destination_spec.rb +52 -0
@@ 1,52 @@
# frozen_string_literal: true

require_relative '../../../lib/ho_tan/destination'

RSpec.describe HoTan::Destination do
  context '#new' do
    subject(:destination) { described_class.new(data) }

    let(:data) do
      {
        'uid' => 'some-identifier',
        'name' => 'Some Name',
        'directory' => 'some/path',
        'base-url' => 'https://example.com/',
        'default' => default
      }.compact
    end
    let(:default) { nil }

    it { expect(destination.to_config).to eq(data.slice('uid', 'name')) }

    context 'when the destination has a default field provided' do
      context 'and the value is "true"' do
        let(:default) { true }

        it { expect(destination.uid).to eq(data['uid']) }
        it { expect(destination.name).to eq(data['name']) }
        it { expect(destination.directory).to eq(data['directory']) }
        it { expect(destination.base_url).to eq(data['base_url']) }
        it { expect(destination.default?).to be true }
      end

      context 'and the value is "false"' do
        let(:default) { false }

        it { expect(destination.uid).to eq(data['uid']) }
        it { expect(destination.name).to eq(data['name']) }
        it { expect(destination.directory).to eq(data['directory']) }
        it { expect(destination.base_url).to eq(data['base_url']) }
        it { expect(destination.default?).to be false }
      end
    end

    context 'when the destination has no default field provided' do
      it { expect(destination.uid).to eq(data['uid']) }
      it { expect(destination.name).to eq(data['name']) }
      it { expect(destination.directory).to eq(data['directory']) }
      it { expect(destination.base_url).to eq(data['base_url']) }
      it { expect(destination.default?).to be false }
    end
  end
end

A  => spec/lib/ho_tan/destinations_spec.rb +101 -0
@@ 1,101 @@
# frozen_string_literal: true

require_relative '../../../lib/ho_tan/destinations'

RSpec.describe HoTan::Destinations do
  let(:data) do
    [
      {
        'uid' => 'https://first-site.example.com/',
        'name' => 'Site 1',
        'directory' => 'data/site/1',
        'base_url' => 'https://first-site.example.com/'
      },
      {
        'uid' => 'https://second-site.example.com/',
        'name' => 'Site 2',
        'directory' => 'data/site/2',
        'base_url' => 'https://second-site.example.com/'
      },
      {
        'uid' => 'https://third-site.example.com/',
        'name' => 'Site 3',
        'directory' => 'data/site/3',
        'base_url' => 'https://third-site.example.com/'
      }
    ]
  end
  let(:destinations) { described_class.from(data) }

  context '#from' do
    before do
      allow(HoTan::Destination).to receive(:new).and_call_original

      destinations
    end

    it 'creates each destination' do
      aggregate_failures do
        expect(HoTan::Destination).to have_received(:new).with(data[0]).once
        expect(HoTan::Destination).to have_received(:new).with(data[1]).once
        expect(HoTan::Destination).to have_received(:new).with(data[2]).once
      end
    end
  end

  context '.all' do
    let(:site_1) { instance_double(HoTan::Destination) }
    let(:site_2) { instance_double(HoTan::Destination) }
    let(:site_3) { instance_double(HoTan::Destination) }

    before do
      allow(HoTan::Destination).to receive(:new).with(data[0]) { site_1 }
      allow(HoTan::Destination).to receive(:new).with(data[1]) { site_2 }
      allow(HoTan::Destination).to receive(:new).with(data[2]) { site_3 }

      destinations
    end

    it { expect(destinations.all).to eq([site_1, site_2, site_3]) }
  end

  context '.to_config' do
    before { destinations }

    let(:expected_config) do
      [
        { 'uid' => 'https://first-site.example.com/', 'name' => 'Site 1' },
        { 'uid' => 'https://second-site.example.com/', 'name' => 'Site 2' },
        { 'uid' => 'https://third-site.example.com/', 'name' => 'Site 3' }
      ]
    end

    it { expect(destinations.to_config).to eq(expected_config) }
  end

  context '.default' do
    let(:site_1) { instance_double(HoTan::Destination, default?: false) }
    let(:site_2) { instance_double(HoTan::Destination, default?: is_default) }
    let(:site_3) { instance_double(HoTan::Destination, default?: false) }

    before do
      allow(HoTan::Destination).to receive(:new).with(data[0]) { site_1 }
      allow(HoTan::Destination).to receive(:new).with(data[1]) { site_2 }
      allow(HoTan::Destination).to receive(:new).with(data[2]) { site_3 }

      destinations
    end

    context 'when a default is explicitly set' do
      let(:is_default) { true }

      it { expect(destinations.default).to eq(site_2) }
    end

    context 'when no default is explicitly set' do
      let(:is_default) { false }

      it { expect(destinations.default).to eq(site_1) }
    end
  end
end

A  => spec/lib/ho_tan/post/data_file_spec.rb +96 -0
@@ 1,96 @@
# frozen_string_literal: true

require_relative '../../../../lib/ho_tan/post/data_file'

RSpec.describe HoTan::Post::DataFile do
  let(:path) { 'a/path/to/a/file.json' }

  context '#new' do
    subject(:data_file) { described_class.new(path) }

    it { expect(data_file.path).to eq(path) }
  end

  context '.read' do
    subject(:data_file) do
      described_class.new(path).read
    end

    let(:json_data) { 'some json' }

    before do
      allow(File).to receive(:exist?) { file_exists }
      allow(File).to receive(:read) { json_data }
      allow(JSON).to receive(:parse)
    end

    context 'when the file exists' do
      let(:file_exists) { true }

      before { data_file }

      it 'attempts to read the data' do
        aggregate_failures do
          expect(File).to have_received(:exist?).with(path)
          expect(File).to have_received(:read).with(path)
          expect(JSON).to have_received(:parse).with(json_data)
        end
      end
    end

    context 'when the file does not exist' do
      let(:file_exists) { false }

      it 'does not attempt to read the data' do
        begin data_file rescue nil end
        aggregate_failures do
          expect(File).to have_received(:exist?).with(path)
          expect(File).not_to have_received(:read)
          expect(JSON).not_to have_received(:parse)
        end
      end

      it { expect { data_file }.to raise_error(HoTan::Post::DataFile::NotFoundError) }
    end
  end

  context '.save' do
    let(:data) do
      {
        'type' => 'h-entry',
        'properties' => {
          'some' => ['data']
        }
      }
    end
    let(:pretty_json) { double(:pretty_json) }

    before do
      allow(FileUtils).to receive(:mkdir_p)
      allow(JSON).to receive(:pretty_generate) { pretty_json }
      allow(File).to receive(:write)

      described_class.new(path).save(data)
    end

    it 'attempts to save the data' do
      aggregate_failures do
        expect(FileUtils).to have_received(:mkdir_p).with('a/path/to/a')
        expect(JSON).to have_received(:pretty_generate).with(data)
        expect(File).to have_received(:write).with(path, pretty_json)
      end
    end
  end

  context '.delete' do
    subject(:data_file) { described_class.new(path) }

    before do
      allow(FileUtils).to receive(:rm)

      data_file.delete!
    end

    it { expect(FileUtils).to have_received(:rm).with(path) }
  end
end

A  => spec/lib/ho_tan/post/location_spec.rb +56 -0
@@ 1,56 @@
# frozen_string_literal: true

require_relative '../../../../lib/ho_tan/post/location'
require_relative '../../../../lib/ho_tan/destination'

RSpec.describe HoTan::Post::Location do
  let(:destination) do
    HoTan::Destination.new('uid' => 'test-site',
                           'name' => 'A Test Site',
                           'directory' => '/some/place/files/live',
                           'base_url' => 'https://test.example.com/')
  end

  context '#new' do
    subject(:location) { described_class.new(destination) }

    it 'creates an instance with the appropriate data' do
      expect(location).to be_an_instance_of(described_class)
      expect(location.destination).to eq(destination)
    end
  end

  context '.url_for_instance' do
    subject(:url_for_instance) { location.url_for_instance(instance) }

    let(:location) { described_class.new(destination) }
    let(:instance) do
      double(:a_post_instance, path: 'some/path/to/files', slug: 'a-file')
    end
    let(:expected_url) { 'https://test.example.com/some/path/to/files/a-file' }

    it { expect(url_for_instance).to eq(expected_url) }
  end

  context '.path_for_instance' do
    subject(:path_for_instance) { location.path_for_instance(instance) }

    let(:location) { described_class.new(destination) }
    let(:instance) do
      double(:a_post_instance, path: 'some/path/to/files', slug: 'a-file')
    end
    let(:expected_path) { '/some/place/files/live/some/path/to/files/a-file.json' }

    it { expect(path_for_instance).to eq(expected_path) }
  end

  context '.path_for_url' do
    subject(:path_for_url) { location.path_for_url(url) }

    let(:location) { described_class.new(destination) }
    let(:url) { 'https://test.example.com/some/path/to/files/a-file' }
    let(:expected_path) { '/some/place/files/live/some/path/to/files/a-file.json' }

    it { expect(path_for_url).to eq(expected_path) }
  end
end

A  => spec/lib/ho_tan/post/normalize_spec.rb +79 -0
@@ 1,79 @@
# frozen_string_literal: true

require_relative '../../../../lib/ho_tan/post/normalize'
require 'timecop'

RSpec.describe HoTan::Post::Normalize do
  context '#for_create' do
    subject(:normalized_data) { described_class.for_create(data) }

    before { Timecop.freeze(Time.parse('2019-01-07 20:00:00 UTC')) }
    after { Timecop.return }

    let(:expected_data) do
      {
        'type' => ['h-entry'],
        'properties' => {
          'content' => ['hello world'],
          'category' => %w[foo bar],
          'photo' => ['https://photos.example.com/592829482876343254.jpg'],
          'published' => [Time.now.utc.iso8601]
        }
      }
    end

    context 'when the format of the data cannot be determined' do
      let(:data) { {} }

      it { expect { normalized_data }.to raise_error(HoTan::Post::Normalize::InvalidCreateError) }
    end

    context 'when the data comes in a URL-encoded form' do
      let(:data) do
        {
          'h' => h_value,
          'content' => 'hello world',
          'category' => %w[foo bar],
          'photo' => 'https://photos.example.com/592829482876343254.jpg'
        }
      end

      context 'but the "h" value is not set to "entry"' do
        let(:h_value) { 'event' }

        it { expect { normalized_data }.to raise_error(HoTan::Post::Normalize::InvalidHError) }
      end

      context 'and the "h" value is set to "entry"' do
        let(:h_value) { 'entry' }

        it { expect(normalized_data).to eq(expected_data) }
      end
    end

    context 'when the data comes as JSON' do
      let(:data) do
        {
          'type' => [type],
          'properties' => {
            'content' => ['hello world'],
            'category' => %w[foo bar],
            'photo' => ['https://photos.example.com/592829482876343254.jpg']
          }
        }
      end

      context 'but the first "type" value is not set to "h-entry"' do
        let(:type) { 'h-event' }

        it { expect { normalized_data }.to raise_error(HoTan::Post::Normalize::InvalidTypeError) }
      end

      context 'and the first "type" value is set to "h-entry"' do
        let(:type) { 'h-entry' }

        it { expect(normalized_data).to eq(expected_data) }
      end
    end
  end
end

A  => spec/lib/ho_tan/post/type/article_spec.rb +62 -0
@@ 1,62 @@
# frozen_string_literal: true

require_relative '../../../../../lib/ho_tan/post/type/article'
require 'timecop'

RSpec.describe HoTan::Post::Type::Article do
  let(:base_data) do
    {
      'type' => ['h-entry'],
      'properties' => {
        'context' => ['Hello'],
        'name' => ['The title of the article'],
        'published' => ['2019-02-07T12:34:56+00:00']
      }
    }
  end
  let(:data) { base_data }

  context '.slug' do
    subject(:instance) { described_class.new(data) }

    context 'when an "mp-slug" is set' do
      let(:mp_slug) { 'my-slug' }
      let(:data) do
        data = base_data.dup
        data['properties']['mp-slug'] = [mp_slug]
        data
      end

      it { expect(instance.slug).to eq(mp_slug) }
    end

    context 'when an "mp-slug" is not set' do
      context 'but a "name" is provided' do
        it { expect(instance.slug).to eq('the-title-of-the-article') }
      end

      context 'and a "name" is not provided' do
        let(:data) do
          data = base_data.dup
          data['properties'].delete('name')
          data['properties'].delete('published') if published.nil?
          data
        end
        let(:published) { base_data['properties']['published'][0] }

        context 'but "published" is set' do
          it { expect(instance.slug).to eq('123456') }
        end

        context 'and "published" is not set' do
          let(:published) { nil }

          before { Timecop.freeze(Time.parse('2019-01-07 23:45:01 UTC')) }
          after { Timecop.return }

          it { expect(instance.slug).to eq('234501') }
        end
      end
    end
  end
end

A  => spec/lib/ho_tan/post/type/base_spec.rb +74 -0
@@ 1,74 @@
# frozen_string_literal: true

require_relative '../../../../../lib/ho_tan/post/type/base'
require 'timecop'

class Mcguffin < HoTan::Post::Type::Base
end

RSpec.describe HoTan::Post::Type::Base do
  let(:base_data) do
    {
      'type' => ['h-entry'],
      'properties' => {
        'context' => ['Hello'],
        'published' => ['2019-02-07T12:34:56+00:00']
      }
    }
  end

  context '#new' do
    subject(:instance) { Mcguffin.new(base_data) }

    let(:expected_data) do
      expected = base_data.dup
      expected['properties'].merge('entry_type' => ['mcguffin'])
      expected
    end

    it 'saves the provided data with expected additions' do
      expect(instance.data).to eq(expected_data)
    end
  end

  context '.path' do
    subject(:instance) { Mcguffin.new(base_data) }

    it { expect(instance.path).to eq('mcguffins/2019/02/07') }
  end

  context '.slug' do
    subject(:instance) { Mcguffin.new(data) }

    let(:data) do
      data = base_data.dup
      data['properties']['mp-slug'] = [mp_slug] unless mp_slug.nil?
      data['properties'].delete('published') if published.nil?
      data
    end
    let(:published) { base_data['properties']['published'][0] }

    context 'when an "mp-slug" is set' do
      let(:mp_slug) { 'my-slug' }

      it { expect(instance.slug).to eq(mp_slug) }
    end

    context 'when an "mp-slug" is not set' do
      let(:mp_slug) { nil }

      context 'but published is set' do
        it { expect(instance.slug).to eq('123456') }
      end

      context 'and "published" is not set' do
        let(:published) { nil }

        before { Timecop.freeze(Time.parse('2019-01-07 23:45:01 UTC')) }
        after { Timecop.return }

        it { expect(instance.slug).to eq('234501') }
      end
    end
  end
end

A  => spec/lib/ho_tan/post/type/read_spec.rb +21 -0
@@ 1,21 @@
# frozen_string_literal: true

require_relative '../../../../../lib/ho_tan/post/type/read'

RSpec.describe HoTan::Post::Type::Read do
  let(:base_data) do
    {
      'type' => ['h-entry'],
      'properties' => {
        'context' => ['Hello'],
        'published' => ['2019-02-07T12:34:56+00:00']
      }
    }
  end

  context '.path' do
    subject(:instance) { described_class.new(base_data) }

    it { expect(instance.path).to eq('reading/2019/02/07') }
  end
end

A  => spec/lib/ho_tan/post/type/reply_spec.rb +21 -0
@@ 1,21 @@
# frozen_string_literal: true

require_relative '../../../../../lib/ho_tan/post/type/reply'

RSpec.describe HoTan::Post::Type::Reply do
  let(:base_data) do
    {
      'type' => ['h-entry'],
      'properties' => {
        'context' => ['Hello'],
        'published' => ['2019-02-07T12:34:56+00:00']
      }
    }
  end

  context '.path' do
    subject(:instance) { described_class.new(base_data) }

    it { expect(instance.path).to eq('replies/2019/02/07') }
  end
end

A  => spec/lib/ho_tan/post/type/scrobble_spec.rb +158 -0
@@ 1,158 @@
# frozen_string_literal: true

require_relative '../../../../../lib/ho_tan/post/type/scrobble'
require 'timecop'

RSpec.describe HoTan::Post::Type::Scrobble do
  subject(:post) { described_class.new(data) }

  let(:base_data) do
    {
      'properties' => {
        'scrobble-of' => [
          {
            'properties' => {
              'artist' => [artist],
              'title' => [title],
            }
          }
        ],
        'published' => ['2016-02-21T12:50:53-08:00']
      }
    }
  end
  let(:artist) { nil }
  let(:title) { nil }

  describe '.slug' do
    let(:artist) { 'Boards of Canada' }
    let(:title) { 'Triangles & Rhombuses' }

    context 'when an mp-slug entry is provided' do
      let(:data) do
        data = base_data.dup
        data['properties']['mp-slug'] = [mp_slug]
        data
      end

      context 'and is valid' do
        let(:mp_slug) { 'a--valid--slug ' }

        it { expect(post.slug).to eq 'a-valid-slug' }
      end

      context 'and is not valid' do
        let(:expected_slug) { 'boards-of-canada-triangles-rhombuses-125053' }

        context 'as it reduces to a dash' do
          let(:mp_slug) { '  -----  ' }

          it { expect(post.slug).to eq expected_slug }
        end
      end
    end

    context 'when an mp-slug entry is not provided' do
      let(:data) { base_data }
      let(:expected_slug) { 'boards-of-canada-triangles-rhombuses-125053' }

      it { expect(post.slug).to eq expected_slug }

      context 'when the slug source contains a period' do
        let(:artist) { 'Public Service Broadcasting' }
        let(:title) { 'E.V.A.' }
        let(:expected_slug) { 'public-service-broadcasting-eva-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a comma' do
        let(:artist) { 'Underworld' }
        let(:title) { 'Boy, Boy, Boy' }
        let(:expected_slug) { 'underworld-boy-boy-boy-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains an apostrophe' do
        let(:title) { "Ready Let's Go" }
        let(:expected_slug) { 'boards-of-canada-ready-lets-go-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains an ampersand' do
        let(:artist) { 'Burger & Ink (Duo)' }
        let(:title) { 'Twelve Miles High' }
        let(:expected_slug) { 'burger-ink-duo-twelve-miles-high-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a digit' do
        let(:title) { '1969' }
        let(:expected_slug) { 'boards-of-canada-1969-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a bracket' do
        let(:artist) { 'Underworld' }
        let(:title) { 'Push Upstairs (Remastered)' }
        let(:expected_slug) { 'underworld-push-upstairs-remastered-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a forward-slash' do
        let(:artist) { 'Autechre' }
        let(:title) { 'C/Pach' }
        let(:expected_slug) { 'autechre-cpach-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a hash' do
        let(:artist) { 'µ-Ziq' }
        let(:title) { 'Secret Stair #1' }
        let(:expected_slug) { 'μ-ziq-secret-stair-1-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a dollar' do
        let(:artist) { 'Aphex Twin' }
        let(:title) { 'Inkey$' }
        let(:expected_slug) { 'aphex-twin-inkey-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a pound sign' do
        let(:artist) { 'Aphex Twin' }
        let(:title) { 'Girl Boy (£18 Snarerush Mix)' }
        let(:expected_slug) { 'aphex-twin-girl-boy-18-snarerush-mix-125053' }

        it { expect(post.slug).to eq expected_slug }
      end

      context 'when the slug source contains a "special" character' do
        context 'such as "µ"' do
          let(:artist) { 'µ-Ziq' }
          let(:title) { 'Hasty Boom Alert' }
          let(:expected_slug) { 'μ-ziq-hasty-boom-alert-125053' }

          it { expect(post.slug).to eq expected_slug }
        end

        context 'such as "é"' do
          let(:artist) { 'Isolée' }
          let(:title) { 'Beau Mot Plage (Original Version)' }
          let(:expected_slug) { 'isolee-beau-mot-plage-original-version-125053' }

          it { expect(post.slug).to eq expected_slug }
        end
      end
    end
  end
end

A  => spec/lib/ho_tan/post_factory_spec.rb +167 -0
@@ 1,167 @@
# frozen_string_literal: true

require_relative '../../../lib/ho_tan/destinations'
require_relative '../../../lib/ho_tan/post_factory'

RSpec.describe HoTan::PostFactory do
  let(:destination_data) do
    [
      {
        'uid' => 'site_1',
        'name' => 'Site 1',
        'directory' => 'data/site/1',
        'base_url' => 'https://first-site.example.com/'
      },
      {
        'uid' => 'site_2',
        'name' => 'Site 2',
        'directory' => 'data/site/2',
        'base_url' => 'https://second-site.example.com/'
      },
      {
        'uid' => 'site_3',
        'name' => 'Site 3',
        'directory' => 'data/site/3',
        'base_url' => 'https://third-site.example.com/'
      }
    ]
  end
  let(:destinations) do
    HoTan::Destinations.from(destination_data)
  end

  context '#new' do
    subject(:post_factory) do
      described_class.new(destinations: destinations)
    end

    it { expect(post_factory.destinations).to eq(destinations) }
  end

  context '.create' do
    subject(:create) do
      described_class.new(destinations: destinations).create(data)
    end

    let(:data) { double(:some_data) }
    let(:normalized_data) do
      {
        'type' => ['h-entry'],
        'properties' => {
          'mp-destination' => mp_destination
        }.compact
      }
    end
    let(:normalized_data_excluding_destination) do
      {
        'type' => ['h-entry'],
        'properties' => {}
      }
    end

    before do
      allow(HoTan::Post::Normalize).to receive(:for_create) { normalized_data }
      allow(HoTan::Post).to receive(:create!)
    end

    context 'when mp-destination is not provided' do
      let(:mp_destination) { nil }

      before { create }

      it { expect(HoTan::Post::Normalize).to have_received(:for_create).with(data) }
      it { expect(HoTan::Post).to have_received(:create!).with(normalized_data_excluding_destination, destinations.default) }
    end

    context 'when mp-destination is provided' do
      context 'and is in the destinations list' do
        let(:mp_destination) { [destination_data[2]['uid']] }

        before { create }

        it { expect(HoTan::Post::Normalize).to have_received(:for_create).with(data) }
        it { expect(HoTan::Post).to have_received(:create!).with(normalized_data_excluding_destination, destinations.all[2]) }
      end

      context 'but is not in the destinations list' do
        let(:mp_destination) { 'non-existent' }

        it 'normalizes the data' do
          begin create rescue nil end
          expect(HoTan::Post::Normalize).to have_received(:for_create).with(data)
        end

        it 'does not attempt to create the post' do
          begin create rescue nil end
          expect(HoTan::Post).not_to have_received(:create!)
        end

        it { expect { create }.to raise_error(HoTan::PostFactory::UnrecognisedDestinationError) }
      end
    end
  end

  context '.from' do
    subject(:from) do
      described_class.new(destinations: destinations).from(url)
    end

    let(:location) { instance_double(HoTan::Post::Location) }

    before do
      allow(HoTan::Post::Location).to receive(:new) { location }
      allow(location).to receive(:path_for_url) { path }
      allow(HoTan::Post).to receive(:retrieve)
    end

    context 'when the url matches one of the destinations' do
      let(:url) { 'https://third-site.example.com/foo/bar/baz' }
      let(:path) { 'a/path/that/is/totally/real' }
      let(:expected_destination) { destinations.all[2] }

      context 'and no error occurs when retrieving the post' do
        before { from }

        it 'retrieves the post with the correct details' do
          aggregate_failures do
            expect(HoTan::Post::Location).to have_received(:new).with(expected_destination)
            expect(location).to have_received(:path_for_url).with(url)
            expect(HoTan::Post).to have_received(:retrieve).with(path, url, expected_destination)
          end
        end
      end

      context 'but an error occurs when retrieving the post' do
        before do
          allow(HoTan::Post).to receive(:retrieve).and_raise(HoTan::Post::InvalidPathError)
        end

        it 'attempts to read the file' do
          begin from rescue nil end
          aggregate_failures do
            expect(HoTan::Post::Location).to have_received(:new).with(expected_destination)
            expect(location).to have_received(:path_for_url).with(url)
            expect(HoTan::Post).to have_received(:retrieve).with(path, url, expected_destination)
          end
        end

        it 'raises an appropriate error' do
          expect { from }.to raise_error(HoTan::PostFactory::InvalidPathError)
        end
      end
    end

    context 'when the url does not match one of the destinations' do
      let(:url) { 'https://fourth-site.example.com/foo/bar/baz' }

      it { expect { from }.to raise_error(HoTan::PostFactory::UnrecognisedDestinationError) }
      it 'does not try to determine the post location' do
        begin from rescue nil end
        expect(HoTan::Post::Location).not_to have_received(:new)
      end
      it 'does not try to retrieve the post' do
        expect(HoTan::Post).not_to have_received(:retrieve)
      end
    end
  end
end

A  => spec/lib/ho_tan/post_spec.rb +563 -0
@@ 1,563 @@
# frozen_string_literal: true

require_relative '../../../lib/ho_tan/post'
require_relative '../../../lib/ho_tan/destination'
require 'indieweb/post_types'
require 'timecop'

RSpec.shared_examples_for 'a valid update' do
  let(:updated_url) { false }

  before do
    allow(post).to receive(:updated_url?) { updated_url }
  end

  it 'updates the properties and saves the data' do
    post.update!(update_data)
    expect(post.instance.properties).to eq(expected_properties)
    expect(post).to have_received(:save!)
  end

  context 'deleting out-dated data files' do
    let(:location) { instance_double(HoTan::Post::Location) }
    let(:original_path) { 'a/path/to/data/for/a/now/deleted/post.json' }
    let(:original_data_file) { instance_double(HoTan::Post::DataFile) }

    before do
      allow(HoTan::Post::Location).to receive(:new) { location }
      allow(location).to receive(:path_for_url) { original_path }
      allow(HoTan::Post::DataFile).to receive(:new).with(original_path) { original_data_file }
      allow(original_data_file).to receive(:delete!)
      post.update!(update_data)
    end

    context 'when the url has not changed' do
      it 'does not attempt to delete the data file' do
        aggregate_failures do
          expect(location).not_to have_received(:path_for_url)
          expect(HoTan::Post::DataFile).not_to have_received(:new).with(original_path)
          expect(original_data_file).not_to have_received(:delete!)
        end
      end
    end

    context 'when the url has changed' do
      let(:updated_url) { true }

      it 'attempts to delete the data file' do
        aggregate_failures do
          expect(location).to have_received(:path_for_url)
          expect(HoTan::Post::DataFile).to have_received(:new).with(original_path)
          expect(original_data_file).to have_received(:delete!)
        end
      end
    end
  end
end

RSpec.shared_examples_for 'an invalid replace or add update' do |update_type|
  context 'when an error occurs' do
    let(:update_data) do
      {
        'action' => 'update',
        'url' => url,
        update_type => actual_update_data
      }
    end

    context 'when the update data is not a hash' do
      let(:actual_update_data) { [] }

      it { expect { post.update!(update_data) }.to raise_error(HoTan::Post::InvalidUpdateError) }
    end

    context 'when the update data is a hash' do
      context 'but not all of the values are arrays' do
        let(:actual_update_data) do
          {
            'foo' => ['bar'],
            'cthulhu' => 'fhtagn'
          }
        end

        it { expect { post.update!(update_data) }.to raise_error(HoTan::Post::InvalidUpdateError) }
      end
    end
  end
end

RSpec.describe HoTan::Post do
  let(:data_file) { instance_double(HoTan::Post::DataFile) }
  let(:destination) do
    HoTan::Destination.new('uid' => 'test-site',
                           'name' => 'A Test Site',
                           'directory' => '/some/place/files/live',
                           'base_url' => 'https://test.example.com/')
  end
  let(:data) do
    {
      'type' => ['h-entry'],
      'properties' => {
        'entry_type' => [post_type],
        'content' => ['Some content']
      }
    }
  end
  let(:post_type_class) do
    %w[Article Bookmark Checkin Note Photo Read Reply Repost].sample
  end
  let(:post_type_full_class) do
    Object.const_get("HoTan::Post::Type::#{post_type_class}")
  end
  let(:post_instance) do
    instance_double(post_type_full_class, data: data)
  end
  let(:post_type) { post_type_class.downcase }

  before do
    allow(Indieweb::PostTypes).to receive(:type_from) { post_type }
    allow(HoTan::Post::DataFile).to receive(:new) { data_file }
    allow(data_file).to receive(:save) { true }
  end

  context '#create!' do
    let(:save_location) { 'path/to/a/file' }

    before do
      allow(File).to receive(:exist?) { file_exists }
      allow(post_type_full_class).to receive(:new) { post_instance }
      allow_any_instance_of(described_class).to receive(:save!).and_call_original
      allow_any_instance_of(described_class).to receive(:save_location) { save_location }
    end

    context 'when the post type is a supported one' do
      subject(:post) { described_class.create!(data, destination) }

      context 'and the post does not already exist' do
        let(:file_exists) { false }

        before { post }

        it 'correctly creates and attempts to save the post' do
          aggregate_failures do
            expect(Indieweb::PostTypes).to have_received(:type_from).with(data)
            expect(post_type_full_class).to have_received(:new).with(data)
            expect(File).to have_received(:exist?).with(save_location)
            expect(post).to have_received(:save!)
            expect(HoTan::Post::DataFile).to have_received(:new).with('path/to/a/file')
            expect(data_file).to have_received(:save).with(post_instance.data)
            expect(post).to be_an_instance_of(described_class)
            expect(post.destination).to eq(destination)
          end
        end
      end

      context 'but the post already exists' do
        let(:file_exists) { true }

        it 'correctly creates and attempts to save the post' do
          begin post rescue nil end

          aggregate_failures do
            expect(Indieweb::PostTypes).to have_received(:type_from).with(data)
            expect(post_type_full_class).to have_received(:new).with(data)
            expect(File).to have_received(:exist?).with(save_location)
            expect(HoTan::Post::DataFile).not_to have_received(:new)
            expect(data_file).not_to have_received(:save)
          end
        end

        it { expect { post }.to raise_error(HoTan::Post::DuplicateCreateError) }
      end
    end

    context 'when the post type is not a supported one' do
      let(:post_type) { 'nope' }
      let(:post) { described_class.create!(data, destination) }

      it 'attempts to determine the post type' do
        begin post rescue nil end
        expect(Indieweb::PostTypes).to have_received(:type_from).with(data)
      end

      it 'raises an error when the post type is not recognised' do
        expect { post }.to raise_error(HoTan::Post::UnrecognisedTypeError)
      end
    end
  end

  context '#retrieve' do
    subject(:post) { described_class.retrieve(path, url, destination) }

    let(:path) { 'a/path/to/a/file.json' }
    let(:url) { 'https://test.example.com/foo/bar/baz' }

    before do
      allow(post_type_full_class).to receive(:new) { post_instance }
      allow(data_file).to receive(:read) { data }
    end

    context 'when the path exists' do
      before { post }

      it 'correctly retrieves the post' do
        aggregate_failures do
          expect(HoTan::Post::DataFile).to have_received(:new).with(path)
          expect(data_file).to have_received(:read)
          expect(Indieweb::PostTypes).to have_received(:type_from).with(data)
          expect(post_type_full_class).to have_received(:new).with(data)
          expect(post).to be_an_instance_of(described_class)
          expect(post.destination).to eq(destination)
          expect(post.original_url).to eq(url)
        end
      end
    end

    context 'when the path does not exist' do
      before do
        allow(data_file).to receive(:read).and_raise(HoTan::Post::DataFile::NotFoundError)
      end

      it 'only attempts to retrieve the post' do
        begin post rescue nil end
        aggregate_failures do
          expect(HoTan::Post::DataFile).to have_received(:new).with(path)
          expect(data_file).to have_received(:read)
          expect(Indieweb::PostTypes).not_to have_received(:type_from)
          expect(post_type_full_class).not_to have_received(:new)
        end
      end

      it 'raises an error' do
        expect { post }.to raise_error(HoTan::Post::InvalidPathError)
      end
    end
  end

  context '.update!' do
    subject(:post) { described_class.retrieve(path, url, destination) }

    let(:post_instance) { post_type_full_class.new(data) }
    let(:path) { 'a/path/to/a/file.json' }
    let(:url) { 'https://test.example.com/foo/bar/baz' }
    let(:data) do
      {
        'type' => 'h-entry',
        'properties' => {
          'content' => ['Some content'],
          'syndication' => ['http://some-site.com/123'],
          'tags' => %w[foo bar baz]
        }
      }
    end

    before do
      allow(data_file).to receive(:read) { data }
      allow(post).to receive(:save!) { true }
      Timecop.freeze(Time.parse('2019-02-11 12:34:56 UTC'))
    end

    after { Timecop.return }

    context 'when properties are to be replaced' do
      let(:update_data) do
        {
          'action' => 'update',
          'url' => url,
          'replace' => {
            'content' => ['hello moon'],
            'name' => ['wibble']
          }
        }
      end
      let(:expected_properties) do
        {
          'entry_type' => [post_type],
          'content' => ['hello moon'],
          'name' => ['wibble'],
          'syndication' => ['http://some-site.com/123'],
          'tags' => %w[foo bar baz],
          'updated_at' => ['2019-02-11T12:34:56Z']
        }
      end

      it_behaves_like 'a valid update'
      it_behaves_like 'an invalid replace or add update', 'replace'
    end

    context 'when properties are to be added' do
      let(:update_data) do
        {
          'action' => 'update',
          'url' => url,
          'add' => {
            'syndication' => ['http://some-other-site.com/456'],
            'name' => ['fhtagn']
          }
        }
      end
      let(:expected_properties) do
        {
          'entry_type' => [post_type],
          'content' => ['Some content'],
          'name' => ['fhtagn'],
          'syndication' => ['http://some-site.com/123', 'http://some-other-site.com/456'],
          'tags' => %w[foo bar baz],
          'updated_at' => ['2019-02-11T12:34:56Z']
        }
      end

      it_behaves_like 'a valid update'
      it_behaves_like 'an invalid replace or add update', 'add'
    end

    context 'when properties are to be deleted' do
      context 'and the properties are to be deleted completely' do
        let(:update_data) do
          {
            'action' => 'update',
            'url' => url,
            'delete' => %w[name syndication]
          }
        end
        let(:expected_properties) do
          {
            'entry_type' => [post_type],
            'content' => ['Some content'],
            'tags' => %w[foo bar baz],
            'updated_at' => ['2019-02-11T12:34:56Z']
          }
        end

        it_behaves_like 'a valid update'
      end

      context 'and the properties are to be partially deleted' do
        let(:update_data) do
          {
            'action' => 'update',
            'url' => url,
            'delete' => {
              'tags' => ['bar']
            }
          }
        end
        let(:expected_properties) do
          {
            'entry_type' => [post_type],
            'content' => ['Some content'],
            'syndication' => ['http://some-site.com/123'],
            'tags' => %w[foo baz],
            'updated_at' => ['2019-02-11T12:34:56Z']
          }
        end

        it_behaves_like 'a valid update'
      end

      context 'when an error occurs' do
        let(:update_data) do
          {
            'action' => 'update',
            'url' => url,
            'delete' => delete_data
          }
        end

        context 'when the update data is not a hash or an array' do
          let(:delete_data) { '' }

          it { expect { post.update!(update_data) }.to raise_error(HoTan::Post::InvalidUpdateError) }
        end

        context 'when the update data is a hash' do
          context 'but not all of the values are arrays' do
            let(:delete_data) do
              {
                'foo' => ['bar'],
                'cthulhu' => 'fhtagn'
              }
            end

            it { expect { post.update!(update_data) }.to raise_error(HoTan::Post::InvalidUpdateError) }
          end
        end
      end
    end

    context 'when all types of changes are provided' do
      let(:update_data) do
        {
          'action' => 'update',
          'url' => url,
          'replace' => {
            'content' => ['hello moon']
          },
          'add' => {
            'syndication' => ['http://some-other-site.com/456'],
            'name' => ['fhtagn']
          },
          'delete' => ['tags']
        }
      end
      let(:expected_properties) do
        {
          'entry_type' => [post_type],
          'content' => ['hello moon'],
          'name' => ['fhtagn'],
          'syndication' => ['http://some-site.com/123', 'http://some-other-site.com/456'],
          'updated_at' => ['2019-02-11T12:34:56Z']
        }
      end

      it_behaves_like 'a valid update'
    end
  end

  context '.delete!' do
    let(:path) { 'a/path/to/a/file.json' }
    let(:url) { 'https://test.example.com/foo/bar/baz' }
    let(:post) do
      described_class.retrieve(path, url, destination)
    end
    let(:expected_deleted_at) { '2019-02-11T12:34:56Z' }

    before do
      allow(post_type_full_class).to receive(:new) { post_instance }
      allow(data_file).to receive(:read) { data }
      allow(post).to receive(:save!) { true }
      allow(post_instance).to receive(:properties) { data['properties'] }
      Timecop.freeze(Time.parse('2019-02-11 12:34:56 UTC'))

      post.delete!
    end

    after { Timecop.return }

    it 'correctly marks the post as deleted' do
      expect(post).to have_received(:save!)
      expect(post.instance.properties).to have_key('deleted_at')
      expect(post.instance.properties['deleted_at']).to eq([expected_deleted_at])
    end
  end

  context '.undelete!' do
    let(:path) { 'a/path/to/a/file.json' }
    let(:url) { 'https://test.example.com/foo/bar/baz' }
    let(:deleted_post) do
      described_class.retrieve(path, url, destination)
    end
    let(:data) do
      {
        'type' => ['h-entry'],
        'properties' => {
          'content' => ['Some content'],
          'deleted_at' => ['2019-02-11 12:34:56 UTC']
        }
      }
    end

    before do
      allow(post_type_full_class).to receive(:new) { post_instance }
      allow(data_file).to receive(:read) { data }
      allow(deleted_post).to receive(:save!) { true }
      allow(post_instance).to receive(:properties) { data['properties'] }

      deleted_post.undelete!
    end

    it 'correctly marks the post as undeleted' do
      expect(deleted_post).to have_received(:save!)
      expect(deleted_post.instance.properties).not_to have_key('deleted_at')
    end
  end

  context '.save_location' do
    let(:post) { described_class.create!(data, destination) }
    let(:save_location) { post.save_location }

    before do
      allow(post_type_full_class).to receive(:new) { post_instance }
      allow(destination).to receive(:directory).and_call_original
      allow(post_instance).to receive(:path) { 'some/path' }
      allow(post_instance).to receive(:slug) { 'a-slug' }
      allow_any_instance_of(described_class).to receive(:save!) { true }
    end

    it 'pulls the data from the appropriate places' do
      save_location

      aggregate_failures do
        expect(destination).to have_received(:directory)
        expect(post_instance).to have_received(:path)
        expect(post_instance).to have_received(:slug)
      end
    end

    it { expect(save_location).to eq('/some/place/files/live/some/path/a-slug.json') }
  end

  context '.absolute_url' do
    let(:post) { described_class.create!(data, destination) }
    let(:absolute_url) { post.absolute_url }

    before do
      allow(post_type_full_class).to receive(:new) { post_instance }
      allow(destination).to receive(:base_url).and_call_original
      allow(post_instance).to receive(:path) { 'some/path' }
      allow(post_instance).to receive(:slug) { 'a-slug' }
      allow_any_instance_of(described_class).to receive(:save!) { true }
    end

    it 'pulls the data from the appropriate places' do
      absolute_url

      aggregate_failures do
        expect(destination).to have_received(:base_url)
        expect(post_instance).to have_received(:path).twice
        expect(post_instance).to have_received(:slug).twice
      end
    end

    it { expect(absolute_url).to eq('https://test.example.com/some/path/a-slug') }
  end

  context '.updated_url?' do
    subject(:post) { described_class.retrieve(path, url, destination) }

    let(:path) { 'a/path/to/a/file.json' }
    let(:url) { 'https://test.example.com/foo/bar/baz' }

    let(:location) do
      instance_double(HoTan::Post::Location, url_for_instance: absolute_url)
    end

    before do
      allow(post_type_full_class).to receive(:new) { post_instance }
      allow(data_file).to receive(:read) { data }
      allow(HoTan::Post::Location).to receive(:new) { location }
    end

    context 'when there is no original url' do
      before do
        allow(post).to receive(:original_url) { nil }
      end

      it { expect(post.updated_url?).to be false }
    end

    context 'when there is an original url' do
      context 'and it matches the current url' do
        let(:absolute_url) { url }

        it { expect(post.updated_url?).to be false }
      end

      context 'and it does not match the current url' do
        let(:absolute_url) { 'https://test/example.com/some/other/place' }

        it { expect(post.updated_url?).to be true }
      end
    end
  end
end

A  => spec/spec_helper.rb +104 -0
@@ 1,104 @@
# frozen_string_literal: true

# This file was generated by the `rspec --init` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.

require 'dotenv'
Dotenv.load('.env.test', '.env')

require 'simplecov'
SimpleCov.start

# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
  # rspec-expectations config goes here. You can use an alternate
  # assertion/expectation library such as wrong or the stdlib/minitest
  # assertions if you prefer.
  config.expect_with :rspec do |expectations|
    # This option will default to `true` in RSpec 4. It makes the `description`
    # and `failure_message` of custom matchers include text for helper methods
    # defined using `chain`, e.g.:
    #     be_bigger_than(2).and_smaller_than(4).description
    #     # => "be bigger than 2 and smaller than 4"
    # ...rather than:
    #     # => "be bigger than 2"
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  # rspec-mocks config goes here. You can use an alternate test double
  # library (such as bogus or mocha) by changing the `mock_with` option here.
  config.mock_with :rspec do |mocks|
    # Prevents you from mocking or stubbing a method that does not exist on
    # a real object. This is generally recommended, and will default to
    # `true` in RSpec 4.
    mocks.verify_partial_doubles = true
  end

  # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
  # have no way to turn it off -- the option exists only for backwards
  # compatibility in RSpec 3). It causes shared context metadata to be
  # inherited by the metadata hash of host groups and examples, rather than
  # triggering implicit auto-inclusion in groups with matching metadata.
  config.shared_context_metadata_behavior = :apply_to_host_groups

  # This allows you to limit a spec run to individual examples or groups
  # you care about by tagging them with `:focus` metadata. When nothing
  # is tagged with `:focus`, all examples get run. RSpec also provides
  # aliases for `it`, `describe`, and `context` that include `:focus`
  # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
  config.filter_run_when_matching :focus

  # Allows RSpec to persist some state between runs in order to support
  # the `--only-failures` and `--next-failure` CLI options. We recommend
  # you configure your source control system to ignore this file.
  config.example_status_persistence_file_path = 'spec/examples.txt'

  # Limits the available syntax to the non-monkey patched syntax that is
  # recommended. For more details, see:
  #   - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
  #   - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
  #   - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
  config.disable_monkey_patching!

  # This setting enables warnings. It's recommended, but in some cases may
  # be too noisy due to issues in dependencies.
  config.warnings = true

  # Many RSpec users commonly either run the entire suite or an individual
  # file, and it's useful to allow more verbose output when running an
  # individual spec file.
  if config.files_to_run.one?
    # Use the documentation formatter for detailed output,
    # unless a formatter has already been configured
    # (e.g. via a command-line flag).
    config.default_formatter = 'doc'
  end

  # Print the 10 slowest examples and example groups at the
  # end of the spec run, to help surface which specs are running
  # particularly slow.
  config.profile_examples = 10

  # Run specs in random order to surface order dependencies. If you find an
  # order dependency and want to debug it, you can fix the order by providing
  # the seed, which is printed after each run.
  #     --seed 1234
  config.order = :random

  # Seed global randomization in this process using the `--seed` CLI option.
  # Setting this allows you to use `--seed` to deterministically reproduce
  # test failures related to randomization by passing the same `--seed` value
  # as the one that triggered the failure.
  Kernel.srand config.seed
end

A  => static/ho-tan.jpeg +0 -0
A  => static/index.html +24 -0
@@ 1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="x-ua-compatible" content="ie=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>Ho-Tan - A Micropub Endpoint</title>
  <style type="text/css" media="screen">
    body { margin: 0; font-family: sans-serif; }
    #container { height: 100vh; display: flex; justify-content: center; align-items: center; flex-direction: column; text-align: left; }
    h1 { font-size: 2em }

    @media (max-width: 50em) {
      .container { padding: 0 1em }
    }
  </style>
</head>
<body>
  <div id="container">
    <img alt="" src="ho-tan.jpeg" />
    <h1>Ho-Tan - A Micropub Endpoint</h1>
  </div>
</body>
</html>