const assert = require('node:assert/strict');
const {assertExists, assertObjectMatches} = require('../../utils/assertions');
const errors = require('@tryghost/errors');
const sinon = require('sinon');
const testUtils = require('../../utils');
const moment = require('moment');
const _ = require('lodash');
const urlService = require('../../../core/server/services/url');
const ghostBookshelf = require('../../../core/server/models/base');
const models = require('../../../core/server/models');
const db = require('../../../core/server/data/db');
const settingsCache = require('../../../core/shared/settings-cache');
const events = require('../../../core/server/lib/common/events');
const configUtils = require('../../utils/config-utils');
const urlUtilsHelper = require('../../utils/url-utils');
const context = testUtils.context.owner;
const markdownToLexical = testUtils.DataGenerator.markdownToLexical;

/**
 * IMPORTANT:
 * - do not spy the events unit, because when we only spy, all listeners get the event
 * - this can cause unexpected behavior as the listeners execute code
 */
describe('Post Model', function () {
    let eventsTriggered = {};

    beforeAll(testUtils.teardownDb);
    beforeAll(testUtils.stopGhost);
    afterAll(testUtils.teardownDb);

    beforeAll(testUtils.setup('users:roles'));

    afterEach(function () {
        sinon.restore();
    });

    beforeEach(function () {
        sinon.stub(urlService, 'getUrlByResourceId').withArgs(testUtils.DataGenerator.Content.posts[0].id).returns('/html-ipsum/');
    });

    describe('Single author posts', function () {
        afterEach(async function () {
            await configUtils.restore();
        });

        describe('fetchOne/fetchAll/fetchPage', function () {
            beforeAll(testUtils.fixtures.insertPostsAndTags);
            afterAll(function () {
                return testUtils.truncate('posts_tags')
                    .then(function () {
                        return testUtils.truncate('tags');
                    })
                    .then(function () {
                        return testUtils.truncate('posts');
                    })
                    .then(function () {
                        return testUtils.truncate('posts_meta');
                    });
            });

            describe('findPage', function () {
                describe('with more posts/tags', function () {
                    beforeEach(function () {
                        return testUtils.truncate('posts_tags')
                            .then(function () {
                                return testUtils.truncate('tags');
                            })
                            .then(function () {
                                return testUtils.truncate('posts_meta');
                            })
                            .then(function () {
                                return testUtils.truncate('posts');
                            });
                    });

                    beforeEach(function () {
                        return testUtils.fixtures.insertPostsAndTags()
                            .then(function () {
                                return testUtils.fixtures.insertExtraPosts();
                            })
                            .then(function () {
                                return testUtils.fixtures.insertExtraPostsTags();
                            });
                    });

                    it('can findPage, with various options', async function () {
                        let paginationResult = await models.Post.findPage({page: 2});
                        assert.equal(paginationResult.meta.pagination.page, 2);
                        assert.equal(paginationResult.meta.pagination.limit, 15);
                        assert.equal(paginationResult.meta.pagination.pages, 4);
                        assert.equal(paginationResult.data.length, 15);

                        paginationResult = await models.Post.findPage({page: 5});
                        assert.equal(paginationResult.meta.pagination.page, 5);
                        assert.equal(paginationResult.meta.pagination.limit, 15);
                        assert.equal(paginationResult.meta.pagination.pages, 4);
                        assert.equal(paginationResult.data.length, 0);

                        paginationResult = await models.Post.findPage({limit: 30});
                        assert.equal(paginationResult.meta.pagination.page, 1);
                        assert.equal(paginationResult.meta.pagination.limit, 30);
                        assert.equal(paginationResult.meta.pagination.pages, 2);
                        assert.equal(paginationResult.data.length, 30);

                        // Test featured pages
                        paginationResult = await models.Post.findPage({limit: 10, filter: 'featured:true'});
                        assert.equal(paginationResult.meta.pagination.page, 1);
                        assert.equal(paginationResult.meta.pagination.limit, 10);
                        assert.equal(paginationResult.meta.pagination.pages, 1);
                        assert.equal(paginationResult.data.length, 2);

                        // Test both boolean formats for featured pages
                        paginationResult = await models.Post.findPage({limit: 10, filter: 'featured:1'});
                        assert.equal(paginationResult.meta.pagination.page, 1);
                        assert.equal(paginationResult.meta.pagination.limit, 10);
                        assert.equal(paginationResult.meta.pagination.pages, 1);
                        assert.equal(paginationResult.data.length, 2);

                        paginationResult = await models.Post.findPage({limit: 10, page: 2, status: 'all'});
                        assert.equal(paginationResult.meta.pagination.pages, 11);

                        paginationResult = await models.Post.findPage({limit: 'all', status: 'all'});
                        assert.equal(paginationResult.meta.pagination.page, 1);
                        assert.equal(paginationResult.meta.pagination.limit, 'all');
                        assert.equal(paginationResult.meta.pagination.pages, 1);
                        assert.equal(paginationResult.data.length, 110);
                    });

                    it('can findPage for tag, with various options', async function () {
                        // Test tag filter
                        let paginationResult = await models.Post.findPage({page: 1, filter: 'tags:bacon'});
                        assert.equal(paginationResult.meta.pagination.page, 1);
                        assert.equal(paginationResult.meta.pagination.limit, 15);
                        assert.equal(paginationResult.meta.pagination.pages, 1);
                        assert.equal(paginationResult.data.length, 2);

                        paginationResult = await models.Post.findPage({page: 1, filter: 'tags:kitchen-sink'});
                        assert.equal(paginationResult.meta.pagination.page, 1);
                        assert.equal(paginationResult.meta.pagination.limit, 15);
                        assert.equal(paginationResult.meta.pagination.pages, 1);
                        assert.equal(paginationResult.data.length, 2);

                        paginationResult = await models.Post.findPage({page: 1, filter: 'tags:injection'});
                        assert.equal(paginationResult.meta.pagination.page, 1);
                        assert.equal(paginationResult.meta.pagination.limit, 15);
                        assert.equal(paginationResult.meta.pagination.pages, 2);
                        assert.equal(paginationResult.data.length, 15);

                        paginationResult = await models.Post.findPage({page: 2, filter: 'tags:injection'});
                        assert.equal(paginationResult.meta.pagination.page, 2);
                        assert.equal(paginationResult.meta.pagination.limit, 15);
                        assert.equal(paginationResult.meta.pagination.pages, 2);
                        assert.equal(paginationResult.data.length, 11);
                    });
                });
            });
        });

        describe('edit', function () {
            beforeEach(testUtils.fixtures.insertPostsAndTags);

            afterEach(function () {
                return testUtils.truncate('posts_tags')
                    .then(function () {
                        return testUtils.truncate('tags');
                    })
                    .then(function () {
                        return testUtils.truncate('posts');
                    })
                    .then(function () {
                        return testUtils.truncate('posts_meta');
                    });
            });

            beforeEach(function () {
                eventsTriggered = {};

                sinon.stub(events, 'emit').callsFake(function (eventName, eventObj) {
                    if (!eventsTriggered[eventName]) {
                        eventsTriggered[eventName] = [];
                    }

                    eventsTriggered[eventName].push(eventObj);
                });
            });

            it('[failure] multiple edits in one transaction', function () {
                const options = _.cloneDeep(context);

                const data = {
                    status: 'published'
                };

                return models.Base.transaction(function (txn) {
                    options.transacting = txn;

                    return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[3].id}, options))
                        .then(function () {
                            return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[5].id}, options));
                        })
                        .then(function () {
                            // force rollback
                            throw new Error();
                        });
                }).catch(function () {
                    // txn was rolled back
                    assert.equal(Object.keys(eventsTriggered).length, 0);
                });
            });

            it('multiple edits in one transaction', function () {
                const options = _.cloneDeep(context);

                const data = {
                    status: 'published'
                };

                return models.Base.transaction(function (txn) {
                    options.transacting = txn;

                    return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[3].id}, options))
                        .then(function () {
                            return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[5].id}, options));
                        });
                }).then(function () {
                    // txn was successful
                    assert.equal(Object.keys(eventsTriggered).length, 4);
                });
            });

            it('can change title', async function () {
                const postId = testUtils.DataGenerator.Content.posts[0].id;
                const results = await models.Post.findOne({id: postId});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.notEqual(post.title, 'new title');

                const edited = await models.Post.edit({title: 'new title'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.title, 'new title');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.published.edited']);
                assertExists(eventsTriggered['post.edited']);
            });

            it('[failure] custom excerpt soft limit reached', async function () {
                const postId = testUtils.DataGenerator.Content.posts[0].id;
                const results = await models.Post.findOne({id: postId});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);

                await assert.rejects(models.Post.edit({
                    custom_excerpt: new Array(302).join('a')
                }, _.extend({}, context, {id: postId})), (err) => {
                    assert.equal(err[0].name, 'ValidationError');
                    return true;
                });
            });

            it('can publish draft post', async function () {
                const postId = testUtils.DataGenerator.Content.posts[3].id;
                const results = await models.Post.findOne({id: postId, status: 'draft'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.equal(post.status, 'draft');

                const edited = await models.Post.edit({status: 'published'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'published');

                assert.equal(Object.keys(eventsTriggered).length, 4);
                assertExists(eventsTriggered['post.published']);
                assertExists(eventsTriggered['post.edited']);
                assertExists(eventsTriggered['tag.attached']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('can unpublish published post', async function () {
                const postId = testUtils.DataGenerator.Content.posts[0].id;
                const results = await models.Post.findOne({id: postId});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.equal(post.status, 'published');

                const edited = await models.Post.edit({status: 'draft'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'draft');

                assert.equal(Object.keys(eventsTriggered).length, 4);
                assertExists(eventsTriggered['post.unpublished']);
                assertExists(eventsTriggered['post.edited']);
            });

            it('draft -> scheduled without published_at update', async function () {
                const results = await models.Post.findOne({status: 'draft'});
                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.status, 'draft');

                results.set('published_at', null);
                await results.save();

                await assert.rejects(models.Post.edit({
                    status: 'scheduled'
                }, _.extend({}, context, {id: post.id})), errors.ValidationError);
            });

            it('draft -> scheduled: expect update of published_at', async function () {
                const newPublishedAt = moment().add(1, 'day').toDate();
                const results = await models.Post.findOne({status: 'draft'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.status, 'draft');

                const edited = await models.Post.edit({
                    status: 'scheduled',
                    published_at: newPublishedAt
                }, _.extend({}, context, {id: post.id}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'scheduled');

                // mysql does not store ms
                assert.equal(moment(edited.attributes.published_at).startOf('seconds').diff(moment(newPublishedAt).startOf('seconds')), 0);

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.scheduled']);
                assertExists(eventsTriggered['post.edited']);
            });

            it('scheduled -> draft: expect unschedule', async function () {
                const results = await models.Post.findOne({status: 'scheduled'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.status, 'scheduled');

                const edited = await models.Post.edit({
                    status: 'draft'
                }, _.extend({}, context, {id: post.id}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'draft');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.unscheduled']);
                assertExists(eventsTriggered['post.edited']);
            });

            it('scheduled -> scheduled with updated published_at', async function () {
                const results = await models.Post.findOne({status: 'scheduled'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.status, 'scheduled');

                const edited = await models.Post.edit({
                    status: 'scheduled',
                    published_at: moment().add(20, 'days').toDate()
                }, _.extend({}, context, {id: post.id}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'scheduled');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.rescheduled']);
                assertExists(eventsTriggered['post.edited']);
            });

            it('scheduled -> scheduled with unchanged published_at', async function () {
                const results = await models.Post.findOne({status: 'scheduled'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.status, 'scheduled');

                const edited = await models.Post.edit({
                    status: 'scheduled'
                }, _.extend({}, context, {id: post.id}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'scheduled');

                // nothing has changed
                assert.equal(Object.keys(eventsTriggered).length, 0);
            });

            it('scheduled -> scheduled with unchanged published_at (within the 2 minutes window)', async function () {
                const results = await models.Post.findOne({status: 'scheduled'});
                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.status, 'scheduled');

                results.set('published_at', moment().add(2, 'minutes').add(2, 'seconds').toDate());
                await results.save();
                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.edited']);
                assertExists(eventsTriggered['post.rescheduled']);
                eventsTriggered = {};

                await new Promise((resolve) => {
                    setTimeout(resolve, 3000);
                });

                const edited = await models.Post.edit({
                    status: 'scheduled'
                }, _.extend({}, context, {id: post.id}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'scheduled');

                assert.equal(Object.keys(eventsTriggered).length, 1);
                assertExists(eventsTriggered['post.edited']);
            });

            it('published -> scheduled and expect update of published_at', async function () {
                const postId = testUtils.DataGenerator.Content.posts[0].id;
                const results = await models.Post.findOne({id: postId});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.equal(post.status, 'published');

                await assert.rejects(models.Post.edit({
                    status: 'scheduled',
                    published_at: moment().add(1, 'day').toDate()
                }, _.extend({}, context, {id: postId})), errors.ValidationError);
            });

            it('can convert draft post to page and back', async function () {
                const postId = testUtils.DataGenerator.Content.posts[3].id;
                const results = await models.Post.findOne({id: postId, status: 'draft'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.equal(post.status, 'draft');

                let edited = await models.Post.edit({type: 'page'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'draft');
                assert.equal(edited.attributes.type, 'page');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['page.added']);

                edited = await models.Post.edit({type: 'post'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'draft');
                assert.equal(edited.attributes.type, 'post');

                assert.equal(Object.keys(eventsTriggered).length, 4);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['page.added']);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['post.added']);
            });

            it('can convert draft to schedule AND post to page and back', async function () {
                const results = await models.Post.findOne({status: 'draft'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.status, 'draft');

                let edited = await models.Post.edit({
                    type: 'page',
                    status: 'scheduled',
                    published_at: moment().add(10, 'days')
                }, _.extend({}, context, {id: post.id}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'scheduled');
                assert.equal(edited.attributes.type, 'page');

                assert.equal(Object.keys(eventsTriggered).length, 3);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['page.added']);
                assertExists(eventsTriggered['page.scheduled']);

                edited = await models.Post.edit({type: 'post'}, _.extend({}, context, {id: edited.id}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'scheduled');
                assert.equal(edited.attributes.type, 'post');

                assert.equal(Object.keys(eventsTriggered).length, 7);
                assertExists(eventsTriggered['page.unscheduled']);
                assertExists(eventsTriggered['page.deleted']);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['post.scheduled']);
            });

            it('can convert published post to page and back', async function () {
                const postId = testUtils.DataGenerator.Content.posts[0].id;
                const results = await models.Post.findOne({id: postId});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.equal(post.status, 'published');

                let edited = await models.Post.edit({type: 'page'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'published');
                assert.equal(edited.attributes.type, 'page');

                assert.equal(Object.keys(eventsTriggered).length, 4);
                assertExists(eventsTriggered['post.unpublished']);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['page.added']);
                assertExists(eventsTriggered['page.published']);

                edited = await models.Post.edit({type: 'post'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'published');
                assert.equal(edited.attributes.type, 'post');

                assert.equal(Object.keys(eventsTriggered).length, 8);
                assertExists(eventsTriggered['page.unpublished']);
                assertExists(eventsTriggered['page.deleted']);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['post.published']);
            });

            it('can change type and status at the same time', async function () {
                const postId = testUtils.DataGenerator.Content.posts[3].id;
                const results = await models.Post.findOne({id: postId, status: 'draft'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.equal(post.status, 'draft');

                let edited = await models.Post.edit({type: 'page', status: 'published'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'published');
                assert.equal(edited.attributes.type, 'page');

                assert.equal(Object.keys(eventsTriggered).length, 5);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['page.added']);
                assertExists(eventsTriggered['page.published']);
                assertExists(eventsTriggered['tag.attached']);
                assertExists(eventsTriggered['user.attached']);

                edited = await models.Post.edit({type: 'post', status: 'draft'}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'draft');
                assert.equal(edited.attributes.type, 'post');

                assert.equal(Object.keys(eventsTriggered).length, 8);
                assertExists(eventsTriggered['page.unpublished']);
                assertExists(eventsTriggered['page.deleted']);
                assertExists(eventsTriggered['post.added']);
            });

            it('cannot override the published_by setting', async function () {
                const postId = testUtils.DataGenerator.Content.posts[3].id;
                const results = await models.Post.findOne({id: postId, status: 'draft'});

                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, postId);
                assert.equal(post.status, 'draft');

                // Test changing status and published_by at the same time
                let edited = await models.Post.edit({
                    status: 'published',
                    published_by: 4
                }, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'published');
                assert.equal(edited.attributes.published_by, context.context.user);

                // Test changing status and published_by on its own
                edited = await models.Post.edit({published_by: 4}, _.extend({}, context, {id: postId}));
                assertExists(edited);
                assert.equal(edited.attributes.status, 'published');
                assert.equal(edited.attributes.published_by, context.context.user);
            });
        });

        describe('add', function () {
            beforeAll(testUtils.fixtures.insertPostsAndTags);

            afterAll(function () {
                return testUtils.truncate('posts_tags')
                    .then(function () {
                        return testUtils.truncate('tags');
                    })
                    .then(function () {
                        return testUtils.truncate('posts');
                    })
                    .then(function () {
                        return testUtils.truncate('posts_meta');
                    });
            });

            beforeEach(function () {
                eventsTriggered = {};

                sinon.stub(events, 'emit').callsFake(function (eventName, eventObj) {
                    if (!eventsTriggered[eventName]) {
                        eventsTriggered[eventName] = [];
                    }

                    eventsTriggered[eventName].push(eventObj);
                });
            });

            it('can add, defaults are all correct', async function () {
                const newPost = testUtils.DataGenerator.forModel.posts[2];
                const newPostDB = testUtils.DataGenerator.Content.posts[2];
                const addedPost = await models.Post.add(newPost, _.merge({withRelated: ['authors']}, context));
                const createdPost = await models.Post.findOne({id: addedPost.id, status: 'all'}, {withRelated: ['authors']});

                assertExists(createdPost);
                assert.equal(createdPost.has('uuid'), true);
                assert.equal(createdPost.get('status'), 'draft');
                assert.equal(createdPost.get('title'), newPost.title, 'title is correct');
                assert.equal(createdPost.get('mobiledoc'), null, 'mobiledoc is converted to lexical');
                assert.ok(createdPost.get('lexical'), 'lexical is set');
                assert.equal(createdPost.has('html'), true);
                assert.ok(createdPost.get('html'));
                assert.equal(createdPost.has('plaintext'), true);
                assert.match(createdPost.get('plaintext'), /^testing/);
                assert.equal(createdPost.get('slug'), newPostDB.slug + '-2');
                assert.equal((!!createdPost.get('featured')), false);
                assert.equal((!!createdPost.get('page')), false);

                assert.equal(createdPost.get('locale'), null);
                assert.equal(createdPost.get('visibility'), 'public');

                // testing for nulls
                assert.equal((createdPost.get('feature_image') === null), true);

                assert(createdPost.get('created_at') > new Date(0).getTime());
                assert.equal(createdPost.relations.authors.models[0].id, testUtils.DataGenerator.Content.users[0].id);
                assert(createdPost.get('updated_at') > new Date(0).getTime());
                assert.equal(createdPost.get('published_at'), null);
                assert.equal(createdPost.get('published_by'), null);

                const createdPostUpdatedDate = createdPost.get('updated_at');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);

                // Set the status to published to check that `published_at` is set.
                const publishedPost = await createdPost.save({status: 'published'}, context);
                assert(publishedPost.get('published_at') instanceof Date);
                assert.equal(publishedPost.get('published_by'), testUtils.DataGenerator.Content.users[0].id);
                assert(publishedPost.get('updated_at') instanceof Date);
                assert.notEqual(publishedPost.get('updated_at'), createdPostUpdatedDate);

                assert.equal(Object.keys(eventsTriggered).length, 4);
                assertExists(eventsTriggered['post.published']);
                assertExists(eventsTriggered['post.edited']);
            });

            it('can add, default visibility is taken from settings cache', async function () {
                const originalSettingsCacheGetFn = settingsCache.get;
                sinon.stub(settingsCache, 'get')
                    .callsFake(function (key, options) {
                        if (key === 'labs') {
                            return {
                                members: true
                            };
                        } else if (key === 'default_content_visibility') {
                            return 'paid';
                        }

                        return originalSettingsCacheGetFn(key, options);
                    });

                const newPost = testUtils.DataGenerator.forModel.posts[2];
                const addedPost = await models.Post.add(newPost, _.merge({withRelated: ['authors']}, context));
                const createdPost = await models.Post.findOne({id: addedPost.id, status: 'all'}, {withRelated: ['authors']});

                assertExists(createdPost);
                assert.equal(createdPost.has('uuid'), true);
                assert.equal(createdPost.get('status'), 'draft');
                assert.equal(createdPost.get('title'), newPost.title, 'title is correct');
                assert.equal(createdPost.get('mobiledoc'), null, 'mobiledoc is converted to lexical');
                assert.ok(createdPost.get('lexical'), 'lexical is set');
                assert.equal(createdPost.has('html'), true);
                assert.ok(createdPost.get('html'));
                assert.equal(createdPost.has('plaintext'), true);
                assert.match(createdPost.get('plaintext'), /^testing/);
                // assert.equal(createdPost.get('slug'), newPostDB.slug + '-3');
                assert.equal((!!createdPost.get('featured')), false);
                assert.equal((!!createdPost.get('page')), false);

                assert.equal(createdPost.get('locale'), null);
                assert.equal(createdPost.get('visibility'), 'paid');

                // testing for nulls
                assert.equal((createdPost.get('feature_image') === null), true);

                assert(createdPost.get('created_at') > new Date(0).getTime());
                assert.equal(createdPost.relations.authors.models[0].id, testUtils.DataGenerator.Content.users[0].id);
                assert(createdPost.get('updated_at') > new Date(0).getTime());
                assert.equal(createdPost.get('published_at'), null);
                assert.equal(createdPost.get('published_by'), null);

                const createdPostUpdatedDate = createdPost.get('updated_at');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);

                // Set the status to published to check that `published_at` is set.
                const publishedPost = await createdPost.save({status: 'published'}, context);
                assert(publishedPost.get('published_at') instanceof Date);
                assert.equal(publishedPost.get('published_by'), testUtils.DataGenerator.Content.users[0].id);
                assert(publishedPost.get('updated_at') instanceof Date);
                assert.notEqual(publishedPost.get('updated_at'), createdPostUpdatedDate);

                assert.equal(Object.keys(eventsTriggered).length, 4);
                assertExists(eventsTriggered['post.published']);
                assertExists(eventsTriggered['post.edited']);
            });

            it('can add, with previous published_at date', async function () {
                const previousPublishedAtDate = new Date(2013, 8, 21, 12);

                const newPost = await models.Post.add({
                    status: 'published',
                    published_at: previousPublishedAtDate,
                    title: 'published_at test',
                    lexical: markdownToLexical('This is some content')
                }, context);

                assertExists(newPost);
                assert.equal(new Date(newPost.get('published_at')).getTime(), previousPublishedAtDate.getTime());

                assert.equal(Object.keys(eventsTriggered).length, 3);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['post.published']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('add draft post without published_at -> we expect no auto insert of published_at', async function () {
                const newPost = await models.Post.add({
                    status: 'draft',
                    title: 'draft 1',
                    lexical: markdownToLexical('This is some content')
                }, context);

                assertExists(newPost);
                assert.equal(newPost.get('published_at'), null);

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('add multiple authors', async function () {
                const newPost = await models.Post.add({
                    status: 'draft',
                    title: 'draft 1',
                    lexical: markdownToLexical('This is some content'),
                    authors: [{
                        id: testUtils.DataGenerator.forKnex.users[0].id,
                        name: testUtils.DataGenerator.forKnex.users[0].name
                    }]
                }, _.merge({withRelated: ['authors']}, context));

                assertExists(newPost);
                assert.equal(newPost.toJSON().primary_author.id, testUtils.DataGenerator.forKnex.users[0].id);
                assert.equal(newPost.toJSON().authors.length, 1);
                assert.equal(newPost.toJSON().authors[0].id, testUtils.DataGenerator.forKnex.users[0].id);
            });

            it('add draft post with published_at -> we expect published_at to exist', async function () {
                const newPost = await models.Post.add({
                    status: 'draft',
                    published_at: moment().toDate(),
                    title: 'draft 1',
                    lexical: markdownToLexical('This is some content')
                }, context);

                assertExists(newPost);
                assertExists(newPost.get('published_at'));

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('add scheduled post without published_at -> we expect an error', async function () {
                await assert.rejects(models.Post.add({
                    status: 'scheduled',
                    title: 'scheduled 1',
                    lexical: markdownToLexical('This is some content')
                }, context), (err) => {
                    assertExists(err);
                    assert.equal((err instanceof errors.ValidationError), true);
                    assert.equal(Object.keys(eventsTriggered).length, 0);
                    return true;
                });
            });

            it('add scheduled post with published_at more than 2 minutes in the past -> we expect an error', async function () {
                await assert.rejects(models.Post.add({
                    status: 'scheduled',
                    published_at: moment().subtract(3, 'minute'),
                    title: 'scheduled 1',
                    lexical: markdownToLexical('This is some content')
                }, context), (err) => {
                    assertExists(err);
                    assert.equal((err instanceof errors.ValidationError), true);
                    assert.equal(Object.keys(eventsTriggered).length, 0);
                    return true;
                });
            });

            it('add scheduled post with published_at 1 minutes in past -> we expect success', async function () {
                const post = await models.Post.add({
                    status: 'scheduled',
                    published_at: moment().add(1, 'minute'),
                    title: 'scheduled 1',
                    lexical: markdownToLexical('This is some content')
                }, context);
                assertExists(post);

                assert.equal(Object.keys(eventsTriggered).length, 3);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['post.scheduled']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('add scheduled post with published_at 10 minutes in future -> we expect success', async function () {
                const post = await models.Post.add({
                    status: 'scheduled',
                    published_at: moment().add(10, 'minute'),
                    title: 'scheduled 1',
                    lexical: markdownToLexical('This is some content')
                }, context);

                assertExists(post);

                assert.equal(Object.keys(eventsTriggered).length, 3);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['post.scheduled']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('can generate a non conflicting slug', async function () {
                // Create 12 posts with the same title
                const createdPosts = [];
                for (let i = 0; i < 12; i += 1) {
                    createdPosts.push(await models.Post.add({
                        title: 'Test Title',
                        lexical: markdownToLexical('Test Content ' + (i + 1))
                    }, context));
                }

                // Should have created 12 posts
                assert.equal(createdPosts.length, 12);

                // Should have unique slugs and contents
                _(createdPosts).each(function (post, i) {
                    const num = i + 1;

                    // First one has normal title
                    if (num === 1) {
                        assert.equal(post.get('slug'), 'test-title');
                        return;
                    }

                    assert.equal(post.get('slug'), 'test-title-' + num);
                    assert.ok(post.get('lexical').includes('Test Content ' + num));

                    assert.equal(Object.keys(eventsTriggered).length, 2);
                    assertExists(eventsTriggered['post.added']);
                    assertExists(eventsTriggered['user.attached']);
                    assert.equal(eventsTriggered['post.added'].length, 12);
                });
            });

            it('can generate slugs without duplicate hyphens', async function () {
                const newPost = {
                    title: 'apprehensive  titles  have  too  many  spaces—and m-dashes  —  –  and also n-dashes  ',
                    lexical: markdownToLexical('Test Content 1')
                };

                const createdPost = await models.Post.add(newPost, context);
                assert.equal(createdPost.get('slug'), 'apprehensive-titles-have-too-many-spaces-and-m-dashes-and-also-n-dashes');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('can generate a safe slug when a protected keyword is used', async function () {
                const newPost = {
                    title: 'rss',
                    lexical: markdownToLexical('Test Content 1')
                };

                const createdPost = await models.Post.add(newPost, context);
                assert.notEqual(createdPost.get('slug'), 'rss');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);
            });

            it('can generate slugs without non-ascii characters', async function () {
                const newPost = {
                    title: 'भुते धडकी भरवणारा आहेत',
                    lexical: markdownToLexical('Test Content 1')
                };

                const createdPost = await models.Post.add(newPost, context);
                assert.equal(createdPost.get('slug'), 'bhute-dhddkii-bhrvnnaaraa-aahet');
            });

            it('detects duplicate slugs before saving', async function () {
                const firstPost = {
                    title: 'First post',
                    lexical: markdownToLexical('First content 1')
                };

                const secondPost = {
                    title: 'Second post',
                    lexical: markdownToLexical('Second content 1')
                };

                // Create the first post
                const createdFirstPost = await models.Post.add(firstPost, context);
                // Store the slug for later
                firstPost.slug = createdFirstPost.get('slug');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);

                // Create the second post
                const createdSecondPost = await models.Post.add(secondPost, context);
                // Store the slug for comparison later
                secondPost.slug = createdSecondPost.get('slug');

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['post.added']);
                assertExists(eventsTriggered['user.attached']);

                // Update with a conflicting slug from the first post
                const updatedSecondPost = await createdSecondPost.save({
                    slug: firstPost.slug
                }, context);
                // Should have updated from original
                assert.notEqual(updatedSecondPost.get('slug'), secondPost.slug);
                // Should not have a conflicted slug from the first
                assert.notEqual(updatedSecondPost.get('slug'), firstPost.slug);

                assert.equal(Object.keys(eventsTriggered).length, 3);
                assertExists(eventsTriggered['post.edited']);

                const foundPost = await models.Post.findOne({
                    id: updatedSecondPost.id,
                    status: 'all'
                });
                // Should have updated from original
                assert.notEqual(foundPost.get('slug'), secondPost.slug);
                // Should not have a conflicted slug from the first
                assert.notEqual(foundPost.get('slug'), firstPost.slug);
            });

            it('it stores urls as transform-ready and reads as absolute', async function () {
                const siteUrl = configUtils.config.get('url');
                const post = {
                    title: 'Absolute->Transform-ready URL Transform Test',
                    mobiledoc: `{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"${siteUrl}/content/images/card.jpg"}]],"markups":[["a",["href","${siteUrl}/test"]]],"sections":[[1,"p",[[0,[0],1,"Testing"]]],[10,0]]}`,
                    custom_excerpt: `Testing <a href="${siteUrl}/internal">links</a> in custom excerpts`,
                    codeinjection_head: `<script src="${siteUrl}/assets/head.js"></script>`,
                    codeinjection_foot: `<script src="${siteUrl}/assets/foot.js"></script>`,
                    feature_image: `${siteUrl}/content/images/feature.png`,
                    canonical_url: `${siteUrl}/canonical`,
                    posts_meta: {
                        og_image: `${siteUrl}/content/images/og.png`,
                        twitter_image: `${siteUrl}/content/images/twitter.png`
                    }
                };

                const createdPost = await models.Post.add(post, context);
                // mobiledoc input is converted to lexical; urls are read back as absolute
                assert.equal(createdPost.get('mobiledoc'), null);
                assert.equal(createdPost.get('lexical'), `{"root":{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Testing","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","rel":null,"target":null,"title":null,"url":"${siteUrl}/test","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"type":"image","src":"${siteUrl}/content/images/card.jpg"}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`);
                assert.equal(createdPost.get('html'), `<p><a href="${siteUrl}/test">Testing</a></p><figure class="kg-card kg-image-card"><img src="${siteUrl}/content/images/card.jpg" class="kg-image" alt="" loading="lazy"></figure>`);
                assert(createdPost.get('plaintext').includes('Testing'));
                assert.equal(createdPost.get('custom_excerpt'), `Testing <a href="${siteUrl}/internal">links</a> in custom excerpts`);
                assert.equal(createdPost.get('codeinjection_head'), `<script src="${siteUrl}/assets/head.js"></script>`);
                assert.equal(createdPost.get('codeinjection_foot'), `<script src="${siteUrl}/assets/foot.js"></script>`);
                assert.equal(createdPost.get('feature_image'), `${siteUrl}/content/images/feature.png`);
                assert.equal(createdPost.get('canonical_url'), `${siteUrl}/canonical`);

                const postMeta = createdPost.relations.posts_meta;

                assert.equal(postMeta.get('og_image'), `${siteUrl}/content/images/og.png`);
                assert.equal(postMeta.get('twitter_image'), `${siteUrl}/content/images/twitter.png`);

                // ensure canonical_url is not transformed when protocol does not match
                const updatedPost = await createdPost.save({
                    canonical_url: `${siteUrl.replace('http:', 'https:')}/https-internal`,
                    // sanity check for general absolute->relative transform during edits
                    feature_image: `${siteUrl}/content/images/updated_feature.png`
                });

                assert.equal(updatedPost.get('canonical_url'), `${siteUrl.replace('http:', 'https:')}/https-internal`);
                assert.equal(updatedPost.get('feature_image'), `${siteUrl}/content/images/updated_feature.png`);

                const knexResult = await db.knex('posts').where({id: updatedPost.id});
                const [knexPost] = knexResult;
                assert.equal(knexPost.mobiledoc, null);
                assert.equal(knexPost.lexical, '{"root":{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Testing","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","rel":null,"target":null,"title":null,"url":"__GHOST_URL__/test","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"type":"image","src":"__GHOST_URL__/content/images/card.jpg"}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}');
                assert.equal(knexPost.html, '<p><a href="__GHOST_URL__/test">Testing</a></p><figure class="kg-card kg-image-card"><img src="__GHOST_URL__/content/images/card.jpg" class="kg-image" alt="" loading="lazy"></figure>');
                assert(knexPost.plaintext.includes('Testing'));
                assert.equal(knexPost.custom_excerpt, 'Testing <a href="__GHOST_URL__/internal">links</a> in custom excerpts');
                assert.equal(knexPost.codeinjection_head, '<script src="__GHOST_URL__/assets/head.js"></script>');
                assert.equal(knexPost.codeinjection_foot, '<script src="__GHOST_URL__/assets/foot.js"></script>');
                assert.equal(knexPost.feature_image, '__GHOST_URL__/content/images/updated_feature.png');
                assert.equal(knexPost.canonical_url, `${siteUrl.replace('http:', 'https:')}/https-internal`);
            });

            // NOTE: separate to the test above because mobiledoc+lexical cannot co-exist
            it('stores lexical as transform-ready and reads as absolute', async function () {
                const siteUrl = configUtils.config.get('url');
                const post = {
                    title: 'Absolute->Transform-ready Lexical URL Transform Test',
                    lexical: `{"root":{"children":[{"type":"image","src":"${siteUrl}/content/images/card.jpg"},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"local link","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"url":"${siteUrl}/local"}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"external link","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"url":"https://example.com/external"}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`
                };

                const createdPost = await models.Post.add(post, context);
                assert.equal(createdPost.get('lexical'), `{"root":{"children":[{"type":"image","src":"${siteUrl}/content/images/card.jpg"},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"local link","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"url":"${siteUrl}/local"}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"external link","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"url":"https://example.com/external"}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, 'Post.add result');

                const knexResult = await db.knex('posts').where({id: createdPost.id});
                const [knexPost] = knexResult;
                assert.equal(knexPost.lexical, '{"root":{"children":[{"type":"image","src":"__GHOST_URL__/content/images/card.jpg"},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"local link","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"url":"__GHOST_URL__/local"}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"external link","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"url":"https://example.com/external"}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}', 'knex result');
            });

            describe('URL transformations without CDN config', function () {
                const siteUrl = configUtils.config.get('url');

                describe('Mobiledoc', function () {
                    let post, postsMeta, mobiledoc;

                    beforeAll(async function () {
                        post = await models.Post.findOne({
                            slug: 'post-with-all-media-types-mobiledoc'
                        }, {withRelated: ['posts_meta']});
                        assertExists(post, 'Post with all media types (Mobiledoc) should exist');
                        postsMeta = post.related('posts_meta');
                        mobiledoc = JSON.parse(post.get('mobiledoc'));
                    });

                    it('transforms feature_image, og_image, and twitter_image to absolute site URLs', function () {
                        assert.equal(post.get('feature_image'), `${siteUrl}/content/images/feature.jpg`);
                        assert.equal(postsMeta.get('og_image'), `${siteUrl}/content/images/og.jpg`);
                        assert.equal(postsMeta.get('twitter_image'), `${siteUrl}/content/images/twitter.jpg`);
                    });

                    it('transforms all media card URLs to absolute site URLs', function () {
                        // Image card
                        const imageCard = mobiledoc.cards.find(card => card[0] === 'image' && card[1].src.includes('inline.jpg'));
                        assert.equal(imageCard[1].src, `${siteUrl}/content/images/inline.jpg`);

                        // Gallery card
                        const galleryCard = mobiledoc.cards.find(card => card[0] === 'gallery');
                        assert.equal(galleryCard[1].images[0].src, `${siteUrl}/content/images/gallery-1.jpg`);
                        assert.equal(galleryCard[1].images[1].src, `${siteUrl}/content/images/gallery-2.jpg`);
                        assert.equal(galleryCard[1].images[2].src, `${siteUrl}/content/images/gallery-3.jpg`);

                        // File card
                        const fileCard = mobiledoc.cards.find(card => card[0] === 'file' && card[1].src.includes('document.pdf'));
                        assert.equal(fileCard[1].src, `${siteUrl}/content/files/document.pdf`);

                        // Video card
                        const videoCard = mobiledoc.cards.find(card => card[0] === 'video' && card[1].src.includes('video.mp4') && !card[1].src.includes('snippet'));
                        assert.equal(videoCard[1].src, `${siteUrl}/content/media/video.mp4`);
                        assert.equal(videoCard[1].thumbnailSrc, `${siteUrl}/content/images/video-thumb.jpg`);

                        // Audio card
                        const audioCard = mobiledoc.cards.find(card => card[0] === 'audio' && card[1].src.includes('audio.mp3') && !card[1].src.includes('snippet'));
                        assert.equal(audioCard[1].src, `${siteUrl}/content/media/audio.mp3`);
                        assert.equal(audioCard[1].thumbnailSrc, `${siteUrl}/content/images/audio-thumb.jpg`);

                        // Link markup
                        const linkMarkup = mobiledoc.markups.find(markup => markup[0] === 'a');
                        assert.equal(linkMarkup[1][1], `${siteUrl}/snippet-link`);
                    });

                    it('transforms inserted snippet URLs to absolute site URLs', function () {
                        // Snippet image
                        const snippetImage = mobiledoc.cards.find(card => card[0] === 'image' && card[1].src.includes('snippet-inline'));
                        assert.equal(snippetImage[1].src, `${siteUrl}/content/images/snippet-inline.jpg`);

                        // Snippet file
                        const snippetFile = mobiledoc.cards.find(card => card[0] === 'file' && card[1].src.includes('snippet-document'));
                        assert.equal(snippetFile[1].src, `${siteUrl}/content/files/snippet-document.pdf`);

                        // Snippet video
                        const snippetVideo = mobiledoc.cards.find(card => card[0] === 'video' && card[1].src.includes('snippet-video'));
                        assert.equal(snippetVideo[1].src, `${siteUrl}/content/media/snippet-video.mp4`);
                        assert.equal(snippetVideo[1].thumbnailSrc, `${siteUrl}/content/images/snippet-video-thumb.jpg`);

                        // Snippet audio
                        const snippetAudio = mobiledoc.cards.find(card => card[0] === 'audio' && card[1].src.includes('snippet-audio'));
                        assert.equal(snippetAudio[1].src, `${siteUrl}/content/media/snippet-audio.mp3`);
                        assert.equal(snippetAudio[1].thumbnailSrc, `${siteUrl}/content/images/snippet-audio-thumb.jpg`);
                    });
                });

                describe('Lexical', function () {
                    let post, postsMeta, lexicalString;

                    beforeAll(async function () {
                        post = await models.Post.findOne({
                            slug: 'post-with-all-media-types-lexical'
                        }, {withRelated: ['posts_meta']});
                        assertExists(post, 'Post with all media types (Lexical) should exist');
                        postsMeta = post.related('posts_meta');
                        lexicalString = post.get('lexical');
                    });

                    it('transforms feature_image, og_image, and twitter_image to absolute site URLs', function () {
                        assert.equal(post.get('feature_image'), `${siteUrl}/content/images/feature.jpg`);
                        assert.equal(postsMeta.get('og_image'), `${siteUrl}/content/images/og.jpg`);
                        assert.equal(postsMeta.get('twitter_image'), `${siteUrl}/content/images/twitter.jpg`);
                    });

                    it('transforms all media URLs to absolute site URLs', function () {
                        // Verify all expected URLs are present as absolute URLs
                        assert(lexicalString.includes(`${siteUrl}/content/images/inline.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/content/images/gallery-1.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/content/images/gallery-2.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/content/images/gallery-3.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/content/files/document.pdf`));
                        assert(lexicalString.includes(`${siteUrl}/content/media/video.mp4`));
                        assert(lexicalString.includes(`${siteUrl}/content/images/video-thumb.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/content/media/audio.mp3`));
                        assert(lexicalString.includes(`${siteUrl}/content/images/audio-thumb.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/snippet-link`));

                        // Verify no __GHOST_URL__ placeholders remain
                        assert(!lexicalString.includes('__GHOST_URL__'));
                    });

                    it('transforms inserted snippet URLs to absolute site URLs', function () {
                        assert(lexicalString.includes(`${siteUrl}/content/images/snippet-inline.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/content/files/snippet-document.pdf`));
                        assert(lexicalString.includes(`${siteUrl}/content/media/snippet-video.mp4`));
                        assert(lexicalString.includes(`${siteUrl}/content/images/snippet-video-thumb.jpg`));
                        assert(lexicalString.includes(`${siteUrl}/content/media/snippet-audio.mp3`));
                        assert(lexicalString.includes(`${siteUrl}/content/images/snippet-audio-thumb.jpg`));
                    });
                });
            });

            describe('URL transformations with CDN config', function () {
                const cdnUrl = 'https://cdn.example.com/c/site-uuid';

                beforeEach(function () {
                    urlUtilsHelper.stubUrlUtilsWithCdn({
                        assetBaseUrls: {
                            media: cdnUrl,
                            files: cdnUrl,
                            image: cdnUrl
                        }
                    }, sinon);
                });

                describe('Mobiledoc', function () {
                    it('transforms file card src to files CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        const mobiledoc = JSON.parse(post.get('mobiledoc'));
                        const fileCard = mobiledoc.cards.find(card => card[0] === 'file');
                        assert.equal(fileCard[1].src, `${cdnUrl}/content/files/document.pdf`);
                    });

                    it('transforms video card src to media CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        const mobiledoc = JSON.parse(post.get('mobiledoc'));
                        const videoCard = mobiledoc.cards.find(card => card[0] === 'video');
                        assert.equal(videoCard[1].src, `${cdnUrl}/content/media/video.mp4`);
                    });

                    it('transforms audio card src to media CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        const mobiledoc = JSON.parse(post.get('mobiledoc'));
                        const audioCard = mobiledoc.cards.find(card => card[0] === 'audio');
                        assert.equal(audioCard[1].src, `${cdnUrl}/content/media/audio.mp3`);
                    });

                    it('transforms inserted snippet file/media URLs to CDN URLs', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        const mobiledocString = post.get('mobiledoc');
                        assert(mobiledocString.includes(`${cdnUrl}/content/files/snippet-document.pdf`));
                        assert(mobiledocString.includes(`${cdnUrl}/content/media/snippet-video.mp4`));
                        assert(mobiledocString.includes(`${cdnUrl}/content/media/snippet-audio.mp3`));
                    });

                    it('transforms feature_image to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        assert.equal(post.get('feature_image'), `${cdnUrl}/content/images/feature.jpg`);
                    });

                    it('transforms gallery images to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        const mobiledoc = JSON.parse(post.get('mobiledoc'));
                        const galleryCard = mobiledoc.cards.find(card => card[0] === 'gallery');
                        galleryCard[1].images.forEach((image) => {
                            assert(image.src.startsWith(cdnUrl));
                            assert(image.src.includes('/content/images/'));
                        });
                    });

                    it('transforms inline images to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        const mobiledoc = JSON.parse(post.get('mobiledoc'));
                        const imageCard = mobiledoc.cards.find(card => card[0] === 'image');
                        assert.equal(imageCard[1].src, `${cdnUrl}/content/images/inline.jpg`);
                    });

                    it('transforms video thumbnailSrc to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-mobiledoc'}, {withRelated: ['tags']});
                        const mobiledoc = JSON.parse(post.get('mobiledoc'));
                        const videoCard = mobiledoc.cards.find(card => card[0] === 'video');
                        assert.equal(videoCard[1].thumbnailSrc, `${cdnUrl}/content/images/video-thumb.jpg`);
                    });
                });

                describe('Lexical', function () {
                    it('transforms file URLs to files CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-lexical'}, {withRelated: ['tags']});
                        const lexicalString = post.get('lexical');
                        assert(lexicalString.includes(`${cdnUrl}/content/files/document.pdf`));
                    });

                    it('transforms video/audio URLs to media CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-lexical'}, {withRelated: ['tags']});
                        const lexicalString = post.get('lexical');
                        assert(lexicalString.includes(`${cdnUrl}/content/media/video.mp4`));
                        assert(lexicalString.includes(`${cdnUrl}/content/media/audio.mp3`));
                    });

                    it('transforms inserted snippet file/media URLs to CDN URLs', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-lexical'}, {withRelated: ['tags']});
                        const lexicalString = post.get('lexical');
                        assert(lexicalString.includes(`${cdnUrl}/content/files/snippet-document.pdf`));
                        assert(lexicalString.includes(`${cdnUrl}/content/media/snippet-video.mp4`));
                        assert(lexicalString.includes(`${cdnUrl}/content/media/snippet-audio.mp3`));
                    });

                    it('transforms feature_image to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-lexical'}, {withRelated: ['tags']});
                        assert.equal(post.get('feature_image'), `${cdnUrl}/content/images/feature.jpg`);
                    });

                    it('transforms gallery images to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-lexical'}, {withRelated: ['tags']});
                        const lexicalString = post.get('lexical');
                        assert(lexicalString.includes(`${cdnUrl}/content/images/gallery-1.jpg`));
                        assert(lexicalString.includes(`${cdnUrl}/content/images/gallery-2.jpg`));
                    });

                    it('transforms inline images to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-lexical'}, {withRelated: ['tags']});
                        const lexicalString = post.get('lexical');
                        assert(lexicalString.includes(`${cdnUrl}/content/images/inline.jpg`));
                    });

                    it('transforms video/audio thumbnails to CDN URL', async function () {
                        const post = await models.Post.findOne({slug: 'post-with-all-media-types-lexical'}, {withRelated: ['tags']});
                        const lexicalString = post.get('lexical');
                        assert(lexicalString.includes(`${cdnUrl}/content/images/video-thumb.jpg`));
                        assert(lexicalString.includes(`${cdnUrl}/content/images/audio-thumb.jpg`));
                    });
                });
            });
        });

        describe('destroy', function () {
            beforeEach(testUtils.fixtures.insertPostsAndTags);

            afterEach(function () {
                return testUtils.truncate('posts_tags')
                    .then(function () {
                        return testUtils.truncate('tags');
                    })
                    .then(function () {
                        return testUtils.truncate('posts');
                    })
                    .then(function () {
                        return testUtils.truncate('posts_meta');
                    });
            });

            beforeEach(function () {
                eventsTriggered = {};
                sinon.stub(events, 'emit').callsFake(function (eventName, eventObj) {
                    if (!eventsTriggered[eventName]) {
                        eventsTriggered[eventName] = [];
                    }

                    eventsTriggered[eventName].push(eventObj);
                });
            });

            it('published post', async function () {
                // We're going to try deleting post id 1 which has tag id 1
                const firstItemData = {id: testUtils.DataGenerator.Content.posts[0].id};

                // Test that we have the post we expect, with exactly one tag
                const results = await models.Post.findOne(firstItemData, {withRelated: ['tags']});
                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, firstItemData.id);
                assert.equal(post.status, 'published');
                assert.equal(post.tags.length, 2);
                assert.equal(post.tags[0].id, testUtils.DataGenerator.Content.tags[0].id);

                // Destroy the post
                const response = await results.destroy();
                const deleted = response.toJSON();

                assert.equal(deleted.author, undefined);

                assert.equal(Object.keys(eventsTriggered).length, 5);
                assertExists(eventsTriggered['post.unpublished']);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['user.detached']);
                assertExists(eventsTriggered['tag.detached']);
                assertExists(eventsTriggered['post.tag.detached']);

                // Double check we can't find the post again
                const newResults = await models.Post.findOne(firstItemData);
                assert.equal(newResults, null);

                // Double check we can't find any related tags
                const postsTags = await ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
                assert.deepEqual(postsTags, []);
            });

            it('draft post', async function () {
                // We're going to try deleting post 4 which also has tag 4
                const firstItemData = {id: testUtils.DataGenerator.Content.posts[3].id, status: 'draft'};

                // Test that we have the post we expect, with exactly one tag
                const results = await models.Post.findOne(firstItemData, {withRelated: ['tags']});
                assertExists(results);
                const post = results.toJSON();
                assert.equal(post.id, firstItemData.id);
                assert.equal(post.tags.length, 1);
                assert.equal(post.tags[0].id, testUtils.DataGenerator.Content.tags[3].id);

                // Destroy the post
                const response = await results.destroy(firstItemData);
                const deleted = response.toJSON();

                assert.equal(deleted.author, undefined);

                assert.equal(Object.keys(eventsTriggered).length, 4);
                assertExists(eventsTriggered['post.deleted']);
                assertExists(eventsTriggered['tag.detached']);
                assertExists(eventsTriggered['post.tag.detached']);
                assertExists(eventsTriggered['user.detached']);

                // Double check we can't find the post again
                const newResults = await models.Post.findOne(firstItemData);
                assert.equal(newResults, null);

                // Double check we can't find any related tags
                const postsTags = await ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
                assert.deepEqual(postsTags, []);
            });

            it('published page', async function () {
                // We're going to try deleting page 6 which has tag 1
                const firstItemData = {id: testUtils.DataGenerator.Content.posts[5].id};

                // Test that we have the post we expect, with exactly one tag
                const results = await models.Post.findOne(firstItemData, {withRelated: ['tags']});
                assertExists(results);
                const page = results.toJSON();
                assert.equal(page.id, firstItemData.id);
                assert.equal(page.status, 'published');
                assert.equal(page.type, 'page');

                // Destroy the page
                const response = await results.destroy(firstItemData);
                const deleted = response.toJSON();

                assert.equal(deleted.author, undefined);

                assert.equal(Object.keys(eventsTriggered).length, 3);
                assertExists(eventsTriggered['page.unpublished']);
                assertExists(eventsTriggered['page.deleted']);
                assertExists(eventsTriggered['user.detached']);

                // Double check we can't find the post again
                const newResults = await models.Post.findOne(firstItemData);
                assert.equal(newResults, null);

                // Double check we can't find any related tags
                const postsTags = await ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
                assert.deepEqual(postsTags, []);
            });

            it('draft page', async function () {
                // We're going to try deleting post 7 which has tag 4
                const firstItemData = {id: testUtils.DataGenerator.Content.posts[6].id, status: 'draft'};

                // Test that we have the post we expect, with exactly one tag
                const results = await models.Post.findOne(firstItemData, {withRelated: ['tags']});
                assertExists(results);
                const page = results.toJSON();
                assert.equal(page.id, firstItemData.id);

                // Destroy the page
                const response = await results.destroy(firstItemData);
                const deleted = response.toJSON();

                assert.equal(deleted.author, undefined);

                assert.equal(Object.keys(eventsTriggered).length, 2);
                assertExists(eventsTriggered['page.deleted']);
                assertExists(eventsTriggered['user.detached']);

                // Double check we can't find the post again
                const newResults = await models.Post.findOne(firstItemData);
                assert.equal(newResults, null);

                // Double check we can't find any related tags
                const postsTags = await ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
                assert.deepEqual(postsTags, []);
            });
        });

        describe('Collision Protection', function () {
            beforeAll(testUtils.fixtures.insertPostsAndTags);

            afterAll(function () {
                return testUtils.truncate('posts_tags')
                    .then(function () {
                        return testUtils.truncate('tags');
                    })
                    .then(function () {
                        return testUtils.truncate('posts');
                    })
                    .then(function () {
                        return testUtils.truncate('posts_meta');
                    });
            });

            it('update post title, but updated_at is out of sync', function () {
                const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};

                return models.Post.edit({
                    title: 'New Post Title',
                    updated_at: moment().subtract(1, 'day').format()
                }, _.extend({}, context, {id: postToUpdate.id}))
                    .then(function () {
                        throw new Error('expected no success');
                    })
                    .catch(function (err) {
                        assert.equal(err.code, 'UPDATE_COLLISION');
                    });
            });

            it('update post tags and updated_at is out of sync', function () {
                const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};

                return models.Post.edit({
                    tags: [{name: 'new-tag-1'}],
                    updated_at: moment().subtract(1, 'day').format()
                }, _.extend({}, context, {id: postToUpdate.id}))
                    .then(function () {
                        throw new Error('expected no success');
                    })
                    .catch(function (err) {
                        assert.equal(err.code, 'UPDATE_COLLISION');
                    });
            });

            it('update post authors and updated_at is out of sync', function () {
                const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};

                return models.Post.edit({
                    authors: [testUtils.DataGenerator.Content.users[3]],
                    updated_at: moment().subtract(1, 'day').format()
                }, _.extend({}, context, {id: postToUpdate.id}))
                    .then(function () {
                        throw new Error('expected no success');
                    })
                    .catch(function (err) {
                        assert.equal(err.code, 'UPDATE_COLLISION');
                    });
            });

            it('update post tags and updated_at is NOT out of sync', function () {
                const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};

                return models.Post.edit({
                    tags: [{name: 'new-tag-1'}]
                }, _.extend({}, context, {id: postToUpdate.id}));
            });

            it('update post with no changes, but updated_at is out of sync', function () {
                const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};

                return models.Post.edit({
                    updated_at: moment().subtract(1, 'day').format()
                }, _.extend({}, context, {id: postToUpdate.id}));
            });

            it('update post with old post title, but updated_at is out of sync', function () {
                const postToUpdate = {
                    id: testUtils.DataGenerator.Content.posts[1].id,
                    title: testUtils.DataGenerator.forModel.posts[1].title
                };

                return models.Post.edit({
                    title: postToUpdate.title,
                    updated_at: moment().subtract(1, 'day').format()
                }, _.extend({}, context, {id: postToUpdate.id}));
            });
        });
    });

    describe('Multiauthor Posts', function () {
        beforeAll(testUtils.teardownDb);

        afterAll(async function () {
            await testUtils.teardownDb();
            await testUtils.setup('users:roles')();
        });

        beforeAll(testUtils.setup('posts:mu'));

        it('can reassign multiple posts by author', async function () {
            // We're going to delete all posts by user 1
            const authorData = {id: testUtils.DataGenerator.Content.users[1].id};
            const ownerData = {
                id: testUtils.DataGenerator.Content.users[0].id,
                slug: testUtils.DataGenerator.Content.users[0].slug
            };
            const staffCount = testUtils.DataGenerator.forKnex.users.length;

            const preReassignPosts = await models.Post.findAll({context: {internal: true}});
            // The posts:mu fixture creates two posts per staff member.
            assert.equal(preReassignPosts.length, 2 * staffCount);

            const preReassignOwnerWithPosts = await models.Post.findAll({
                filter: `authors:${ownerData.slug}`,
                context: {internal: true}
            });
            assert.equal(preReassignOwnerWithPosts.length, 2);

            await models.Post.reassignByAuthor(authorData);

            const postReassignPosts = await models.Post.findAll({context: {internal: true}});
            // All posts should remain
            assert.equal(postReassignPosts.length, 2 * staffCount);

            const postReassignOwnerWithPosts = await models.Post.findAll({
                filter: `authors:${ownerData.slug}`,
                context: {internal: true}
            });
            // 2 own and 2 reassigned from the other author
            assert.equal(postReassignOwnerWithPosts.length, 4);
        });

        it('can reassign posts with mixed primary and secondary authors', async function () {
            const authorData = {
                id: testUtils.DataGenerator.Content.users[2].id,
                slug: testUtils.DataGenerator.Content.users[2].slug
            };
            const ownerData = {
                id: testUtils.DataGenerator.Content.users[0].id,
                slug: testUtils.DataGenerator.Content.users[0].slug
            };
            const otherAuthorDate = {
                id: testUtils.DataGenerator.Content.users[3].id,
                slug: testUtils.DataGenerator.Content.users[3].slug
            };

            await testUtils.fixtures.insertPosts([{
                title: 'primary_author',
                authors: [{
                    id: authorData.id
                }, {
                    id: ownerData.id
                }]
            }, {
                title: 'secondary_author',
                authors: [{
                    id: ownerData.id
                }, {
                    id: authorData.id
                }]
            }, {
                title: 'multiple_authors',
                authors: [{
                    id: ownerData.id
                }, {
                    id: authorData.id
                }, {
                    id: otherAuthorDate.id
                }]
            }]);

            const preReassignAuthorWithPosts = await models.Post.findAll({
                filter: `authors:${authorData.slug}`,
                context: {internal: true}
            });
            // 2 from 'posts:mu' fixtures and 3 inserted in the test case
            assert.equal(preReassignAuthorWithPosts.length, 5);

            const preReassignOtherAuthorWithPosts = await models.Post.findAll({
                filter: `authors:${otherAuthorDate.slug}`,
                context: {internal: true}
            });
            // 2 from 'posts:mu' fixtures and 1 inserted in the test case
            assert.equal(preReassignOtherAuthorWithPosts.length, 3);

            await models.Post.reassignByAuthor(authorData);

            const postReassignAuthorWithPosts = await models.Post.findAll({
                filter: `authors:${authorData.slug}`,
                context: {internal: true}
            });
            // author under test should own nothing after reassignment
            assert.equal(postReassignAuthorWithPosts.length, 0);

            const postReassignOtherAuthorWithPosts = await models.Post.findAll({
                filter: `authors:${otherAuthorDate.slug}`,
                context: {internal: true}
            });
            // should stay the same as preassignment for another author
            assert.equal(postReassignOtherAuthorWithPosts.length, 3);

            const postReassignOwnerWithPosts = await models.Post.findAll({
                filter: `authors:${ownerData.slug}`,
                context: {internal: true}
            });
            // 5 from this test case's author under test + 4 from the test above (if executed exclusively will fail)
            assert.equal(postReassignOwnerWithPosts.length, 9);
        });
    });

    describe('Post tag handling edge cases', function () {
        let postJSON;
        let tagJSON;
        let editOptions;
        const createTag = testUtils.DataGenerator.forKnex.createTag;

        beforeEach(function () {
            return testUtils.truncate('posts_tags')
                .then(function () {
                    return testUtils.truncate('tags');
                })
                .then(function () {
                    return testUtils.truncate('posts');
                })
                .then(function () {
                    return testUtils.truncate('posts_meta');
                });
        });

        beforeEach(async function () {
            tagJSON = [];

            const post = _.cloneDeep(testUtils.DataGenerator.forModel.posts[0]);

            const postTags = [
                createTag({name: 'tag1', slug: 'tag1'}),
                createTag({name: 'tag2', slug: 'tag2'}),
                createTag({name: 'tag3', slug: 'tag3'})
            ];

            const extraTags = [
                createTag({name: 'existing tag a', slug: 'existing-tag-a'}),
                createTag({name: 'existing-tag-b', slug: 'existing-tag-b'}),
                createTag({name: 'existing_tag_c', slug: 'existing_tag_c'})
            ];

            post.tags = postTags;
            post.status = 'published';

            const [newPost, tag1, tag2, tag3] = await Promise.all([
                models.Post.add(post, _.extend({}, context, {withRelated: ['tags']})),
                models.Tag.add(extraTags[0], context),
                models.Tag.add(extraTags[1], context),
                models.Tag.add(extraTags[2], context)
            ]);

            postJSON = newPost.toJSON({withRelated: ['tags']});
            tagJSON.push(tag1.toJSON());
            tagJSON.push(tag2.toJSON());
            tagJSON.push(tag3.toJSON());
            editOptions = _.extend({}, context, {id: postJSON.id, withRelated: ['tags']});

            // reset the eventSpy here
            sinon.restore();
        });

        it('should create the test data correctly', function () {
            // creates a test tag
            assertExists(tagJSON);
            assert(Array.isArray(tagJSON));
            assert.equal(tagJSON.length, 3);

            assert.equal(tagJSON[0].name, 'existing tag a');
            assert.equal(tagJSON[1].name, 'existing-tag-b');
            assert.equal(tagJSON[2].name, 'existing_tag_c');

            // creates a test post with an array of tags in the correct order
            assertExists(postJSON);
            assert.equal(postJSON.title, 'HTML Ipsum');
            assert(Array.isArray(postJSON.tags));
            assert.equal(postJSON.tags.length, 3);

            assert.equal(postJSON.tags[0].name, 'tag1');
            assert.equal(postJSON.tags[1].name, 'tag2');
            assert.equal(postJSON.tags[2].name, 'tag3');
        });

        it('can edit slug of existing tag', function () {
            const newJSON = _.cloneDeep(postJSON);

            // Add an existing tag to the beginning of the array
            newJSON.tags = [{id: postJSON.tags[0].id, slug: 'eins'}];

            // Edit the post
            return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
                updatedPost = updatedPost.toJSON({withRelated: ['tags']});

                assert.equal(updatedPost.tags.length, 1);
                assert.equal(updatedPost.tags[0].name, postJSON.tags[0].name);
                assert.equal(updatedPost.tags[0].slug, 'eins');
                assert.equal(updatedPost.tags[0].id, postJSON.tags[0].id);
            });
        });

        it('can\'t edit dates and authors of existing tag', function () {
            const newJSON = _.cloneDeep(postJSON);
            let updatedAtFormat;
            let createdAtFormat;

            // Add an existing tag to the beginning of the array
            newJSON.tags = [_.cloneDeep(postJSON.tags[0])];
            newJSON.tags[0].created_at = moment().add(2, 'days').format('YYYY-MM-DD HH:mm:ss');
            newJSON.tags[0].updated_at = moment().add(2, 'days').format('YYYY-MM-DD HH:mm:ss');

            // NOTE: this is currently only removed in the API layer
            newJSON.tags[0].parent_id = newJSON.tags[0].parent;
            delete newJSON.tags[0].parent;

            // Edit the post
            return new Promise((resolve) => {
                setTimeout(resolve, 1000);
            })
                .then(function () {
                    return models.Post.edit(newJSON, editOptions);
                })
                .then(function (updatedPost) {
                    updatedPost = updatedPost.toJSON({withRelated: ['tags']});

                    assert.equal(updatedPost.tags.length, 1);
                    assertObjectMatches(updatedPost.tags[0], {
                        name: postJSON.tags[0].name,
                        slug: postJSON.tags[0].slug,
                        id: postJSON.tags[0].id
                    });

                    updatedAtFormat = moment(updatedPost.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss');
                    assert.equal(updatedAtFormat, moment(postJSON.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss'));
                    assert.notEqual(updatedAtFormat, moment(newJSON.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss'));

                    createdAtFormat = moment(updatedPost.tags[0].created_at).format('YYYY-MM-DD HH:mm:ss');
                    assert.equal(createdAtFormat, moment(postJSON.tags[0].created_at).format('YYYY-MM-DD HH:mm:ss'));
                    assert.notEqual(createdAtFormat, moment(newJSON.tags[0].created_at).format('YYYY-MM-DD HH:mm:ss'));
                });
        });

        it('can reorder existing, added and deleted tags', function () {
            const newJSON = _.cloneDeep(postJSON);

            // remove tag in the middle (tag1, tag2, tag3 -> tag1, tag3)
            newJSON.tags.splice(1, 1);

            // add a new one as first tag and reorder existing (tag4, tag3, tag1)
            newJSON.tags = [{name: 'tag4'}].concat([newJSON.tags[1]]).concat([newJSON.tags[0]]);

            // Edit the post
            return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
                updatedPost = updatedPost.toJSON({withRelated: ['tags']});

                assert.equal(updatedPost.tags.length, 3);
                assertObjectMatches(updatedPost.tags[0], {
                    name: 'tag4'
                });

                assertObjectMatches(updatedPost.tags[1], {
                    name: 'tag3',
                    id: postJSON.tags[2].id
                });

                assertObjectMatches(updatedPost.tags[2], {
                    name: 'tag1',
                    id: postJSON.tags[0].id
                });
            });
        });

        it('can add multiple tags with conflicting slugs', function () {
            const newJSON = _.cloneDeep(postJSON);

            // Add conflicting tags to the end of the array
            newJSON.tags = [];
            newJSON.tags.push({name: 'C'});
            newJSON.tags.push({name: 'C++'});
            newJSON.tags.push({name: 'C#'});

            // Edit the post
            return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
                updatedPost = updatedPost.toJSON({withRelated: ['tags']});

                assert.equal(updatedPost.tags.length, 3);

                assertObjectMatches(updatedPost.tags[0], {name: 'C', slug: 'c'});
                assertObjectMatches(updatedPost.tags[1], {name: 'C++', slug: 'c-2'});
                assertObjectMatches(updatedPost.tags[2], {name: 'C#', slug: 'c-3'});
            });
        });

        it('can handle lowercase/uppercase tags', function () {
            const newJSON = _.cloneDeep(postJSON);

            // Add conflicting tags to the end of the array
            newJSON.tags = [];
            newJSON.tags.push({name: 'test'});
            newJSON.tags.push({name: 'tEst'});

            // Edit the post
            return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
                updatedPost = updatedPost.toJSON({withRelated: ['tags']});

                assert.equal(updatedPost.tags.length, 1);
            });
        });
    });

    // disabling sanitization until we can implement a better version
    // it('should sanitize the title', function () {
    //    new models.Post().fetch().then(function (model) {
    //        return model.set({'title': "</title></head><body><script>alert('blogtitle');</script>"}).save();
    //    }).then(function (saved) {
    //        assert.equal(saved.get('title'), "&lt;/title&gt;&lt;/head>&lt;body&gt;[removed]alert&#40;'blogtitle'&#41;;[removed]");
    //    });
    // });
});
