diff --git a/.env.travis b/.env.travis index a160c615..766e9ed8 100644 --- a/.env.travis +++ b/.env.travis @@ -1,9 +1,9 @@ APP_ENV=testing APP_DEBUG=true APP_KEY=base64:6DJhvZLVjE6dD4Cqrteh+6Z5vZlG+v/soCKcDHLOAH0= -APP_URL=http://localhost:8000 -APP_LONGURL=localhost -APP_SHORTURL=local +APP_URL=http://jonnybarnes.localhost:8000 +APP_LONGURL=jonnybarnes.localhost +APP_SHORTURL=jmb.localhost DB_CONNECTION=travis diff --git a/.travis.yml b/.travis.yml index 5240b18d..68e984a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,12 +7,13 @@ cache: - apt addons: + hosts: + - jmb.localhost + - jonnybarnes.localhost postgresql: "9.6" apt: - sources: - - sourceline: 'deb http://ppa.launchpad.net/nginx/development/ubuntu trusty main' packages: - - nginx + - nginx-full - realpath - postgresql-9.6-postgis-2.3 - imagemagick @@ -36,10 +37,6 @@ php: - 7.1 - 7.2 -matrix: - allow_failures: - - php: 7.2 - before_install: - printf "\n" | pecl install imagick - cp .env.travis .env @@ -67,7 +64,10 @@ before_script: #- sleep 5 script: - - php vendor/bin/phpunit --coverage-text + - php vendor/bin/phpunit --coverage-clover build/logs/clover.xml - phpcs #- php artisan dusk - php vendor/bin/security-checker security:check --end-point=http://security.sensiolabs.org/check_lock + +after_success: + - travis_retry php vendor/bin/coveralls diff --git a/app/Article.php b/app/Article.php index 22d8b5e8..7a1d4c71 100644 --- a/app/Article.php +++ b/app/Article.php @@ -17,7 +17,7 @@ class Article extends Model * * @var array */ - protected $dates = ['deleted_at']; + protected $dates = ['created_at', 'updated_at', 'deleted_at']; /** * The database table used by the model. @@ -40,16 +40,6 @@ class Article extends Model ]; } - /** - * Define the relationship with webmentions. - * - * @var array - */ - public function webmentions() - { - return $this->morphMany('App\WebMention', 'commentable'); - } - /** * We shall set a blacklist of non-modifiable model attributes. * @@ -66,7 +56,7 @@ class Article extends Model { $markdown = new CommonMarkConverter(); $html = $markdown->convertToHtml($this->main); - //change
[lang] ~> 

+        // changes 
[lang] ~> 

         $match = '/
\[(.*)\]\n/';
         $replace = '
';
         $text = preg_replace($match, $replace, $html);
@@ -130,20 +120,20 @@ class Article extends Model
      *
      * @return \Illuminate\Database\Eloquent\Builder
      */
-    public function scopeDate($query, $year = null, $month = null)
+    public function scopeDate($query, int $year = null, int $month = null)
     {
         if ($year == null) {
             return $query;
         }
         $start = $year . '-01-01 00:00:00';
         $end = ($year + 1) . '-01-01 00:00:00';
-        if (($month !== null) && ($month !== '12')) {
+        if (($month !== null) && ($month !== 12)) {
             $start = $year . '-' . $month . '-01 00:00:00';
             $end = $year . '-' . ($month + 1) . '-01 00:00:00';
         }
-        if ($month === '12') {
+        if ($month === 12) {
             $start = $year . '-12-01 00:00:00';
-            //$end as above
+            $end = ($year + 1) . '-01-01 00:00:00';
         }
 
         return $query->where([
diff --git a/app/Console/Commands/SecurityCheck.php b/app/Console/Commands/SecurityCheck.php
index c2888b0f..6b60caa1 100644
--- a/app/Console/Commands/SecurityCheck.php
+++ b/app/Console/Commands/SecurityCheck.php
@@ -5,6 +5,9 @@ namespace App\Console\Commands;
 use Illuminate\Console\Command;
 use SensioLabs\Security\SecurityChecker;
 
+/**
+ * @codeCoverageIgnore
+ */
 class SecurityCheck extends Command
 {
     /**
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 500911a0..971523ba 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -7,6 +7,9 @@ use Illuminate\Support\Facades\Route;
 use Illuminate\Session\TokenMismatchException;
 use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
 
+/**
+ * @codeCoverageIgnore
+ */
 class Handler extends ExceptionHandler
 {
     /**
diff --git a/app/Exceptions/InternetArchiveErrorSavingException.php b/app/Exceptions/InternetArchiveErrorSavingException.php
deleted file mode 100644
index 939a79ed..00000000
--- a/app/Exceptions/InternetArchiveErrorSavingException.php
+++ /dev/null
@@ -1,10 +0,0 @@
-delete();
+        MicropubClient::where('id', $clientId)->delete();
 
         return redirect('/admin/clients');
     }
diff --git a/app/Http/Controllers/Admin/ContactsController.php b/app/Http/Controllers/Admin/ContactsController.php
index b77b630b..6e6b6927 100644
--- a/app/Http/Controllers/Admin/ContactsController.php
+++ b/app/Http/Controllers/Admin/ContactsController.php
@@ -83,16 +83,14 @@ class ContactsController extends Controller
         $contact->facebook = $request->input('facebook');
         $contact->save();
 
-        if ($request->hasFile('avatar')) {
-            if ($request->input('homepage') != '') {
-                $dir = parse_url($request->input('homepage'))['host'];
-                $destination = public_path() . '/assets/profile-images/' . $dir;
-                $filesystem = new Filesystem();
-                if ($filesystem->isDirectory($destination) === false) {
-                    $filesystem->makeDirectory($destination);
-                }
-                $request->file('avatar')->move($destination, 'image');
+        if ($request->hasFile('avatar') && ($request->input('homepage') != '')) {
+            $dir = parse_url($request->input('homepage'), PHP_URL_HOST);
+            $destination = public_path() . '/assets/profile-images/' . $dir;
+            $filesystem = new Filesystem();
+            if ($filesystem->isDirectory($destination) === false) {
+                $filesystem->makeDirectory($destination);
             }
+            $request->file('avatar')->move($destination, 'image');
         }
 
         return redirect('/admin/contacts');
@@ -123,37 +121,47 @@ class ContactsController extends Controller
      */
     public function getAvatar($contactId)
     {
+        // Initialising
+        $avatarURL = null;
+        $avatar = null;
         $contact = Contact::findOrFail($contactId);
-        $homepage = $contact->homepage;
-        if (($homepage !== null) && ($homepage !== '')) {
-            $client = new Client();
+        if (mb_strlen($contact->homepage !== null) !== 0) {
+            $client = resolve(Client::class);
             try {
-                $response = $client->get($homepage);
-                $html = (string) $response->getBody();
-                $mf2 = \Mf2\parse($html, $homepage);
+                $response = $client->get($contact->homepage);
             } catch (\GuzzleHttp\Exception\BadResponseException $e) {
-                return "Bad Response from $homepage";
+                return redirect('/admin/contacts/' . $contactId . '/edit')
+                    ->with('error', 'Bad resposne from contact’s homepage');
             }
-            $avatarURL = null; // Initialising
+            $mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage);
             foreach ($mf2['items'] as $microformat) {
-                if ($microformat['type'][0] == 'h-card') {
-                    $avatarURL = $microformat['properties']['photo'][0];
+                if (array_get($microformat, 'type.0') == 'h-card') {
+                    $avatarURL = array_get($microformat, 'properties.photo.0');
                     break;
                 }
             }
-            try {
-                $avatar = $client->get($avatarURL);
-            } catch (\GuzzleHttp\Exception\BadResponseException $e) {
-                return "Unable to get $avatarURL";
+            if ($avatarURL !== null) {
+                try {
+                    $avatar = $client->get($avatarURL);
+                } catch (\GuzzleHttp\Exception\BadResponseException $e) {
+                    return redirect('/admin/contacts/' . $contactId . '/edit')
+                        ->with('error', 'Unable to download avatar');
+                }
             }
-            $directory = public_path() . '/assets/profile-images/' . parse_url($homepage)['host'];
-            $filesystem = new Filesystem();
-            if ($filesystem->isDirectory($directory) === false) {
-                $filesystem->makeDirectory($directory);
-            }
-            $filesystem->put($directory . '/image', $avatar->getBody());
+            if ($avatar !== null) {
+                $directory = public_path() . '/assets/profile-images/' . parse_url($contact->homepage, PHP_URL_HOST);
+                $filesystem = new Filesystem();
+                if ($filesystem->isDirectory($directory) === false) {
+                    $filesystem->makeDirectory($directory);
+                }
+                $filesystem->put($directory . '/image', $avatar->getBody());
 
-            return view('admin.contacts.getavatarsuccess', ['homepage' => parse_url($homepage)['host']]);
+                return view('admin.contacts.getavatarsuccess', [
+                    'homepage' => parse_url($contact->homepage, PHP_URL_HOST),
+                ]);
+            }
         }
+
+        return redirect('/admin/contacts/' . $contactId . '/edit');
     }
 }
diff --git a/app/Http/Controllers/Admin/NotesController.php b/app/Http/Controllers/Admin/NotesController.php
index 0ace90fb..951e0841 100644
--- a/app/Http/Controllers/Admin/NotesController.php
+++ b/app/Http/Controllers/Admin/NotesController.php
@@ -3,21 +3,12 @@
 namespace App\Http\Controllers\Admin;
 
 use App\Note;
-use Validator;
 use Illuminate\Http\Request;
 use App\Jobs\SendWebMentions;
-use App\Services\NoteService;
 use App\Http\Controllers\Controller;
 
 class NotesController extends Controller
 {
-    protected $noteService;
-
-    public function __construct(NoteService $noteService)
-    {
-        $this->noteService = $noteService;
-    }
-
     /**
      * List the notes that can be edited.
      *
@@ -51,30 +42,10 @@ class NotesController extends Controller
      */
     public function store(Request $request)
     {
-        $validator = Validator::make(
-            $request->all(),
-            ['photo' => 'photosize'],
-            ['photosize' => 'At least one uploaded file exceeds size limit of 5MB']
-        );
-        if ($validator->fails()) {
-            return redirect('/admin/notes/create')
-                ->withErrors($validator)
-                ->withInput();
-        }
-
-        $data = [];
-        $data['content'] = $request->input('content');
-        $data['in-reply-to'] = $request->input('in-reply-to');
-        $data['location'] = $request->input('location');
-        $data['syndicate'] = [];
-        if ($request->input('twitter')) {
-            $data['syndicate'][] = 'twitter';
-        }
-        if ($request->input('facebook')) {
-            $data['syndicate'][] = 'facebook';
-        }
-
-        $note = $this->noteService->createNote($data);
+        Note::create([
+            'in-reply-to' => $request->input('in-reply-to'),
+            'note' => $request->input('content'),
+        ]);
 
         return redirect('/admin/notes');
     }
diff --git a/app/Http/Controllers/Admin/PlacesController.php b/app/Http/Controllers/Admin/PlacesController.php
index 9d1ef100..f08b0c49 100644
--- a/app/Http/Controllers/Admin/PlacesController.php
+++ b/app/Http/Controllers/Admin/PlacesController.php
@@ -47,11 +47,7 @@ class PlacesController extends Controller
      */
     public function store(Request $request)
     {
-        $data = [];
-        $data['name'] = $request->name;
-        $data['description'] = $request->description;
-        $data['latitude'] = $request->latitude;
-        $data['longitude'] = $request->longitude;
+        $data = $request->only(['name', 'description', 'latitude', 'longitude']);
         $place = $this->placeService->createPlace($data);
 
         return redirect('/admin/places');
@@ -67,14 +63,7 @@ class PlacesController extends Controller
     {
         $place = Place::findOrFail($placeId);
 
-        return view('admin.places.edit', [
-            'id' => $placeId,
-            'name' => $place->name,
-            'description' => $place->description,
-            'latitude' => $place->latitude,
-            'longitude' => $place->longitude,
-            'icon' => $place->icon ?? 'marker',
-        ]);
+        return view('admin.places.edit', compact('place'));
     }
 
     /**
diff --git a/app/Http/Controllers/ArticlesController.php b/app/Http/Controllers/ArticlesController.php
index 77a35ef5..127f7e3b 100644
--- a/app/Http/Controllers/ArticlesController.php
+++ b/app/Http/Controllers/ArticlesController.php
@@ -15,7 +15,7 @@ class ArticlesController extends Controller
     public function index($year = null, $month = null)
     {
         $articles = Article::where('published', '1')
-                        ->date($year, $month)
+                        ->date((int) $year, (int) $month)
                         ->orderBy('updated_at', 'desc')
                         ->simplePaginate(5);
 
@@ -31,7 +31,7 @@ class ArticlesController extends Controller
     {
         $article = Article::where('titleurl', $slug)->firstOrFail();
         if ($article->updated_at->year != $year || $article->updated_at->month != $month) {
-            throw new \Exception;
+            return redirect('/blog/' . $article->updated_at->year . '/' . $article->updated_at->month .'/' . $slug);
         }
 
         return view('articles.show', compact('article'));
diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php
deleted file mode 100644
index 6a247fef..00000000
--- a/app/Http/Controllers/Auth/ForgotPasswordController.php
+++ /dev/null
@@ -1,32 +0,0 @@
-middleware('guest');
-    }
-}
diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php
deleted file mode 100644
index b2ea669a..00000000
--- a/app/Http/Controllers/Auth/LoginController.php
+++ /dev/null
@@ -1,39 +0,0 @@
-middleware('guest')->except('logout');
-    }
-}
diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php
deleted file mode 100644
index f77265ab..00000000
--- a/app/Http/Controllers/Auth/RegisterController.php
+++ /dev/null
@@ -1,71 +0,0 @@
-middleware('guest');
-    }
-
-    /**
-     * Get a validator for an incoming registration request.
-     *
-     * @param  array  $data
-     * @return \Illuminate\Contracts\Validation\Validator
-     */
-    protected function validator(array $data)
-    {
-        return Validator::make($data, [
-            'name' => 'required|string|max:255',
-            'email' => 'required|string|email|max:255|unique:users',
-            'password' => 'required|string|min:6|confirmed',
-        ]);
-    }
-
-    /**
-     * Create a new user instance after a valid registration.
-     *
-     * @param  array  $data
-     * @return \App\User
-     */
-    protected function create(array $data)
-    {
-        return User::create([
-            'name' => $data['name'],
-            'email' => $data['email'],
-            'password' => bcrypt($data['password']),
-        ]);
-    }
-}
diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php
deleted file mode 100644
index cf726eec..00000000
--- a/app/Http/Controllers/Auth/ResetPasswordController.php
+++ /dev/null
@@ -1,39 +0,0 @@
-middleware('guest');
-    }
-}
diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php
index e1877bd3..b9471497 100644
--- a/app/Http/Controllers/MicropubController.php
+++ b/app/Http/Controllers/MicropubController.php
@@ -5,47 +5,41 @@ namespace App\Http\Controllers;
 use Storage;
 use Monolog\Logger;
 use Ramsey\Uuid\Uuid;
-use App\Jobs\ProcessImage;
-use App\Services\LikeService;
-use App\Services\BookmarkService;
+use App\Jobs\ProcessMedia;
+use Illuminate\Http\UploadedFile;
 use Monolog\Handler\StreamHandler;
 use App\{Like, Media, Note, Place};
 use Intervention\Image\ImageManager;
 use Illuminate\Http\{Request, Response};
 use App\Exceptions\InvalidTokenException;
 use Phaza\LaravelPostgis\Geometries\Point;
-use Illuminate\Database\Eloquent\ModelNotFoundException;
-use Ramsey\Uuid\Exception\UnsatisfiedDependencyException;
+use Intervention\Image\Exception\NotReadableException;
 use App\Services\{NoteService, PlaceService, TokenService};
+use App\Services\Micropub\{HCardService, HEntryService, UpdateService};
 
 class MicropubController extends Controller
 {
-    /**
-     * The Token service container.
-     */
     protected $tokenService;
-
-    /**
-     * The Note service container.
-     */
     protected $noteService;
-
-    /**
-     * The Place service container.
-     */
     protected $placeService;
+    protected $hentryService;
+    protected $hcardService;
+    protected $updateService;
 
-    /**
-     * Inject the dependencies.
-     */
     public function __construct(
         TokenService $tokenService,
         NoteService $noteService,
-        PlaceService $placeService
+        PlaceService $placeService,
+        HEntryService $hentryService,
+        HCardService $hcardService,
+        UpdateService $updateService
     ) {
         $this->tokenService = $tokenService;
         $this->noteService = $noteService;
         $this->placeService = $placeService;
+        $this->hentryService = $hentryService;
+        $this->hcardService = $hcardService;
+        $this->updateService = $updateService;
     }
 
     /**
@@ -60,262 +54,46 @@ class MicropubController extends Controller
         try {
             $tokenData = $this->tokenService->validateToken($request->bearerToken());
         } catch (InvalidTokenException $e) {
+            return $this->invalidTokenResponse();
+        }
+
+        if ($tokenData->hasClaim('scope') === false) {
+            return $this->tokenHasNoScopeResponse();
+        }
+
+        $this->logMicropubRequest($request);
+
+        if (($request->input('h') == 'entry') || ($request->input('type.0') == 'h-entry')) {
+            if (stristr($tokenData->getClaim('scope'), 'create') === false) {
+                return $this->insufficientScopeResponse();
+            }
+            $location = $this->hentryService->process($request);
+
             return response()->json([
-                'response' => 'error',
-                'error' => 'invalid_token',
-                'error_description' => 'The provided token did not pass validation',
-            ], 400);
-        }
-        // Log the request
-        $logger = new Logger('micropub');
-        $logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')), Logger::DEBUG);
-        $logger->debug('MicropubLog', $request->all());
-        if ($tokenData->hasClaim('scope')) {
-            if (($request->input('h') == 'entry') || ($request->input('type.0') == 'h-entry')) {
-                if (stristr($tokenData->getClaim('scope'), 'create') === false) {
-                    return $this->returnInsufficientScopeResponse();
-                }
-                if ($request->has('properties.like-of') || $request->has('like-of')) {
-                    $like = (new LikeService())->createLike($request);
-
-                    return response()->json([
-                        'response' => 'created',
-                        'location' => config('app.url') . "/likes/$like->id",
-                    ], 201)->header('Location', config('app.url') . "/likes/$like->id");
-                }
-                if ($request->has('properties.bookmark-of') || $request->has('bookmark-of')) {
-                    $bookmark = (new BookmarkService())->createBookmark($request);
-
-                    return response()->json([
-                        'response' => 'created',
-                        'location' => config('app.url') . "/bookmarks/$bookmark->id",
-                    ], 201)->header('Location', config('app.url') . "/bookmarks/$bookmark->id");
-                }
-                $data = [];
-                $data['client-id'] = $tokenData->getClaim('client_id');
-                if ($request->header('Content-Type') == 'application/json') {
-                    if (is_string($request->input('properties.content.0'))) {
-                        $data['content'] = $request->input('properties.content.0'); //plaintext content
-                    }
-                    if (is_array($request->input('properties.content.0'))
-                        && array_key_exists('html', $request->input('properties.content.0'))
-                    ) {
-                        $data['content'] = $request->input('properties.content.0.html');
-                    }
-                    $data['in-reply-to'] = $request->input('properties.in-reply-to.0');
-                    // check location is geo: string
-                    if (is_string($request->input('properties.location.0'))) {
-                        $data['location'] = $request->input('properties.location.0');
-                    }
-                    // check location is h-card
-                    if (is_array($request->input('properties.location.0'))) {
-                        if ($request->input('properties.location.0.type.0' === 'h-card')) {
-                            try {
-                                $place = $this->placeService->createPlaceFromCheckin(
-                                    $request->input('properties.location.0')
-                                );
-                                $data['checkin'] = $place->longurl;
-                            } catch (\Exception $e) {
-                                //
-                            }
-                        }
-                    }
-                    $data['published'] = $request->input('properties.published.0');
-                    //create checkin place
-                    if (array_key_exists('checkin', $request->input('properties'))) {
-                        $data['swarm-url'] = $request->input('properties.syndication.0');
-                        try {
-                            $place = $this->placeService->createPlaceFromCheckin(
-                                $request->input('properties.checkin.0')
-                            );
-                            $data['checkin'] = $place->longurl;
-                        } catch (\Exception $e) {
-                            $data['checkin'] = null;
-                            $data['swarm-url'] = null;
-                        }
-                    }
-                } else {
-                    $data['content'] = $request->input('content');
-                    $data['in-reply-to'] = $request->input('in-reply-to');
-                    $data['location'] = $request->input('location');
-                    $data['published'] = $request->input('published');
-                }
-                $data['syndicate'] = [];
-                $targets = array_pluck(config('syndication.targets'), 'uid', 'service.name');
-                $mpSyndicateTo = null;
-                if ($request->has('mp-syndicate-to')) {
-                    $mpSyndicateTo = $request->input('mp-syndicate-to');
-                }
-                if ($request->has('properties.mp-syndicate-to')) {
-                    $mpSyndicateTo = $request->input('properties.mp-syndicate-to');
-                }
-                if (is_string($mpSyndicateTo)) {
-                    $service = array_search($mpSyndicateTo, $targets);
-                    if ($service == 'Twitter') {
-                        $data['syndicate'][] = 'twitter';
-                    }
-                    if ($service == 'Facebook') {
-                        $data['syndicate'][] = 'facebook';
-                    }
-                }
-                if (is_array($mpSyndicateTo)) {
-                    foreach ($mpSyndicateTo as $uid) {
-                        $service = array_search($uid, $targets);
-                        if ($service == 'Twitter') {
-                            $data['syndicate'][] = 'twitter';
-                        }
-                        if ($service == 'Facebook') {
-                            $data['syndicate'][] = 'facebook';
-                        }
-                    }
-                }
-                $data['photo'] = [];
-                $photos = null;
-                if ($request->has('photo')) {
-                    $photos = $request->input('photo');
-                }
-                if ($request->has('properties.photo')) {
-                    $photos = $request->input('properties.photo');
-                }
-                if ($photos !== null) {
-                    foreach ($photos as $photo) {
-                        if (is_string($photo)) {
-                            //only supporting media URLs for now
-                            $data['photo'][] = $photo;
-                        }
-                    }
-                    if (starts_with($request->input('properties.syndication.0'), 'https://www.instagram.com')) {
-                        $data['instagram-url'] = $request->input('properties.syndication.0');
-                    }
-                }
-                try {
-                    $note = $this->noteService->createNote($data);
-                } catch (\Exception $exception) {
-                    return response()->json(['error' => true], 400);
-                }
-
-                return response()->json([
-                    'response' => 'created',
-                    'location' => $note->longurl,
-                ], 201)->header('Location', $note->longurl);
-            }
-            if ($request->input('h') == 'card' || $request->input('type')[0] == 'h-card') {
-                if (stristr($tokenData->getClaim('scope'), 'create') === false) {
-                    return $this->returnInsufficientScopeResponse();
-                }
-                $data = [];
-                if ($request->header('Content-Type') == 'application/json') {
-                    $data['name'] = $request->input('properties.name');
-                    $data['description'] = $request->input('properties.description') ?? null;
-                    if ($request->has('properties.geo')) {
-                        $data['geo'] = $request->input('properties.geo');
-                    }
-                } else {
-                    $data['name'] = $request->input('name');
-                    $data['description'] = $request->input('description');
-                    if ($request->has('geo')) {
-                        $data['geo'] = $request->input('geo');
-                    }
-                    if ($request->has('latitude')) {
-                        $data['latitude'] = $request->input('latitude');
-                        $data['longitude'] = $request->input('longitude');
-                    }
-                }
-                try {
-                    $place = $this->placeService->createPlace($data);
-                } catch (\Exception $exception) {
-                    return response()->json(['error' => true], 400);
-                }
-
-                return response()->json([
-                    'response' => 'created',
-                    'location' => $place->longurl,
-                ], 201)->header('Location', $place->longurl);
-            }
-            if ($request->input('action') == 'update') {
-                if (stristr($tokenData->getClaim('scope'), 'update') === false) {
-                    return $this->returnInsufficientScopeResponse();
-                }
-                $urlPath = parse_url($request->input('url'), PHP_URL_PATH);
-                //is it a note we are updating?
-                if (mb_substr($urlPath, 1, 5) === 'notes') {
-                    try {
-                        $note = Note::nb60(basename($urlPath))->firstOrFail();
-                    } catch (ModelNotFoundException $exception) {
-                        return response()->json([
-                            'error' => 'invalid_request',
-                            'error_description' => 'No known note with given ID',
-                        ]);
-                    }
-                    //got the note, are we dealing with a “replace” request?
-                    if ($request->has('replace')) {
-                        foreach ($request->input('replace') as $property => $value) {
-                            if ($property == 'content') {
-                                $note->note = $value[0];
-                            }
-                            if ($property == 'syndication') {
-                                foreach ($value as $syndicationURL) {
-                                    if (starts_with($syndicationURL, 'https://www.facebook.com')) {
-                                        $note->facebook_url = $syndicationURL;
-                                    }
-                                    if (starts_with($syndicationURL, 'https://www.swarmapp.com')) {
-                                        $note->swarm_url = $syndicationURL;
-                                    }
-                                    if (starts_with($syndicationURL, 'https://twitter.com')) {
-                                        $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH));
-                                    }
-                                }
-                            }
-                        }
-                        $note->save();
-
-                        return response()->json([
-                            'response' => 'updated',
-                        ]);
-                    }
-                    //how about “add”
-                    if ($request->has('add')) {
-                        foreach ($request->input('add') as $property => $value) {
-                            if ($property == 'syndication') {
-                                foreach ($value as $syndicationURL) {
-                                    if (starts_with($syndicationURL, 'https://www.facebook.com')) {
-                                        $note->facebook_url = $syndicationURL;
-                                    }
-                                    if (starts_with($syndicationURL, 'https://www.swarmapp.com')) {
-                                        $note->swarm_url = $syndicationURL;
-                                    }
-                                    if (starts_with($syndicationURL, 'https://twitter.com')) {
-                                        $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH));
-                                    }
-                                }
-                            }
-                            if ($property == 'photo') {
-                                foreach ($value as $photoURL) {
-                                    if (start_with($photo, 'https://')) {
-                                        $media = new Media();
-                                        $media->path = $photoURL;
-                                        $media->type = 'image';
-                                        $media->save();
-                                        $note->media()->save($media);
-                                    }
-                                }
-                            }
-                        }
-                        $note->save();
-
-                        return response()->json([
-                            'response' => 'updated',
-                        ]);
-                    }
-                }
-            }
+                'response' => 'created',
+                'location' => $location,
+            ], 201)->header('Location', $location);
         }
 
-        return response()->json([
-            'response' => 'error',
-            'error' => 'forbidden',
-            'error_description' => 'The token has no scopes',
-        ], 403);
+        if ($request->input('h') == 'card' || $request->input('type')[0] == 'h-card') {
+            if (stristr($tokenData->getClaim('scope'), 'create') === false) {
+                return $this->insufficientScopeResponse();
+            }
+            $location = $this->hcardService->process($request);
+
+            return response()->json([
+                'response' => 'created',
+                'location' => $location,
+            ], 201)->header('Location', $location);
+        }
+
+        if ($request->input('action') == 'update') {
+            if (stristr($tokenData->getClaim('scope'), 'update') === false) {
+                return $this->returnInsufficientScopeResponse();
+            }
+
+            return $this->updateService->process($request);
+        }
     }
 
     /**
@@ -332,20 +110,15 @@ class MicropubController extends Controller
         try {
             $tokenData = $this->tokenService->validateToken($request->bearerToken());
         } catch (InvalidTokenException $e) {
-            return response()->json([
-                'response' => 'error',
-                'error' => 'invalid_token',
-                'error_description' => 'The provided token did not pass validation',
-            ], 400);
+            return $this->invalidTokenResponse();
         }
-        //we have a valid token, is `syndicate-to` set?
+
         if ($request->input('q') === 'syndicate-to') {
             return response()->json([
                 'syndicate-to' => config('syndication.targets'),
             ]);
         }
 
-        //nope, how about a config query?
         if ($request->input('q') == 'config') {
             return response()->json([
                 'syndicate-to' => config('syndication.targets'),
@@ -353,7 +126,6 @@ class MicropubController extends Controller
             ]);
         }
 
-        //nope, how about a geo URL?
         if (substr($request->input('q'), 0, 4) === 'geo:') {
             preg_match_all(
                 '/([0-9\.\-]+)/',
@@ -362,9 +134,6 @@ class MicropubController extends Controller
             );
             $distance = (count($matches[0]) == 3) ? 100 * $matches[0][2] : 1000;
             $places = Place::near(new Point($matches[0][0], $matches[0][1]))->get();
-            foreach ($places as $place) {
-                $place->uri = config('app.url') . '/places/' . $place->slug;
-            }
 
             return response()->json([
                 'response' => 'places',
@@ -372,7 +141,7 @@ class MicropubController extends Controller
             ]);
         }
 
-        //nope, just return the token
+        // default response is just to return the token data
         return response()->json([
             'response' => 'token',
             'token' => [
@@ -394,69 +163,18 @@ class MicropubController extends Controller
         try {
             $tokenData = $this->tokenService->validateToken($request->bearerToken());
         } catch (InvalidTokenException $e) {
-            return response()->json([
-                'response' => 'error',
-                'error' => 'invalid_token',
-                'error_description' => 'The provided token did not pass validation',
-            ], 400);
+            return $this->invalidTokenResponse();
         }
 
-        $logger = new Logger('micropub');
-        $logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')), Logger::DEBUG);
-        $logger->debug('MicropubMediaLog', $request->all());
-        //check post scope
-        if ($tokenData->hasClaim('scope')) {
-            if (stristr($tokenData->getClaim('scope'), 'create') === false) {
-                return $this->returnInsufficientScopeResponse();
-            }
-            //check media valid
-            if ($request->hasFile('file') && $request->file('file')->isValid()) {
-                try {
-                    $filename = Uuid::uuid4() . '.' . $request->file('file')->extension();
-                } catch (UnsatisfiedDependencyException $e) {
-                    return response()->json([
-                        'response' => 'error',
-                        'error' => 'internal_server_error',
-                        'error_description' => 'A problem occured handling your request',
-                    ], 500);
-                }
+        if ($tokenData->hasClaim('scope') === false) {
+            return $this->tokenHasNoScopeResponse();
+        }
 
-                $size = $request->file('file')->getClientSize();
-                Storage::disk('local')->put($filename, $request->file('file')->openFile()->fread($size));
-                try {
-                    Storage::disk('s3')->put('media/' . $filename, $request->file('file')->openFile()->fread($size));
-                } catch (Exception $e) { // which exception?
-                    return response()->json([
-                        'response' => 'error',
-                        'error' => 'service_unavailable',
-                        'error_description' => 'Unable to save media to S3',
-                    ], 503);
-                }
-
-                $manager = app()->make(ImageManager::class);
-                try {
-                    $image = $manager->make($request->file('file'));
-                    $width = $image->width();
-                } catch (\Intervention\Image\Exception\NotReadableException $exception) {
-                    // not an image
-                    $width = null;
-                }
-
-                $media = new Media();
-                $media->token = $request->bearerToken();
-                $media->path = 'media/' . $filename;
-                $media->type = $this->getFileTypeFromMimeType($request->file('file')->getMimeType());
-                $media->image_widths = $width;
-                $media->save();
-
-                dispatch(new ProcessImage($filename));
-
-                return response()->json([
-                    'response' => 'created',
-                    'location' => $media->url,
-                ], 201)->header('Location', $media->url);
-            }
+        if (stristr($tokenData->getClaim('scope'), 'create') === false) {
+            return $this->insufficientScopeResponse();
+        }
 
+        if (($request->hasFile('file') && $request->file('file')->isValid()) === false) {
             return response()->json([
                 'response' => 'error',
                 'error' => 'invalid_request',
@@ -464,11 +182,32 @@ class MicropubController extends Controller
             ], 400);
         }
 
+        $this->logMicropubRequest($request);
+
+        $filename = $this->saveFile($request->file('file'));
+
+        $manager = resolve(ImageManager::class);
+        try {
+            $image = $manager->make($request->file('file'));
+            $width = $image->width();
+        } catch (NotReadableException $exception) {
+            // not an image
+            $width = null;
+        }
+
+        $media = Media::create([
+            'token' => $request->bearerToken(),
+            'path' => 'media/' . $filename,
+            'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()),
+            'image_widths' => $width,
+        ]);
+
+        ProcessMedia::dispatch($filename);
+
         return response()->json([
-            'response' => 'error',
-            'error' => 'invalid_request',
-            'error_description' => 'The provided token has no scopes',
-        ], 400);
+            'response' => 'created',
+            'location' => $media->url,
+        ], 201)->header('Location', $media->url);
     }
 
     /**
@@ -515,7 +254,23 @@ class MicropubController extends Controller
         return 'download';
     }
 
-    private function returnInsufficientScopeResponse()
+    private function logMicropubRequest(Request $request)
+    {
+        $logger = new Logger('micropub');
+        $logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')), Logger::DEBUG);
+        $logger->debug('MicropubLog', $request->all());
+    }
+
+    private function saveFile(UploadedFile $file)
+    {
+        $filename = Uuid::uuid4() . '.' . $file->extension();
+        $size = $file->getClientSize();
+        Storage::disk('local')->put($filename, $file->openFile()->fread($size));
+
+        return $filename;
+    }
+
+    private function insufficientScopeResponse()
     {
         return response()->json([
             'response' => 'error',
@@ -523,4 +278,22 @@ class MicropubController extends Controller
             'error_description' => 'The token’s scope does not have the necessary requirements.',
         ], 401);
     }
+
+    private function invalidTokenResponse()
+    {
+        return response()->json([
+            'response' => 'error',
+            'error' => 'invalid_token',
+            'error_description' => 'The provided token did not pass validation',
+        ], 400);
+    }
+
+    private function tokenHasNoScopeResponse()
+    {
+        return response()->json([
+            'response' => 'error',
+            'error' => 'invalid_request',
+            'error_description' => 'The provided token has no scopes',
+        ], 400);
+    }
 }
diff --git a/app/Http/Controllers/PhotosController.php b/app/Http/Controllers/PhotosController.php
deleted file mode 100644
index 9eaeb769..00000000
--- a/app/Http/Controllers/PhotosController.php
+++ /dev/null
@@ -1,94 +0,0 @@
-imageResizeLimit = 800;
-    }
-
-    /**
-     * Save an uploaded photo to the image folder.
-     *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  string  The associated note’s nb60 ID
-     * @return bool
-     */
-    public function saveImage(Request $request, $nb60id)
-    {
-        if ($request->hasFile('photo') !== true) {
-            return false;
-        }
-        $photoFilename = 'note-' . $nb60id;
-        $path = public_path() . '/assets/img/notes/';
-        $ext = $request->file('photo')->getClientOriginalExtension();
-        $photoFilename .= '.' . $ext;
-        $request->file('photo')->move($path, $photoFilename);
-
-        return true;
-    }
-
-    /**
-     * Prepare a photo for posting to twitter.
-     *
-     * @param  string  photo fileanme
-     * @return string  small photo filename, or null
-     */
-    public function makeSmallPhotoForTwitter($photoFilename)
-    {
-        $imagine = new Imagine();
-        $orig = $imagine->open(public_path() . '/assets/img/notes/' . $photoFilename);
-        $size = [$orig->getSize()->getWidth(), $orig->getSize()->getHeight()];
-        if ($size[0] > $this->imageResizeLimit || $size[1] > $this->imageResizeLimit) {
-            $filenameParts = explode('.', $photoFilename);
-            $preExt = count($filenameParts) - 2;
-            $filenameParts[$preExt] .= '-small';
-            $photoFilenameSmall = implode('.', $filenameParts);
-            $aspectRatio = $size[0] / $size[1];
-            $box = ($aspectRatio >= 1) ?
-                [$this->imageResizeLimit, (int) round($this->imageResizeLimit / $aspectRatio)]
-                :
-                [(int) round($this->imageResizeLimit * $aspectRatio), $this->imageResizeLimit];
-            $orig->resize(new Box($box[0], $box[1]))
-                 ->save(public_path() . '/assets/img/notes/' . $photoFilenameSmall);
-
-            return $photoFilenameSmall;
-        }
-    }
-
-    /**
-     * Get the image path for a note.
-     *
-     * @param  string $nb60id
-     * @return string | null
-     */
-    public function getPhotoPath($nb60id)
-    {
-        $filesystem = new Filesystem();
-        $photoDir = public_path() . '/assets/img/notes';
-        $files = $filesystem->files($photoDir);
-        foreach ($files as $file) {
-            $parts = explode('.', $file);
-            $name = $parts[0];
-            $dirs = explode('/', $name);
-            $actualname = last($dirs);
-            if ($actualname == 'note-' . $nb60id) {
-                $ext = $parts[1];
-            }
-        }
-        if (isset($ext)) {
-            return '/assets/img/notes/note-' . $nb60id . '.' . $ext;
-        }
-    }
-}
diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php
index 9f68227b..92b7e767 100644
--- a/app/Http/Controllers/SearchController.php
+++ b/app/Http/Controllers/SearchController.php
@@ -10,16 +10,6 @@ class SearchController extends Controller
     public function search(Request $request)
     {
         $notes = Note::search($request->terms)->paginate(10);
-        foreach ($notes as $note) {
-            $note->iso8601_time = $note->updated_at->toISO8601String();
-            $note->human_time = $note->updated_at->diffForHumans();
-            $photoURLs = [];
-            $photos = $note->getMedia();
-            foreach ($photos as $photo) {
-                $photoURLs[] = $photo->getUrl();
-            }
-            $note->photoURLs = $photoURLs;
-        }
 
         return view('search', compact('notes'));
     }
diff --git a/app/Http/Controllers/ShortURLsController.php b/app/Http/Controllers/ShortURLsController.php
index 9c34ca0f..a4979ca0 100644
--- a/app/Http/Controllers/ShortURLsController.php
+++ b/app/Http/Controllers/ShortURLsController.php
@@ -2,9 +2,6 @@
 
 namespace App\Http\Controllers;
 
-use App\ShortURL;
-use Jonnybanres\IndieWeb\Numbers;
-
 class ShortURLsController extends Controller
 {
     /*
@@ -41,21 +38,11 @@ class ShortURLsController extends Controller
      *
      * @return \Illuminate\Routing\RedirectResponse redirect
      */
-    public function googlePLus()
+    public function googlePlus()
     {
         return redirect('https://plus.google.com/u/0/117317270900655269082/about');
     }
 
-    /**
-     * Redirect from '/α' to an App.net profile.
-     *
-     * @return \Illuminate\Routing\Redirector redirect
-     */
-    public function appNet()
-    {
-        return redirect('https://alpha.app.net/jonnybarnes');
-    }
-
     /**
      * Redirect a short url of this site out to a long one based on post type.
      * Further redirects may happen.
@@ -75,46 +62,4 @@ class ShortURLsController extends Controller
 
         return redirect(config('app.url') . '/' . $type . '/' . $postId);
     }
-
-    /**
-     * Redirect a saved short URL, this is generic.
-     *
-     * @param  string The short URL id
-     * @return \Illuminate\Routing\Redirector redirect
-     */
-    public function redirect($shortURLId)
-    {
-        $numbers = new Numbers();
-        $num = $numbers->b60tonum($shortURLId);
-        $shorturl = ShortURL::find($num);
-        $redirect = $shorturl->redirect;
-
-        return redirect($redirect);
-    }
-
-    /**
-     * I had an old redirect systme breifly, but cool URLs should still work.
-     *
-     * @param  string URL ID
-     * @return \Illuminate\Routing\Redirector redirect
-     */
-    public function oldRedirect($shortURLId)
-    {
-        $filename = base_path() . '/public/assets/old-shorturls.json';
-        $handle = fopen($filename, 'r');
-        $contents = fread($handle, filesize($filename));
-        $object = json_decode($contents);
-
-        foreach ($object as $key => $val) {
-            if ($shortURLId == $key) {
-                return redirect($val);
-            }
-        }
-
-        return 'This id was never used.
-        Old redirects are located at
-        
-            old-shorturls.json
-        .';
-    }
 }
diff --git a/app/Http/Controllers/WebMentionsController.php b/app/Http/Controllers/WebMentionsController.php
index 41d8cc9b..281794eb 100644
--- a/app/Http/Controllers/WebMentionsController.php
+++ b/app/Http/Controllers/WebMentionsController.php
@@ -36,35 +36,32 @@ class WebMentionsController extends Controller
         $path = parse_url($request->input('target'), PHP_URL_PATH);
         $pathParts = explode('/', $path);
 
-        switch ($pathParts[1]) {
-            case 'notes':
-                //we have a note
-                $noteId = $pathParts[2];
-                $numbers = new Numbers();
-                try {
-                    $note = Note::findOrFail($numbers->b60tonum($noteId));
-                    dispatch(new ProcessWebMention($note, $request->input('source')));
-                } catch (ModelNotFoundException $e) {
-                    return new Response('This note doesn’t exist.', 400);
-                }
+        if ($pathParts[1] == 'notes') {
+            //we have a note
+            $noteId = $pathParts[2];
+            $numbers = new Numbers();
+            try {
+                $note = Note::findOrFail($numbers->b60tonum($noteId));
+                dispatch(new ProcessWebMention($note, $request->input('source')));
+            } catch (ModelNotFoundException $e) {
+                return new Response('This note doesn’t exist.', 400);
+            }
 
-                return new Response(
-                    'Webmention received, it will be processed shortly',
-                    202
-                );
-                break;
-            case 'blog':
-                return new Response(
-                    'I don’t accept webmentions for blog posts yet.',
-                    501
-                );
-                break;
-            default:
-                return new Response(
-                    'Invalid request',
-                    400
-                );
-                break;
+            return new Response(
+                'Webmention received, it will be processed shortly',
+                202
+            );
         }
+        if ($pathParts[1] == 'blog') {
+            return new Response(
+                'I don’t accept webmentions for blog posts yet.',
+                501
+            );
+        }
+
+        return new Response(
+            'Invalid request',
+            400
+        );
     }
 }
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 1f67d385..005c0d69 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -36,7 +36,6 @@ class Kernel extends HttpKernel
             \App\Http\Middleware\VerifyCsrfToken::class,
             \Illuminate\Routing\Middleware\SubstituteBindings::class,
             \App\Http\Middleware\LinkHeadersMiddleware::class,
-            //\App\Http\Middleware\DevTokenMiddleware::class,
             \App\Http\Middleware\LocalhostSessionMiddleware::class,
             \App\Http\Middleware\ActivityStreamLinks::class,
         ],
diff --git a/app/Http/Middleware/DevTokenMiddleware.php b/app/Http/Middleware/DevTokenMiddleware.php
deleted file mode 100644
index dabc2ca2..00000000
--- a/app/Http/Middleware/DevTokenMiddleware.php
+++ /dev/null
@@ -1,36 +0,0 @@
- config('app.url')]);
-            if (Storage::exists('dev-token')) {
-                session(['token' => Storage::get('dev-token')]);
-            } else {
-                $data = [
-                    'me' => config('app.url'),
-                    'client_id' => route('micropub-client'),
-                    'scope' => 'post',
-                ];
-                $tokenService = new \App\Services\TokenService();
-                session(['token' => $tokenService->getNewToken($data)]);
-            }
-        }
-
-        return $next($request);
-    }
-}
diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php
index e4cec9c8..92c2fff8 100644
--- a/app/Http/Middleware/RedirectIfAuthenticated.php
+++ b/app/Http/Middleware/RedirectIfAuthenticated.php
@@ -5,6 +5,9 @@ namespace App\Http\Middleware;
 use Closure;
 use Illuminate\Support\Facades\Auth;
 
+/**
+ * @codeCoverageIgnore
+ */
 class RedirectIfAuthenticated
 {
     /**
diff --git a/app/Jobs/DownloadWebMention.php b/app/Jobs/DownloadWebMention.php
index c12a92d4..9fe80f7e 100644
--- a/app/Jobs/DownloadWebMention.php
+++ b/app/Jobs/DownloadWebMention.php
@@ -41,7 +41,7 @@ class DownloadWebMention implements ShouldQueue
         //Laravel should catch and retry these automatically.
         if ($response->getStatusCode() == '200') {
             $filesystem = new \Illuminate\FileSystem\FileSystem();
-            $filename = storage_path() . '/HTML/' . $this->createFilenameFromURL($this->source);
+            $filename = storage_path('HTML') . '/' . $this->createFilenameFromURL($this->source);
             //backup file first
             $filenameBackup = $filename . '.' . date('Y-m-d') . '.backup';
             if ($filesystem->exists($filename)) {
diff --git a/app/Jobs/ProcessBookmark.php b/app/Jobs/ProcessBookmark.php
index 1ed51240..348564c0 100644
--- a/app/Jobs/ProcessBookmark.php
+++ b/app/Jobs/ProcessBookmark.php
@@ -9,7 +9,7 @@ use Illuminate\Queue\SerializesModels;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
-use App\Exceptions\InternetArchiveErrorSavingException;
+use App\Exceptions\InternetArchiveException;
 
 class ProcessBookmark implements ShouldQueue
 {
@@ -34,12 +34,12 @@ class ProcessBookmark implements ShouldQueue
      */
     public function handle()
     {
-        $uuid = (new BookmarkService())->saveScreenshot($this->bookmark->url);
+        $uuid = (resolve(BookmarkService::class))->saveScreenshot($this->bookmark->url);
         $this->bookmark->screenshot = $uuid;
 
         try {
-            $archiveLink = (new BookmarkService())->getArchiveLink($this->bookmark->url);
-        } catch (InternetArchiveErrorSavingException $e) {
+            $archiveLink = (resolve(BookmarkService::class))->getArchiveLink($this->bookmark->url);
+        } catch (InternetArchiveException $e) {
             $archiveLink = null;
         }
         $this->bookmark->archive = $archiveLink;
diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php
index 84fffa7d..ef9f67c2 100644
--- a/app/Jobs/ProcessLike.php
+++ b/app/Jobs/ProcessLike.php
@@ -44,8 +44,8 @@ class ProcessLike implements ShouldQueue
         try {
             $author = $authorship->findAuthor($mf2);
             if (is_array($author)) {
-                $this->like->author_name = $author['name'];
-                $this->like->author_url = $author['url'];
+                $this->like->author_name = array_get($author, 'properties.name.0');
+                $this->like->author_url = array_get($author, 'properties.url.0');
             }
             if (is_string($author) && $author !== '') {
                 $this->like->author_name = $author;
diff --git a/app/Jobs/ProcessImage.php b/app/Jobs/ProcessMedia.php
similarity index 90%
rename from app/Jobs/ProcessImage.php
rename to app/Jobs/ProcessMedia.php
index 4ba0060f..08985383 100644
--- a/app/Jobs/ProcessImage.php
+++ b/app/Jobs/ProcessMedia.php
@@ -2,16 +2,16 @@
 
 namespace App\Jobs;
 
-use Storage;
 use Illuminate\Bus\Queueable;
 use Intervention\Image\ImageManager;
 use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Storage;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Intervention\Image\Exception\NotReadableException;
 
-class ProcessImage implements ShouldQueue
+class ProcessMedia implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
@@ -34,6 +34,10 @@ class ProcessImage implements ShouldQueue
      */
     public function handle(ImageManager $manager)
     {
+        Storage::disk('s3')->put(
+            'media/' . $this->filename,
+            storage_path('app') . '/' . $this->filename
+        );
         //open file
         try {
             $image = $manager->make(storage_path('app') . '/' . $this->filename);
diff --git a/app/Jobs/ProcessWebMention.php b/app/Jobs/ProcessWebMention.php
index 5a24f003..392f36b0 100644
--- a/app/Jobs/ProcessWebMention.php
+++ b/app/Jobs/ProcessWebMention.php
@@ -41,23 +41,25 @@ class ProcessWebMention implements ShouldQueue
      */
     public function handle(Parser $parser, Client $guzzle)
     {
-        $remoteContent = $this->getRemoteContent($this->source, $guzzle);
-        if ($remoteContent === null) {
+        try {
+            $response = $guzzle->request('GET', $this->source);
+        } catch (RequestException $e) {
             throw new RemoteContentNotFoundException;
         }
-        $microformats = Mf2\parse($remoteContent, $this->source);
+        $this->saveRemoteContent((string) $response->getBody(), $this->source);
+        $microformats = Mf2\parse((string) $response->getBody(), $this->source);
         $webmentions = WebMention::where('source', $this->source)->get();
         foreach ($webmentions as $webmention) {
-            //check webmention still references target
-            //we try each type of mention (reply/like/repost)
+            // check webmention still references target
+            // we try each type of mention (reply/like/repost)
             if ($webmention->type == 'in-reply-to') {
                 if ($parser->checkInReplyTo($microformats, $this->note->longurl) == false) {
-                    //it doesn't so delete
+                    // it doesn’t so delete
                     $webmention->delete();
 
                     return;
                 }
-                //webmenion is still a reply, so update content
+                // webmenion is still a reply, so update content
                 dispatch(new SaveProfileImage($microformats));
                 $webmention->mf2 = json_encode($microformats);
                 $webmention->save();
@@ -66,25 +68,25 @@ class ProcessWebMention implements ShouldQueue
             }
             if ($webmention->type == 'like-of') {
                 if ($parser->checkLikeOf($microformats, $note->longurl) == false) {
-                    //it doesn't so delete
+                    // it doesn’t so delete
                     $webmention->delete();
 
                     return;
-                } //note we don't need to do anything if it still is a like
+                } // note we don’t need to do anything if it still is a like
             }
             if ($webmention->type == 'repost-of') {
                 if ($parser->checkRepostOf($microformats, $note->longurl) == false) {
-                    //it doesn't so delete
+                    // it doesn’t so delete
                     $webmention->delete();
 
                     return;
-                } //again, we don't need to do anything if it still is a repost
+                } // again, we don’t need to do anything if it still is a repost
             }
-        }//foreach
+        }// foreach
 
-        //no wemention in db so create new one
+        // no webmention in the db so create new one
         $webmention = new WebMention();
-        $type = $parser->getMentionType($microformats); //throw error here?
+        $type = $parser->getMentionType($microformats); // throw error here?
         dispatch(new SaveProfileImage($microformats));
         $webmention->source = $this->source;
         $webmention->target = $this->note->longurl;
@@ -96,21 +98,23 @@ class ProcessWebMention implements ShouldQueue
     }
 
     /**
-     * Retreive the remote content from a URL, and caches the result.
+     * Save the HTML of a webmention for future use.
      *
+     * @param  string  $html
      * @param  string  $url
-     * @param  GuzzleHttp\client  $guzzle
      * @return string|null
      */
-    private function getRemoteContent($url, Client $guzzle)
+    private function saveRemoteContent($html, $url)
     {
-        try {
-            $response = $guzzle->request('GET', $url);
-        } catch (RequestException $e) {
-            return;
+        $filenameFromURL = str_replace(
+            ['https://', 'http://'],
+            ['https/', 'http/'],
+            $url
+        );
+        if (substr($url, -1) == '/') {
+            $filenameFromURL .= 'index.html';
         }
-        $html = (string) $response->getBody();
-        $path = storage_path() . '/HTML/' . $this->createFilenameFromURL($url);
+        $path = storage_path() . '/HTML/' . $filenameFromURL;
         $parts = explode('/', $path);
         $name = array_pop($parts);
         $dir = implode('/', $parts);
@@ -118,24 +122,5 @@ class ProcessWebMention implements ShouldQueue
             mkdir($dir, 0755, true);
         }
         file_put_contents("$dir/$name", $html);
-
-        return $html;
-    }
-
-    /**
-     * Create a file path from a URL. This is used when caching the HTML
-     * response.
-     *
-     * @param  string  The URL
-     * @return string  The path name
-     */
-    private function createFilenameFromURL($url)
-    {
-        $url = str_replace(['https://', 'http://'], ['https/', 'http/'], $url);
-        if (substr($url, -1) == '/') {
-            $url = $url . 'index.html';
-        }
-
-        return $url;
     }
 }
diff --git a/app/Jobs/SaveProfileImage.php b/app/Jobs/SaveProfileImage.php
index ffb0452a..2c657c14 100644
--- a/app/Jobs/SaveProfileImage.php
+++ b/app/Jobs/SaveProfileImage.php
@@ -44,7 +44,7 @@ class SaveProfileImage implements ShouldQueue
         //dont save pbs.twimg.com links
         if (parse_url($photo, PHP_URL_HOST) != 'pbs.twimg.com'
               && parse_url($photo, PHP_URL_HOST) != 'twitter.com') {
-            $client = new Client();
+            $client = resolve(Client::class);
             try {
                 $response = $client->get($photo);
                 $image = $response->getBody(true);
diff --git a/app/Jobs/SendWebMentions.php b/app/Jobs/SendWebMentions.php
index 5bcbda58..dd9d9a45 100644
--- a/app/Jobs/SendWebMentions.php
+++ b/app/Jobs/SendWebMentions.php
@@ -29,18 +29,18 @@ class SendWebMentions implements ShouldQueue
     /**
      * Execute the job.
      *
-     * @param  \GuzzleHttp\Client $guzzle
      * @return void
      */
-    public function handle(Client $guzzle)
+    public function handle()
     {
         //grab the URLs
         $urlsInReplyTo = explode(' ', $this->note->in_reply_to);
         $urlsNote = $this->getLinks($this->note->note);
         $urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); //filter out none URLs
         foreach ($urls as $url) {
-            $endpoint = $this->discoverWebmentionEndpoint($url, $guzzle);
-            if ($endpoint) {
+            $endpoint = $this->discoverWebmentionEndpoint($url);
+            if ($endpoint !== null) {
+                $guzzle = resolve(Client::class);
                 $guzzle->post($endpoint, [
                     'form_params' => [
                         'source' => $this->note->longurl,
@@ -55,21 +55,21 @@ class SendWebMentions implements ShouldQueue
      * Discover if a URL has a webmention endpoint.
      *
      * @param  string  The URL
-     * @param  \GuzzleHttp\Client $guzzle
      * @return string  The webmention endpoint URL
      */
-    public function discoverWebmentionEndpoint($url, $guzzle)
+    public function discoverWebmentionEndpoint($url)
     {
         //let’s not send webmentions to myself
         if (parse_url($url, PHP_URL_HOST) == config('app.longurl')) {
-            return false;
+            return;
         }
         if (starts_with($url, '/notes/tagged/')) {
-            return false;
+            return;
         }
 
         $endpoint = null;
 
+        $guzzle = resolve(Client::class);
         $response = $guzzle->get($url);
         //check HTTP Headers for webmention endpoint
         $links = \GuzzleHttp\Psr7\parse_header($response->getHeader('Link'));
@@ -92,8 +92,6 @@ class SendWebMentions implements ShouldQueue
         if ($endpoint) {
             return $this->resolveUri($endpoint, $url);
         }
-
-        return false;
     }
 
     /**
diff --git a/app/Jobs/SyndicateBookmarkToFacebook.php b/app/Jobs/SyndicateBookmarkToFacebook.php
index 9d24fbd9..3e09e432 100644
--- a/app/Jobs/SyndicateBookmarkToFacebook.php
+++ b/app/Jobs/SyndicateBookmarkToFacebook.php
@@ -8,10 +8,11 @@ use Illuminate\Bus\Queueable;
 use Illuminate\Queue\SerializesModels;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
 
 class SyndicateBookmarkToFacebook implements ShouldQueue
 {
-    use InteractsWithQueue, Queueable, SerializesModels;
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $bookmark;
 
diff --git a/app/Jobs/SyndicateBookmarkToTwitter.php b/app/Jobs/SyndicateBookmarkToTwitter.php
index 2e441bb6..e048186a 100644
--- a/app/Jobs/SyndicateBookmarkToTwitter.php
+++ b/app/Jobs/SyndicateBookmarkToTwitter.php
@@ -8,10 +8,11 @@ use Illuminate\Bus\Queueable;
 use Illuminate\Queue\SerializesModels;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
 
 class SyndicateBookmarkToTwitter implements ShouldQueue
 {
-    use InteractsWithQueue, Queueable, SerializesModels;
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $bookmark;
 
diff --git a/app/Like.php b/app/Like.php
index aae728e6..4fe353a9 100644
--- a/app/Like.php
+++ b/app/Like.php
@@ -24,7 +24,7 @@ class Like extends Model
     public function getContentAttribute($value)
     {
         if ($value === null) {
-            return $this->url;
+            return null;
         }
 
         $mf2 = Mf2\parse($value, $this->url);
diff --git a/app/Media.php b/app/Media.php
index 916fca2d..83f8af4d 100644
--- a/app/Media.php
+++ b/app/Media.php
@@ -18,7 +18,7 @@ class Media extends Model
      *
      * @var array
      */
-    protected $fillable = ['path'];
+    protected $fillable = ['token', 'path', 'type', 'image_widths'];
 
     /**
      * Get the note that owns this media.
@@ -70,10 +70,10 @@ class Media extends Model
 
     public function getBasename($path)
     {
-        $filenameParts = explode('.', $path);
-
         // the following achieves this data flow
         // foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
+        $filenameParts = explode('.', $path);
+        array_pop($filenameParts);
         $basename = ltrim(array_reduce($filenameParts, function ($carry, $item) {
             return $carry . '.' . $item;
         }, ''), '.');
diff --git a/app/Note.php b/app/Note.php
index ef94e327..ed9eda1a 100644
--- a/app/Note.php
+++ b/app/Note.php
@@ -146,9 +146,9 @@ class Note extends Model
         $emoji = new EmojiModifier();
 
         $hcards = $this->makeHCards($value);
-        $html = $this->convertMarkdown($hcards);
-        $hashtags = $this->autoLinkHashtag($html);
-        $modified = $emoji->makeEmojiAccessible($hashtags);
+        $hashtags = $this->autoLinkHashtag($hcards);
+        $html = $this->convertMarkdown($hashtags);
+        $modified = $emoji->makeEmojiAccessible($html);
 
         return $modified;
     }
@@ -223,9 +223,7 @@ class Note extends Model
     public function getLatitudeAttribute()
     {
         if ($this->place !== null) {
-            $lnglat = explode(' ', $this->place->location);
-
-            return $lnglat[1];
+            return $this->place->location->getLat();
         }
         if ($this->location !== null) {
             $pieces = explode(':', $this->location);
@@ -243,9 +241,7 @@ class Note extends Model
     public function getLongitudeAttribute()
     {
         if ($this->place !== null) {
-            $lnglat = explode(' ', $this->place->location);
-
-            return $lnglat[1];
+            return $this->place->location->getLng();
         }
         if ($this->location !== null) {
             $pieces = explode(':', $this->location);
@@ -281,12 +277,13 @@ class Note extends Model
         if (Cache::has($tweetId)) {
             return Cache::get($tweetId);
         }
+
         try {
             $oEmbed = Twitter::getOembed([
-                'id' => $tweetId,
+                'url' => $this->in_reply_to,
                 'dnt' => true,
                 'align' => 'center',
-                'maxwidth' => 550,
+                'maxwidth' => 512,
             ]);
         } catch (\Exception $e) {
             return;
@@ -408,10 +405,10 @@ class Note extends Model
 
                 $contact = $this->contacts[$matches[1]]; // easier to read the following code
                 $host = parse_url($contact->homepage, PHP_URL_HOST);
-                $contact->photo = (file_exists(public_path() . '/assets/profile-images/' . $host . '/image')) ?
-                    '/assets/profile-images/' . $host . '/image'
-                :
-                    '/assets/profile-images/default-image';
+                $contact->photo = '/assets/profile-images/default-image';
+                if (file_exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
+                    $contact->photo = '/assets/profile-images/' . $host . '/image';
+                }
 
                 return trim(view('templates.mini-hcard', ['contact' => $contact])->render());
             },
@@ -450,31 +447,17 @@ class Note extends Model
      * @param  string  The note
      * @return string
      */
-    private function autoLinkHashtag($text)
+    public function autoLinkHashtag($text)
     {
-        // $replacements = ["#tag" => "]
-        $replacements = [];
-        $matches = [];
-
-        if (preg_match_all('/(?<=^|\s)\#([a-zA-Z0-9\-\_]+)/i', $text, $matches, PREG_PATTERN_ORDER)) {
-            // Look up #tags, get Full name and URL
-            foreach ($matches[0] as $name) {
-                $name = str_replace('#', '', $name);
-                $replacements[$name] =
-                  '';
-            }
-
-            // Replace #tags with valid microformat-enabled link
-            foreach ($replacements as $name => $replacement) {
-                $text = str_replace('#' . $name, $replacement, $text);
-            }
-        }
-
-        return $text;
+        return preg_replace_callback(
+            '/#([^\s]*)\b/',
+            function ($matches) {
+                return '';
+            },
+            $text
+        );
     }
 
     private function convertMarkdown($text)
@@ -536,7 +519,7 @@ class Note extends Model
 
                 return $address;
             }
-            $adress = '' . $json->address->country . '';
+            $address = '' . $json->address->country . '';
             Cache::forever($latlng, $address);
 
             return $address;
diff --git a/app/Observers/NoteObserver.php b/app/Observers/NoteObserver.php
index 4b9dc1dd..7bc061e0 100644
--- a/app/Observers/NoteObserver.php
+++ b/app/Observers/NoteObserver.php
@@ -21,12 +21,10 @@ class NoteObserver
         }
 
         $tags->transform(function ($tag) {
-            return Tag::firstOrCreate(['tag' => $tag]);
-        });
+            return Tag::firstOrCreate(['tag' => $tag])->id;
+        })->toArray();
 
-        $note->tags()->attach($tags->map(function ($tag) {
-            return $tag->id;
-        }));
+        $note->tags()->attach($tags);
     }
 
     /**
@@ -65,9 +63,6 @@ class NoteObserver
     public function getTagsFromNote($note)
     {
         preg_match_all('/#([^\s<>]+)\b/', $note, $tags);
-        if (array_get($tags, '1') === null) {
-            return [];
-        }
 
         return collect($tags[1])->map(function ($tag) {
             return Tag::normalize($tag);
diff --git a/app/Place.php b/app/Place.php
index 7093d194..74588148 100644
--- a/app/Place.php
+++ b/app/Place.php
@@ -2,7 +2,7 @@
 
 namespace App;
 
-use DB;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Builder;
 use Cviebrock\EloquentSluggable\Sluggable;
@@ -79,15 +79,8 @@ class Place extends Model
 
     public function scopeWhereExternalURL(Builder $query, string $url)
     {
-        $type = $this->getType($url);
-        if ($type === null) {
-            // we haven’t set a type, therefore result must be empty set
-            // id can’t be null, so this will return empty set
-            return $query->whereNull('id');
-        }
-
         return $query->where('external_urls', '@>', json_encode([
-            $type => $url,
+            $this->getType($url) => $url,
         ]));
     }
 
@@ -131,12 +124,19 @@ class Place extends Model
         return config('app.shorturl') . '/places/' . $this->slug;
     }
 
+    /**
+     * This method is an alternative for `longurl`.
+     *
+     * @return string
+     */
+    public function getUriAttribute()
+    {
+        return $this->longurl;
+    }
+
     public function setExternalUrlsAttribute($url)
     {
         $type = $this->getType($url);
-        if ($type === null) {
-            throw new \Exception('Unkown external url type ' . $url);
-        }
         $already = [];
         if (array_key_exists('external_urls', $this->attributes)) {
             $already = json_decode($this->attributes['external_urls'], true);
@@ -155,6 +155,6 @@ class Place extends Model
             return 'osm';
         }
 
-        return null;
+        return 'default';
     }
 }
diff --git a/app/Providers/BroadcastServiceProvider.php b/app/Providers/BroadcastServiceProvider.php
index 352cce44..ddd525d7 100644
--- a/app/Providers/BroadcastServiceProvider.php
+++ b/app/Providers/BroadcastServiceProvider.php
@@ -5,6 +5,9 @@ namespace App\Providers;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\Broadcast;
 
+/**
+ * @codeCoverageIgnore
+ */
 class BroadcastServiceProvider extends ServiceProvider
 {
     /**
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
index bca0414f..3a3503f2 100644
--- a/app/Providers/HorizonServiceProvider.php
+++ b/app/Providers/HorizonServiceProvider.php
@@ -6,6 +6,9 @@ use Illuminate\Http\Request;
 use Laravel\Horizon\Horizon;
 use Illuminate\Support\ServiceProvider;
 
+/**
+ * @codeCoverageIgnore
+ */
 class HorizonServiceProvider extends ServiceProvider
 {
     /**
diff --git a/app/Services/BookmarkService.php b/app/Services/BookmarkService.php
index c8215f1a..0966a996 100644
--- a/app/Services/BookmarkService.php
+++ b/app/Services/BookmarkService.php
@@ -13,7 +13,8 @@ use App\Jobs\ProcessBookmark;
 use Spatie\Browsershot\Browsershot;
 use App\Jobs\SyndicateBookmarkToTwitter;
 use App\Jobs\SyndicateBookmarkToFacebook;
-use App\Exceptions\InternetArchiveErrorSavingException;
+use GuzzleHttp\Exception\ClientException;
+use App\Exceptions\InternetArchiveException;
 
 class BookmarkService
 {
@@ -103,20 +104,20 @@ class BookmarkService
 
     public function getArchiveLink(string $url): string
     {
-        $client = new Client();
-
-        $response = $client->request('GET', 'https://web.archive.org/save/' . $url);
+        $client = resolve(Client::class);
+        try {
+            $response = $client->request('GET', 'https://web.archive.org/save/' . $url);
+        } catch (ClientException $e) {
+            //throw an exception to be caught
+            throw new InternetArchiveException;
+        }
         if ($response->hasHeader('Content-Location')) {
-            if (starts_with($response->getHeader('Content-Location')[0], '/web')) {
+            if (starts_with(array_get($response->getHeader('Content-Location'), 0), '/web')) {
                 return $response->getHeader('Content-Location')[0];
             }
         }
 
-        if (starts_with(array_get($response->getHeader('Content-Location'), 0), '/web')) {
-            return $response->getHeader('Content-Location')[0];
-        }
-
         //throw an exception to be caught
-        throw new InternetArchiveErrorSavingException;
+        throw new InternetArchiveException;
     }
 }
diff --git a/app/Services/LikeService.php b/app/Services/LikeService.php
index a51fc509..855640da 100644
--- a/app/Services/LikeService.php
+++ b/app/Services/LikeService.php
@@ -21,7 +21,7 @@ class LikeService
             //micropub request
             $url = normalize_url($request->input('properties.like-of.0'));
         }
-        if (($request->header('Content-Type') == 'x-www-url-formencoded')
+        if (($request->header('Content-Type') == 'application/x-www-form-urlencoded')
             ||
             ($request->header('Content-Type') == 'multipart/form-data')
         ) {
diff --git a/app/Services/Micropub/HCardService.php b/app/Services/Micropub/HCardService.php
new file mode 100644
index 00000000..4488aaad
--- /dev/null
+++ b/app/Services/Micropub/HCardService.php
@@ -0,0 +1,34 @@
+header('Content-Type') == 'application/json') {
+            $data['name'] = $request->input('properties.name');
+            $data['description'] = $request->input('properties.description') ?? null;
+            if ($request->has('properties.geo')) {
+                $data['geo'] = $request->input('properties.geo');
+            }
+        } else {
+            $data['name'] = $request->input('name');
+            $data['description'] = $request->input('description');
+            if ($request->has('geo')) {
+                $data['geo'] = $request->input('geo');
+            }
+            if ($request->has('latitude')) {
+                $data['latitude'] = $request->input('latitude');
+                $data['longitude'] = $request->input('longitude');
+            }
+        }
+        $place = resolve(PlaceService::class)->createPlace($data);
+
+        return $place->longurl;
+    }
+}
diff --git a/app/Services/Micropub/HEntryService.php b/app/Services/Micropub/HEntryService.php
new file mode 100644
index 00000000..acff6e2a
--- /dev/null
+++ b/app/Services/Micropub/HEntryService.php
@@ -0,0 +1,28 @@
+has('properties.like-of') || $request->has('like-of')) {
+            $like = resolve(LikeService::class)->createLike($request);
+
+            return $like->longurl;
+        }
+
+        if ($request->has('properties.bookmark-of') || $request->has('bookmark-of')) {
+            $bookmark = resolve(BookmarkService::class)->createBookmark($request);
+
+            return $bookmark->longurl;
+        }
+
+        $note = resolve(NoteService::class)->createNote($request);
+
+        return $note->longurl;
+    }
+}
diff --git a/app/Services/Micropub/UpdateService.php b/app/Services/Micropub/UpdateService.php
new file mode 100644
index 00000000..d0138360
--- /dev/null
+++ b/app/Services/Micropub/UpdateService.php
@@ -0,0 +1,95 @@
+input('url'), PHP_URL_PATH);
+
+        //is it a note we are updating?
+        if (mb_substr($urlPath, 1, 5) !== 'notes') {
+            return response()->json([
+                'error' => 'invalid',
+                'error_description' => 'This implementation currently only support the updating of notes',
+            ], 500);
+        }
+
+        try {
+            $note = Note::nb60(basename($urlPath))->firstOrFail();
+        } catch (ModelNotFoundException $exception) {
+            return response()->json([
+                'error' => 'invalid_request',
+                'error_description' => 'No known note with given ID',
+            ], 404);
+        }
+
+        //got the note, are we dealing with a “replace” request?
+        if ($request->has('replace')) {
+            foreach ($request->input('replace') as $property => $value) {
+                if ($property == 'content') {
+                    $note->note = $value[0];
+                }
+                if ($property == 'syndication') {
+                    foreach ($value as $syndicationURL) {
+                        if (starts_with($syndicationURL, 'https://www.facebook.com')) {
+                            $note->facebook_url = $syndicationURL;
+                        }
+                        if (starts_with($syndicationURL, 'https://www.swarmapp.com')) {
+                            $note->swarm_url = $syndicationURL;
+                        }
+                        if (starts_with($syndicationURL, 'https://twitter.com')) {
+                            $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH));
+                        }
+                    }
+                }
+            }
+            $note->save();
+
+            return response()->json([
+                'response' => 'updated',
+            ]);
+        }
+
+        //how about “add”
+        if ($request->has('add')) {
+            foreach ($request->input('add') as $property => $value) {
+                if ($property == 'syndication') {
+                    foreach ($value as $syndicationURL) {
+                        if (starts_with($syndicationURL, 'https://www.facebook.com')) {
+                            $note->facebook_url = $syndicationURL;
+                        }
+                        if (starts_with($syndicationURL, 'https://www.swarmapp.com')) {
+                            $note->swarm_url = $syndicationURL;
+                        }
+                        if (starts_with($syndicationURL, 'https://twitter.com')) {
+                            $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH));
+                        }
+                    }
+                }
+                if ($property == 'photo') {
+                    foreach ($value as $photoURL) {
+                        if (start_with($photo, 'https://')) {
+                            $media = new Media();
+                            $media->path = $photoURL;
+                            $media->type = 'image';
+                            $media->save();
+                            $note->media()->save($media);
+                        }
+                    }
+                }
+            }
+            $note->save();
+
+            return response()->json([
+                'response' => 'updated',
+            ]);
+        }
+    }
+}
diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php
index 4d3f4da0..dea0d6f3 100644
--- a/app/Services/NoteService.php
+++ b/app/Services/NoteService.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace App\Services;
 
+use Illuminate\Http\Request;
 use App\{Media, Note, Place};
 use App\Jobs\{SendWebMentions, SyndicateNoteToFacebook, SyndicateNoteToTwitter};
 
@@ -12,12 +13,111 @@ class NoteService
     /**
      * Create a new note.
      *
-     * @param  array $data
+     * @param  \Illuminate\Http\Request $request
      * @return \App\Note $note
      */
-    public function createNote(array $data): Note
+    public function createNote(Request $request): Note
     {
-
+        //move the request to data code here before refactor
+        $data = [];
+        $data['client-id'] = resolve(TokenService::class)
+            ->validateToken($request->bearerToken())
+            ->getClaim('client_id');
+        if ($request->header('Content-Type') == 'application/json') {
+            if (is_string($request->input('properties.content.0'))) {
+                $data['content'] = $request->input('properties.content.0'); //plaintext content
+            }
+            if (is_array($request->input('properties.content.0'))
+                && array_key_exists('html', $request->input('properties.content.0'))
+            ) {
+                $data['content'] = $request->input('properties.content.0.html');
+            }
+            $data['in-reply-to'] = $request->input('properties.in-reply-to.0');
+            // check location is geo: string
+            if (is_string($request->input('properties.location.0'))) {
+                $data['location'] = $request->input('properties.location.0');
+            }
+            // check location is h-card
+            if (is_array($request->input('properties.location.0'))) {
+                if ($request->input('properties.location.0.type.0' === 'h-card')) {
+                    try {
+                        $place = resolve(PlaceService::class)->createPlaceFromCheckin(
+                            $request->input('properties.location.0')
+                        );
+                        $data['checkin'] = $place->longurl;
+                    } catch (\Exception $e) {
+                        //
+                    }
+                }
+            }
+            $data['published'] = $request->input('properties.published.0');
+            //create checkin place
+            if (array_key_exists('checkin', $request->input('properties'))) {
+                $data['swarm-url'] = $request->input('properties.syndication.0');
+                try {
+                    $place = resolve(PlaceService::class)->createPlaceFromCheckin(
+                        $request->input('properties.checkin.0')
+                    );
+                    $data['checkin'] = $place->longurl;
+                } catch (\Exception $e) {
+                    $data['checkin'] = null;
+                    $data['swarm-url'] = null;
+                }
+            }
+        } else {
+            $data['content'] = $request->input('content');
+            $data['in-reply-to'] = $request->input('in-reply-to');
+            $data['location'] = $request->input('location');
+            $data['published'] = $request->input('published');
+        }
+        $data['syndicate'] = [];
+        $targets = array_pluck(config('syndication.targets'), 'uid', 'service.name');
+        $mpSyndicateTo = null;
+        if ($request->has('mp-syndicate-to')) {
+            $mpSyndicateTo = $request->input('mp-syndicate-to');
+        }
+        if ($request->has('properties.mp-syndicate-to')) {
+            $mpSyndicateTo = $request->input('properties.mp-syndicate-to');
+        }
+        if (is_string($mpSyndicateTo)) {
+            $service = array_search($mpSyndicateTo, $targets);
+            if ($service == 'Twitter') {
+                $data['syndicate'][] = 'twitter';
+            }
+            if ($service == 'Facebook') {
+                $data['syndicate'][] = 'facebook';
+            }
+        }
+        if (is_array($mpSyndicateTo)) {
+            foreach ($mpSyndicateTo as $uid) {
+                $service = array_search($uid, $targets);
+                if ($service == 'Twitter') {
+                    $data['syndicate'][] = 'twitter';
+                }
+                if ($service == 'Facebook') {
+                    $data['syndicate'][] = 'facebook';
+                }
+            }
+        }
+        $data['photo'] = [];
+        $photos = null;
+        if ($request->has('photo')) {
+            $photos = $request->input('photo');
+        }
+        if ($request->has('properties.photo')) {
+            $photos = $request->input('properties.photo');
+        }
+        if ($photos !== null) {
+            foreach ($photos as $photo) {
+                if (is_string($photo)) {
+                    //only supporting media URLs for now
+                    $data['photo'][] = $photo;
+                }
+            }
+            if (starts_with($request->input('properties.syndication.0'), 'https://www.instagram.com')) {
+                $data['instagram-url'] = $request->input('properties.syndication.0');
+            }
+        }
         //check the input
         if (array_key_exists('content', $data) === false) {
             $data['content'] = null;
@@ -37,8 +137,8 @@ class NoteService
         );
 
         if (array_key_exists('published', $data) && empty($data['published']) === false) {
-            $carbon = carbon($data['published']);
-            $note->created_at = $note->updated_at = $carbon->toDateTimeString();
+            $note->created_at = $note->updated_at = carbon($data['published'])
+                                                          ->toDateTimeString();
         }
 
         if (array_key_exists('location', $data) && $data['location'] !== null && $data['location'] !== 'no-location') {
@@ -81,7 +181,7 @@ class NoteService
         */
         //add support for media uploaded as URLs
         if (array_key_exists('photo', $data)) {
-            foreach ($data['photo'] as $photo) {
+            foreach ((array) $data['photo'] as $photo) {
                 // check the media was uploaded to my endpoint, and use path
                 if (starts_with($photo, config('filesystems.disks.s3.url'))) {
                     $path = substr($photo, strlen(config('filesystems.disks.s3.url')));
@@ -104,11 +204,13 @@ class NoteService
         dispatch(new SendWebMentions($note));
 
         //syndication targets
-        if (in_array('twitter', $data['syndicate'])) {
-            dispatch(new SyndicateNoteToTwitter($note));
-        }
-        if (in_array('facebook', $data['syndicate'])) {
-            dispatch(new SyndicateNoteToFacebook($note));
+        if (array_key_exists('syndicate', $data)) {
+            if (in_array('twitter', $data['syndicate'])) {
+                dispatch(new SyndicateNoteToTwitter($note));
+            }
+            if (in_array('facebook', $data['syndicate'])) {
+                dispatch(new SyndicateNoteToFacebook($note));
+            }
         }
 
         return $note;
diff --git a/app/Services/PlaceService.php b/app/Services/PlaceService.php
index 2a823f33..7d397a5e 100644
--- a/app/Services/PlaceService.php
+++ b/app/Services/PlaceService.php
@@ -46,16 +46,16 @@ class PlaceService
     public function createPlaceFromCheckin(array $checkin): Place
     {
         //check if the place exists if from swarm
-        if (array_key_exists('url', $checkin['properties'])) {
+        if (array_has($checkin, 'properties.url')) {
             $place = Place::whereExternalURL($checkin['properties']['url'][0])->get();
             if (count($place) === 1) {
                 return $place->first();
             }
         }
-        if (array_key_exists('name', $checkin['properties']) === false) {
+        if (array_has($checkin, 'properties.name') === false) {
             throw new \InvalidArgumentException('Missing required name');
         }
-        if (array_key_exists('latitude', $checkin['properties']) === false) {
+        if (array_has($checkin, 'properties.latitude') === false) {
             throw new \InvalidArgumentException('Missing required longitude/latitude');
         }
         $place = new Place();
diff --git a/app/Services/TokenService.php b/app/Services/TokenService.php
index a91c9dc9..acedba76 100644
--- a/app/Services/TokenService.php
+++ b/app/Services/TokenService.php
@@ -38,7 +38,7 @@ class TokenService
      * @param  string The token
      * @return mixed
      */
-    public function validateToken(string $bearerToken): ?Token
+    public function validateToken(string $bearerToken): Token
     {
         $signer = new Sha256();
         try {
@@ -47,7 +47,7 @@ class TokenService
             throw new InvalidTokenException('Token could not be parsed');
         }
         if (! $token->verify($signer, config('app.key'))) {
-            throw new InvalidTokenException('Token failed verification');
+            throw new InvalidTokenException('Token failed validation');
         }
 
         return $token;
diff --git a/app/Tag.php b/app/Tag.php
index 8b876d2e..2168ed04 100644
--- a/app/Tag.php
+++ b/app/Tag.php
@@ -6,6 +6,13 @@ use Illuminate\Database\Eloquent\Model;
 
 class Tag extends Model
 {
+    /**
+     * We shall set a blacklist of non-modifiable model attributes.
+     *
+     * @var array
+     */
+    protected $guarded = ['id'];
+
     /**
      * Define the relationship with tags.
      *
@@ -24,13 +31,6 @@ class Tag extends Model
         return $this->belongsToMany('App\Bookmark');
     }
 
-    /**
-     * We shall set a blacklist of non-modifiable model attributes.
-     *
-     * @var array
-     */
-    protected $guarded = ['id'];
-
     /**
      * Normalize tags so they’re lowercase and fancy diatrics are removed.
      *
diff --git a/app/WebMention.php b/app/WebMention.php
index 5e3d570f..ca05bd73 100644
--- a/app/WebMention.php
+++ b/app/WebMention.php
@@ -19,6 +19,13 @@ class WebMention extends Model
      */
     protected $table = 'webmentions';
 
+    /**
+     * We shall set a blacklist of non-modifiable model attributes.
+     *
+     * @var array
+     */
+    protected $guarded = ['id'];
+
     /**
      * Define the relationship.
      *
@@ -29,13 +36,6 @@ class WebMention extends Model
         return $this->morphTo();
     }
 
-    /**
-     * We shall set a blacklist of non-modifiable model attributes.
-     *
-     * @var array
-     */
-    protected $guarded = ['id'];
-
     /**
      * Get the author of the webmention.
      *
@@ -78,9 +78,9 @@ class WebMention extends Model
     }
 
     /**
-     * Get the filteres HTML of a reply.
+     * Get the filtered HTML of a reply.
      *
-     * @return strin|null
+     * @return string|null
      */
     public function getReplyAttribute()
     {
@@ -108,14 +108,10 @@ class WebMention extends Model
             if (Cache::has($url)) {
                 return Cache::get($url);
             }
-            $username = parse_url($url, PHP_URL_PATH);
-            try {
-                $info = Twitter::getUsers(['screen_name' => $username]);
-                $profile_image = $info->profile_image_url_https;
-                Cache::put($url, $profile_image, 10080); //1 week
-            } catch (Exception $e) {
-                return $url; //not sure here
-            }
+            $username = ltrim(parse_url($url, PHP_URL_PATH), '/');
+            $info = Twitter::getUsers(['screen_name' => $username]);
+            $profile_image = $info->profile_image_url_https;
+            Cache::put($url, $profile_image, 10080); //1 week
 
             return $profile_image;
         }
diff --git a/composer.json b/composer.json
index c093a12b..285377d3 100644
--- a/composer.json
+++ b/composer.json
@@ -41,6 +41,7 @@
         "laravel/dusk": "^2.0",
         "mockery/mockery": "0.9.*",
         "nunomaduro/collision": "^1.1",
+        "php-coveralls/php-coveralls": "^1.0",
         "phpunit/phpunit": "~6.0",
         "sebastian/phpcpd": "^3.0"
     },
diff --git a/composer.lock b/composer.lock
index ca3d140d..014d0129 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "content-hash": "f669c85a04a86625c32349be0ef1fc16",
+    "content-hash": "5b3735b76d6821f7de296da60f7cceca",
     "packages": [
         {
             "name": "aws/aws-sdk-php",
@@ -5019,6 +5019,99 @@
             ],
             "time": "2017-08-15T16:48:10+00:00"
         },
+        {
+            "name": "guzzle/guzzle",
+            "version": "v3.8.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/guzzle.git",
+                "reference": "4de0618a01b34aa1c8c33a3f13f396dcd3882eba"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/4de0618a01b34aa1c8c33a3f13f396dcd3882eba",
+                "reference": "4de0618a01b34aa1c8c33a3f13f396dcd3882eba",
+                "shasum": ""
+            },
+            "require": {
+                "ext-curl": "*",
+                "php": ">=5.3.3",
+                "symfony/event-dispatcher": ">=2.1"
+            },
+            "replace": {
+                "guzzle/batch": "self.version",
+                "guzzle/cache": "self.version",
+                "guzzle/common": "self.version",
+                "guzzle/http": "self.version",
+                "guzzle/inflection": "self.version",
+                "guzzle/iterator": "self.version",
+                "guzzle/log": "self.version",
+                "guzzle/parser": "self.version",
+                "guzzle/plugin": "self.version",
+                "guzzle/plugin-async": "self.version",
+                "guzzle/plugin-backoff": "self.version",
+                "guzzle/plugin-cache": "self.version",
+                "guzzle/plugin-cookie": "self.version",
+                "guzzle/plugin-curlauth": "self.version",
+                "guzzle/plugin-error-response": "self.version",
+                "guzzle/plugin-history": "self.version",
+                "guzzle/plugin-log": "self.version",
+                "guzzle/plugin-md5": "self.version",
+                "guzzle/plugin-mock": "self.version",
+                "guzzle/plugin-oauth": "self.version",
+                "guzzle/service": "self.version",
+                "guzzle/stream": "self.version"
+            },
+            "require-dev": {
+                "doctrine/cache": "*",
+                "monolog/monolog": "1.*",
+                "phpunit/phpunit": "3.7.*",
+                "psr/log": "1.0.*",
+                "symfony/class-loader": "*",
+                "zendframework/zend-cache": "<2.3",
+                "zendframework/zend-log": "<2.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.8-dev"
+                }
+            },
+            "autoload": {
+                "psr-0": {
+                    "Guzzle": "src/",
+                    "Guzzle\\Tests": "tests/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Guzzle Community",
+                    "homepage": "https://github.com/guzzle/guzzle/contributors"
+                }
+            ],
+            "description": "Guzzle is a PHP HTTP client library and framework for building RESTful web service clients",
+            "homepage": "http://guzzlephp.org/",
+            "keywords": [
+                "client",
+                "curl",
+                "framework",
+                "http",
+                "http client",
+                "rest",
+                "web service"
+            ],
+            "abandoned": "guzzlehttp/guzzle",
+            "time": "2014-01-28T22:29:15+00:00"
+        },
         {
             "name": "hamcrest/hamcrest-php",
             "version": "v1.2.2",
@@ -5502,6 +5595,67 @@
             "description": "Library for handling version information and constraints",
             "time": "2017-03-05T17:38:23+00:00"
         },
+        {
+            "name": "php-coveralls/php-coveralls",
+            "version": "v1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-coveralls/php-coveralls.git",
+                "reference": "9c07b63acbc9709344948b6fd4f63a32b2ef4127"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-coveralls/php-coveralls/zipball/9c07b63acbc9709344948b6fd4f63a32b2ef4127",
+                "reference": "9c07b63acbc9709344948b6fd4f63a32b2ef4127",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "ext-simplexml": "*",
+                "guzzle/guzzle": "^2.8 || ^3.0",
+                "php": "^5.3.3 || ^7.0",
+                "psr/log": "^1.0",
+                "symfony/config": "^2.1 || ^3.0 || ^4.0",
+                "symfony/console": "^2.1 || ^3.0 || ^4.0",
+                "symfony/stopwatch": "^2.0 || ^3.0 || ^4.0",
+                "symfony/yaml": "^2.0 || ^3.0 || ^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35 || ^5.4.3 || ^6.0"
+            },
+            "suggest": {
+                "symfony/http-kernel": "Allows Symfony integration"
+            },
+            "bin": [
+                "bin/coveralls"
+            ],
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Satooshi\\": "src/Satooshi/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Kitamura Satoshi",
+                    "email": "with.no.parachute@gmail.com",
+                    "homepage": "https://www.facebook.com/satooshi.jp"
+                }
+            ],
+            "description": "PHP client library for Coveralls API",
+            "homepage": "https://github.com/php-coveralls/php-coveralls",
+            "keywords": [
+                "ci",
+                "coverage",
+                "github",
+                "test"
+            ],
+            "time": "2017-10-14T23:15:34+00:00"
+        },
         {
             "name": "phpdocumentor/reflection-common",
             "version": "1.0.1",
@@ -6752,6 +6906,166 @@
             "homepage": "https://github.com/sebastianbergmann/version",
             "time": "2016-10-03T07:35:21+00:00"
         },
+        {
+            "name": "symfony/config",
+            "version": "v3.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/config.git",
+                "reference": "1de51a6c76359897ab32c309934b93d036bccb60"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/config/zipball/1de51a6c76359897ab32c309934b93d036bccb60",
+                "reference": "1de51a6c76359897ab32c309934b93d036bccb60",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/filesystem": "~2.8|~3.0|~4.0"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<3.3",
+                "symfony/finder": "<3.3"
+            },
+            "require-dev": {
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/finder": "~3.3|~4.0",
+                "symfony/yaml": "~3.0|~4.0"
+            },
+            "suggest": {
+                "symfony/yaml": "To use the yaml reference dumper"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Config\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony Config Component",
+            "homepage": "https://symfony.com",
+            "time": "2017-11-19T20:09:36+00:00"
+        },
+        {
+            "name": "symfony/filesystem",
+            "version": "v4.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/filesystem.git",
+                "reference": "c9d4a26759ff75a077e4e334315cb632739b661a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/c9d4a26759ff75a077e4e334315cb632739b661a",
+                "reference": "c9d4a26759ff75a077e4e334315cb632739b661a",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Filesystem\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony Filesystem Component",
+            "homepage": "https://symfony.com",
+            "time": "2017-11-21T14:14:53+00:00"
+        },
+        {
+            "name": "symfony/stopwatch",
+            "version": "v4.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/stopwatch.git",
+                "reference": "ac0e49150555c703fef6b696d8eaba1db7a3ca03"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/stopwatch/zipball/ac0e49150555c703fef6b696d8eaba1db7a3ca03",
+                "reference": "ac0e49150555c703fef6b696d8eaba1db7a3ca03",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Stopwatch\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony Stopwatch Component",
+            "homepage": "https://symfony.com",
+            "time": "2017-11-09T12:45:29+00:00"
+        },
         {
             "name": "symfony/yaml",
             "version": "v3.3.13",
diff --git a/database/seeds/ContactsTableSeeder.php b/database/seeds/ContactsTableSeeder.php
index 28ac804d..1d7f3244 100644
--- a/database/seeds/ContactsTableSeeder.php
+++ b/database/seeds/ContactsTableSeeder.php
@@ -22,7 +22,6 @@ class ContactsTableSeeder extends Seeder
             'nick' => 'aaron',
             'name' => 'Aaron Parecki',
             'homepage' => 'https://aaronparecki.com',
-            'twitter' => 'aaronpk',
             'facebook' => '123456',
         ]);
     }
diff --git a/database/seeds/LikesTableSeeder.php b/database/seeds/LikesTableSeeder.php
index 59c5af07..fed15569 100644
--- a/database/seeds/LikesTableSeeder.php
+++ b/database/seeds/LikesTableSeeder.php
@@ -12,5 +12,15 @@ class LikesTableSeeder extends Seeder
     public function run()
     {
         factory(App\Like::class, 10)->create();
+
+        $faker = new \Faker\Generator();
+        $faker->addProvider(new \Faker\Provider\en_US\Person($faker));
+        $faker->addProvider(new \Faker\Provider\Lorem($faker));
+        $faker->addProvider(new \Faker\Provider\Internet($faker));
+        App\Like::create([
+            'url' => $faker->url,
+            'author_url' => $faker->url,
+            'author_name' => $faker->name,
+        ]);
     }
 }
diff --git a/database/seeds/NotesTableSeeder.php b/database/seeds/NotesTableSeeder.php
index 55329626..ba478935 100644
--- a/database/seeds/NotesTableSeeder.php
+++ b/database/seeds/NotesTableSeeder.php
@@ -13,6 +13,11 @@ class NotesTableSeeder extends Seeder
     {
         factory(App\Note::class, 10)->create();
         sleep(1);
+        $noteTwitterReply = App\Note::create([
+            'note' => 'What does this even mean?',
+            'in_reply_to' => 'https://twitter.com/realDonaldTrump/status/933662564587855877',
+        ]);
+        sleep(1);
         $noteWithPlace = App\Note::create([
             'note' => 'Having a #beer at the local. 🍺',
         ]);
@@ -42,11 +47,29 @@ class NotesTableSeeder extends Seeder
             copy(base_path() . '/tests/aaron.png', public_path() . '/assets/profile-images/aaronparecki.com/image');
         }
         $noteWithCoords = App\Note::create([
-            'note' => 'Note from somehwere',
+            'note' => 'Note from a town',
         ]);
         $noteWithCoords->location = '53.499,-2.379';
         $noteWithCoords->save();
         sleep(1);
+        $noteWithCoords2 = App\Note::create([
+            'note' => 'Note from a city',
+        ]);
+        $noteWithCoords2->location = '53.9026894,-2.42250444118781';
+        $noteWithCoords2->save();
+        sleep(1);
+        $noteWithCoords3 = App\Note::create([
+            'note' => 'Note from a county',
+        ]);
+        $noteWithCoords3->location = '57.5066357,-5.0038367';
+        $noteWithCoords3->save();
+        sleep(1);
+        $noteWithCoords4 = App\Note::create([
+            'note' => 'Note from a country',
+        ]);
+        $noteWithCoords4->location = '63.000147,-136.002502';
+        $noteWithCoords4->save();
+        sleep(1);
         $noteSyndicated = App\Note::create([
             'note' => 'This note has all the syndication targets',
         ]);
@@ -59,5 +82,26 @@ class NotesTableSeeder extends Seeder
         $noteWithTextLinkandEmoji = App\Note::create([
             'note' => 'I love https://duckduckgo.com 💕' // theres a two-heart emoji at the end of this
         ]);
+        sleep(1);
+        $media = new App\Media();
+        $media->path = 'media/f1bc8faa-1a8f-45b8-a9b1-57282fa73f87.jpg';
+        $media->type = 'image';
+        $media->image_widths = '3648';
+        $media->save();
+        $noteWithImage = App\Note::create([
+            'note' => 'A lovely waterfall',
+        ]);
+        $noteWithImage->media()->save($media);
+        sleep(1);
+        $noteFromInstagram = App\Note::create([
+            'note' => 'Lovely #wedding #weddingfavour',
+        ]);
+        $noteFromInstagram->instagram_url = 'https://www.instagram.com/p/Bbo22MHhE_0';
+        $noteFromInstagram->save();
+        $mediaInstagram = new App\Media();
+        $mediaInstagram->path = 'https://scontent-lhr3-1.cdninstagram.com/t51.2885-15/e35/23734479_149605352435937_400133507076063232_n.jpg';
+        $mediaInstagram->type = 'image';
+        $mediaInstagram->save();
+        $noteFromInstagram->media()->save($mediaInstagram);
     }
 }
diff --git a/database/seeds/WebMentionsTableSeeder.php b/database/seeds/WebMentionsTableSeeder.php
index c0da2809..72935206 100644
--- a/database/seeds/WebMentionsTableSeeder.php
+++ b/database/seeds/WebMentionsTableSeeder.php
@@ -12,13 +12,21 @@ class WebMentionsTableSeeder extends Seeder
      */
     public function run()
     {
-        $webmention = WebMention::create([
-            'source' => 'https://aaornpk.localhost/reply/1',
-            'target' => 'https://jonnybarnes.localhost/notes/D',
+        $webmentionAaron = WebMention::create([
+            'source' => 'https://aaronpk.localhost/reply/1',
+            'target' => config('app.url') . '/notes/E',
+            'commentable_id' => '14',
+            'commentable_type' => 'App\Note',
+            'type' => 'in-reply-to',
+            'mf2' => '{"rels": [], "items": [{"type": ["h-entry"], "properties": {"url": ["https://aaronpk.localhost/reply/1"], "name": ["Hi too"], "author": [{"type": ["h-card"], "value": "Aaron Parecki", "properties": {"url": ["https://aaronpk.localhost"], "name": ["Aaron Parecki"], "photo": ["https://aaronparecki.com/images/profile.jpg"]}}], "content": [{"html": "Hi too", "value": "Hi too"}], "published": ["' . date(DATE_W3C) . '"], "in-reply-to": ["https://aaronpk.loclahost/reply/1", "' . config('app.url') .'/notes/E"]}}]}'
+        ]);
+        $webmentionTantek = WebMention::create([
+            'source' => 'http://tantek.com/',
+            'target' => config('app.url') . '/notes/D',
             'commentable_id' => '13',
             'commentable_type' => 'App\Note',
             'type' => 'in-reply-to',
-            'mf2' => '{"rels": [], "items": [{"type": ["h-entry"], "properties": {"url": ["https://aaronpk.localhost/reply/1"], "name": ["Hi too"], "author": [{"type": ["h-card"], "value": "Aaron Parecki", "properties": {"url": ["https://aaronpk.localhost"], "name": ["Aaron Parecki"], "photo": ["https://aaronparecki.com/images/profile.jpg"]}}], "content": [{"html": "Hi too", "value": "Hi too"}], "published": ["' . date(DATE_W3C) . '"], "in-reply-to": ["https://aaronpk.loclahost/reply/1", "https://jonnybarnes.uk/notes/D"]}}]}'
+            'mf2' => '{"rels": [], "items": [{"type": ["h-entry"], "properties": {"url": ["http://tantek.com/"], "name": ["KUTGW"], "author": [{"type": ["h-card"], "value": "Tantek Celik", "properties": {"url": ["http://tantek.com/"], "name": ["Tantek Celik"]}}], "content": [{"html": "kutgw", "value": "kutgw"}], "published": ["' . date(DATE_W3C) . '"], "in-reply-to": ["' . config('app.url') . '/notes/D"]}}]}'
         ]);
     }
 }
diff --git a/public/assets/old-shorturls.json b/public/assets/old-shorturls.json
deleted file mode 100644
index 26b0e4c4..00000000
--- a/public/assets/old-shorturls.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
-    "RbrS": "https://jonnybarnes.net/note/1",
-    "pfua": "https://jonnybarnes.net/note/2",
-    "HQ8X": "https://jonnybarnes.net/note/3",
-    "7Duc": "https://jonnybarnes.net/note/4",
-    "m0vZ": "https://jonnybarnes.net/note/5",
-    "uB95": "https://jonnybarnes.net/note/6",
-    "yUx8": "https://jonnybarnes.net/note/7",
-    "tMLB": "https://jonnybarnes.net/note/8",
-    "a1HU": "https://jonnybarnes.net/note/9",
-    "rx3e": "https://jonnybarnes.net/note/10",
-    "dW3p": "https://jonnybarnes.net/note/11",
-    "_6za": "https://jonnybarnes.net/note/12",
-    "eTvB": "https://jonnybarnes.net/note/13",
-    "6kMh": "https://jonnybarnes.net/note/14",
-    "T72f": "https://jonnybarnes.net/note/15",
-    "enot": "https://jonnybarnes.net/note/16",
-    "QCDv": "https://jonnybarnes.net/note/17"
-}
\ No newline at end of file
diff --git a/resources/views/admin/notes/create.blade.php b/resources/views/admin/notes/create.blade.php
index 3512797d..72ad0566 100644
--- a/resources/views/admin/notes/create.blade.php
+++ b/resources/views/admin/notes/create.blade.php
@@ -12,12 +12,7 @@
                 
             
 @endif
-@include('templates.new-note-form', [
-  'micropub' => false,
-  'action' => '/admin/note',
-  'id' => 'newnote-admin'
-])
-            
+ {{ csrf_field() }}
New Note @@ -27,7 +22,6 @@ name="in-reply-to" id="in-reply-to" placeholder="in-reply-to-1 in-reply-to-2 …" - value="{{ old('in-reply-to') }}" >
@@ -40,23 +34,7 @@
- - -
- -
@endforeach diff --git a/resources/views/notes/show.blade.php b/resources/views/notes/show.blade.php index e4f7cc12..38afd9b1 100644 --- a/resources/views/notes/show.blade.php +++ b/resources/views/notes/show.blade.php @@ -10,7 +10,7 @@ }) as $reply)
- {{ $reply['author']['properties']['name'][0] }} + @if (array_key_exists('photo', $reply['author']['properties']))@endif {{ $reply['author']['properties']['name'][0] }} said at {{ $reply['published'] }}
{!! $reply['reply'] !!} diff --git a/resources/views/search.blade.php b/resources/views/search.blade.php index 78843653..1b55e32e 100644 --- a/resources/views/search.blade.php +++ b/resources/views/search.blade.php @@ -9,12 +9,11 @@ @include('templates.note', ['note' => $note])
@endforeach +{{ $notes->links() }} @stop @section('scripts') -@include('templates.mapbox-links') - - + @include('templates.mapbox-links') diff --git a/resources/views/templates/mini-hcard.blade.php b/resources/views/templates/mini-hcard.blade.php index 88f9874b..6ec30989 100644 --- a/resources/views/templates/mini-hcard.blade.php +++ b/resources/views/templates/mini-hcard.blade.php @@ -1 +1 @@ -{!! $contact->name !!}@if ($contact->facebook) {{ $contact->facebook_name ?: 'Facebook' }}@endif @if ($contact->twitter) {{ $contact->twitter }}@endif +{!! $contact->name !!}@if ($contact->facebook) {{ $contact->facebook_name ?: 'Facebook' }}@endif @if ($contact->twitter) {{ $contact->twitter }}@endif diff --git a/routes/web.php b/routes/web.php index 7f3b4bef..f0a54a44 100644 --- a/routes/web.php +++ b/routes/web.php @@ -150,7 +150,6 @@ Route::group(['domain' => config('url.shorturl')], function () { Route::get('/', 'ShortURLsController@baseURL'); Route::get('@', 'ShortURLsController@twitter'); Route::get('+', 'ShortURLsController@googlePlus'); - Route::get('α', 'ShortURLsController@appNet'); Route::get('{type}/{id}', 'ShortURLsController@expandType')->where( [ @@ -160,9 +159,4 @@ Route::group(['domain' => config('url.shorturl')], function () { ); Route::get('h/{id}', 'ShortURLsController@redirect'); - Route::get('{id}', 'ShortURLsController@oldRedirect')->where( - [ - 'id' => '[0-9A-HJ-NP-Z_a-km-z]{4}', - ] - ); }); diff --git a/tests/Feature/AdminHomeControllerTest.php b/tests/Feature/AdminHomeControllerTest.php new file mode 100644 index 00000000..20a259e0 --- /dev/null +++ b/tests/Feature/AdminHomeControllerTest.php @@ -0,0 +1,16 @@ +withSession(['loggedin' => true]) + ->get('/admin'); + $response->assertViewIs('admin.welcome'); + } +} diff --git a/tests/Feature/AdminTest.php b/tests/Feature/AdminTest.php new file mode 100644 index 00000000..31d4851e --- /dev/null +++ b/tests/Feature/AdminTest.php @@ -0,0 +1,39 @@ +get('/admin'); + $response->assertRedirect('/login'); + } + + public function test_login_page() + { + $response = $this->get('/login'); + $response->assertViewIs('login'); + } + + public function test_attempt_login_with_good_credentials() + { + $response = $this->post('/login', [ + 'username' => config('admin.user'), + 'password' => config('admin.pass'), + ]); + $response->assertRedirect('/admin'); + } + + public function test_attempt_login_with_bad_credentials() + { + $response = $this->post('/login', [ + 'username' => 'bad', + 'password' => 'credentials', + ]); + $response->assertRedirect('/login'); + } +} diff --git a/tests/Feature/ArticlesAdminTest.php b/tests/Feature/ArticlesAdminTest.php index f1a395a9..156eeac3 100644 --- a/tests/Feature/ArticlesAdminTest.php +++ b/tests/Feature/ArticlesAdminTest.php @@ -10,6 +10,20 @@ class ArticlesAdminTest extends TestCase { use DatabaseTransactions; + public function test_index_page() + { + $response = $this->withSession(['loggedin' => true]) + ->get('/admin/blog'); + $response->assertSeeText('Select article to edit:'); + } + + public function test_create_page() + { + $response = $this->withSession(['loggedin' => true]) + ->get('/admin/blog/create'); + $response->assertSeeText('Title (URL)'); + } + public function test_create_new_article() { $this->withSession(['loggedin' => true]) @@ -42,4 +56,36 @@ class ArticlesAdminTest extends TestCase 'main' => $text, ]); } + + public function test_see_edit_form() + { + $response = $this->withSession(['loggedin' => true]) + ->get('/admin/blog/1/edit'); + $response->assertSeeText('This is *my* new blog. It uses `Markdown`.'); + } + + public function test_edit_article() + { + $this->withSession(['loggedin' => true]) + ->post('/admin/blog/1', [ + '_method' => 'PUT', + 'title' => 'My New Blog', + 'main' => 'This article has been edited', + ]); + $this->assertDatabaseHas('articles', [ + 'title' => 'My New Blog', + 'main' => 'This article has been edited', + ]); + } + + public function test_delete_article() + { + $this->withSession(['loggedin' => true]) + ->post('/admin/blog/1', [ + '_method' => 'DELETE', + ]); + $this->assertSoftDeleted('articles', [ + 'title' => 'My New Blog', + ]); + } } diff --git a/tests/Feature/ArticlesTest.php b/tests/Feature/ArticlesTest.php new file mode 100644 index 00000000..ec051cc0 --- /dev/null +++ b/tests/Feature/ArticlesTest.php @@ -0,0 +1,33 @@ +get('/blog'); + $response->assertViewIs('articles.index'); + } + + public function test_single_article() + { + $response = $this->get('/blog/' . date('Y') . '/' . date('m') . '/my-new-blog'); + $response->assertViewIs('articles.show'); + } + + public function test_wrong_date_redirects() + { + $response = $this->get('/blog/1900/01/my-new-blog'); + $response->assertRedirect('/blog/' . date('Y') . '/' . date('m') . '/my-new-blog'); + } + + public function test_redirect_for_id() + { + $response = $this->get('/blog/s/1'); + $response->assertRedirect('/blog/' . date('Y') . '/' . date('m') . '/my-new-blog'); + } +} diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index 6a1aac5b..3ec9c6e6 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -6,13 +6,27 @@ use Tests\TestCase; use Tests\TestToken; use App\Jobs\ProcessBookmark; use Illuminate\Support\Facades\Queue; +use App\Jobs\SyndicateBookmarkToTwitter; +use App\Jobs\SyndicateBookmarkToFacebook; use Illuminate\Foundation\Testing\DatabaseTransactions; class BookmarksTest extends TestCase { use DatabaseTransactions, TestToken; - public function test_browsershot_job_dispatches_when_bookmark_added() + public function test_bookmarks_page() + { + $response = $this->get('/bookmarks'); + $response->assertViewIs('bookmarks.index'); + } + + public function test_single_bookmark_page() + { + $response = $this->get('/bookmarks/1'); + $response->assertViewIs('bookmarks.show'); + } + + public function test_browsershot_job_dispatches_when_bookmark_added_http_post_syntax() { Queue::fake(); @@ -21,6 +35,95 @@ class BookmarksTest extends TestCase ])->post('/api/post', [ 'h' => 'entry', 'bookmark-of' => 'https://example.org/blog-post', + 'mp-syndicate-to' => [ + 'https://twitter.com/jonnybarnes', + 'https://facebook.com/jonnybarnes', + ], + ]); + + $response->assertJson(['response' => 'created']); + + Queue::assertPushed(ProcessBookmark::class); + Queue::assertPushed(SyndicateBookmarkToTwitter::class); + Queue::assertPushed(SyndicateBookmarkToFacebook::class); + $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); + } + + public function test_browsershot_job_dispatches_when_bookmark_added_json_syntax() + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->getToken(), + ])->json('POST', '/api/post', [ + 'type' => ['h-entry'], + 'properties' => [ + 'bookmark-of' => ['https://example.org/blog-post'], + 'mp-syndicate-to' => [ + 'https://twitter.com/jonnybarnes', + 'https://facebook.com/jonnybarnes', + ], + ], + ]); + + $response->assertJson(['response' => 'created']); + + Queue::assertPushed(ProcessBookmark::class); + Queue::assertPushed(SyndicateBookmarkToTwitter::class); + Queue::assertPushed(SyndicateBookmarkToFacebook::class); + $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); + } + + public function test_single_twitter_syndication_target_causes_job_dispatch_http_post_syntax() + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->getToken(), + ])->post('/api/post', [ + 'h' => 'entry', + 'bookmark-of' => 'https://example.org/blog-post', + 'mp-syndicate-to' => 'https://twitter.com/jonnybarnes', + ]); + + $response->assertJson(['response' => 'created']); + + Queue::assertPushed(ProcessBookmark::class); + Queue::assertPushed(SyndicateBookmarkToTwitter::class); + $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); + } + + public function test_single_facebook_syndication_target_causes_job_dispatch_http_post_syntax() + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->getToken(), + ])->post('/api/post', [ + 'h' => 'entry', + 'bookmark-of' => 'https://example.org/blog-post', + 'mp-syndicate-to' => 'https://facebook.com/jonnybarnes', + ]); + + $response->assertJson(['response' => 'created']); + + Queue::assertPushed(ProcessBookmark::class); + Queue::assertPushed(SyndicateBookmarkToFacebook::class); + $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); + } + + public function test_tags_created_with_new_bookmark() + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->getToken(), + ])->json('POST', '/api/post', [ + 'type' => ['h-entry'], + 'properties' => [ + 'bookmark-of' => ['https://example.org/blog-post'], + 'category' => ['tag1', 'tag2'], + ], ]); $response->assertJson(['response' => 'created']); @@ -28,13 +131,4 @@ class BookmarksTest extends TestCase Queue::assertPushed(ProcessBookmark::class); $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); } - - public function test_screenshot_of_google() - { - $url = 'https://www.google.co.uk'; - - $uuid = (new \App\Services\BookmarkService())->saveScreenshot($url); - - $this->assertTrue(file_exists(public_path() . '/assets/img/bookmarks/' . $uuid . '.png')); - } } diff --git a/tests/Feature/BridgyPosseTest.php b/tests/Feature/BridgyPosseTest.php index 229e7d4b..ed191b33 100644 --- a/tests/Feature/BridgyPosseTest.php +++ b/tests/Feature/BridgyPosseTest.php @@ -8,7 +8,7 @@ class BridgyPosseTest extends TestCase { public function test_bridgy_twitter_content() { - $response = $this->get('/notes/C'); + $response = $this->get('/notes/E'); $html = $response->content(); $this->assertTrue(is_string(mb_stristr($html, 'p-bridgy-twitter-content'))); @@ -16,7 +16,7 @@ class BridgyPosseTest extends TestCase public function test_bridgy_facebook_content() { - $response = $this->get('/notes/C'); + $response = $this->get('/notes/E'); $html = $response->content(); $this->assertTrue(is_string(mb_stristr($html, 'p-bridgy-facebook-content'))); diff --git a/tests/Feature/ClientsAdminTest.php b/tests/Feature/ClientsAdminTest.php new file mode 100644 index 00000000..8c6fe798 --- /dev/null +++ b/tests/Feature/ClientsAdminTest.php @@ -0,0 +1,70 @@ +withSession(['loggedin' => true]) + ->get('/admin/clients'); + $response->assertSeeText('Clients'); + } + + public function test_create_page() + { + $response = $this->withSession(['loggedin' => true]) + ->get('/admin/clients/create'); + $response->assertSeeText('New Client'); + } + + public function test_create_new_client() + { + $this->withSession(['loggedin' => true]) + ->post('/admin/clients', [ + 'client_name' => 'Micropublish', + 'client_url' => 'https://micropublish.net' + ]); + $this->assertDatabaseHas('clients', [ + 'client_name' => 'Micropublish', + 'client_url' => 'https://micropublish.net' + ]); + } + + public function test_see_edit_form() + { + $response = $this->withSession(['loggedin' => true]) + ->get('/admin/clients/1/edit'); + $response->assertSee('https://jbl5.dev/notes/new'); + } + + public function test_edit_client() + { + $this->withSession(['loggedin' => true]) + ->post('/admin/clients/1', [ + '_method' => 'PUT', + 'client_url' => 'https://jbl5.dev/notes/new', + 'client_name' => 'JBL5dev', + ]); + $this->assertDatabaseHas('clients', [ + 'client_url' => 'https://jbl5.dev/notes/new', + 'client_name' => 'JBL5dev', + ]); + } + + public function test_delete_client() + { + $this->withSession(['loggedin' => true]) + ->post('/admin/clients/1', [ + '_method' => 'DELETE', + ]); + $this->assertDatabaseMissing('clients', [ + 'client_url' => 'https://jbl5.dev/notes/new', + ]); + } +} diff --git a/tests/Feature/ContactsAdminTest.php b/tests/Feature/ContactsAdminTest.php new file mode 100644 index 00000000..25ce3784 --- /dev/null +++ b/tests/Feature/ContactsAdminTest.php @@ -0,0 +1,194 @@ +withSession([ + 'loggedin' => true + ])->get('/admin/contacts'); + $response->assertViewIs('admin.contacts.index'); + } + + public function test_create_page() + { + $response = $this->withSession([ + 'loggedin' => true + ])->get('/admin/contacts/create'); + $response->assertViewIs('admin.contacts.create'); + } + + public function test_create_new_contact() + { + $this->withSession([ + 'loggedin' => true + ])->post('/admin/contacts', [ + 'name' => 'Fred Bloggs', + 'nick' => 'fred', + 'homepage' => 'https://fred.blog/gs', + ]); + $this->assertDatabaseHas('contacts', [ + 'name' => 'Fred Bloggs', + 'nick' => 'fred', + 'homepage' => 'https://fred.blog/gs' + ]); + } + + public function test_see_edit_form() + { + $response = $this->withSession([ + 'loggedin' => true + ])->get('/admin/contacts/1/edit'); + $response->assertViewIs('admin.contacts.edit'); + } + + public function test_update_contact_no_uploaded_avatar() + { + $this->withSession([ + 'loggedin' => true + ])->post('/admin/contacts/1', [ + '_method' => 'PUT', + 'name' => 'Tantek Celik', + 'nick' => 'tantek', + 'homepage' => 'https://tantek.com', + 'twitter' => 't', + ]); + $this->assertDatabaseHas('contacts', [ + 'name' => 'Tantek Celik', + 'homepage' => 'https://tantek.com', + ]); + } + + public function test_edit_contact_with_uploaded_avatar() + { + copy(__DIR__ . '/../aaron.png', sys_get_temp_dir() . '/tantek.png'); + $path = sys_get_temp_dir() . '/tantek.png'; + $file = new UploadedFile($path, 'tantek.png', 'image/png', filesize($path), null, true); + $this->withSession([ + 'loggedin' => true + ])->post('/admin/contacts/1', [ + '_method' => 'PUT', + 'name' => 'Tantek Celik', + 'nick' => 'tantek', + 'homepage' => 'https://tantek.com', + 'twitter' => 't', + 'avatar' => $file, + ]); + $this->assertFileEquals( + __DIR__ . '/../aaron.png', + public_path() . '/assets/profile-images/tantek.com/image' + ); + } + + public function test_delete_contact() + { + $this->withSession([ + 'loggedin' => true + ])->post('/admin/contacts/1', [ + '_method' => 'DELETE', + ]); + $this->assertDatabaseMissing('contacts', [ + 'nick' => 'tantek', + ]); + } + + public function test_get_avatar_method() + { + $html = << + +
+HTML; + $file = fopen(__DIR__ . '/../aaron.png', 'r'); + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'text/html'], $html), + new Response(200, ['Content-Type' => 'iamge/png'], $file), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/contacts/1/getavatar'); + + $this->assertFileEquals( + __DIR__ . '/../aaron.png', + public_path() . '/assets/profile-images/tantek.com/image' + ); + } + + public function test_get_avatar_method_redirects_with_failed_homepage() + { + $mock = new MockHandler([ + new Response(404), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/contacts/1/getavatar'); + + $response->assertRedirect('/admin/contacts/1/edit'); + } + + public function test_get_avatar_method_redirects_with_failed_avatar_download() + { + $html = << + + +HTML; + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'text/html'], $html), + new Response(404), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/contacts/1/getavatar'); + + $response->assertRedirect('/admin/contacts/1/edit'); + } + + public function test_get_avatar_for_contact_with_no_homepage() + { + $contact = Contact::create([ + 'nick' => 'fred', + 'name' => 'Fred Bloggs', + ]); + + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/contacts/' . $contact->id . '/getavatar'); + + $response->assertRedirect('/admin/contacts/' . $contact->id . '/edit'); + } +} diff --git a/tests/Feature/LikesTest.php b/tests/Feature/LikesTest.php index f9f49d26..874b3b5d 100644 --- a/tests/Feature/LikesTest.php +++ b/tests/Feature/LikesTest.php @@ -32,7 +32,7 @@ class LikesTest extends TestCase $response->assertViewIs('likes.show'); } - public function test_like_micropub_request() + public function test_like_micropub_json_request() { Queue::fake(); @@ -51,7 +51,24 @@ class LikesTest extends TestCase $this->assertDatabaseHas('likes', ['url' => 'https://example.org/blog-post']); } - public function test_process_like_job() + public function test_like_micropub_form_request() + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->getToken(), + ])->post('/api/post', [ + 'h' => 'entry', + 'like-of' => 'https://example.org/blog-post', + ]); + + $response->assertStatus(201); + + Queue::assertPushed(ProcessLike::class); + $this->assertDatabaseHas('likes', ['url' => 'https://example.org/blog-post']); + } + + public function test_process_like_job_with_simple_author() { $like = new Like(); $like->url = 'http://example.org/note/id'; @@ -85,4 +102,75 @@ END; $this->assertEquals('Fred Bloggs', Like::find($id)->author_name); } + + public function test_process_like_job_with_h_card() + { + $like = new Like(); + $like->url = 'http://example.org/note/id'; + $like->save(); + $id = $like->id; + + $job = new ProcessLike($like); + + $content = << + +
+
+ A post that I like. +
+ by +
+ Fred Bloggs + +
+
+ + +END; + $mock = new MockHandler([ + new Response(200, [], $content), + new Response(200, [], $content), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->bind(Client::class, $client); + $authorship = new Authorship(); + + $job->handle($client, $authorship); + + $this->assertEquals('Fred Bloggs', Like::find($id)->author_name); + } + + public function test_process_like_job_without_mf2() + { + $like = new Like(); + $like->url = 'http://example.org/note/id'; + $like->save(); + $id = $like->id; + + $job = new ProcessLike($like); + + $content = << + +
+ I liked a post +
+ + +END; + $mock = new MockHandler([ + new Response(200, [], $content), + new Response(200, [], $content), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->bind(Client::class, $client); + $authorship = new Authorship(); + + $job->handle($client, $authorship); + + $this->assertNull(Like::find($id)->author_name); + } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 925d0932..1fd006b2 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -5,7 +5,11 @@ namespace Tests\Feature; use Tests\TestCase; use Tests\TestToken; use Lcobucci\JWT\Builder; +use App\Jobs\ProcessMedia; +use Illuminate\Http\UploadedFile; use Lcobucci\JWT\Signer\Hmac\Sha256; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Illuminate\Foundation\Testing\DatabaseTransactions; class MicropubControllerTest extends TestCase @@ -18,7 +22,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_without_token_returns_401_response() + public function test_micropub_get_request_without_token_returns_401_response() { $response = $this->get('/api/post'); $response->assertStatus(401); @@ -31,7 +35,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_without_valid_token_returns_400_response() + public function test_micropub_get_request_without_valid_token_returns_400_response() { $response = $this->call('GET', '/api/post', [], [], [], ['HTTP_Authorization' => 'Bearer abc123']); $response->assertStatus(400); @@ -44,7 +48,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_with_valid_token_returns_200_response() + public function test_micropub_get_request_with_valid_token_returns_200_response() { $response = $this->call('GET', '/api/post', [], [], [], ['HTTP_Authorization' => 'Bearer ' . $this->getToken()]); $response->assertStatus(200); @@ -56,7 +60,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_for_syndication_targets() + public function test_micropub_get_request_for_syndication_targets() { $response = $this->call('GET', '/api/post', ['q' => 'syndicate-to'], [], [], ['HTTP_Authorization' => 'Bearer ' . $this->getToken()]); $response->assertJsonFragment(['uid' => 'https://twitter.com/jonnybarnes']); @@ -67,7 +71,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_for_nearby_places() + public function test_micropub_get_request_for_nearby_places() { $response = $this->call('GET', '/api/post', ['q' => 'geo:53.5,-2.38'], [], [], ['HTTP_Authorization' => 'Bearer ' . $this->getToken()]); $response->assertJson(['places' => [['slug' =>'the-bridgewater-pub']]]); @@ -78,7 +82,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_for_nearby_places_with_uncertainty_parameter() + public function test_micropub_get_request_for_nearby_places_with_uncertainty_parameter() { $response = $this->call('GET', '/api/post', ['q' => 'geo:53.5,-2.38;u=35'], [], [], ['HTTP_Authorization' => 'Bearer ' . $this->getToken()]); $response->assertJson(['places' => [['slug' => 'the-bridgewater-pub']]]); @@ -89,7 +93,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_for_nearby_places_where_non_exist() + public function test_micropub_get_request_for_nearby_places_where_non_exist() { $response = $this->call('GET', '/api/post', ['q' => 'geo:1.23,4.56'], [], [], ['HTTP_Authorization' => 'Bearer ' . $this->getToken()]); $response->assertJson(['places' => []]); @@ -100,7 +104,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_for_config() + public function test_micropub_get_request_for_config() { $response = $this->call('GET', '/api/post', ['q' => 'config'], [], [], ['HTTP_Authorization' => 'Bearer ' . $this->getToken()]); $response->assertJsonFragment(['uid' => 'https://twitter.com/jonnybarnes']); @@ -111,7 +115,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_creates_new_note() + public function test_micropub_post_request_creates_new_note() { $faker = \Faker\Factory::create(); $note = $faker->text; @@ -135,7 +139,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_creates_new_place() + public function test_micropub_post_request_creates_new_place() { $response = $this->call( 'POST', @@ -153,12 +157,37 @@ class MicropubControllerTest extends TestCase $this->assertDatabaseHas('places', ['slug' => 'the-barton-arms']); } + /** + * Test a valid micropub requests creates a new place with latitude + * and longitude values defined separately. + * + * @return void + */ + public function test_micropub_post_request_creates_new_place_with_latlng() + { + $response = $this->call( + 'POST', + '/api/post', + [ + 'h' => 'card', + 'name' => 'The Barton Arms', + 'latitude' => '53.4974', + 'longitude' => '-2.3768', + ], + [], + [], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response->assertJson(['response' => 'created']); + $this->assertDatabaseHas('places', ['slug' => 'the-barton-arms']); + } + /** * Test a valid micropub requests using JSON syntax creates a new note. * * @return void */ - public function test_micropub_request_with_json_syntax_creates_new_note() + public function test_micropub_post_request_with_json_syntax_creates_new_note() { $faker = \Faker\Factory::create(); $note = $faker->text; @@ -184,7 +213,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_with_json_syntax_without_token_returns_error() + public function test_micropub_post_request_with_json_syntax_without_token_returns_error() { $faker = \Faker\Factory::create(); $note = $faker->text; @@ -212,7 +241,7 @@ class MicropubControllerTest extends TestCase * * @return void */ - public function test_micropub_request_with_json_syntax_with_invalid_token_returns_error() + public function test_micropub_post_request_with_json_syntax_with_invalid_token_returns_error() { $faker = \Faker\Factory::create(); $note = $faker->text; @@ -235,7 +264,7 @@ class MicropubControllerTest extends TestCase ->assertStatus(401); } - public function test_micropub_request_with_json_syntax_creates_new_place() + public function test_micropub_post_request_with_json_syntax_creates_new_place() { $faker = \Faker\Factory::create(); $response = $this->json( @@ -255,7 +284,7 @@ class MicropubControllerTest extends TestCase ->assertStatus(201); } - public function test_micropub_request_with_json_syntax_and_uncertainty_parameter_creates_new_place() + public function test_micropub_post_request_with_json_syntax_and_uncertainty_parameter_creates_new_place() { $faker = \Faker\Factory::create(); $response = $this->json( @@ -275,7 +304,7 @@ class MicropubControllerTest extends TestCase ->assertStatus(201); } - public function test_micropub_request_with_json_syntax_update_replace_post() + public function test_micropub_post_request_with_json_syntax_update_replace_post() { $response = $this->json( 'POST', @@ -294,7 +323,7 @@ class MicropubControllerTest extends TestCase ->assertStatus(200); } - public function test_micropub_request_with_json_syntax_update_add_post() + public function test_micropub_post_request_with_json_syntax_update_add_post() { $response = $this->json( 'POST', @@ -315,4 +344,54 @@ class MicropubControllerTest extends TestCase 'swarm_url' => 'https://www.swarmapp.com/checkin/123' ]); } + + public function test_media_endpoint_request_with_invalid_token_return_400_response() + { + $response = $this->call( + 'POST', + '/api/media', + [], + [], + [], + ['HTTP_Authorization' => 'Bearer abc123'] + ); + $response->assertStatus(400); + $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); + } + + public function test_media_endpoint_request_with_insufficient_token_scopes_returns_401_response() + { + $response = $this->call( + 'POST', + '/api/media', + [], + [], + [], + ['HTTP_Authorization' => 'Bearer ' . $this->getInvalidToken()] + ); + $response->assertStatus(401); + $response->assertJsonFragment(['error_description' => 'The token’s scope does not have the necessary requirements.']); + } + + public function test_media_endpoint_upload_a_file() + { + Queue::fake(); + Storage::fake('local'); + + $response = $this->call( + 'POST', + '/api/media', + [], + [], + [ + 'file' => UploadedFile::fake()->image('scrot.png', 1920, 1080)->size(250), + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + + $path = parse_url($response->getData()->location, PHP_URL_PATH); + $filename = substr($path, 7); + Queue::assertPushed(ProcessMedia::class); + Storage::disk('local')->assertExists($filename); + } } diff --git a/tests/Feature/NoteServiceTest.php b/tests/Feature/NoteServiceTest.php deleted file mode 100644 index 2b08beb5..00000000 --- a/tests/Feature/NoteServiceTest.php +++ /dev/null @@ -1,58 +0,0 @@ -createNote([ - 'content' => 'Hello Fred', - 'in-reply-to' => 'https://fredbloggs.com/note/abc', - 'syndicate' => ['twitter'], - ]); - - Queue::assertPushed(SyndicateNoteToTwitter::class); - } - - public function test_syndicate_to_facebook_job_is_sent() - { - Queue::fake(); - - $noteService = new NoteService(); - $note = $noteService->createNote([ - 'content' => 'Hello Fred', - 'in-reply-to' => 'https://fredbloggs.com/note/abc', - 'syndicate' => ['facebook'], - ]); - - Queue::assertPushed(SyndicateNoteToFacebook::class); - } - - public function test_syndicate_to_target_jobs_are_sent() - { - Queue::fake(); - - $noteService = new NoteService(); - $note = $noteService->createNote([ - 'content' => 'Hello Fred', - 'in-reply-to' => 'https://fredbloggs.com/note/abc', - 'syndicate' => ['twitter', 'facebook'], - ]); - - Queue::assertPushed(SyndicateNoteToTwitter::class); - Queue::assertPushed(SyndicateNoteToFacebook::class); - } -} diff --git a/tests/Feature/NotesAdminTest.php b/tests/Feature/NotesAdminTest.php new file mode 100644 index 00000000..c0341263 --- /dev/null +++ b/tests/Feature/NotesAdminTest.php @@ -0,0 +1,79 @@ +withSession([ + 'loggedin' => true, + ])->get('/admin/notes'); + $response->assertViewIs('admin.notes.index'); + } + + public function test_create_page() + { + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/notes/create'); + $response->assertViewIs('admin.notes.create'); + } + + public function test_create_a_new_note() + { + $this->withSession([ + 'loggedin' => true, + ])->post('/admin/notes', [ + 'content' => 'A new test note', + ]); + $this->assertDatabaseHas('notes', [ + 'note' => 'A new test note', + ]); + } + + public function test_edit_page() + { + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/notes/1/edit'); + $response->assertViewIs('admin.notes.edit'); + } + + public function test_edit_a_note() + { + Queue::fake(); + + $this->withSession([ + 'loggedin' => true, + ])->post('/admin/notes/1', [ + '_method' => 'PUT', + 'content' => 'An edited note', + 'webmentions' => true, + ]); + + $this->assertDatabaseHas('notes', [ + 'note' => 'An edited note', + ]); + Queue::assertPushed(SendWebMentions::class); + } + + public function test_delete_note() + { + $this->withSession([ + 'loggedin' => true, + ])->post('/admin/notes/1', [ + '_method' => 'DELETE', + ]); + $this->assertSoftDeleted('notes', [ + 'id' => '1', + ]); + } +} diff --git a/tests/Feature/NotesControllerTest.php b/tests/Feature/NotesControllerTest.php index fb71c33e..7715c23a 100644 --- a/tests/Feature/NotesControllerTest.php +++ b/tests/Feature/NotesControllerTest.php @@ -30,6 +30,12 @@ class NotesControllerTest extends TestCase $response->assertViewHas('note'); } + public function test_note_replying_to_tweet() + { + $response = $this->get('/notes/B'); + $response->assertViewHas('note'); + } + /** * Test that `/note/{decID}` redirects to `/notes/{nb60id}`. * diff --git a/tests/Feature/ParseCachedWebMentionsTest.php b/tests/Feature/ParseCachedWebMentionsTest.php new file mode 100644 index 00000000..81c2d45d --- /dev/null +++ b/tests/Feature/ParseCachedWebMentionsTest.php @@ -0,0 +1,57 @@ +assertFileExists(storage_path('HTML') . '/https/aaronpk.localhost/reply/1'); + $this->assertFileExists(storage_path('HTML') . '/http/tantek.com/index.html'); + $htmlAaron = file_get_contents(storage_path('HTML') . '/https/aaronpk.localhost/reply/1'); + $htmlAaron = str_replace('href="/notes', 'href="' . config('app.url') . '/notes', $htmlAaron); + $htmlAaron = str_replace('datetime=""', 'dateime="' . carbon()->now()->toIso8601String() . '"', $htmlAaron); + file_put_contents(storage_path('HTML') . '/https/aaronpk.localhost/reply/1', $htmlAaron); + $htmlTantek = file_get_contents(storage_path('HTML') . '/http/tantek.com/index.html'); + $htmlTantek = str_replace('href="/notes', 'href="' . config('app.url') . '/notes', $htmlTantek); + $htmlTantek = str_replace('datetime=""', 'dateime="' . carbon()->now()->toIso8601String() . '"', $htmlTantek); + file_put_contents(storage_path('HTML') . '/http/tantek.com/index.html', $htmlTantek); + + Artisan::call('webmentions:parsecached'); + + $webmentionAaron = WebMention::find(1); + $webmentionTantek = WebMention::find(2); + $this->assertTrue($webmentionAaron->updated_at->gt($webmentionAaron->created_at)); + $this->assertTrue($webmentionTantek->updated_at->gt($webmentionTantek->created_at)); + } + + public function tearDown() + { + unlink(storage_path('HTML') . '/https/aaronpk.localhost/reply/1'); + rmdir(storage_path('HTML') . '/https/aaronpk.localhost/reply'); + rmdir(storage_path('HTML') . '/https/aaronpk.localhost'); + rmdir(storage_path('HTML') . '/https'); + unlink(storage_path('HTML') . '/http/tantek.com/index.html'); + rmdir(storage_path('HTML') . '/http/tantek.com'); + rmdir(storage_path('HTML') . '/http'); + + parent::tearDown(); + } +} diff --git a/tests/Feature/PlacesAdminTest.php b/tests/Feature/PlacesAdminTest.php new file mode 100644 index 00000000..b348a6be --- /dev/null +++ b/tests/Feature/PlacesAdminTest.php @@ -0,0 +1,67 @@ +withSession([ + 'loggedin' => true, + ])->get('/admin/places'); + $response->assertViewIs('admin.places.index'); + } + + public function test_create_page() + { + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/places/create'); + $response->assertViewIs('admin.places.create'); + } + + public function test_create_new_place() + { + $this->withSession([ + 'loggedin' => true, + ])->post('/admin/places', [ + 'name' => 'Test Place', + 'description' => 'A dummy place for feature tests', + 'latitude' => '1.23', + 'longitude' => '4.56', + ]); + $this->assertDatabaseHas('places', [ + 'name' => 'Test Place', + 'description' => 'A dummy place for feature tests', + ]); + } + + public function test_edit_page() + { + $response = $this->withSession([ + 'loggedin' => true, + ])->get('/admin/places/1/edit'); + $response->assertViewIs('admin.places.edit'); + } + + public function test_updating_a_place() + { + $this->withSession([ + 'loggedin' => true, + ])->post('/admin/places/1', [ + '_method' => 'PUT', + 'name' => 'The Bridgewater', + 'description' => 'Who uses “Pub” anyway', + 'latitude' => '53.4983', + 'longitude' => '-2.3805', + ]); + $this->assertDatabaseHas('places', [ + 'name' => 'The Bridgewater', + ]); + } +} diff --git a/tests/Feature/ReDownloadWebMentionsTest.php b/tests/Feature/ReDownloadWebMentionsTest.php new file mode 100644 index 00000000..60e4c2c1 --- /dev/null +++ b/tests/Feature/ReDownloadWebMentionsTest.php @@ -0,0 +1,20 @@ +get('/search?terms=wedding'); + $response->assertSee('#weddingfavour'); + } +} diff --git a/tests/Feature/SessionStoreControllerTest.php b/tests/Feature/SessionStoreControllerTest.php new file mode 100644 index 00000000..36fbaddd --- /dev/null +++ b/tests/Feature/SessionStoreControllerTest.php @@ -0,0 +1,15 @@ +post('update-colour-scheme', ['css' => 'some.css']); + $response->assertJson(['status' => 'ok']); + } +} diff --git a/tests/Feature/ShortURLsControllerTest.php b/tests/Feature/ShortURLsControllerTest.php new file mode 100644 index 00000000..e26aee76 --- /dev/null +++ b/tests/Feature/ShortURLsControllerTest.php @@ -0,0 +1,39 @@ +get('http://' . config('app.shorturl')); + $response->assertRedirect(config('app.url')); + } + + public function test_short_domain_slashat_redirects_to_twitter() + { + $response = $this->get('http://' . config('app.shorturl') . '/@'); + $response->assertRedirect('https://twitter.com/jonnybarnes'); + } + + public function test_short_domain_slashplus_redirects_to_googleplus() + { + $response = $this->get('http://' . config('app.shorturl') . '/+'); + $response->assertRedirect('https://plus.google.com/u/0/117317270900655269082/about'); + } + + public function test_short_domain_slasht_redirects_to_long_domain_slash_notes() + { + $response = $this->get('http://' . config('app.shorturl') . '/t/E'); + $response->assertRedirect(config('app.url') . '/notes/E'); + } + + public function test_short_domain_slashb_redirects_to_long_domain_slash_blog() + { + $response = $this->get('http://' . config('app.shorturl') . '/b/1'); + $response->assertRedirect(config('app.url') . '/blog/s/1'); + } +} diff --git a/tests/Feature/SwarmTest.php b/tests/Feature/SwarmTest.php index 25780dc7..1c0b0730 100644 --- a/tests/Feature/SwarmTest.php +++ b/tests/Feature/SwarmTest.php @@ -12,7 +12,7 @@ class SwarmTest extends TestCase { use DatabaseTransactions, TestToken; - public function test_faked_ownyourswarm_request() + public function test_faked_ownyourswarm_request_with_foursquare() { $response = $this->json( 'POST', @@ -42,11 +42,81 @@ class SwarmTest extends TestCase $response ->assertStatus(201) ->assertJson(['response' => 'created']); + $this->assertDatabaseHas('notes', [ + 'swarm_url' => 'https://www.swarmapp.com/checkin/abc' + ]); $this->assertDatabaseHas('places', [ 'external_urls' => '{"foursquare": "https://foursquare.com/v/123456"}' ]); - $this->assertDatabaseHas('notes', [ - 'swarm_url' => 'https://www.swarmapp.com/checkin/abc' + } + + // this request would actually come from another client than OwnYourSwarm + public function test_faked_ownyourswarm_request_with_osm() + { + $response = $this->json( + 'POST', + 'api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'published' => [\Carbon\Carbon::now()->toDateTimeString()], + 'content' => [[ + 'value' => 'My first #checkin using Example Product', + 'html' => 'My first #checkin using Example Product', + ]], + 'checkin' => [[ + 'type' => ['h-card'], + 'properties' => [ + 'name' => ['Awesome Venue'], + 'url' => ['https://www.openstreetmap.org/way/123456'], + 'latitude' => ['1.23'], + 'longitude' => ['4.56'], + ], + ]], + ], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertStatus(201) + ->assertJson(['response' => 'created']); + $this->assertDatabaseHas('places', [ + 'external_urls' => '{"osm": "https://www.openstreetmap.org/way/123456"}' + ]); + } + + // this request would actually come from another client than OwnYourSwarm + public function test_faked_ownyourswarm_request_without_known_external_url() + { + $response = $this->json( + 'POST', + 'api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'published' => [\Carbon\Carbon::now()->toDateTimeString()], + 'content' => [[ + 'value' => 'My first #checkin using Example Product', + 'html' => 'My first #checkin using Example Product', + ]], + 'checkin' => [[ + 'type' => ['h-card'], + 'properties' => [ + 'name' => ['Awesome Venue'], + 'url' => ['https://www.example.org/way/123456'], + 'latitude' => ['1.23'], + 'longitude' => ['4.56'], + ], + ]], + ], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertStatus(201) + ->assertJson(['response' => 'created']); + $this->assertDatabaseHas('places', [ + 'external_urls' => '{"default": "https://www.example.org/way/123456"}' ]); } diff --git a/tests/Feature/TokenServiceTest.php b/tests/Feature/TokenServiceTest.php index 63c8aca6..c9c33078 100644 --- a/tests/Feature/TokenServiceTest.php +++ b/tests/Feature/TokenServiceTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature; use Tests\TestCase; +use Lcobucci\JWT\Builder; use App\Services\TokenService; +use Lcobucci\JWT\Signer\Hmac\Sha256; class TokenServiceTest extends TestCase { @@ -30,4 +32,29 @@ class TokenServiceTest extends TestCase ]; $this->assertSame($data, $validData); } + + /** + * @expectedException App\Exceptions\InvalidTokenException + * @expectedExceptionMessage Token failed validation + */ + public function test_token_with_different_singing_key_throws_exception() + { + $data = [ + 'me' => 'https://example.org', + 'client_id' => 'https://quill.p3k.io', + 'scope' => 'post' + ]; + $signer = new Sha256(); + $token = (new Builder())->set('me', $data['me']) + ->set('client_id', $data['client_id']) + ->set('scope', $data['scope']) + ->set('date_issued', time()) + ->set('nonce', bin2hex(random_bytes(8))) + ->sign($signer, 'r4ndomk3y') + ->getToken(); + + $service = new TokenService(); + $token = $service->validateToken($token); + dump($token); + } } diff --git a/tests/Feature/WebMentionsControllerTest.php b/tests/Feature/WebMentionsControllerTest.php index 69eb07ce..bf7edfae 100644 --- a/tests/Feature/WebMentionsControllerTest.php +++ b/tests/Feature/WebMentionsControllerTest.php @@ -8,6 +8,12 @@ use Illuminate\Support\Facades\Queue; class WebMentionsControllerTest extends TestCase { + public function test_get_endpoint() + { + $response = $this->get('/webmention'); + $response->assertViewIs('webmention-endpoint'); + } + /** * Test webmentions without source and target are rejected. * diff --git a/tests/Unit/AddClientToDatabaseJobTest.php b/tests/Unit/AddClientToDatabaseJobTest.php new file mode 100644 index 00000000..459d0b3c --- /dev/null +++ b/tests/Unit/AddClientToDatabaseJobTest.php @@ -0,0 +1,22 @@ +handle(); + $this->assertDatabaseHas('clients', [ + 'client_url' => 'https://example.org/client', + 'client_name' => 'https://example.org/client', + ]); + } +} diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php new file mode 100644 index 00000000..c4b1f364 --- /dev/null +++ b/tests/Unit/ArticlesTest.php @@ -0,0 +1,72 @@ +title = 'My Title'; + $article->main = 'Content'; + $article->save(); + + $this->assertEquals('my-title', $article->titleurl); + } + + public function test_markdown_conversion() + { + $article = new Article(); + $article->main = 'Some *markdown*'; + + $this->assertEquals('

Some markdown

'.PHP_EOL, $article->html); + } + + public function test_time_attributes() + { + $article = Article::create([ + 'title' => 'Test', + 'main' => 'test', + ]); + + $this->assertEquals($article->w3c_time, $article->updated_at->toW3CString()); + $this->assertEquals($article->tooltip_time, $article->updated_at->toRFC850String()); + $this->assertEquals($article->human_time, $article->updated_at->diffForHumans()); + $this->assertEquals($article->pubdate, $article->updated_at->toRSSString()); + } + + public function test_link_accessor() + { + $article = Article::create([ + 'title' => 'Test', + 'main' => 'Test', + ]); + $article->title = 'Test Title'; + + $this->assertEquals( + '/blog/' . date('Y') . '/' . date('m') . '/test', + $article->link + ); + } + + public function test_date_scope() + { + $yearAndMonth = Article::date(date('Y'), date('m'))->get(); + $this->assertTrue(count($yearAndMonth) === 1); + + $monthDecember = Article::date(date('Y') - 1, 12)->get(); + $this->assertTrue(count($monthDecember) === 0); + + $monthNotDecember = Article::date(date('Y') - 1, 1)->get(); + $this->assertTrue(count($monthNotDecember) === 0); + + $emptyScope = Article::date()->get(); + $this->assertTrue(count($emptyScope) === 1); + } +} diff --git a/tests/Unit/BookmarksTest.php b/tests/Unit/BookmarksTest.php new file mode 100644 index 00000000..83638e8b --- /dev/null +++ b/tests/Unit/BookmarksTest.php @@ -0,0 +1,59 @@ +saveScreenshot('https://www.google.co.uk'); + $this->assertTrue(file_exists(public_path() . '/assets/img/bookmarks/' . $uuid . '.png')); + } + + public function test_archive_link_method() + { + $mock = new MockHandler([ + new Response(200, ['Content-Location' => '/web/1234/example.org']), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + $url = (new BookmarkService())->getArchiveLink('https://example.org'); + $this->assertEquals('/web/1234/example.org', $url); + } + + /** + * @expectedException App\Exceptions\InternetArchiveException + */ + public function test_archive_link_method_archive_site_error_exception() + { + $mock = new MockHandler([ + new Response(403), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + $url = (new BookmarkService())->getArchiveLink('https://example.org'); + } + + /** + * @expectedException App\Exceptions\InternetArchiveException + */ + public function test_archive_link_method_archive_site_no_location_exception() + { + $mock = new MockHandler([ + new Response(200), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + $url = (new BookmarkService())->getArchiveLink('https://example.org'); + } +} diff --git a/tests/Unit/DownloadWebMentionJobTest.php b/tests/Unit/DownloadWebMentionJobTest.php new file mode 100644 index 00000000..543cbaea --- /dev/null +++ b/tests/Unit/DownloadWebMentionJobTest.php @@ -0,0 +1,112 @@ +delTree(storage_path('HTML/https')); + parent::tearDown(); + } + + public function test_the_job_saves_html() + { + $this->assertFileNotExists(storage_path('HTML/https')); + $source = 'https://example.org/reply/1'; + $html = << + + +HTML; + $html = str_replace('href=""', 'href="' . config('app.url') . '/notes/A"', $html); + $mock = new MockHandler([ + new Response(200, ['X-Foo' => 'Bar'], $html), + new Response(200, ['X-Foo' => 'Bar'], $html), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $job = new DownloadWebMention($source); + $job->handle($client); + + $this->assertFileExists(storage_path('HTML/https')); + + $job->handle($client); + + $this->assertFileNotExists(storage_path('HTML/https/example.org/reply') . '/1.' . date('Y-m-d') . '.backup'); + } + + public function test_the_job_saves_html_and_backup() + { + $this->assertFileNotExists(storage_path('HTML/https')); + $source = 'https://example.org/reply/1'; + $html = << + + +HTML; + $html2 = << + + + +HTML; + $html = str_replace('href=""', 'href="' . config('app.url') . '/notes/A"', $html); + $html2 = str_replace('href=""', 'href="' . config('app.url') . '/notes/A"', $html2); + $mock = new MockHandler([ + new Response(200, ['X-Foo' => 'Bar'], $html), + new Response(200, ['X-Foo' => 'Bar'], $html2), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $job = new DownloadWebMention($source); + $job->handle($client); + + $this->assertFileExists(storage_path('HTML/https')); + + $job->handle($client); + + $this->assertFileExists(storage_path('HTML/https/example.org/reply') . '/1.' . date('Y-m-d') . '.backup'); + } + + public function test_an_index_html_file() + { + $this->assertFileNotExists(storage_path('HTML/https')); + $source = 'https://example.org/reply-one/'; + $html = << + + +HTML; + $html = str_replace('href=""', 'href="' . config('app.url') . '/notes/A"', $html); + $mock = new MockHandler([ + new Response(200, ['X-Foo' => 'Bar'], $html), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $job = new DownloadWebMention($source); + $job->handle($client); + + $this->assertFileExists(storage_path('HTML/https/example.org/reply-one/index.html')); + } + + private function delTree($dir) { + $files = array_diff(scandir($dir), array('.','..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->delTree("$dir/$file") : unlink("$dir/$file"); + } + + return rmdir($dir); + } +} diff --git a/tests/Unit/LikesTest.php b/tests/Unit/LikesTest.php new file mode 100644 index 00000000..06ed09d4 --- /dev/null +++ b/tests/Unit/LikesTest.php @@ -0,0 +1,16 @@ +author_url = 'https://joe.bloggs/'; + $this->assertEquals('https://joe.bloggs', $like->author_url); + } +} diff --git a/tests/Unit/MediaTest.php b/tests/Unit/MediaTest.php new file mode 100644 index 00000000..06cf7c72 --- /dev/null +++ b/tests/Unit/MediaTest.php @@ -0,0 +1,16 @@ +note; + $this->assertInstanceOf('App\Note', $note); + } +} diff --git a/tests/Unit/MicropbClientsTest.php b/tests/Unit/MicropbClientsTest.php new file mode 100644 index 00000000..bd851c8f --- /dev/null +++ b/tests/Unit/MicropbClientsTest.php @@ -0,0 +1,17 @@ +assertInstanceOf(Collection::class, $client->notes); + } +} diff --git a/tests/Unit/NotesTest.php b/tests/Unit/NotesTest.php index 4a651a72..5e1f2fc7 100644 --- a/tests/Unit/NotesTest.php +++ b/tests/Unit/NotesTest.php @@ -2,21 +2,23 @@ namespace Tests\Unit; +use App\Tag; use App\Note; use Tests\TestCase; +use Thujohn\Twitter\Facades\Twitter; class NotesTest extends TestCase { /** - * Test the getNoteAttribute method. This note will check the markdown, - * emoji-a11y, and hashtag sub-methods. + * Test the getNoteAttribute method. This will then also call the + * relevant sub-methods. * * @return void */ public function test_get_note_attribute_method() { $expected = '

Having a at the local. 🍺

' . PHP_EOL; - $note = Note::find(11); + $note = Note::find(12); $this->assertEquals($expected, $note->note); } @@ -28,7 +30,7 @@ class NotesTest extends TestCase public function test_default_image_used_in_makehcards_method() { $expected = '

Hi Tantek Çelik t

' . PHP_EOL; - $note = Note::find(12); + $note = Note::find(13); $this->assertEquals($expected, $note->note); } @@ -39,8 +41,8 @@ class NotesTest extends TestCase */ public function test_specific_profile_image_used_in_makehcards_method() { - $expected = '

Hi Aaron Parecki Facebook aaronpk

' . PHP_EOL; - $note = Note::find(13); + $expected = '

Hi Aaron Parecki Facebook

' . PHP_EOL; + $note = Note::find(14); $this->assertEquals($expected, $note->note); } @@ -52,7 +54,47 @@ class NotesTest extends TestCase public function test_twitter_link_created_when_no_contact_found() { $expected = '

Hi @bob

' . PHP_EOL; - $note = Note::find(14); + $note = Note::find(15); $this->assertEquals($expected, $note->note); } + + public function test_shorturl_method() + { + $note = Note::find(14); + $this->assertEquals(config('app.shorturl') . '/notes/E', $note->shorturl); + } + + public function test_latlng_of_associated_place() + { + $note = Note::find(12); // should be having beer at bridgewater note + $this->assertEquals('53.4983', $note->latitude); + $this->assertEquals('-2.3805', $note->longitude); + } + + public function test_latlng_returns_null_otherwise() + { + $note = Note::find(5); + $this->assertNull($note->latitude); + $this->assertNull($note->longitude); + } + + public function test_address_attribute_for_places() + { + $note = Note::find(12); + $this->assertEquals('The Bridgewater Pub', $note->address); + } + + public function test_deleting_event_observer() + { + // first we’ll create a temporary note to delete + $note = Note::create(['note' => 'temporary #temp']); + $this->assertDatabaseHas('tags', [ + 'tag' => 'temp', + ]); + $tag = Tag::where('tag', 'temp')->first(); + $note->forceDelete(); + $this->assertDatabaseMissing('note_tag', [ + 'tag_id' => $tag->id, + ]); + } } diff --git a/tests/Unit/PlacesTest.php b/tests/Unit/PlacesTest.php index 23303ecf..e7e3f4a3 100644 --- a/tests/Unit/PlacesTest.php +++ b/tests/Unit/PlacesTest.php @@ -4,10 +4,21 @@ namespace Tests\Unit; use App\Place; use Tests\TestCase; +use App\Services\PlaceService; use Phaza\LaravelPostgis\Geometries\Point; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Foundation\Testing\DatabaseTransactions; class PlacesTest extends TestCase { + use DatabaseTransactions; + + public function test_notes_method() + { + $place = Place::find(1); + $this->assertInstanceOf(Collection::class, $place->notes); + } + /** * Test the near method returns a collection. * @@ -18,4 +29,67 @@ class PlacesTest extends TestCase $nearby = Place::near(new Point(53.5, -2.38), 1000)->get(); $this->assertEquals('the-bridgewater-pub', $nearby[0]->slug); } + + public function test_longurl_method() + { + $place = Place::find(1); + $this->assertEquals(config('app.url') . '/places/the-bridgewater-pub', $place->longurl); + } + + public function test_uri_method() + { + $place = Place::find(1); + $this->assertEquals(config('app.url') . '/places/the-bridgewater-pub', $place->uri); + + } + + public function test_shorturl_method() + { + $place = Place::find(1); + $this->assertEquals(config('app.shorturl') . '/places/the-bridgewater-pub', $place->shorturl); + } + + public function test_service_returns_existing_place() + { + $place = new Place(); + $place->name = 'Temp Place'; + $place->location = new Point(37.422009, -122.084047); + $place->external_urls = 'https://www.openstreetmap.org/way/1234'; + $place->save(); + $service = new PlaceService(); + $ret = $service->createPlaceFromCheckin([ + 'properties' => [ + 'url' => ['https://www.openstreetmap.org/way/1234'], + ] + ]); + $this->assertInstanceOf('App\Place', $ret); // a place was returned + $this->assertEquals(2, count(Place::all())); // still 2 places + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Missing required name + */ + public function test_service_requires_name() + { + $service = new PlaceService(); + $service->createPlaceFromCheckin(['foo' => 'bar']); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Missing required longitude/latitude + */ + public function test_service_requires_latitude() + { + $service = new PlaceService(); + $service->createPlaceFromCheckin(['properties' => ['name' => 'bar']]); + } + + public function test_updating_external_urls() + { + $place = Place::find(1); + $place->external_urls = 'https://bridgewater.pub'; + $this->assertEquals('{"osm":"https:\/\/www.openstreetmap.org\/way\/987654","foursquare":"https:\/\/foursquare.com\/v\/123435\/the-bridgewater-pub","default":"https:\/\/bridgewater.pub"}', $place->external_urls); + } } diff --git a/tests/Unit/ProcessBookmarkJobTest.php b/tests/Unit/ProcessBookmarkJobTest.php new file mode 100644 index 00000000..d76bf00f --- /dev/null +++ b/tests/Unit/ProcessBookmarkJobTest.php @@ -0,0 +1,60 @@ +createMock(BookmarkService::class); + $service->method('saveScreenshot') + ->willReturn($uuid->toString()); + $service->method('getArchiveLink') + ->willReturn('https://web.archive.org/web/1234'); + $this->app->instance(BookmarkService::class, $service); + + $job = new ProcessBookmark($bookmark); + $job->handle(); + + $this->assertDatabaseHas('bookmarks', [ + 'screenshot' => $uuid->toString(), + 'archive' => 'https://web.archive.org/web/1234', + ]); + } + + public function test_exception_casesu_null_value_for_archive_link() + { + $bookmark = Bookmark::find(1); + $uuid = Uuid::uuid4(); + $service = $this->createMock(BookmarkService::class); + $service->method('saveScreenshot') + ->willReturn($uuid->toString()); + $service->method('getArchiveLink') + ->will($this->throwException(new InternetArchiveException)); + $this->app->instance(BookmarkService::class, $service); + + $job = new ProcessBookmark($bookmark); + $job->handle(); + + $this->assertDatabaseHas('bookmarks', [ + 'screenshot' => $uuid->toString(), + 'archive' => null, + ]); + } +} diff --git a/tests/Feature/ProcesImageTest.php b/tests/Unit/ProcessMediaJobTest.php similarity index 81% rename from tests/Feature/ProcesImageTest.php rename to tests/Unit/ProcessMediaJobTest.php index aa977e0e..1e43972f 100644 --- a/tests/Feature/ProcesImageTest.php +++ b/tests/Unit/ProcessMediaJobTest.php @@ -1,18 +1,20 @@ make(ImageManager::class); Storage::disk('local')->put('file.txt', 'This is not an image'); - $job = new \App\Jobs\ProcessImage('file.txt'); + $job = new ProcessMedia('file.txt'); $job->handle($manager); $this->assertFalse(file_exists(storage_path('app') . '/file.txt')); @@ -20,9 +22,10 @@ class ProcessImageTest extends TestCase public function test_job_does_nothing_to_small_images() { + Storage::fake('s3'); $manager = app()->make(ImageManager::class); Storage::disk('local')->put('aaron.png', file_get_contents(__DIR__.'/../aaron.png')); - $job = new \App\Jobs\ProcessImage('aaron.png'); + $job = new ProcessMedia('aaron.png'); $job->handle($manager); $this->assertFalse(file_exists(storage_path('app') . '/aaron.png')); @@ -33,7 +36,7 @@ class ProcessImageTest extends TestCase $manager = app()->make(ImageManager::class); Storage::disk('local')->put('test-image.jpg', file_get_contents(__DIR__.'/../test-image.jpg')); Storage::fake('s3'); - $job = new \App\Jobs\ProcessImage('test-image.jpg'); + $job = new ProcessMedia('test-image.jpg'); $job->handle($manager); Storage::disk('s3')->assertExists('media/test-image-small.jpg'); diff --git a/tests/Unit/ProcessWebMentionJobTest.php b/tests/Unit/ProcessWebMentionJobTest.php new file mode 100644 index 00000000..a631063d --- /dev/null +++ b/tests/Unit/ProcessWebMentionJobTest.php @@ -0,0 +1,121 @@ + $handler]); + + $note = Note::find(1); + $source = 'https://example.org/mention/1/'; + + $job = new ProcessWebMention($note, $source); + $job->handle($parser, $client); + } + + public function test_a_new_webmention_gets_saved() + { + Queue::fake(); + + $parser = new Parser(); + + $html = << + I liked a note. + +HTML; + $html = str_replace('href="', 'href="' . config('app.url'), $html); + $mock = new MockHandler([ + new Response(200, [], $html), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $note = Note::find(1); + $source = 'https://example.org/mention/1/'; + + $job = new ProcessWebMention($note, $source); + $job->handle($parser, $client); + + Queue::assertPushed(SaveProfileImage::class); + $this->assertDatabaseHas('webmentions', [ + 'source' => $source, + 'type' => 'like-of', + ]); + } + + public function test_existing_webmention_gets_updated() + { + Queue::fake(); + + $parser = new Parser(); + + $html = << +

In reply to a note

+
Updated reply
+ +HTML; + $html = str_replace('href="', 'href="' . config('app.url'), $html); + $mock = new MockHandler([ + new Response(200, [], $html), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $note = Note::find(14); + $source = 'https://aaronpk.localhost/reply/1'; + + $job = new ProcessWebMention($note, $source); + $job->handle($parser, $client); + + Queue::assertPushed(SaveProfileImage::class); + $this->assertDatabaseHas('webmentions', [ + 'source' => $source, + 'type' => 'in-reply-to', + 'mf2' => '{"rels": [], "items": [{"type": ["h-entry"], "properties": {"name": ["In reply to a note \\n Updated reply"], "content": [{"html": "Updated reply", "value": "Updated reply"}], "in-reply-to": ["' . config('app.url') . '/notes/E"]}}]}', + ]); + } +} diff --git a/tests/Unit/SaveProfileImageJobTest.php b/tests/Unit/SaveProfileImageJobTest.php new file mode 100644 index 00000000..991c2ef1 --- /dev/null +++ b/tests/Unit/SaveProfileImageJobTest.php @@ -0,0 +1,103 @@ + []]; + $authorship = $this->createMock(Authorship::class); + $authorship->method('findAuthor') + ->will($this->throwException(new AuthorshipParserException)); + $job = new SaveProfileImage($mf); + + $this->assertNull($job->handle($authorship)); + } + + public function test_we_dont_process_twitter_images() + { + $mf = ['items' => []]; + $author = [ + 'properties' => [ + 'photo' => ['https://pbs.twimg.com/abc.jpg'], + 'url' => ['https://twitter.com/profile'], + ], + ]; + $authorship = $this->createMock(Authorship::class); + $authorship->method('findAuthor') + ->willReturn($author); + $job = new SaveProfileImage($mf); + + $this->assertNull($job->handle($authorship)); + } + + public function test_saving_of_remote_image() + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + $mf = ['items' => []]; + $author = [ + 'properties' => [ + 'photo' => ['https://example.org/profile.jpg'], + 'url' => ['https://example.org'], + ], + ]; + $authorship = $this->createMock(Authorship::class); + $authorship->method('findAuthor') + ->willReturn($author); + + $job = new SaveProfileImage($mf); + $job->handle($authorship); + $this->assertFileExists(public_path() . '/assets/profile-images/example.org/image'); + } + + public function test_copying_of_local_image() + { + $mock = new MockHandler([ + new Response(404), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + $mf = ['items' => []]; + $author = [ + 'properties' => [ + 'photo' => ['https://example.org/profile.jpg'], + 'url' => ['https://example.org'], + ], + ]; + $authorship = $this->createMock(Authorship::class); + $authorship->method('findAuthor') + ->willReturn($author); + + $job = new SaveProfileImage($mf); + $job->handle($authorship); + $this->assertFileEquals( + public_path() . '/assets/profile-images/default-image', + public_path() . '/assets/profile-images/example.org/image' + ); + } +} diff --git a/tests/Unit/SendWebMentionJobTest.php b/tests/Unit/SendWebMentionJobTest.php new file mode 100644 index 00000000..4850a750 --- /dev/null +++ b/tests/Unit/SendWebMentionJobTest.php @@ -0,0 +1,102 @@ +assertNull($job->discoverWebmentionEndpoint(config('app.url'))); + $this->assertNull($job->discoverWebmentionEndpoint('/notes/tagged/test')); + } + + public function test_discover_endpoint_gets_link_from_headers() + { + $url = 'https://example.org/webmention'; + $mock = new MockHandler([ + new Response(200, ['Link' => '<' . $url . '>; rel="webmention"']), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + + $job = new SendWebMentions(new Note()); + $this->assertEquals($url, $job->discoverWebmentionEndpoint('https://example.org')); + } + + public function test_discover_endpoint_correctly_parses_html() + { + $html = ''; + $mock = new MockHandler([ + new Response(200, [], $html), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + + $job = new SendWebMentions(new Note()); + $this->assertEquals( + 'https://example.org/webmention', + $job->discoverWebmentionEndpoint('https://example.org') + ); + } + + public function test_discover_endpoint_correctly_parses_html_legacy() + { + $html = ''; + $mock = new MockHandler([ + new Response(200, [], $html), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + + $job = new SendWebMentions(new Note()); + $this->assertEquals( + 'https://example.org/webmention', + $job->discoverWebmentionEndpoint('https://example.org') + ); + } + + public function test_empty_note_does_nothing() + { + $job = new SendWebMentions(new Note()); + $this->assertNull($job->handle()); + } + + public function test_resolve_uri() + { + $uri = '/blog/post'; + $base = 'https://example.org/'; + $job = new SendWebMentions(new Note()); + $this->assertEquals('https://example.org/blog/post', $job->resolveUri($uri, $base)); + } + + public function test_the_job() + { + $html = ''; + $mock = new MockHandler([ + new Response(200, [], $html), + new Response(202), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->instance(Client::class, $client); + + $note = new Note(); + $note->note = 'Hi [Aaron](https://aaronparecki.com)'; + $job = new SendWebMentions($note); + $this->assertNull($job->handle()); + } +} diff --git a/tests/Unit/SyndicateBookmarkToFacebookJobTest.php b/tests/Unit/SyndicateBookmarkToFacebookJobTest.php new file mode 100644 index 00000000..03877722 --- /dev/null +++ b/tests/Unit/SyndicateBookmarkToFacebookJobTest.php @@ -0,0 +1,38 @@ + 'https://facebook.com/123' + ]); + $mock = new MockHandler([ + new Response(201, ['Content-Type' => 'application/json'], $json), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $bookmark = Bookmark::find(1); + $job = new SyndicateBookmarkToFacebook($bookmark); + $job->handle($client); + + $this->assertDatabaseHas('bookmarks', [ + 'id' => 1, + 'syndicates' => '{"facebook": "https://facebook.com/123"}', + ]); + } +} diff --git a/tests/Unit/SyndicateBookmarkToTwitterJobTest.php b/tests/Unit/SyndicateBookmarkToTwitterJobTest.php new file mode 100644 index 00000000..c92efa62 --- /dev/null +++ b/tests/Unit/SyndicateBookmarkToTwitterJobTest.php @@ -0,0 +1,38 @@ + 'https://twitter.com/123' + ]); + $mock = new MockHandler([ + new Response(201, ['Content-Type' => 'application/json'], $json), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $bookmark = Bookmark::find(1); + $job = new SyndicateBookmarkToTwitter($bookmark); + $job->handle($client); + + $this->assertDatabaseHas('bookmarks', [ + 'id' => 1, + 'syndicates' => '{"twitter": "https://twitter.com/123"}', + ]); + } +} diff --git a/tests/Unit/SyndicateNoteToFacebookJobTest.php b/tests/Unit/SyndicateNoteToFacebookJobTest.php new file mode 100644 index 00000000..57b09315 --- /dev/null +++ b/tests/Unit/SyndicateNoteToFacebookJobTest.php @@ -0,0 +1,38 @@ + 'https://facebook.com/123' + ]); + $mock = new MockHandler([ + new Response(201, ['Content-Type' => 'application/json'], $json), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $note = Note::find(1); + $job = new SyndicateNoteToFacebook($note); + $job->handle($client); + + $this->assertDatabaseHas('notes', [ + 'id' => 1, + 'facebook_url' => 'https://facebook.com/123', + ]); + } +} diff --git a/tests/Unit/SyndicateNoteToTwitterJobTest.php b/tests/Unit/SyndicateNoteToTwitterJobTest.php new file mode 100644 index 00000000..1621b2b9 --- /dev/null +++ b/tests/Unit/SyndicateNoteToTwitterJobTest.php @@ -0,0 +1,38 @@ + 'https://twitter.com/i/web/status/123' + ]); + $mock = new MockHandler([ + new Response(201, ['Content-Type' => 'application/json'], $json), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $note = Note::find(1); + $job = new SyndicateNoteToTwitter($note); + $job->handle($client); + + $this->assertDatabaseHas('notes', [ + 'id' => 1, + 'tweet_id' => '123', + ]); + } +} diff --git a/tests/Unit/TagsTest.php b/tests/Unit/TagsTest.php new file mode 100644 index 00000000..3c8b77c0 --- /dev/null +++ b/tests/Unit/TagsTest.php @@ -0,0 +1,21 @@ +assertEquals(1, count($tag->notes)); + } + + public function test_bookmarks_method() + { + $tag = Tag::find(4); //should be first random tag for bookmarks + $this->assertEquals(1, count($tag->bookmarks)); + } +} diff --git a/tests/Unit/WebMentionTest.php b/tests/Unit/WebMentionTest.php index bbd7331b..86d5f384 100644 --- a/tests/Unit/WebMentionTest.php +++ b/tests/Unit/WebMentionTest.php @@ -2,12 +2,43 @@ namespace Tests\Unit; -use Cache; use App\WebMention; use Tests\TestCase; +use Thujohn\Twitter\Facades\Twitter; +use Illuminate\Support\Facades\Cache; class WebMentionTest extends TestCase { + public function test_commentable_method() + { + $webmention = WebMention::find(1); + $this->assertInstanceOf('App\Note', $webmention->commentable); + } + public function test_published_attribute_when_no_relavent_mf2() + { + $webmention = new WebMention(); + $updated_at = carbon()->now(); + $webmention->updated_at = $updated_at; + $this->assertEquals($updated_at->toDayDateTimeString(), $webmention->published); + } + + public function test_published_attribute_when_error_parsing_mf2() + { + $webmention = new WebMention(); + $updated_at = carbon()->now(); + $webmention->updated_at = $updated_at; + $webmention->mf2 = json_encode([ + 'items' => [[ + 'properties' => [ + 'published' => [ + 'error', + ], + ], + ]], + ]); + $this->assertEquals($updated_at->toDayDateTimeString(), $webmention->published); + } + /** * Test a correct profile link is formed from a generic URL. * @@ -60,4 +91,31 @@ class WebMentionTest extends TestCase Cache::put($twitterURL, $expected, 1); $this->assertEquals($expected, $webmention->createPhotoLink($twitterURL)); } + + public function test_create_photo_link_with_noncached_twitter_url() + { + Cache::shouldReceive('has') + ->once() + ->andReturn(false); + Cache::shouldReceive('put') + ->once() + ->andReturn(true); + $info = new \stdClass(); + $info->profile_image_url_https = 'https://pbs.twimg.com/static_profile_link.jpg'; + Twitter::shouldReceive('getUsers') + ->once() + ->with(['screen_name' => 'example']) + ->andReturn($info); + + $webmention = new WebMention(); + $twitterURL = 'https://twitter.com/example'; + $expected = 'https://pbs.twimg.com/static_profile_link.jpg'; + $this->assertEquals($expected, $webmention->createPhotoLink($twitterURL)); + } + + public function test_get_reply_attribute_returns_null() + { + $webmention = new WebMention(); + $this->assertNull($webmention->reply); + } } diff --git a/tests/aaron.html b/tests/aaron.html new file mode 100644 index 00000000..a1d236fa --- /dev/null +++ b/tests/aaron.html @@ -0,0 +1,11 @@ +
+ + +
Hi too
+ + +
+

Posted by:

+ diff --git a/tests/f1bc8faa-1a8f-45b8-a9b1-57282fa73f87.jpg b/tests/f1bc8faa-1a8f-45b8-a9b1-57282fa73f87.jpg new file mode 100644 index 00000000..3b52bdf3 Binary files /dev/null and b/tests/f1bc8faa-1a8f-45b8-a9b1-57282fa73f87.jpg differ diff --git a/tests/tantek.html b/tests/tantek.html new file mode 100644 index 00000000..352215db --- /dev/null +++ b/tests/tantek.html @@ -0,0 +1,10 @@ +
+ +
Hi too
+ + +
+

Posted by:

+ diff --git a/travis/install-nginx.sh b/travis/install-nginx.sh index d74b44a8..ddf32102 100755 --- a/travis/install-nginx.sh +++ b/travis/install-nginx.sh @@ -7,7 +7,8 @@ DIR=$(realpath $(dirname "$0")) USER=$(whoami) PHP_VERSION=$(phpenv version-name) ROOT=$(realpath "$DIR/..") -PORT=9000 +HTTP_PORT=8000 +PHP_PORT=9000 SERVER="/tmp/php.sock" function tpl { @@ -16,7 +17,8 @@ function tpl { -e "s|{USER}|$USER|g" \ -e "s|{PHP_VERSION}|$PHP_VERSION|g" \ -e "s|{ROOT}|$ROOT|g" \ - -e "s|{PORT}|$PORT|g" \ + -e "s|{HTTP_PORT}|$HTTP_PORT|g" \ + -e "s|{PHP_PORT}|$PHP_PORT|g" \ -e "s|{SERVER}|$SERVER|g" \ < $1 > $2 } @@ -38,7 +40,8 @@ tpl "$DIR/php-fpm.tpl.conf" "$PHP_FPM_CONF" # Build the default nginx config files. tpl "$DIR/nginx.tpl.conf" "$DIR/nginx/nginx.conf" tpl "$DIR/fastcgi.tpl.conf" "$DIR/nginx/fastcgi.conf" -tpl "$DIR/default-site.tpl.conf" "$DIR/nginx/sites-enabled/default-site.conf" +tpl "$DIR/longurl.tpl.conf" "$DIR/nginx/sites-enabled/longurl.conf" +tpl "$DIR/shorturl.tpl.conf" "$DIR/nginx/sites-enabled/shorturl.conf" # Start nginx. nginx -c "$DIR/nginx/nginx.conf" diff --git a/travis/longurl.tpl.conf b/travis/longurl.tpl.conf new file mode 100644 index 00000000..42a9239c --- /dev/null +++ b/travis/longurl.tpl.conf @@ -0,0 +1,21 @@ +server { + listen {HTTP_PORT} default_server; + listen [::]:{HTTP_PORT} default_server ipv6only=on; + server_name jonnybarnes.localhost; + + root {ROOT}/public; + index index.php; + + access_log /tmp/access.log; + error_log /tmp/error.log; + + location / { + # First attempt to serve request as file, then as directory, then fall back to index.php. + try_files $uri $uri/ /index.php$is_args$args; + } + + location ~* "\.php(/|$)" { + include fastcgi.conf; + fastcgi_pass php; + } +} diff --git a/travis/nginx.tpl.conf b/travis/nginx.tpl.conf index 13a26ad4..dca77271 100644 --- a/travis/nginx.tpl.conf +++ b/travis/nginx.tpl.conf @@ -1,9 +1,10 @@ +worker_processes 1; error_log /tmp/error.log; pid /tmp/nginx.pid; -worker_processes 1; + events { - worker_connections 1024; + worker_connections 1024; } http { @@ -47,6 +48,6 @@ http { include {DIR}/nginx/sites-enabled/*; upstream php { - server 127.0.0.1:{PORT}; + server 127.0.0.1:{PHP_PORT}; } } diff --git a/travis/php-fpm.tpl.conf b/travis/php-fpm.tpl.conf index c1b75401..def755d1 100644 --- a/travis/php-fpm.tpl.conf +++ b/travis/php-fpm.tpl.conf @@ -3,7 +3,7 @@ error_log = /tmp/php-fpm.error.log [travis] user = {USER} -listen = {PORT} +listen = {PHP_PORT} listen.mode = 0666 pm = static pm.max_children = 5 diff --git a/travis/default-site.tpl.conf b/travis/shorturl.tpl.conf similarity index 82% rename from travis/default-site.tpl.conf rename to travis/shorturl.tpl.conf index 3e41e4e8..926596d3 100644 --- a/travis/default-site.tpl.conf +++ b/travis/shorturl.tpl.conf @@ -1,6 +1,7 @@ server { - listen 8000 default_server; - listen [::]:8000 default_server ipv6only=on; + listen {HTTP_PORT}; + listen [::]:{HTTP_PORT}; + server_name jmb.localhost; root {ROOT}/public; index index.php;