OurBigBook
web/test.js
const assert = require('assert');

const { WebApi } = require('ourbigbook/web_api')
const {
  assert_xpath
} = require('ourbigbook/test_lib')
const ourbigbook = require('ourbigbook')

const app = require('./app')
const config = require('./front/config')
const routes = require('./front/routes')
const convert = require('./convert')
const models = require('./models')
const test_lib = require('./test_lib')
const { AUTH_COOKIE_NAME } = require('./front/js')

const web_api = require('ourbigbook/web_api');
const { execPath } = require('process');

const testNext = process.env.OURBIGBOOK_TEST_NEXT === 'true'

async function assertNestedSets(sequelize, expects) {
  const articles = await sequelize.models.Article.treeFindInOrder({
    includeNulls: true,
    refs: true
  })
  let i = 0
  const articleObjs = []
  for (const article of articles) {
    const expect = expects[i]
    const articleObj = {}
    for (const key in expect) {
      if (key !== 'to_id_index') {
        articleObj[key] = article.get(key)
      }
    }
    const ref = article.file.toplevelId
    let to_id_index, parentId
    if (ref === null) {
      to_id_index = null
      parentId = null
    } else {
      const tos = ref.to
      assert.strictEqual(tos.length, 1)
      to_id_index = tos[0].to_id_index
      parentId = tos[0].from_id
    }
    articleObj.to_id_index = to_id_index
    articleObj.parentId = parentId
    articleObjs.push(articleObj)
    i++
  }
  assertRows(
    articleObjs,
    expects,
    {
      msgFn: () => 'actual:\nnestedSetIndex, nestedSetNextSibling, depth, to_id_index, slug, parentId\n' +
        articleObjs.map(a => `${a.nestedSetIndex}, ${a.nestedSetNextSibling}, ${a.depth}, ${a.to_id_index}, ${a.slug}, ${a.parentId}`).join('\n')
    }
  )
}

function assertRows(rows, rowsExpect, opts={}) {
  const msgFn = opts.msgFn
  assert.strictEqual(rows.length, rowsExpect.length, `wrong number of rows: ${rows.length}, expected: ${rowsExpect.length}`)
  function printMsg(i, key) {
    if (msgFn) console.error(msgFn())
    console.error({ i, key })
  }
  for (let i = 0; i < rows.length; i++) {
    let row = rows[i]
    let rowExpect = rowsExpect[i]
    for (let key in rowExpect) {
      let val
      if (typeof row.get === 'function') {
        val = row.get(key)
      } else {
        val = row[key]
      }
      if (val === undefined) {
        assert(false, `key "${key}" not found in available keys: ${Object.keys(row).join(', ')}`)
      }
      const expect = rowExpect[key]
      if (expect instanceof RegExp) {
        if (!val.match(expect)) {
          printMsg(i, key)
        }
        assert.match(val, expect)
      } else {
        if (typeof expect === 'function') {
          if (!expect(val)) {
            printMsg(i, key)
            assert(false)
          }
        } else {
          if (val !== expect) {
            printMsg(i, key)
          }
          assert.strictEqual(val, expect)
        }
      }
    }
  }
}

// assertRows helpers.
const ne = (expect) => (v) => v !== expect

// 200 status assertion helper that also prints the data to help
// quickly see what the error is about.
function assertStatus(status, data) {
  if (status !== 200) {
    console.error(require('util').inspect(data));
    assert.strictEqual(status, 200)
  }
}

async function createArticleApi(test, article, opts={}) {
  if (opts.parentId === undefined && test.user) {
    opts = Object.assign({ parentId: `${ourbigbook.AT_MENTION_CHAR}${test.user.username}` }, opts)
  }
  return test.webApi.articleCreate(article, opts)
}

async function createOrUpdateArticleApi(test, article, opts={}) {
  if (opts.parentId === undefined && test.user && article.titleSource.toLowerCase() !== ourbigbook.INDEX_BASENAME_NOEXT) {
    opts = Object.assign({ parentId: `${ourbigbook.AT_MENTION_CHAR}${test.user.username}` }, opts)
  }
  return test.webApi.articleCreateOrUpdate(article, opts)
}

async function createArticles(sequelize, author, opts) {
  const articleArg = createArticleArg(opts, author)
  const { articles } = await convert.convertArticle({
    author,
    bodySource: articleArg.bodySource,
    path: opts.path,
    parentId: articleArg.parentId || `${ourbigbook.AT_MENTION_CHAR}${author.username}`,
    sequelize,
    titleSource: articleArg.titleSource,
  })
  return articles
}

async function createArticle(sequelize, author, opts) {
  return (await createArticles(sequelize, author, opts))[0]
}

function createArticleArg(opts, author) {
  const i = opts.i
  const ret = {}
  if (opts.hasOwnProperty('titleSource')) {
    ret.titleSource = opts.titleSource
  } else {
    ret.titleSource = `Title ${i}`
  }
  if (opts.hasOwnProperty('bodySource')) {
    ret.bodySource = opts.bodySource
  }  else {
    ret.bodySource = `Body ${i}\.`
  }
  if (author) {
    ret.authorId = author.id
  }
  ret.parentId = opts.parentId
  return ret
}

function createIssueArg(i, j, k, opts={}) {
  const ret = {
    titleSource: `The \\i[title] ${i} ${j} ${k}.`,
    bodySource: `The \\i[body] ${i} ${j} ${k}.`,
  }
  if (opts.titleSource !== undefined) {
    ret.titleSource = opts.titleSource
  }
  if (opts.bodySource !== undefined) {
    ret.bodySource = opts.bodySource
  }
  return ret
}

async function createUser(sequelize, i) {
  const user = new sequelize.models.User(createUserArg(i, { password: false }))
  sequelize.models.User.setPassword(user, 'asdf')
  return user.save()
}

function createUserArg(i, opts={}) {
  let { password } = opts
  if (password === undefined) {
    password = true
  }
  const ret = {
    email: `user${i}@mail.com`,
    username: `user${i}`,
    displayName: `User ${i}`,
  }
  if (opts.username !== undefined) {
    ret.username = opts.username
  }
  if (opts.displayName !== undefined) {
    ret.displayName = opts.displayName
  }
  if (opts.email !== undefined) {
    ret.email = opts.email
  }
  if (password) {
    ret.password = 'asdf'
  }
  return ret
}

// https://stackoverflow.com/questions/8175093/simple-function-to-sort-an-array-of-objects
function sortByKey(arr, key) {
  return arr.sort((a, b) => {
    let x = a[key]
    var y = b[key]
    return ((x < y) ? -1 : ((x > y) ? 1 : 0));
  })
}

function testApp(cb, opts={}) {
  const canTestNext = opts.canTestNext === undefined ? false : opts.canTestNext
  return app.start(0, canTestNext && testNext, async (server, sequelize) => {
    const test = {
      sequelize
    }
    test.user = undefined
    test.userSave = undefined
    test.loginUser = function(newUser) {
      if (newUser) {
        test.user = newUser
        test.userSave = newUser
      } else {
        test.user = test.userSave
      }
    }
    test.disableToken = function() {
      test.user = undefined
    }
    const jsonHttpOpts = {
      getToken: function () { return test.user ? test.user.token : undefined },
      https: false,
      port: server.address().port,
      hostname: 'localhost',
      validateStatus: () => true,
    }
    test.sendJsonHttp = async function (method, path, opts={}) {
      const { body, useToken } = opts
      let token, headers = {}
      if (useToken === undefined || useToken) {
        token = test.user ? test.user.token : undefined
        if (token) {
          // So we can test logged-in Next.js GET requests.
          // This is never done on the actual website from js (cookies are sent by browser automatically only).
          headers.Cookie = `${AUTH_COOKIE_NAME}=${token}`
        }
      } else {
        token = undefined
      }
      return web_api.sendJsonHttp(
        method,
        path,
        Object.assign({ body, headers }, jsonHttpOpts)
      )
    }
    // Create user and save the token for future requests.
    test.createUserApi = async function(i, opts) {
      const { data, status } = await test.webApi.userCreate(createUserArg(i, opts))
      assertStatus(status, data)
      test.tokenSave = data.user.token
      test.loginUser()
      assert.strictEqual(data.user.username, `user${i}`)
      return data.user
    }
    test.webApi = new WebApi(jsonHttpOpts)
    await cb(test)
    server.close()
  })
}

beforeEach(async function () {
  this.currentTest.sequelize = await test_lib.generateDemoData({ empty: true })
})

afterEach(async function () {
  return this.currentTest.sequelize.close()
})

it('User.findAndCountArticlesByFollowed', async function() {
  const sequelize = this.test.sequelize
  const user0 = await createUser(sequelize, 0)
  const user1 = await createUser(sequelize, 1)
  const user2 = await createUser(sequelize, 2)
  const user3 = await createUser(sequelize, 3)

  await (user0.addFollowSideEffects(user1))
  await (user0.addFollowSideEffects(user3))

  const article0_0 = await createArticle(sequelize, user0, { i: 0 })
  const article1_0 = await createArticle(sequelize, user1, { i: 0 })
  const article1_1 = await createArticle(sequelize, user1, { i: 1 })
  const article1_2 = await createArticle(sequelize, user1, { i: 2 })
  const article1_3 = await createArticle(sequelize, user1, { i: 3 })
  const article2_0 = await createArticle(sequelize, user2, { i: 0 })
  const article2_1 = await createArticle(sequelize, user2, { i: 1 })
  const article3_0 = await createArticle(sequelize, user3, { i: 0 })
  const article3_1 = await createArticle(sequelize, user3, { i: 1 })

  const { count, rows } = await user0.findAndCountArticlesByFollowed(1, 3)
  assert.strictEqual(rows[0].titleRender, 'Title 0')
  assert.strictEqual(rows[0].file.authorId, user3.id)
  assert.strictEqual(rows[1].titleRender, 'Title 3')
  assert.strictEqual(rows[1].file.authorId, user1.id)
  assert.strictEqual(rows[2].titleRender, 'Title 2')
  assert.strictEqual(rows[2].file.authorId, user1.id)
  assert.strictEqual(rows.length, 3)
  // 6 manually from all follows + 2 for the automatically created indexes.
  assert.strictEqual(count, 8)
})

it('Article.getArticlesInSamePage', async function() {
  let rows
  let article_0_0, article_0_0_0, article_1_0, article
  const sequelize = this.test.sequelize
  const user0 = await createUser(sequelize, 0)
  const user1 = await createUser(sequelize, 1)

  // Create some articles.
  await createArticle(sequelize, user0, { titleSource: 'Title 0' })
  await createArticle(sequelize, user0, { titleSource: 'Title 0 1', parentId: '@user0/title-0'  })
  await createArticle(sequelize, user0, { titleSource: 'Title 0 0', parentId: '@user0/title-0' })
  await createArticle(sequelize, user0, { titleSource: 'Title 0 0 0', parentId: '@user0/title-0-0'  })

  // Single user tests.
  article = await sequelize.models.Article.getArticle({ sequelize, slug: 'user0/title-0' })
  rows = await sequelize.models.Article.getArticlesInSamePage({ sequelize, article, loggedInUser: user0 })
  assertRows(rows, [
    { slug: 'user0/title-0-0',   topicCount: 1, issueCount: 0, hasSameTopic: true, liked: false },
    { slug: 'user0/title-0-0-0', topicCount: 1, issueCount: 0, hasSameTopic: true, liked: false },
    { slug: 'user0/title-0-1',   topicCount: 1, issueCount: 0, hasSameTopic: true, liked: false },
  ])

  rows = await sequelize.models.Article.getArticlesInSamePage({ sequelize, article, loggedInUser: user0, h1: true })
  assertRows(rows, [
    { slug: 'user0/title-0', topicCount: 1, issueCount: 0, hasSameTopic: true, liked: false },
  ])

  article = await sequelize.models.Article.getArticle({ sequelize, slug: 'user0/title-0-0' })
  rows = await sequelize.models.Article.getArticlesInSamePage({ sequelize, article, loggedInUser: user0 })
  assertRows(rows, [
    { slug: 'user0/title-0-0-0', topicCount: 1, issueCount: 0, hasSameTopic: true, liked: false },
  ])

  article = await sequelize.models.Article.getArticle({ sequelize, slug: 'user0/title-0-0-0' })
  rows = await sequelize.models.Article.getArticlesInSamePage({ sequelize, article, loggedInUser: user0 })
  assertRows(rows, [])

  await createArticle(sequelize, user1, { titleSource: 'Title 0' })
  await createArticle(sequelize, user1, { titleSource: 'Title 0 1', parentId: '@user1/title-0' })
  await createArticle(sequelize, user1, { titleSource: 'Title 0 1 0', parentId: '@user1/title-0-1' })
  await createArticle(sequelize, user1, { titleSource: 'Title 0 0', parentId: '@user1/title-0' })

  // We have to refetch here because the counts involved are changed by other articles/issues/likes.
  article_0_0 = await sequelize.models.Article.getArticle({ sequelize, slug: 'user0/title-0' })
  article_0_0_0 = await sequelize.models.Article.getArticle({ sequelize, slug: 'user0/title-0-0' })
  article_1_0 = await sequelize.models.Article.getArticle({ sequelize, slug: 'user1/title-0' })

  // User1 likes user0/title-0-0
  await user1.addArticleLikeSideEffects(article_0_0_0)

  // Add an issue to Title 0 0 0.
  await convert.convertIssue({
    article: article_0_0_0,
    bodySource: '',
    number: 1,
    sequelize,
    titleSource: 'a',
    user: user0
  })

  // Multi user tests.
  rows = await sequelize.models.Article.getArticlesInSamePage({
    sequelize,
    article: article_0_0,
    loggedInUser: user0,
  })
  assertRows(rows, [
    { slug: 'user0/title-0-0',   topicCount: 2, issueCount: 1, hasSameTopic: true, liked: false },
    { slug: 'user0/title-0-0-0', topicCount: 1, issueCount: 0, hasSameTopic: true, liked: false },
    { slug: 'user0/title-0-1',   topicCount: 2, issueCount: 0, hasSameTopic: true, liked: false },
  ])
  rows = await sequelize.models.Article.getArticlesInSamePage({
    sequelize,
    article: article_0_0,
    loggedInUser: user1,
  })
  assertRows(rows, [
    { slug: 'user0/title-0-0',   topicCount: 2, issueCount: 1, hasSameTopic: true,  liked: true },
    { slug: 'user0/title-0-0-0', topicCount: 1, issueCount: 0, hasSameTopic: false, liked: false },
    { slug: 'user0/title-0-1',   topicCount: 2, issueCount: 0, hasSameTopic: true,  liked: false },
  ])
  rows = await sequelize.models.Article.getArticlesInSamePage({
    sequelize,
    article: article_1_0,
    loggedInUser: user0,
  })
  assertRows(rows, [
    { slug: 'user1/title-0-0',   topicCount: 2, issueCount: 0, hasSameTopic: true,  liked: false },
    { slug: 'user1/title-0-1',   topicCount: 2, issueCount: 0, hasSameTopic: true,  liked: false },
    { slug: 'user1/title-0-1-0', topicCount: 1, issueCount: 0, hasSameTopic: false, liked: false },
  ])
  rows = await sequelize.models.Article.getArticlesInSamePage({
    sequelize,
    article: article_1_0,
    loggedInUser: user1,
  })
  assertRows(rows, [
    { slug: 'user1/title-0-0',   topicCount: 2, issueCount: 0, hasSameTopic: true, liked: false },
    { slug: 'user1/title-0-1',   topicCount: 2, issueCount: 0, hasSameTopic: true, liked: false },
    { slug: 'user1/title-0-1-0', topicCount: 1, issueCount: 0, hasSameTopic: true, liked: false },
  ])
})

it('Article.rerender', async function() {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)

    // Create articles

      article = createArticleArg({
        i: 0,
        titleSource: 'Mathematics',
        // We had some predefined ourbigbook KaTeX macro issues in the past.
        bodySource: `$$
\\abs{x}
$$
`,
      })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Physics' })
      ;({data, status} = await createArticleApi(test, article, { previousSiblingId: '@user0/mathematics' }))
      assertStatus(status, data)

      // Sanity check.
      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
        { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/physics' },
      ])

    // Rerender does not set previousSibligId to undefined (thus moving article as first child).
    await sequelize.models.Article.rerender({ slugs: ['user0/physics'] })
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/physics' },
    ])

    // Works with OurBigBook predefined macros.
    await sequelize.models.Article.rerender({ slugs: ['user0/mathematics'] })
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/physics' },
    ])

    // Works for root article.
    await sequelize.models.Article.rerender({ slugs: ['user0'] })
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/physics' },
    ])
  })
})

it('normalize nested-set', async function() {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)

    // Create articles

      article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Calculus' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Physics' })
      ;({data, status} = await createArticleApi(test, article, { previousSiblingId: '@user0/mathematics' }))
      assertStatus(status, data)

    // The nested-set for the API is correctly normalized.
    await models.normalize({
      check: true,
      sequelize,
      whats: ['nested-set'],
    })
  })
})

it('Article.updateTopicsNewArticles', async function() {
  const sequelize = this.test.sequelize

  async function getTopicIds(topicIds) {
    return (await sequelize.models.Topic.getTopics({
      sequelize,
      articleOrder: 'topicId',
      articleWhere: { topicId: topicIds },
    })).rows
  }

  const nArticles = config.topicConsiderNArticles + 1
  const users = []
  for (let i = 0; i < nArticles; i++) {
    users.push(await createUser(sequelize, i))
  }

  const articles = []
  articles.push(await createArticle(sequelize, users[0], { i: 0 }))
  assertRows(
    await getTopicIds(['title-0']),
    [{ articleId: articles[0].id, articleCount: 1 }]
  )

  // Article update does not increment the Topic.articleCount.
  await createArticle(sequelize, users[0], { i: 0, body: 'Body 0 hacked' })
  assertRows(
    await getTopicIds(['title-0']),
    [{ articleId: articles[0].id, articleCount: 1 }]
  )

  articles.push(await createArticle(sequelize, users[1], { i: 99, path: 'title-0' }))
  // The topic title-0 is tied with two different titles, "Title 0" and "Title 99".
  // So we keep the oldest one, "Title 0".
  assertRows(
    await getTopicIds(['title-0']),
    [{ articleId: articles[0].id, articleCount: 2 }]
  )

  articles.push(await createArticle(sequelize, users[2], { i: 99, path: 'title-0' }))
  // This broke the above tie, now "Title 99" became the representative title with 2 entries,
  // so we update the topic to point to the oldest "Title 99".
  assertRows(
    await getTopicIds(['title-0']),
    [{ articleId: articles[1].id, articleCount: 3 }]
  )

  for (let i = 3; i < 7; i++) {
    articles.push(await createArticle(sequelize, users[i], { i: 99, path: 'title-0' }))
  }
  for (let i = 7; i < nArticles; i++) {
    articles.push(await createArticle(sequelize, users[i], { i: 0, path: 'title-0' }))
  }

  // Now we have 6 "Title 99" and 5 "Title 0". All have score 0.
  // Only top 10 most voted are considered, but we take lower IDs first,
  // and the Title 99 are IDs 1 through 6, so it wins, 6 to 4.
  assertRows(
    await getTopicIds(['title-0']),
    [{ articleId: articles[1].id, articleCount: nArticles }]
  )

  // So let's upvote the 4 trailing ones to bring them up in score.
  // This will bring the count 5 to 5, and "Title 0" will win because
  // it holds the smallest ID 0.
  for (let i = 7; i < nArticles; i++) {
    await users[0].addArticleLikeSideEffects(articles[i])
  }
  assertRows(
    await getTopicIds(['title-0']),
    [{ articleId: articles[0].id, articleCount: nArticles }]
  )
})

it('api: create an article and see it on global feed', async () => {
  await testApp(async (test) => {
    let data, status, article

    // User

      // Create user errors

        // Invalid username: too short
        ;({ data, status } = await test.webApi.userCreate(createUserArg(0, { username: 'a'.repeat(config.usernameMinLength - 1) })))
        assert.strictEqual(status, 422)

        // Invalid username: too long
        ;({ data, status } = await test.webApi.userCreate(createUserArg(0, { username: 'a'.repeat(config.usernameMaxLength + 1) })))
        assert.strictEqual(status, 422)

        // Invalid username char: _
        ;({ data, status } = await test.webApi.userCreate(createUserArg(0, { username: 'ab_cd' })))
        assert.strictEqual(status, 422)

        // Invalid username char: uppercase
        ;({ data, status } = await test.webApi.userCreate(createUserArg(0, { username: 'abCd' })))
        assert.strictEqual(status, 422)

        // Invalid username: starts in -, ends in -, double -
        ;({ data, status } = await test.webApi.userCreate(createUserArg(0, { username: '-abcd' })))
        assert.strictEqual(status, 422)
        ;({ data, status } = await test.webApi.userCreate(createUserArg(0, { username: 'abcd-' })))
        assert.strictEqual(status, 422)
        ;({ data, status } = await test.webApi.userCreate(createUserArg(0, { username: 'ab--cd' })))
        assert.strictEqual(status, 422)

      // Create users
      const user = await test.createUserApi(0)
      const user1 = await test.createUserApi(1)
      const user2 = await test.createUserApi(2)
      // Make user2 admin via direct DB access (the only way).
      await test.sequelize.models.User.update({ admin: true }, { where: { username: 'user2' } })
      test.loginUser(user)

      // User GET
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assertRows([data], [{ username: 'user0', displayName: 'User 0' }])

      // Edit users

        ;({data, status} = await test.webApi.userUpdate('user0', { displayName: 'User 0 hacked' }))
        assertStatus(status, data)
        ;({data, status} = await test.webApi.user('user0'))
        assertStatus(status, data)
        assertRows([data], [{ username: 'user0', displayName: 'User 0 hacked' }])

        // Non-admin users cannot edit other users.
        test.loginUser(user1)
        ;({data, status} = await test.webApi.userUpdate('user0', { displayName: 'User 0 hacked 2' }))
        assert.strictEqual(status, 403)
        test.loginUser(user)

        // Admin users can edit other users.
        test.loginUser(user2)
        ;({data, status} = await test.webApi.userUpdate('user0', { displayName: 'User 0 hacked 3' }))
        assertStatus(status, data)
        test.loginUser(user)
        ;({data, status} = await test.webApi.user('user0'))
        assertStatus(status, data)
        assertRows([data], [{ username: 'user0', displayName: 'User 0 hacked 3' }])

      // Users see their own email on GET.
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assertRows([data], [{ email: 'user0@mail.com' }])

      // Non-admin users don't see other users' email on GET.
      ;({data, status} = await test.webApi.user('user1'))
      assertStatus(status, data)
      assert.strictEqual(data.email, undefined)

      // Admin users see other users emails on GET.
      test.loginUser(user2)
      ;({data, status} = await test.webApi.user('user1'))
      assertStatus(status, data)
      assertRows([data], [{ email: 'user1@mail.com' }])
      test.loginUser(user)

      // Cannot modify username.
      ;({data, status} = await test.webApi.userUpdate('user0', { username: 'user0hacked' }))
      assert.strictEqual(status, 422)

      // Cannot modify email.
      // TODO https://github.com/ourbigbook/ourbigbook/issues/268
      // Once the above is fixed, this will likely just be allowed on dev mode and then
      // we will just remove this test.
      ;({data, status} = await test.webApi.userUpdate('user0', { email: 'user0hacked@mail.com' }))
      assert.strictEqual(status, 422)

    // Create article in one go

      article = createArticleArg({ i: 0 })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)
      assertRows(data.articles, [{ titleRender: 'Title 0' }])

    // Create article errors

      // Cannot create article if logged out.
      test.disableToken()
      article = createArticleArg({ i: 1 })
      ;({data, status} = await createArticleApi(test, article))
      assert.strictEqual(status, 401)
      test.loginUser()

      // Cannot create article if token is given but wrong.
      test.loginUser('asdfqwer')
      article = createArticleArg({ i: 1 })
      ;({data, status} = await createArticleApi(test, article))
      assert.strictEqual(status, 401)
      test.loginUser(user)

      // Recreating an article with POST is not allowed.
      article = createArticleArg({ i: 0, bodySource: 'Body 1' })
      ;({data, status} = await createArticleApi(test, article))
      assert.strictEqual(status, 422)

      // Wrong field type.
      ;({data, status} = await createArticleApi(test, { titleSource: 1, bodySource: 'Body 1' }))
      assert.strictEqual(status, 422)

      // Empty path.
      ;({data, status} = await createOrUpdateArticleApi(test, {
        titleSource: 'Empty path attempt', bodySource: 'Body 1' }, { path: '' }
      ))
      assert.strictEqual(status, 422)

      // Missing title and no path existing article to take it from
      ;({data, status} = await createArticleApi(test, { bodySource: 'Body 1' }))
      assert.strictEqual(status, 422)

      // Missing all data and no path to existing article to take it from
      ;({data, status} = await createArticleApi(test, {}))
      assert.strictEqual(status, 422)

      // Missing data, has path to existing article, but is not render.
      // Doesn't make sense as no changes can come from this.
      ;({data, status} = await createArticleApi(test, {}, { path: 'title-0' }))
      assert.strictEqual(status, 422)

      // Markup errors.
      ;({data, status} = await createArticleApi(test, {
        titleSource: 'The \\notdefined', bodySource: 'The \\i[body]' }))
      assert.strictEqual(status, 422)
      ;({data, status} = await createArticleApi(test,
        { titleSource: 'Error', bodySource: 'The \\notdefined' }))
      assert.strictEqual(status, 422)

    // View articles.

      // Access the article directly
      ;({data, status} = await test.webApi.article('user0/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Title 0')
      assert.strictEqual(data.titleSource, 'Title 0')
      assert.match(data.render, /Body 0\./)

      // See articles on global feed.

      ;({data, status} = await test.webApi.articles())
      assertStatus(status, data)
      assertRows(data.articles, [
        { titleRender: 'Title 0', slug: 'user0/title-0', render: /Body 0/ },
        { titleRender: 'Index', slug: 'user2' },
        { titleRender: 'Index', slug: 'user1' },
        { titleRender: 'Index', slug: 'user0' },
      ])

      // See latest articles by a user.

      ;({data, status} = await test.webApi.articles({ author: 'user0' }))
      assertStatus(status, data)
      assertRows(data.articles, [
        { titleRender: 'Title 0', slug: 'user0/title-0', render: /Body 0/ },
        { titleRender: 'Index', slug: 'user0' },
      ])

    // Edit article.

      article = createArticleArg({ i: 0, bodySource: 'Body 0 hacked.' })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assertStatus(status, data)
      assertRows(data.articles, [{ render: /Body 0 hacked\./ }])

      ;({data, status} = await test.webApi.article('user0/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Title 0')
      assert.match(data.render, /Body 0 hacked\./)

      // Undo it for test sanity.
      article = createArticleArg({ i: 0 })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assertStatus(status, data)
      assertRows(data.articles, [{ render: /Body 0\./ }])

      // Edit article with render: false followed by render: true without parameters.
      // Take bodySource parameter from the database state of the previous render: false.

        // render: false
        article = createArticleArg({ i: 0, bodySource: 'Body 0 hacked.' })
        ;({data, status} = await createOrUpdateArticleApi(test,
          article,
          { path: 'title-0', render: false }
        ))
        assertStatus(status, data)
        // Maybe we could return the pre-existing article here.
        assertRows(data.articles, [])

        // Also take this chance to check that /hash renderOutdated is correct.
        ;({data, status} = await test.webApi.articlesHash({ author: 'user0' }))
        assertStatus(status, data)
        assertRows(data.articles, [
          { path: '@user0/index.bigb',   renderOutdated: false, },
          { path: '@user0/title-0.bigb', renderOutdated: true,  },
        ])

        // render: true
        article = createArticleArg({ i: 0, bodySource: undefined })
        ;({data, status} = await createOrUpdateArticleApi(test,
          article,
          { path: 'title-0', render: true }
        ))
        assertStatus(status, data)
        assertRows(data.articles, [{ render: /Body 0 hacked\./ }])

        // And now not outdated after render.
        ;({data, status} = await test.webApi.articlesHash({ author: 'user0' }))
        assertStatus(status, data)
        assertRows(data.articles, [
          { path: '@user0/index.bigb',   renderOutdated: false, },
          { path: '@user0/title-0.bigb', renderOutdated: false,  },
        ])

        // Undo it for test sanity.
        article = createArticleArg({ i: 0 })
        ;({data, status} = await createOrUpdateArticleApi(test, article))
        assertStatus(status, data)
        assertRows(data.articles, [{ render: /Body 0\./ }])

    // Edit index article.

      ;({data, status} = await createOrUpdateArticleApi(test, {
        titleSource: 'Index',
        bodySource: 'Welcome to my home page hacked!'
      }))
      assertStatus(status, data)
      assertRows(data.articles, [{ render: /Welcome to my home page hacked!/ }])

      ;({data, status} = await test.webApi.article('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Index')
      assert.match(data.render, /Welcome to my home page hacked!/)

    // View articles

      // Test global feed paging.
      ;({data, status} = await test.webApi.articles({ limit: 2, page: 0 }))
      assertStatus(status, data)
      assertRows(data.articles, [
        { slug: 'user0/title-0' },
        { slug: 'user2' },
      ])
      ;({data, status} = await test.webApi.articles({ limit: 2, page: 1 }))
      assertStatus(status, data)
      assertRows(data.articles, [
        { slug: 'user1' },
        { slug: 'user0' },
      ])

      // Invalid limit or page.
      ;({data, status} = await test.webApi.articles({ limit: 'dontexist', page: 1 }))
      assert.strictEqual(status, 422)
      ;({data, status} = await test.webApi.articles({ limit: 2, page: 'dontexist' }))
      assert.strictEqual(status, 422)
      // Limit too large
      ;({data, status} = await test.webApi.articles({ limit: config.articleLimitMax + 1, page: 1 }))
      assert.strictEqual(status, 422)

    // Update articles

      // Create article with PUT.
      article = createArticleArg({ i: 1 })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assertStatus(status, data)
      articles = data.articles
      assert.strictEqual(articles[0].titleRender, 'Title 1')
      assert.strictEqual(articles.length, 1)

      // Access the article directly
      ;({data, status} = await test.webApi.article('user0/title-1'))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Title 1')
      assert.match(data.render, /Body 1/)

      // Update article with PUT.
      article = createArticleArg({ i: 1, bodySource: 'Body 2' })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assertStatus(status, data)

      // Access the article directly
      ;({data, status} = await test.webApi.article('user0/title-1'))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Title 1')
      assert.match(data.render, /Body 2/)

    // User following.

      // user2 follows user0
      test.loginUser(user2)
      ;({data, status} = await test.webApi.userFollow('user0'))
      assertStatus(status, data)

      // Follower count increases.
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.username, 'user0')
      assert.strictEqual(data.followerCount, 1)

      // user2 follows user2
      ;({data, status} = await test.webApi.userFollow('user2'))
      assertStatus(status, data)
      test.loginUser(user)

      // user0 follows user1
      ;({data, status} = await test.webApi.userFollow('user1'))

      // Users cannot follow another user twice.
      ;({data, status} = await test.webApi.userFollow('user1'))
      assert.strictEqual(status, 403)

      // Trying to follow an user that does not exist fails gracefully.
      ;({data, status} = await test.webApi.userFollow('dontexist'))
      assert.strictEqual(status, 404)

      // users followedBy
      ;({data, status} = await test.webApi.users({ followedBy: 'user0' }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user1' },
      ])
      ;({data, status} = await test.webApi.users({ followedBy: 'user1' }))
      assertStatus(status, data)
      assertRows(data.users, [])
      ;({data, status} = await test.webApi.users({ followedBy: 'user2' }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user2' },
        { username: 'user0' },
      ])
      ;({data, status} = await test.webApi.users({ followedBy: 'user2', limit: 1 }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user2' },
      ])

      // users following
      ;({data, status} = await test.webApi.users({ following: 'user0' }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user2' },
      ])
      ;({data, status} = await test.webApi.users({ following: 'user1' }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user0' },
      ])
      ;({data, status} = await test.webApi.users({ following: 'user2' }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user2' },
      ])

      // Both followedBy and following together also works.
      ;({data, status} = await test.webApi.users({
        following: 'user2',
        followedBy: 'user2',
      }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user2' },
      ])

    // User unfollowing.

      // user2 unfollows user0
      test.loginUser(user2)
      ;({data, status} = await test.webApi.userUnfollow('user0'))
      assertStatus(status, data)

      // Follower count decreases.
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.username, 'user0')
      assert.strictEqual(data.followerCount, 0)

      // user0 unfollows user2
      ;({data, status} = await test.webApi.userUnfollow('user2'))
      assertStatus(status, data)
      test.loginUser(user)

      // user0 unfollows user1
      ;({data, status} = await test.webApi.userUnfollow('user1'))

      // Users cannot unfollow another user twice.
      ;({data, status} = await test.webApi.userUnfollow('user1'))
      assert.strictEqual(status, 403)

      // Trying to follow an user that does not exist fails gracefully.
      ;({data, status} = await test.webApi.userUnfollow('dontexist'))
      assert.strictEqual(status, 404)

    // No more article index operations after this point, we are not going to create some more specialized articles.

      // Link to another article.
      article = createArticleArg({ titleSource: 'x', bodySource: '<Title 1>' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)
      // https://github.com/ourbigbook/ourbigbook/issues/283
      assert_xpath("//x:a[@href='/user0/title-1' and text()='Title 1']", data.articles[0].render)

    // Create issues

      ;({data, status} = await test.webApi.issueCreate('user0',
        {
          titleSource: 'The \\i[title] 0 index 0.',
          bodySource: 'The \\i[body] 0 index 0.',
        }
      ))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 0 index 0\./)
      assert.match(data.issue.render, /The <i>body<\/i> 0 index 0\./)
      assert.strictEqual(data.issue.number, 1)

      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 0)))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 0 0 0\./)
      assert.match(data.issue.render, /The <i>body<\/i> 0 0 0\./)
      assert.strictEqual(data.issue.number, 1)

      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 1)))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 0 0 1\./)
      assert.match(data.issue.render, /The <i>body<\/i> 0 0 1\./)
      assert.strictEqual(data.issue.number, 2)

      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 2)))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 0 0 2\./)
      assert.match(data.issue.render, /The <i>body<\/i> 0 0 2\./)
      assert.strictEqual(data.issue.number, 3)

      // Users can create issues on other users articles.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 3)))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 0 0 3\./)
      assert.match(data.issue.render, /The <i>body<\/i> 0 0 3\./)
      assert.strictEqual(data.issue.number, 4)
      test.loginUser(user)

      ;({data, status} = await test.webApi.issueCreate('user0/title-1', createIssueArg(0, 1, 0)))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 0 1 0\./)
      assert.match(data.issue.render, /The <i>body<\/i> 0 1 0\./)
      assert.strictEqual(data.issue.number, 1)

      ;({data, status} = await test.webApi.issueCreate('user0/title-1', createIssueArg(0, 1, 1)))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 0 1 1\./)
      assert.match(data.issue.render, /The <i>body<\/i> 0 1 1\./)
      assert.strictEqual(data.issue.number, 2)

      ;({data, status} = await test.webApi.issueCreate('user1',
        { titleSource: 'The \\i[title] 1 index 0.', bodySource: 'The \\i[body] 1 index 0.' }))
      assertStatus(status, data)
      assert.match(data.issue.titleRender, /The <i>title<\/i> 1 index 0\./)
      assert.match(data.issue.render, /The <i>body<\/i> 1 index 0\./)
      assert.strictEqual(data.issue.number, 1)

    // Create issue errors

    // Article does not exist
    ;({data, status} = await test.webApi.issueCreate('user0/dontexist', createIssueArg(0, 2, 0)))
    assert.strictEqual(status, 404)

    // Markup error on title
    ;({data, status} = await test.webApi.issueCreate('user0/title-0', {
      titleSource: 'The \\notdefined 0 2.', bodySource: 'The \\i[body] 0 2.' }))
    assert.strictEqual(status, 422)

    // Markup error on body
    ;({data, status} = await test.webApi.issueCreate('user0/title-0',
      { titleSource: 'The \\i[title] 0 2.', bodySource: 'The \\notdefined 0 2.' }))
    assert.strictEqual(status, 422)

    // Get issues

      // Get one issue.
      ;({data, status} = await test.webApi.issues({ id: 'user0/title-0' }))
      assertStatus(status, data)
      assertRows(data.issues, [
        { number: 4, titleRender: /The <i>title<\/i> 0 0 3\./ },
        { number: 3, titleRender: /The <i>title<\/i> 0 0 2\./ },
        { number: 2, titleRender: /The <i>title<\/i> 0 0 1\./ },
        { number: 1, titleRender: /The <i>title<\/i> 0 0 0\./ },
      ])

      // Article issue count increments when new issues are created.
      ;({data, status} = await test.webApi.article('user0/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.issueCount, 4)

      // Get another issue.
      ;({data, status} = await test.webApi.issues({ id: 'user0/title-1' }))
      assertStatus(status, data)
      assertRows(data.issues, [
        { number: 2, titleRender: /The <i>title<\/i> 0 1 1\./ },
        { number: 1, titleRender: /The <i>title<\/i> 0 1 0\./ },
      ])

      // Article issue count increments when new issues are created.
      ;({data, status} = await test.webApi.article('user0/title-1'))
      assertStatus(status, data)
      assert.strictEqual(data.issueCount, 2)

      // Get an index page issue.
      ;({data, status} = await test.webApi.issues({ id: 'user0' }))
      assertStatus(status, data)
      assertRows(data.issues, [
        { number: 1, titleRender: /The <i>title<\/i> 0 index 0\./ },
      ])

      // Article issue count increments when new issues are created.
      ;({data, status} = await test.webApi.article('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.issueCount, 1)

      // Get another index page issue.
      ;({data, status} = await test.webApi.issues({ id: 'user1' }))
      assertStatus(status, data)
      assertRows(data.issues, [
        { number: 1, titleRender: /The <i>title<\/i> 1 index 0\./ },
      ])

      // Article issue count increments when new issues are created.
      ;({data, status} = await test.webApi.article('user1'))
      assertStatus(status, data)
      assert.strictEqual(data.issueCount, 1)

      // Getting issues from an article that doesn't exist fails gracefully.
      ;({data, status} = await test.webApi.issues({ id: 'user0/dontexist' }))
      assert.strictEqual(status, 404)

    // Edit issue.

    ;({data, status} = await test.webApi.issueEdit('user1', 1,
      { bodySource: 'The \\i[body] 1 index 0 hacked.' }))
    assertStatus(status, data)
    assert.match(data.issue.titleRender, /The <i>title<\/i> 1 index 0\./)
    assert.match(data.issue.render, /The <i>body<\/i> 1 index 0 hacked\./)
    assert.strictEqual(data.issue.number, 1)

    ;({data, status} = await test.webApi.issues({ id: 'user1' }))
    assertRows(data.issues, [
      {
        number: 1,
        titleRender: /The <i>title<\/i> 1 index 0\./,
        render: /The <i>body<\/i> 1 index 0 hacked\./,
      },
    ])

    ;({data, status} = await test.webApi.issueEdit('user1', 1,
      { titleSource: 'The \\i[title] 1 index 0 hacked.' }))
    assertStatus(status, data)
    assert.match(data.issue.titleRender, /The <i>title<\/i> 1 index 0 hacked\./)
    assert.match(data.issue.render, /The <i>body<\/i> 1 index 0 hacked\./)
    assert.strictEqual(data.issue.number, 1)

    ;({data, status} = await test.webApi.issues({ id: 'user1' }))
    assertRows(data.issues, [
      {
        number: 1,
        titleRender: /The <i>title<\/i> 1 index 0 hacked\./,
        render: /The <i>body<\/i> 1 index 0 hacked\./,
      },
    ])

    // Edit issue errors

    // Article does not exist
    ;({data, status} = await test.webApi.issueEdit('user0/dontexist', 1, { titleSource: 'asdf' }))
    assert.strictEqual(status, 404)

    // Markup error on title
    ;({data, status} = await test.webApi.issueEdit('user0/title-0', 1, { titleSource: '\\notdefined' }))
    assert.strictEqual(status, 422)

    // Markup error on body
    ;({data, status} = await test.webApi.issueEdit('user0/title-0', 1, { bodySource: '\\notdefined' }))
    assert.strictEqual(status, 422)

    // Trying to edit someone else's issue fails.

      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueEdit('user1', 1,
        { bodySource: 'The \\i[body] 1 index 0 hacked by user1.' }))
      assert.strictEqual(status, 403)
      test.loginUser(user)

      // The issue didn't change.
      ;({data, status} = await test.webApi.issues({ id: 'user1' }))
      assertRows(data.issues, [
        {
          number: 1,
          titleRender: /The <i>title<\/i> 1 index 0 hacked\./,
          render: /The <i>body<\/i> 1 index 0 hacked\./,
        },
      ])

    // Issue likes.

      // Make user1 like one of the issues.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueLike('user0/title-1', 1))
      assertStatus(status, data)
      test.loginUser(user)

      // Score goes up.
      ;({data, status} = await test.webApi.issues({ id: 'user0/title-1', sort: 'score' } ))
      assertStatus(status, data)
      assertRows(data.issues, [
        { number: 1, titleRender: /The <i>title<\/i> 0 1 0\./, score: 1 },
        { number: 2, titleRender: /The <i>title<\/i> 0 1 1\./, score: 0 },
      ])

      // Users cannot like issue twice.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueLike('user0/title-1', 1))
      assert.strictEqual(status, 403)
      test.loginUser(user)

      // Users cannot like their own issue.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueLike('user0/title-0', 4))
      assert.strictEqual(status, 403)
      test.loginUser(user1)

      // Trying to like issue that does not exist fails gracefully.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueLike('user0/dontexist', 1))
      assert.strictEqual(status, 404)
      ;({data, status} = await test.webApi.issueLike('user0/title-1', 999))
      assert.strictEqual(status, 404)
      test.loginUser(user)

    // Make user1 unlike one of an issues.

      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueUnlike('user0/title-1', 1))
      assertStatus(status, data)
      test.loginUser(user)

      // Score goes up.
      ;({data, status} = await test.webApi.issues({ id: 'user0/title-1' }))
      assertStatus(status, data)
      assertRows(data.issues, [
        { number: 2, titleRender: /The <i>title<\/i> 0 1 1\./, score: 0 },
        { number: 1, titleRender: /The <i>title<\/i> 0 1 0\./, score: 0 },
      ])

      // Cannot unlike issue twice.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueUnlike('user0/title-1', 1))
      assert.strictEqual(status, 403)
      test.loginUser(user)

      // Trying to like article that does not exist fails gracefully.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.issueUnlike('user0/dontexist', 1))
      assert.strictEqual(status, 404)
      ;({data, status} = await test.webApi.issueUnlike('user0/title-1', 999))
      assert.strictEqual(status, 404)
      test.loginUser(user)

    // No more issue indexes after this point.

      // Link to article by issue author.
      ;({data, status} = await test.webApi.issueCreate('user1',
        {
          titleSource: 'x',
          bodySource: '<Title 1>',
        }
      ))
      assertStatus(status, data)
      // Has to account for go/issues/<number>/<username>, so four levels.
      assert_xpath("//x:a[@href='/user0/title-1' and text()='Title 1']", data.issue.render)

    // Create comments

      ;({data, status} = await test.webApi.commentCreate('user0', 1, 'The \\i[body] 0 index 0.'))
      assertStatus(status, data)
      assert.match(data.comment.render, /The <i>body<\/i> 0 index 0\./)
      assert.strictEqual(data.comment.number, 1)

      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'The \\i[body] 0 0 0.'))
      assertStatus(status, data)
      assert.match(data.comment.render, /The <i>body<\/i> 0 0 0\./)
      assert.strictEqual(data.comment.number, 1)

      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'The \\i[body] 0 0 1.'))
      assertStatus(status, data)
      assert.match(data.comment.render, /The <i>body<\/i> 0 0 1\./)
      assert.strictEqual(data.comment.number, 2)

      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 2, 'The \\i[body] 0 1 0.'))
      assertStatus(status, data)
      assert.match(data.comment.render, /The <i>body<\/i> 0 1 0\./)
      assert.strictEqual(data.comment.number, 1)

      ;({data, status} = await test.webApi.commentCreate('user0/title-1', 1, 'The \\i[body] 1 0 0.'))
      assertStatus(status, data)
      assert.match(data.comment.render, /The <i>body<\/i> 1 0 0\./)
      assert.strictEqual(data.comment.number, 1)

    // Create comment errors

      // Article does not exist
      ;({data, status} = await test.webApi.commentCreate('user0/title-1', 999, 'The \\i[body] 1 0 0.'))
      assert.strictEqual(status, 404)

      // Issues does not exist
      ;({data, status} = await test.webApi.commentCreate('user0/dontexist', 1, 'The \\i[body] 1 0 0.'))
      assert.strictEqual(status, 404)

      // Markup error
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'The \\notdefined 0 0 0.'))
      assert.strictEqual(status, 422)

    // Get some comments.

      // Get all comments in article.
      ;({data, status} = await test.webApi.comments('user0/title-0', 1))
      assertStatus(status, data)
      assertRows(data.comments, [
        { number: 1, render: /The <i>body<\/i> 0 0 0\./ },
        { number: 2, render: /The <i>body<\/i> 0 0 1\./ },
      ])

      // Get one comment in article
      ;({data, status} = await test.webApi.comment('user0/title-0', 1, 1))
      assertStatus(status, data)
      assertRows([data], [
        { number: 1, render: /The <i>body<\/i> 0 0 0\./ },
      ])

      // Get another comment in article
      ;({data, status} = await test.webApi.comment('user0/title-0', 1, 2))
      assertStatus(status, data)
      assertRows([data], [
        { number: 2, render: /The <i>body<\/i> 0 0 1\./ },
      ])

      // Getting a comment that doesn't exist fails gracefully.
      ;({data, status} = await test.webApi.comment('user0/title-0', 1, 3))
      assert.strictEqual(status, 404)

      // The issue comment count goes up with comment creation.
      ;({data, status} = await test.webApi.issue('user0/title-0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.commentCount, 2)

      // Get all comments in another article
      ;({data, status} = await test.webApi.comments('user0/title-0', 2))
      assertRows(data.comments, [
        { number: 1, render: /The <i>body<\/i> 0 1 0\./ },
      ])

      // The issue comment count goes up with comment creation.
      ;({data, status} = await test.webApi.issue('user0/title-0', 2))
      assertStatus(status, data)
      assert.strictEqual(data.commentCount, 1)

      // Getting comments from articles or issues that don't exist fails gracefully.
      ;({data, status} = await test.webApi.comments('user0/title-1', 999))
      assert.strictEqual(status, 404)
      ;({data, status} = await test.webApi.comments('user0/dontexist', 1))
      assert.strictEqual(status, 404)

    // Delete comment

      // Non-admin users cannot delete comments.
      ;({data, status} = await test.webApi.commentDelete('user0/title-0', 1, 1))
      assert.strictEqual(status, 403)

      // Trying to delete a comment that doesn't exist fails gracefully.
      test.loginUser(user2)
      ;({data, status} = await test.webApi.commentDelete('user0/title-0', 1, 3))
      assert.strictEqual(status, 404)
      test.loginUser(user)

      // Admin can delete comments.
      test.loginUser(user2)
      ;({data, status} = await test.webApi.commentDelete('user0/title-0', 1, 1))
      assert.strictEqual(status, 204)
      test.loginUser(user)

      // The issue comment count goes down with comment deletion.
      ;({data, status} = await test.webApi.issue('user0/title-0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.commentCount, 1)

      // The deleted comment is no longer visible
      ;({data, status} = await test.webApi.comment('user0/title-0', 1, 1))
      assert.strictEqual(status, 404)

    // No more comment index gets from now on.

      //// Link to article by comment author.
      // TODO https://github.com/ourbigbook/ourbigbook/issues/277
      // Changing titleSource: undefined, in convertComment to titleSource: 'asdf' makes it not fail,
      // but adds the title to the render.
      //;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, '<Title 1>'))
      //assertStatus(status, data)
      //assert_xpath("//x:a[@href='/user0/title-1' and text()='Title 1']", data.comment.render)

    if (testNext) {
      // Tests with the same result for logged in or off.
      async function testNextLoggedInOrOff() {
        // Index.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.home(),
        ))
        assertStatus(status, data)

        // User index.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.users(),
        ))
        assertStatus(status, data)

        // User.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.user('user0'),
        ))
        assertStatus(status, data)

        // User that doesn't exist.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.user('dontexist'),
        ))
        assert.strictEqual(status, 404)

        // Article.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.article('user0/title-0'),
        ))
        assertStatus(status, data)

        // Article that doesn't exist.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.article('user0/dontexist'),
        ))
        assert.strictEqual(status, 404)

        // Issue list for article.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.issues('user0/title-0'),
        ))
        assertStatus(status, data)

        // An issue of article.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.issue('user0/title-0', 1),
        ))
        assertStatus(status, data)

        // An issue that doesn't exist.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.issue('user0/title-0', 999),
        ))
        assert.strictEqual(status, 404)

        // Topic index.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.topics(),
        ))
        assertStatus(status, data)

        // Topic.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.topic('title-0'),
        ))
        assertStatus(status, data)

        // Empty topic.
        ;({data, status} = await test.sendJsonHttp(
          'GET',
          routes.topic('dontexist'),
        ))
        // Maybe we want 404?
        assertStatus(status, data)
      }

      // Logged in.
      await testNextLoggedInOrOff()

      // Logged out.
      test.disableToken()
      await testNextLoggedInOrOff()
      test.loginUser(user)

      // Cases where logged out access leads to redirect to signup page.
      async function testRedirIfLoggedOff(cb) {
        test.disableToken()
        let {data, status} = await cb()
        assert.strictEqual(status, 307)
        test.loginUser(user)
        ;({data, status} = await cb())
        assertStatus(status, data)
      }
      await testRedirIfLoggedOff(async () => test.sendJsonHttp(
        'GET',
        routes.articleNew(),
      ))
      await testRedirIfLoggedOff(async () => test.sendJsonHttp(
        'GET',
        routes.articleEdit('user0/title-0'),
      ))
      await testRedirIfLoggedOff(async () => test.sendJsonHttp(
        'GET',
        routes.issueNew('user0/title-0'),
      ))
      await testRedirIfLoggedOff(async () => test.sendJsonHttp(
        'GET',
        routes.issueEdit('user0/title-0', 1),
      ))
      await testRedirIfLoggedOff(async () => test.sendJsonHttp(
        'GET',
        routes.userEdit('user0'),
      ))

      // Non admins cannot see the settings page of other users.
      test.loginUser(user)
      ;({data, status} = await test.sendJsonHttp(
        'GET',
        routes.userEdit('user1'),
      ))
      assert.strictEqual(status, 404)

      // Admins can see the settings page of other users.
      test.loginUser(user2)
      ;({data, status} = await test.sendJsonHttp(
        'GET',
        routes.userEdit('user1'),
      ))
      assertStatus(status, data)
      test.loginUser(user)
    }
  }, { canTestNext: true })
})

it('api: article like', async () => {
  await testApp(async (test) => {
    let data, status, article

    // Create users
    const user0 = await test.createUserApi(0)
    const user1 = await test.createUserApi(1)
    const user2 = await test.createUserApi(2)
    test.loginUser(user0)

    // user0 creates another article
    article = createArticleArg({ i: 0 })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)
    assertRows(data.articles, [{ titleRender: 'Title 0' }])

    // Make user1 like article user0
    test.loginUser(user1)
    ;({data, status} = await test.webApi.articleLike('user0'))
    assertStatus(status, data)
    test.loginUser(user0)

    // Article score goes up.
    ;({data, status} = await test.webApi.article('user0'))
    assertStatus(status, data)
    assert.strictEqual(data.score, 1)

    // Make user2 like article user0
    test.loginUser(user2)
    ;({data, status} = await test.webApi.articleLike('user0'))
    assertStatus(status, data)
    test.loginUser(user0)

    // Like effects.

      // Article score goes up.
      ;({data, status} = await test.webApi.article('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.score, 2)

      // Shows on likedBy list of user1.
      ;({data, status} = await test.webApi.articles({ likedBy: 'user1' }))
      assertStatus(status, data)
      assertRows(data.articles, [
        { titleRender: 'Index', slug: 'user0' },
      ])

      // Does not show up on likedBy list of user0.
      ;({data, status} = await test.webApi.articles({ likedBy: 'user0' }))
      assertStatus(status, data)
      assertRows(data.articles, [])

      // Top articles by a user.
      ;({data, status} = await test.webApi.articles({ author: 'user0', sort: 'score' }))
      assertStatus(status, data)
      assertRows(data.articles, [
        { titleRender: 'Index', slug: 'user0', score: 2 },
        { titleRender: 'Title 0', slug: 'user0/title-0', render: /Body 0/, score: 0 },
      ])

      // Invalid sort.
      ;({data, status} = await test.webApi.articles({ author: 'user0', sort: 'dontexist' }))
      assert.strictEqual(status, 422)

      // User score.
      ;({data, status} = await test.webApi.users({ sort: 'score' }))
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user0', score: 2 },
        { username: 'user2', score: 0 },
        { username: 'user1', score: 0 },
      ])

    // Article like errors.

      // Users cannot like articles twice.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.articleLike('user0'))
      assert.strictEqual(status, 403)
      test.loginUser(user0)

      // Users cannot like their own article.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.articleLike('user1'))
      assert.strictEqual(status, 403)
      test.loginUser(user0)

      // Trying to like article that does not exist fails gracefully.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.articleLike('user0/dontexist'))
      assert.strictEqual(status, 404)
      test.loginUser(user0)

    // Make user1 unlike one of the articles.
    test.loginUser(user1)
    ;({data, status} = await test.webApi.articleUnlike('user0'))
    assertStatus(status, data)
    test.loginUser(user0)

    // Make user2 unlike one of the articles.
    test.loginUser(user2)
    ;({data, status} = await test.webApi.articleUnlike('user0'))
    assertStatus(status, data)
    test.loginUser(user0)

    // Unlike effects

      // Score goes back down.
      ;({data, status} = await test.webApi.article('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.score, 0)

      // User score.
      ;({data, status} = await test.webApi.users())
      assertStatus(status, data)
      assertRows(data.users, [
        { username: 'user2', score: 0 },
        { username: 'user1', score: 0 },
        { username: 'user0', score: 0 },
      ])

    // Unlike errors.

      // Cannot unlike article twice.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.articleUnlike('user0'))
      assert.strictEqual(status, 403)
      test.loginUser(user0)

      // Trying to like article that does not exist fails gracefully.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.articleUnlike('user0/dontexist'))
      assert.strictEqual(status, 404)
      test.loginUser(user0)

  })
})

it('api: article follow', async () => {
  await testApp(async (test) => {
    let data, status, article

    // Create users
    const user0 = await test.createUserApi(0)
    const user1 = await test.createUserApi(1)

    // user1 creates another article
    test.loginUser(user1)
    article = createArticleArg({ i: 0 })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)
    assertRows(data.articles, [{ titleRender: 'Title 0' }])
    test.loginUser(user0)

    // Users follow their own articles by default.

      ;({data, status} = await test.webApi.article('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, true)

      ;({data, status} = await test.webApi.article('user1'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, false)

      test.loginUser(user1)
      ;({data, status} = await test.webApi.article('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, false)

      ;({data, status} = await test.webApi.article('user1'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, true)

      ;({data, status} = await test.webApi.article('user1/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, true)
      test.loginUser(user0)

    // Make user0 follow article user1/title-0
    ;({data, status} = await test.webApi.articleFollow('user1/title-0'))
    assertStatus(status, data)

    // Follow effects.

      // Article follower count goes up and shows on logged in user as followed.
      ;({data, status} = await test.webApi.article('user1/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 2)
      assert.strictEqual(data.followed, true)

      // Shows on user0's followedBy list.
      ;({data, status} = await test.webApi.articles({ followedBy: 'user0' }))
      assertStatus(status, data)
      assertRows(data.articles, [
        { slug: 'user1/title-0' },
        { slug: 'user0' },
      ])

      // Most followed articles by user
      ;({data, status} = await test.webApi.articles({ author: 'user1', sort: 'followerCount' }))
      assertStatus(status, data)
      assertRows(data.articles, [
        { slug: 'user1/title-0', followerCount: 2 },
        { slug: 'user1', followerCount: 1 },
      ])

    // Article follow errors.

      // Users cannot follow articles twice.
      ;({data, status} = await test.webApi.articleFollow('user1/title-0'))
      assert.strictEqual(status, 403)

      // Trying to follow article that does not exist fails gracefully.
      ;({data, status} = await test.webApi.articleFollow('user1/dontexist'))
      assert.strictEqual(status, 404)

    // Make user0 unfollowe article user1/title-0.
    ;({data, status} = await test.webApi.articleUnfollow('user1/title-0'))
    assertStatus(status, data)

    // Unfollow effects

      // Follower count goes back down.
      ;({data, status} = await test.webApi.article('user1/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, false)

    // Unfollow errors.

      // Cannot unfollow article twice.
      ;({data, status} = await test.webApi.articleUnfollow('user1/title-0'))
      assert.strictEqual(status, 403)

      // Trying to follow article that does not exist fails gracefully.
      ;({data, status} = await test.webApi.articleUnfollow('user0/dontexist'))
      assert.strictEqual(status, 404)

    // Updating your own article does not make you follow it, only new article creation does.

      // Make user1 unfollowe article user1/title-0.
      test.loginUser(user1)
      ;({data, status} = await test.webApi.articleUnfollow('user1/title-0'))
      assertStatus(status, data)

      // Follower count goes back down.
      ;({data, status} = await test.webApi.article('user1/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 0)
      assert.strictEqual(data.followed, false)

      // Edit the article.
      article = createArticleArg({ i: 0, bodySource: 'hacked' })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assertStatus(status, data)

      // This does not make user1 follow the article.
      ;({data, status} = await test.webApi.article('user1/title-0'))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 0)
      assert.strictEqual(data.followed, false)

      test.loginUser(user0)
  })
})

it('api: issue follow', async () => {
  await testApp(async (test) => {
    let data, status, article

    // Create users
    const user0 = await test.createUserApi(0)
    const user1 = await test.createUserApi(1)

    // user1 creates issue user0#1
    test.loginUser(user1)
    ;({data, status} = await test.webApi.issueCreate('user0', createIssueArg(0, 0, 0)))
    assertStatus(status, data)
    assert.strictEqual(data.issue.number, 1)
    test.loginUser(user0)

    // Users follow their own issues by default

      ;({data, status} = await test.webApi.issue('user0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, false)

      test.loginUser(user1)
      ;({data, status} = await test.webApi.issue('user0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, true)
      test.loginUser(user0)

    // Make user0 follow issue user0#1
    ;({data, status} = await test.webApi.issueFollow('user0', 1))
    assertStatus(status, data)

    // Follow effects

      // Issue follower count goes up and shows on logged in user as followed.
      ;({data, status} = await test.webApi.issue('user0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 2)
      assert.strictEqual(data.followed, true)

      //// TODO Shows on user0's followedBy list.
      //;({data, status} = await test.webApi.issues({ followedBy: 'user0' }))
      //assertStatus(status, data)
      //assertRows(data.issues, [
      //  { slug: 'user1/title-0' },
      //  { slug: 'user0' },
      //])

    // issue follow errors.

      // Users cannot follow issues twice.
      ;({data, status} = await test.webApi.issueFollow('user0', 1))
      assert.strictEqual(status, 403)

      // Trying to follow issue on article that does not exist fails gracefully.
      ;({data, status} = await test.webApi.issueFollow('user0/dontexist', 1))
      assert.strictEqual(status, 404)

      // Trying to follow issue that does not exist fails gracefully.
      ;({data, status} = await test.webApi.issueFollow('user0', 2))
      assert.strictEqual(status, 404)

    // Make user0 unfollow issue user1/title-0.

      ;({data, status} = await test.webApi.issueUnfollow('user0', 1))
      assertStatus(status, data)

    // Unfollow effects

      // Follower count goes back down.
      ;({data, status} = await test.webApi.issue('user0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 1)
      assert.strictEqual(data.followed, false)

    // Unfollow errors

      // Cannot unfollow issue twice.
      ;({data, status} = await test.webApi.issueUnfollow('user0', 1))
      assert.strictEqual(status, 403)

      // Trying to follow issue on article that does not exist fails gracefully.
      ;({data, status} = await test.webApi.issueUnfollow('user0/dontexist', 1))
      assert.strictEqual(status, 404)

      // Trying to follow issue that does not exist fails gracefully.
      ;({data, status} = await test.webApi.issueUnfollow('user0', 2))
      assert.strictEqual(status, 404)

    // Commenting on an issue makes you follow it automatically

      ;({data, status} = await test.webApi.commentCreate('user0', 1, 'The \\i[body] 0 index 0.'))
      assertStatus(status, data)

      ;({data, status} = await test.webApi.issue('user0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 2)
      assert.strictEqual(data.followed, true)

      // Commenting again does not keep increasing the followerCount.
      ;({data, status} = await test.webApi.commentCreate('user0', 1, 'The \\i[body] 0 index 0.'))
      assertStatus(status, data)

      ;({data, status} = await test.webApi.issue('user0', 1))
      assertStatus(status, data)
      assert.strictEqual(data.followerCount, 2)
      assert.strictEqual(data.followed, true)
  })
})

// TODO this is an initial sketch of https://docs.ourbigbook.com/todo/delete-articles
// Some steps have been skipped because we are initially only enabling this for article moves:
// and issues and children are moved out to the new merge target by the migration code prior
// to deletion. For this reason, it is also not yet exposed on the API, and is tested by calling
// via the sequelize model directly.
it('api: delete article', async () => {
  await testApp(async (test) => {
    let data, status, article

    const sequelize = test.sequelize

    // Create users
    const user = await test.createUserApi(0)
    const admin = await test.createUserApi(1)
    await test.sequelize.models.User.update({ admin: true }, { where: { username: 'user1' } })
    test.loginUser(user)

    // Create a basic hierarchy.

      article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Algebra' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Calculus', bodySource: '\\Image[http://jpg]{title=My calculus image}\n\n<Algebra>\n' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Geometry' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Derivative', bodySource: '<Calculus>\n' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Limit' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus' }))
      assertStatus(status, data)

      // Create some data from another user
      test.loginUser(admin)

        // Add user1 metadata to user0/calculus

          // Like.
          ;({data, status} = await test.webApi.articleLike('user0/calculus'))
          assertStatus(status, data)

          // Create issue.
          ;({data, status} = await test.webApi.issueCreate('user0/calculus',
            { titleSource: 'Calculus issue 1' }
          ))
          assertStatus(status, data)

        // Create another "calculus" article to see handling of topic count side effects.

          article = createArticleArg({ i: 0, titleSource: 'Calculus' })
          ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user1' }))
          assertStatus(status, data)

      test.loginUser(user)

      // Create a comment
      ;({data, status} = await test.webApi.commentCreate('user0/calculus', 1, 'user0/title-1 1 1'))
      assertStatus(status, data)

    // Sanity checks before deletion.

      // User score is now up to 1 after like
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.username, 'user0')
      assert.strictEqual(data.score, 1)

      // The topic has 2 entries
      ;({data, status} = await test.webApi.topics({ id: 'calculus' }))
      assertStatus(status, data)
      assert.strictEqual(data.topics[0].articleCount, 2)

      // Issue is visible.
      ;({data, status} = await test.webApi.issue('user0/calculus', 1))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Calculus issue 1')

      // Comment is visible
      ;({data, status} = await test.webApi.comment('user0/calculus', 1, 1))
      assertStatus(status, data)
      assert.strictEqual(data.source, 'user0/title-1 1 1')

      // The in-article ID image-my-calculus-image is defined.
      {
        const id = await sequelize.models.Id.findOne({ where: { idid: '@user0/image-my-calculus-image' } })
        assert.notStrictEqual(id, null)
      }

      // References defined in calculus are present
      {
        const count = await sequelize.models.Ref.count({
          include: [
            {
              model: sequelize.models.File,
              as: 'definedAt',
              where: { path: '@user0/calculus.bigb' }
            }
          ]
        })
        assert.notStrictEqual(count, 0)
      }

      // The File object exists.
      {
        const file = await sequelize.models.File.findOne({ where: { path: '@user0/calculus.bigb' } })
        assert.notStrictEqual(file, null)
      }

      // Parent and previous siblings that involve calculus.
      {
        // Current tree state:
        // * 0 user0/Index
        //  * 1 Mathematics
        //    * 2 Geometry
        //    * 3 Calculus
        //      * 4 Limit
        //      * 5 Derivative
        //    * 6 Algebra
        // * 0 user1/Index
        //   * 1 Calculus

        const algebra = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          sequelize,
          slug: 'user0/algebra',
        })
        assert.strictEqual(algebra.parentId.idid, '@user0/mathematics')
        assert.strictEqual(algebra.previousSiblingId.idid, '@user0/calculus')

        const limit = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          slug: 'user0/limit',
          sequelize
        })
        assert.strictEqual(limit.parentId.idid, '@user0/calculus')
        assert.strictEqual(limit.previousSiblingId, undefined)

        const derivative = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          slug: 'user0/derivative',
          sequelize
        })
        assert.strictEqual(derivative.parentId.idid, '@user0/calculus')
        assert.strictEqual(derivative.previousSiblingId.idid, '@user0/limit')

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 7, depth: 0, to_id_index: null, slug: 'user0', parentId: null },
          { nestedSetIndex: 1, nestedSetNextSibling: 7, depth: 1, to_id_index: 0, slug: 'user0/mathematics', parentId: '@user0' },
          { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, to_id_index: 0, slug: 'user0/geometry', parentId: '@user0/mathematics' },
          { nestedSetIndex: 3, nestedSetNextSibling: 6, depth: 2, to_id_index: 1, slug: 'user0/calculus', parentId: '@user0/mathematics' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 3, to_id_index: 0, slug: 'user0/limit', parentId: '@user0/calculus' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 3, to_id_index: 1, slug: 'user0/derivative', parentId: '@user0/calculus' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 2, to_id_index: 2, slug: 'user0/algebra', parentId: '@user0/mathematics' },
          { nestedSetIndex: 0, nestedSetNextSibling: 2, depth: 0, to_id_index: null, slug: 'user1', parentId: null },
          { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user1/calculus', parentId: '@user1' },
        ])
      }

    // Delete user0/calculus and check the side effects!!!
    {
      const article = await sequelize.models.Article.getArticle({
        includeParentAndPreviousSibling: true,
        sequelize,
        slug: 'user0/calculus',
      })
      await article.destroySideEffects()
    }

      // User score is is decremented when an article is deleted
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.username, 'user0')
      assert.strictEqual(data.score, 0)

      // Topic article count is decremented when the article the topic pointed to is deleted
      // Here the topic pointed to user0/calculus because it was created before user1/calculus.
      ;({data, status} = await test.webApi.topics({ id: 'calculus' }))
      assertStatus(status, data)
      assert.strictEqual(data.topics[0].articleCount, 1)

      //// Issues are deleted TODO
      //;({data, status} = await test.webApi.issue('user0/calculus', 1))
      //assert.strictEqual(status, 404)

      //// Comments are deleted TODO
      //;({data, status} = await test.webApi.comment('user0/calculus', 1, 1))
      ////assert.strictEqual(status, 404)

      // The in-article ID image-my-calculus-image was deleted
      {
        const id = await sequelize.models.Id.findOne({ where: { idid: '@user0/image-my-calculus-image' } })
        assert.strictEqual(id, null)
      }

      // References defined in calculus were deleted
      {
        const count = await sequelize.models.Ref.count({
          include: [
            {
              model: sequelize.models.File,
              as: 'definedAt',
              where: { path: '@user0/calculus.bigb' }
            }
          ]
        })
        assert.strictEqual(count, 0)
      }

      // The File object were deleted
      {
        const file = await sequelize.models.File.findOne({ where: { path: '@user0/calculus.bigb' } })
        assert.strictEqual(file, null)
      }

      // Parent and previous siblings that involve deleted article are updated.
      // All child pages are moved up the tree and placed where the parent was. 
      {
        // Current tree state:
        // * 0 user0/Index
        //  * 1 Mathematics
        //    * 2 Geometry
        //    * 3 Limit
        //    * 4 Derivative
        //    * 5 Algebra
        // * 0 user1/Index
        //   * 1 Calculus

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 6, depth: 0, to_id_index: null, slug: 'user0', parentId: null },
          { nestedSetIndex: 1, nestedSetNextSibling: 6, depth: 1, to_id_index: 0, slug: 'user0/mathematics', parentId: '@user0' },
          { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, to_id_index: 0, slug: 'user0/geometry', parentId: '@user0/mathematics' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 2, to_id_index: 1, slug: 'user0/limit', parentId: '@user0/mathematics' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 2, to_id_index: 2, slug: 'user0/derivative', parentId: '@user0/mathematics' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 2, to_id_index: 3, slug: 'user0/algebra', parentId: '@user0/mathematics' },
          { nestedSetIndex: 0, nestedSetNextSibling: 2, depth: 0, to_id_index: null, slug: 'user1', parentId: null },
          { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user1/calculus', parentId: '@user1' },
        ])

        const limit = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          slug: 'user0/limit',
          sequelize
        })
        assert.strictEqual(limit.parentId.idid, '@user0/mathematics')
        assert.strictEqual(limit.previousSiblingId.idid, '@user0/geometry')

        const derivative = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          slug: 'user0/derivative',
          sequelize
        })
        assert.strictEqual(derivative.parentId.idid, '@user0/mathematics')
        assert.strictEqual(derivative.previousSiblingId.idid, '@user0/limit')

        const algebra = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          sequelize,
          slug: 'user0/algebra',
        })
        assert.strictEqual(algebra.parentId.idid, '@user0/mathematics')
        assert.strictEqual(algebra.previousSiblingId.idid, '@user0/derivative')
      }

    // Delete user1/calculus and check the topic deletion effects.
    {
      const article = await sequelize.models.Article.getArticle({
        includeParentAndPreviousSibling: true,
        sequelize,
        slug: 'user1/calculus',
      })
      await article.destroySideEffects()
    }

      // Topics without articles are deleted
      // This behaviour might have to change later on if we implement features
      // such as topic following and topic issues:
      // https://docs.ourbigbook.com/todo/follow-topic
      // https://github.com/ourbigbook/ourbigbook/issues/257
      ;({data, status} = await test.webApi.topics({ id: 'calculus' }))
      assertStatus(status, data)
      assertRows(data.topics, [])
  })
})

// This used to work at one point working. But then we
// when we started exposing the parentId via API, and decided it would be
// less confusing if we instead forbade multiheader articles to start with.
// Maybe one day we can bring them back, but e.g. forbidding removal after published.
//it('api: multiheader file creates multiple articles', async () => {
//  await testApp(async (test) => {
//    let res,
//      data,
//      article
//
//    // Create user.
//    const user = await test.createUserApi(0)
//
//    // Create article.
//    article = createArticleArg({ i: 0, bodySource: `Body 0.
//
//== Title 0 0
//
//Body 0 0.
//
//== Title 0 1
//
//Body 0 1.
//`})
//    ;({data, status} = await createArticleApi(test, article))
//    assertStatus(status, data)
//    assertRows(data.articles, [
//      { titleRender: 'Title 0', slug: 'user0/title-0' },
//      { titleRender: 'Title 0 0', slug: 'user0/title-0-0' },
//      { titleRender: 'Title 0 1', slug: 'user0/title-0-1' },
//    ])
//    assert.match(data.articles[0].render, /Body 0\./)
//    assert.match(data.articles[0].render, /Body 0 0\./)
//    assert.match(data.articles[0].render, /Body 0 1\./)
//    assert.match(data.articles[1].render, /Body 0 0\./)
//    assert.match(data.articles[2].render, /Body 0 1\./)
//
//    // See them on global feed.
//    ;({data, status} = await test.webApi.articles())
//    assertStatus(status, data)
//    sortByKey(data.articles, 'slug')
//    assertRows(data.articles, [
//      { titleRender: 'Index', slug: 'user0' },
//      { titleRender: 'Title 0', slug: 'user0/title-0' },
//      { titleRender: 'Title 0 0', slug: 'user0/title-0-0' },
//      { titleRender: 'Title 0 1', slug: 'user0/title-0-1' },
//    ])
//
//    // Access one of the articles directly.
//    ;({data, status} = await test.webApi.article('user0/title-0-0'))
//    assertStatus(status, data)
//    assert.strictEqual(data.titleRender, 'Title 0 0')
//    assert.match(data.render, /Body 0 0\./)
//    assert.doesNotMatch(data.render, /Body 0 1\./)
//
//    // Modify the file.
//    article = createArticleArg({ i: 0, bodySource: `Body 0.
//
//== Title 0 0 hacked
//
//Body 0 0 hacked.
//
//== Title 0 1
//
//Body 0 1.
//`})
//    ;({data, status} = await createOrUpdateArticleApi(test, article))
//    assertStatus(status, data)
//    assertRows(data.articles, [
//      { titleRender: 'Title 0', slug: 'user0/title-0' },
//      { titleRender: 'Title 0 0 hacked', slug: 'user0/title-0-0-hacked' },
//      { titleRender: 'Title 0 1', slug: 'user0/title-0-1' },
//    ])
//    assert.match(data.articles[0].render, /Body 0\./)
//    assert.match(data.articles[0].render, /Body 0 0 hacked\./)
//    assert.match(data.articles[0].render, /Body 0 1\./)
//    assert.match(data.articles[1].render, /Body 0 0 hacked\./)
//    assert.match(data.articles[2].render, /Body 0 1\./)
//
//    // See them on global feed.
//    ;({data, status} = await test.webApi.articles())
//    assertStatus(status, data)
//    sortByKey(data.articles, 'slug')
//    assertRows(data.articles, [
//      { titleRender: 'Index',     slug: 'user0', },
//      { titleRender: 'Title 0',   slug: 'user0/title-0',  render: /Body 0 0 hacked\./ },
//      { titleRender: 'Title 0 0', slug: 'user0/title-0-0', render: /Body 0 0\./ },
//      { titleRender: 'Title 0 0 hacked', slug: 'user0/title-0-0-hacked', render: /Body 0 0 hacked\./ },
//      { titleRender: 'Title 0 1', slug: 'user0/title-0-1', render: /Body 0 1\./ },
//    ])
//
//    // Topic shows only one subarticle.
//    ;({data, status} = await test.webApi.articles({ topicId: 'title-0-0' }))
//    assertStatus(status, data)
//    sortByKey(data.articles, 'slug')
//    assertRows(data.articles, [
//      { titleRender: 'Title 0 0', slug: 'user0/title-0-0', render: /Body 0 0\./ },
//    ])
//  })
//})

it('api: resource limits', async () => {
  await testApp(async (test) => {
    let data, status, article

    const user = await test.createUserApi(0)
    const admin = await test.createUserApi(1)
    await test.sequelize.models.User.update({ admin: true }, { where: { username: 'user1' } })
    test.loginUser(user)

    // Non-admin users cannot edit their own resource limits.
    ;({data, status} = await test.webApi.userUpdate('user0', {
      maxArticles: 1,
      maxArticleSize: 2,
    }))
    assertStatus(status, data)
    assertRows([data.user], [{
      username: 'user0',
      maxArticles: config.maxArticles,
      maxArticleSize: config.maxArticleSize,
    }])

    // Admin users can edit other users' resource limits.
    test.loginUser(admin)
    ;({data, status} = await test.webApi.userUpdate('user0', {
      maxArticles: 3,
      maxArticleSize: 3,
    }))
    assertStatus(status, data)
    assertRows([data.user], [{
      username: 'user0',
      maxArticles: 3,
      maxArticleSize: 3,
    }])
    test.loginUser(user)

    // Article.

      // maxArticleSize resource limit is enforced for non-admins.
      article = createArticleArg({ i: 0, bodySource: 'abcd' })
      ;({data, status} = await createArticleApi(test, article))
      assert.strictEqual(status, 403)

      // maxArticleSize resource limit is not enforced for admins.
      test.loginUser(admin)
      article = createArticleArg({ i: 0, bodySource: 'abcd' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)
      test.loginUser(user)

      // OK, second article including Index.
      article = createArticleArg({ i: 0, bodySource: 'abc' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)

      // maxArticleSize resource limit is enforced for all users.
      article = createArticleArg({ titleSource: '0'.repeat(config.maxArticleTitleSize + 1), bodySource: 'abc' })
      ;({data, status} = await createArticleApi(test, article))
      assert.strictEqual(status, 422)

      // Even admin.
      test.loginUser(admin)
      article = createArticleArg({ titleSource: '0'.repeat(config.maxArticleTitleSize + 1), bodySource: 'abc' })
      ;({data, status} = await createArticleApi(test, article))
      assert.strictEqual(status, 422)
      test.loginUser(user)

      // OK 2, third article including Index.
      article = createArticleArg({ titleSource: '0'.repeat(config.maxArticleTitleSize), bodySource: 'abc' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)

      // maxArticles resource limit is enforced for non-admins.
      article = createArticleArg({ i: 2, bodySource: 'abc' })
      ;({data, status} = await createArticleApi(test, article))
      assert.strictEqual(status, 403)

      // maxArticles resource limit is enforced for non-admins when creating article with PUT.
      article = createArticleArg({ i: 2, bodySource: 'abc' })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assert.strictEqual(status, 403)

      // OK 2 for admin.
      test.loginUser(admin)
      article = createArticleArg({ i: 1, bodySource: 'abc' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)
      test.loginUser(user)

      // maxArticles resource limit is not enforced for admins.
      test.loginUser(admin)
      article = createArticleArg({ i: 2, bodySource: 'abc' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)
      test.loginUser(user)

    // Multiheader articles count as just one article.
    // We forbade multiheader articles at one point. Might be reallowed later on.
//      // Increment article limit by two from 3 to 5. User had 3, so now there are two left.
//      // Also increment article size so we can fit the header in.
//      test.loginUser(admin)
//      ;({data, status} = await test.webApi.userUpdate('user0', {
//        maxArticles: 5,
//        maxArticleSize: 100,
//      }))
//      assertStatus(status, data)
//      test.loginUser(user)
//
//      // This should count as just one, totalling 4.
//      article = createArticleArg({ i: 2, bodySource: `== Title 2 1
//` })
//      ;({data, status} = await createArticleApi(test, article))
//      assertStatus(status, data)
//
//      // So now we can still do one more, totalling 5.
//      article = createArticleArg({ i: 3, bodySource: `abc`})
//      ;({data, status} = await createArticleApi(test, article))
//      assertStatus(status, data)

    // Issue.

      // Change limit to 2 now that we don't have Index.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.userUpdate('user0', {
        maxArticles: 2,
        maxArticleSize: 3,
      }))
      assertStatus(status, data)
      test.loginUser(user)

      // maxArticleSize resource limit is enforced for non-admins.
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 0, { bodySource: 'abcd' })))
      assert.strictEqual(status, 403)

      // maxArticleSize resource limit is not enforced for admins.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 0, { bodySource: 'abcd' })))
      assertStatus(status, data)
      test.loginUser(user)

      // OK.
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 0, { bodySource: 'abc' })))
      assertStatus(status, data)

      // maxArticleSize resource limit is enforced for all users.
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(
        0, 0, 0, { titleSource: '0'.repeat(config.maxArticleTitleSize + 1), bodySource: 'abc' })))
      assert.strictEqual(status, 422)

      // Even admin.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(
        0, 0, 0, { titleSource: '0'.repeat(config.maxArticleTitleSize + 1), bodySource: 'abc' })))
      assert.strictEqual(status, 422)
      test.loginUser(user)

      // OK 2.
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(
        0, 0, 0, { titleSource: '0'.repeat(config.maxArticleTitleSize), bodySource: 'abc' })))
      assertStatus(status, data)

      // maxArticles resource limit is enforced for non-admins.
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 0, { bodySource: 'abc' })))
      assert.strictEqual(status, 403)

      // OK 2 for admin.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 0, { bodySource: 'abc' })))
      assertStatus(status, data)
      test.loginUser(user)

      // maxArticles resource limit is not enforced for admins.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.issueCreate('user0/title-0', createIssueArg(0, 0, 0, { bodySource: 'abc' })))
      assertStatus(status, data)
      test.loginUser(user)

    // Comment.

      // maxArticleSize resource limit is enforced for non-admins.
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'abcd'))
      assert.strictEqual(status, 403)

      // maxArticleSize resource limit is not enforced for admins.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'abcd'))
      assertStatus(status, data)
      test.loginUser(user)

      // OK.
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'abc'))
      assertStatus(status, data)

      // OK 2.
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'abc'))
      assertStatus(status, data)

      // maxArticles resource limit is enforced for non-admins.
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'abc'))
      assert.strictEqual(status, 403)

      // OK 2 for admin.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'abc'))
      assertStatus(status, data)
      test.loginUser(user)

      // maxArticles resource limit is not enforced for admins.
      test.loginUser(admin)
      ;({data, status} = await test.webApi.commentCreate('user0/title-0', 1, 'abc'))
      assertStatus(status, data)
      test.loginUser(user)
  })
})

it('api: article tree: single user', async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    // Create a second user and index to ensure that the nested set indexes are independent for each user.
    // Because of course we didn't do this when originally implementing.
    const user1 = await test.createUserApi(1)
    test.loginUser(user)

    // Article.

      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
      ])

      article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)

      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 2, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
      ])

      article = createArticleArg({ i: 0, titleSource: 'Calculus' })
      ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
        { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, to_id_index: 0, slug: 'user0/calculus' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
      ])

      // It is possible to change a parent ID.

        // Create a new test ID.
        article = createArticleArg({ i: 0, titleSource: 'Derivative' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
        assertStatus(status, data)

        // Current tree state:
        // * 0 Index
        //  * 1 Mathematics
        //    * 2 Derivative
        //    * 3 Calculus

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 4, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 4, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, to_id_index: 0, slug: 'user0/derivative' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 2, to_id_index: 1, slug: 'user0/calculus' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // Modify the parent of derivative from Mathematics to its next sibling Calculus.
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus' }))
        assertStatus(status, data)

        // Current tree state:
        // * 0 Index
        //  * 1 Mathematics
        //    * 2 Calculus
        //      * 3 Derivative

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 4, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 4, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 2, to_id_index: 0, slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0, slug: 'user0/derivative' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

      // parentId errors

        // Parent ID that doesn't exist gives an error on new article.
        article = createArticleArg({ i: 0, titleSource: 'Physics' })
        ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/dontexist' }))
        assert.strictEqual(status, 422)

        // Parent ID that doesn't exist gives an error on existing article.
        article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/dontexist' }))
        assert.strictEqual(status, 422)

        // It is not possible to change the index parentId.
        article = createArticleArg({ i: 0, titleSource: 'Index' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
        assert.strictEqual(status, 422)

        // Also doesn't work with render: false
        article = createArticleArg({ i: 0, titleSource: 'Index' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics', render: false }))
        assert.strictEqual(status, 422)

        // It it not possible to set the parentId to an article of another user.
        article = createArticleArg({ i: 0, titleSource: 'Physics' })
        ;({data, status} = await createArticleApi(test, article, { parentId: '@user1' }))
        assert.strictEqual(status, 422)

        // Circular parent loops fail gracefully.
        article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus' }))
        assert.strictEqual(status, 422)

        // Circular parent loops fail gracefully with render: false.
        article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus', render: false }))
        // TODO bad.
        //assert.strictEqual(status, 422)
        // OK at least DB seems consistent.
        {
          const article = await sequelize.models.Article.getArticle({
            includeParentAndPreviousSibling: true,
            sequelize,
            slug: 'user0/mathematics',
          })
          assert.strictEqual(article.parentId.idid, '@user0')
        }

        // This is where it might go infinite if it hadn't been prevented above.
        article = createArticleArg({ i: 0, titleSource: 'Calculus' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
        assertStatus(status, data)

        // Circular parent loops to self fail gracefully.
        article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
        ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/mathematics' }))
        assert.strictEqual(status, 422)

      // previousSiblingId

        // Also add \\Image here, as we once had a bug where non header children were messing up the header tree
        article = createArticleArg({ i: 0, titleSource: 'Integral', bodySource: '\\Image[http://example.com]{title=My image}\n' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus', previousSiblingId: '@user0/derivative' }))
        assertStatus(status, data)

        // Current tree state:
        // * Index
        //  * 0 Mathematics
        //    * 1 Calculus
        //      * 2 Derivative
        //      * 3 Integral

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 5, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 5, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 5, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/derivative' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 3, to_id_index: 1,    slug: 'user0/integral' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // Refresh the parent index to show this new child.
        // TODO restore toc asserts. Requires next, not currently exposed on the API.
        //assert_xpath("//*[@id='toc']//x:a[@href='/user0/mathematics' and @data-test='0' and text()='Mathematics']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/calculus'    and @data-test='1' and text()='Calculus']",    data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/derivative'  and @data-test='2' and text()='Derivative']",  data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/integral'    and @data-test='3' and text()='Integral']",    data.articles[0].render)

        // Add another one, and update it a few times.
        // Empty goes first.
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: '@user0/calculus' }
        ))
        assertStatus(status, data)

        // Current tree state:
        // * Index
        //  * 0 Mathematics
        //    * 1 Calculus
        //      * 2 Limit
        //      * 3 Derivative
        //      * 4 Integral

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 6, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 6, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 6, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/limit' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 3, to_id_index: 2,    slug: 'user0/integral' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // TODO restore toc asserts.
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit'      and @data-test='2' and text()='Limit']",      data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/derivative' and @data-test='3' and text()='Derivative']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/integral'   and @data-test='4' and text()='Integral']",   data.articles[0].render)

        // Add some children to limit as we will be moving it around a bit,
        // and want to ensure that the children move with it.
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit of a function' }),
          { parentId: '@user0/limit' }
        ))
        assertStatus(status, data)
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit of a sequence' }),
          { parentId: '@user0/limit' }
        ))
        assertStatus(status, data)

        // Current tree state:
        // * Index
        //  * 0 Mathematics
        //    * 1 Calculus
        //      * 2 Limit
        //        * 3 Limit of a sequence
        //        * 4 Limit of a function
        //      * 5 Derivative
        //      * 6 Integral

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 8, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 8, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 8, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 6, depth: 3, to_id_index: 0,    slug: 'user0/limit' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-sequence' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 4, to_id_index: 1,    slug: 'user0/limit-of-a-function' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
          { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 3, to_id_index: 2,    slug: 'user0/integral' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // TODO restore toc asserts.
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit'      and @data-test='2' and text()='Limit']",      data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-sequence' and @data-test='3' and text()='Limit of a sequence']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-function' and @data-test='4' and text()='Limit of a function']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/derivative' and @data-test='5' and text()='Derivative']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/integral'   and @data-test='6' and text()='Integral']",   data.articles[0].render)

        // Move Limit to after a later sibling. Give a parentId as well as sibling. parentId is not necessary
        // in this case as it is implied by previousSibling, but it is allowed.
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: '@user0/calculus', previousSiblingId: '@user0/integral' }
        ))
        assertStatus(status, data)

        // Current tree state:
        // * 0 Index
        //  * 1 Mathematics
        //    * 2 Calculus
        //      * 3 Derivative
        //      * 4 Integral
        //      * 5 Limit
        //        * 6 Limit of a sequence
        //        * 7 Limit of a function

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 8, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 8, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 8, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/derivative' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 3, to_id_index: 1,    slug: 'user0/integral' },
          { nestedSetIndex: 5, nestedSetNextSibling: 8, depth: 3, to_id_index: 2,    slug: 'user0/limit' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-sequence' },
          { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 4, to_id_index: 1,    slug: 'user0/limit-of-a-function' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // TODO restore toc asserts.
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/derivative' and @data-test='2' and text()='Derivative']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/integral'   and @data-test='3' and text()='Integral']",   data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit'      and @data-test='4' and text()='Limit']",      data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-sequence' and @data-test='5' and text()='Limit of a sequence']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-function' and @data-test='6' and text()='Limit of a function']", data.articles[0].render)

        // Move to previous sibling. Don't give parentId on update. Parent will be derived from sibling.

          // First create a link to derivative from here. This is to stress the case where there is a non-parent
          // link to the previousSiblingId, to ensure that the Ref type is chosen on the query. This failed to exercise
          // the bug concretely, as it is an undocumented behaviour ordering issue.
          article = createArticleArg({ i: 0, titleSource: 'Mathematics', bodySource: '<derivative>' })
          ;({data, status} = await createOrUpdateArticleApi(test, article))
          assertStatus(status, data)

          ;({data, status} = await createOrUpdateArticleApi(test,
            createArticleArg({ i: 0, titleSource: 'Limit' }),
            { parentId: undefined, previousSiblingId: '@user0/derivative' }
          ))
          assertStatus(status, data)

        // Current tree state:
        // * 0 Index
        //  * 1 Mathematics
        //    * 2 Calculus
        //      * 3 Derivative
        //      * 4 Limit
        //        * 5 Limit of a sequence
        //        * 6 Limit of a function
        //      * 7 Integral

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 8, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 8, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 8, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/derivative' },
          { nestedSetIndex: 4, nestedSetNextSibling: 7, depth: 3, to_id_index: 1,    slug: 'user0/limit' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-sequence' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 4, to_id_index: 1,    slug: 'user0/limit-of-a-function' },
          { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 3, to_id_index: 2,    slug: 'user0/integral' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // Move limit to before ancestor to check that nested set doesn't blow up.
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: '@user0/mathematics', previousSiblingId: undefined }
        ))
        assertStatus(status, data)
        // Ancestors placeholder is present.
        assert_xpath("//x:div[@class='nav ancestors']", data.articles[0].h1Render)

        // Current tree state:
        // * Index
        //  * Mathematics
        //    * Limit
        //      * Limit of a sequence
        //      * Limit of a function
        //    * Calculus
        //      * Derivative
        //      * Integral

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 8, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 8, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 5, depth: 2, to_id_index: 0,    slug: 'user0/limit' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/limit-of-a-sequence' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 3, to_id_index: 1,    slug: 'user0/limit-of-a-function' },
          { nestedSetIndex: 5, nestedSetNextSibling: 8, depth: 2, to_id_index: 1,    slug: 'user0/calculus' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 3, to_id_index: 0,    slug: 'user0/derivative' },
          { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 3, to_id_index: 1,    slug: 'user0/integral' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // Move limit back to where it was.
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: undefined, previousSiblingId: '@user0/derivative' }
        ))
        assertStatus(status, data)

        // Current tree state:
        // * Index
        //  * 0 Mathematics
        //    * 1 Calculus
        //      * 2 Derivative
        //      * 3 Limit
        //        * 4 Limit of a sequence
        //        * 5 Limit of a function
        //      * 6 Integral

        // TODO restore toc asserts.
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/derivative' and @data-test='2' and text()='Derivative']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit'      and @data-test='3' and text()='Limit']",      data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-sequence' and @data-test='4' and text()='Limit of a sequence']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-function' and @data-test='5' and text()='Limit of a function']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/integral'   and @data-test='6' and text()='Integral']",   data.articles[0].render)

        // Move back to first by not giving previousSiblingId. previousSiblingId is not maintained like most updated properties.
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: undefined }
        ))
        assertStatus(status, data)

        // Ancestors placeholder is when neither parentId nor previousSiblingId are given.
        assert_xpath("//x:div[@class='nav ancestors']", data.articles[0].h1Render)

        // Current tree state:
        // * Index
        //  * 0 Mathematics
        //    * 1 Calculus
        //      * 2 Limit
        //        * 3 Limit of a sequence
        //        * 4 Limit of a function
        //      * 5 Derivative
        //      * 6 Integral

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 8, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 8, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 8, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 6, depth: 3, to_id_index: 0,    slug: 'user0/limit' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-sequence' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 4, to_id_index: 1,    slug: 'user0/limit-of-a-function' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
          { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 3, to_id_index: 2,    slug: 'user0/integral' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // TODO restore toc asserts.
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit'      and @data-test='2' and text()='Limit']",      data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-sequence' and @data-test='3' and text()='Limit of a sequence']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-function' and @data-test='4' and text()='Limit of a function']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/derivative' and @data-test='5' and text()='Derivative']", data.articles[0].render)
        //assert_xpath("//*[@id='toc']//x:a[@href='user0/integral'   and @data-test='6' and text()='Integral']",   data.articles[0].render)

        // Deduce parentId from previousSiblingid on new article.
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Measure' }),
          { parentId: undefined, previousSiblingId: '@user0/integral' }
        ))
        assertStatus(status, data)

        // Current tree state:
        // * Index
        //  * 0 Mathematics
        //    * 1 Calculus
        //      * 2 Limit
        //        * 3 Limit of a sequence
        //        * 4 Limit of a function
        //      * 5 Derivative
        //      * 6 Integral
        //      * 7 Measure

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 9, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 9, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 9, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 6, depth: 3, to_id_index: 0,    slug: 'user0/limit' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-sequence' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 4, to_id_index: 1,    slug: 'user0/limit-of-a-function' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
          { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 3, to_id_index: 2,    slug: 'user0/integral' },
          { nestedSetIndex: 8, nestedSetNextSibling: 9, depth: 3, to_id_index: 3,    slug: 'user0/measure' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // TODO restore toc asserts.
        // assert_xpath("//*[@id='toc']//x:a[@href='user0/limit'      and @data-test='2' and text()='Limit']",      data.articles[0].render)
        // assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-sequence' and @data-test='3' and text()='Limit of a sequence']", data.articles[0].render)
        // assert_xpath("//*[@id='toc']//x:a[@href='user0/limit-of-a-function' and @data-test='4' and text()='Limit of a function']", data.articles[0].render)
        // assert_xpath("//*[@id='toc']//x:a[@href='user0/derivative' and @data-test='5' and text()='Derivative']", data.articles[0].render)
        // assert_xpath("//*[@id='toc']//x:a[@href='user0/integral'   and @data-test='6' and text()='Integral']",   data.articles[0].render)
        // assert_xpath("//*[@id='toc']//x:a[@href='user0/measure'    and @data-test='7' and text()='Measure']",    data.articles[0].render)

        // Refresh Mathematics to show the source ToC.
        // Add a reference to the article self: we once had a bug where this was preventing the ToC from showing.
        article = createArticleArg({ i: 0, titleSource: 'Mathematics', bodySource: 'I like mathematics.' })
        ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0' }))
        assertStatus(status, data)
        // TODO restore toc asserts.
        // assert_xpath("//*[@id='toc']//x:a[@href='/user0/calculus' and @data-test='0' and text()='Calculus']", data.articles[0].render)

      // Article.getArticle includeParentAndPreviousSibling argument test.
      // Used on editor only for now, so a bit hard to test on UI. But this tests the crux MEGAJOIN just fine.

        await assertNestedSets(sequelize, [
          { nestedSetIndex: 0, nestedSetNextSibling: 9, depth: 0, to_id_index: null, slug: 'user0' },
          { nestedSetIndex: 1, nestedSetNextSibling: 9, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 9, depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
          { nestedSetIndex: 3, nestedSetNextSibling: 6, depth: 3, to_id_index: 0,    slug: 'user0/limit' },
          { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-sequence' },
          { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 4, to_id_index: 1,    slug: 'user0/limit-of-a-function' },
          { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
          { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 3, to_id_index: 2,    slug: 'user0/integral' },
          { nestedSetIndex: 8, nestedSetNextSibling: 9, depth: 3, to_id_index: 3,    slug: 'user0/measure' },
          { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1' },
        ])

        // First sibling.
        article = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          sequelize,
          slug: 'user0/limit',
        })
        assert.strictEqual(article.parentId.idid, '@user0/calculus')
        assert.strictEqual(article.previousSiblingId, undefined)

        // Not first sibling.
        article = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          sequelize,
          slug: 'user0/derivative',
        })
        assert.strictEqual(article.parentId.idid, '@user0/calculus')
        assert.strictEqual(article.previousSiblingId.idid,  '@user0/limit')

        // Index.
        article = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          sequelize,
          slug: 'user0',
        })
        assert.strictEqual(article.parentId, undefined)
        assert.strictEqual(article.previousSiblingId,  undefined)

        // Hopefully the above tests would have caught any wrong to_id_index issues, but just in case.
        const refs = await sequelize.models.Ref.findAll({
          where: {
            type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT],
          },
          include: [
            {
              model: sequelize.models.Id,
              as: 'to',
              where: {
                macro_name: ourbigbook.Macro.HEADER_MACRO_NAME,
              },
            },
          ],
          order: [
            ['from_id', 'ASC'],
            ['to_id_index', 'ASC'],
            ['to_id', 'ASC'],
          ],
        })
        assertRows(refs, [
          { from_id: '@user0',             to_id: '@user0/mathematics',         to_id_index: 0, },
          { from_id: '@user0/calculus',    to_id: '@user0/limit',               to_id_index: 0, },
          { from_id: '@user0/calculus',    to_id: '@user0/derivative',          to_id_index: 1, },
          { from_id: '@user0/calculus',    to_id: '@user0/integral',            to_id_index: 2, },
          { from_id: '@user0/calculus',    to_id: '@user0/measure',             to_id_index: 3, },
          { from_id: '@user0/limit',       to_id: '@user0/limit-of-a-sequence', to_id_index: 0, },
          { from_id: '@user0/limit',       to_id: '@user0/limit-of-a-function', to_id_index: 1, },
          { from_id: '@user0/mathematics', to_id: '@user0/calculus',            to_id_index: 0, },
        ])

      // previousSiblingId errors

        // previousSiblingId that does not exist fails
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: undefined, previousSiblingId: '@user0/dontexist' }
        ))
        assert.strictEqual(status, 422)

        // previousSiblingId empty string fails
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: undefined, previousSiblingId: '' }
        ))
        assert.strictEqual(status, 422)

        // previousSiblingId that is not a child of parentId fails
        ;({data, status} = await createOrUpdateArticleApi(test,
          createArticleArg({ i: 0, titleSource: 'Limit' }),
          { parentId: '@user0/mathematics', previousSiblingId: '@user0/derivative' }
        ))
        assert.strictEqual(status, 422)

      // Forbidden elements

        // Cannot use Include on web.
        article = createArticleArg({ i: 0, titleSource: 'Physics', bodySource: `\\Include[mathematics]` })
        ;({data, status} = await createArticleApi(test, article))
        assert.strictEqual(status, 422)

        // Cannot have multiple headers per article on web.
        article = createArticleArg({ i: 0, titleSource: 'Physics', bodySource: `== Mechanics` })
        ;({data, status} = await createArticleApi(test, article))
        assert.strictEqual(status, 422)
  })
})

it('api: article tree: update first sibling to become a child', async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    // Create a second user and index to ensure that the nested set indexes are independent for each user.
    // Because of course we didn't do this when originally implementing.
    test.loginUser(user)

    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user0' },
    ])

    article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)

    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 2, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
    ])

    article = createArticleArg({ i: 0, titleSource: 'Physics' })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)

    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/physics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/mathematics' },
    ])

    article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/physics' }))
    assertStatus(status, data)

    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, to_id_index: 0, slug: 'user0/physics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, to_id_index: 0, slug: 'user0/mathematics' },
    ])
  })
})

async function createArticleApiMultiuser(test, users, articleArg, meta={}) {
  for (const user of users) {
    test.loginUser(user)
    const parentId = meta.parentId
    const newMeta = Object.assign({}, meta)
    if (parentId) {
      newMeta.parentId = `@${user.username}/${parentId}`
    }
    ;({data, status} = await createArticleApi(test, articleArg, newMeta))
    assertStatus(status, data)
  }
}

async function assertNestedSetsMultiuser(sequelize, users, rows) {
  const newRows = []
  for (const user of users) {
    for (const row of rows) {
      const slug = row.slug
      let sep
      if (slug) {
        sep = '/'
      } else {
        sep = ''
      }
      const newRow = Object.assign({}, row)
      newRow.slug = user.username + sep + slug
      newRows.push(newRow)
    }
  }
  return assertNestedSets(sequelize, newRows)
}

it('api: article tree: multiuser', async () => {
  await testApp(async (test) => {
    let article, data, status
    const sequelize = test.sequelize
    const user0 = await test.createUserApi(0)
    const user1 = await test.createUserApi(1)
    const users = [user0, user1]

    await assertNestedSetsMultiuser(sequelize, users, [
      { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, slug: '' },
    ])

    article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
    await createArticleApiMultiuser(test, users, article)

    await assertNestedSetsMultiuser(sequelize, users, [
      { nestedSetIndex: 0, nestedSetNextSibling: 2, depth: 0, slug: '' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, slug: 'mathematics' },
    ])

    article = createArticleArg({ i: 0, titleSource: 'Calculus',  })
    await createArticleApiMultiuser(test, users, article, { parentId: 'mathematics' })

    await assertNestedSetsMultiuser(sequelize, users, [
      { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, slug: '' },
        { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, slug: 'mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, slug: 'calculus' },
    ])

    article = createArticleArg({ i: 0, titleSource: 'Natural science' })
    await createArticleApiMultiuser(test, users, article)

    await assertNestedSetsMultiuser(sequelize, users, [
      { nestedSetIndex: 0, nestedSetNextSibling: 4, depth: 0, slug: '' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, slug: 'natural-science' },
        { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 1, slug: 'mathematics' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 2, slug: 'calculus' },
    ])

    // Sanity check because now we are going to start modifying just one tree.
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 4, depth: 0, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, slug: 'user0/natural-science' },
        { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 1, slug: 'user0/mathematics' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 2, slug: 'user0/calculus' },
      { nestedSetIndex: 0, nestedSetNextSibling: 4, depth: 0, slug: 'user1' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, slug: 'user1/natural-science' },
        { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 1, slug: 'user1/mathematics' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 2, slug: 'user1/calculus' },
    ])

    // Move user0/mathematics before natural science.
    test.loginUser(user0)
    article = createArticleArg({ i: 0, titleSource: 'Mathematics',  })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)

    // Moving a user0 article does not affect user1's tree.
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 4, depth: 0, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, slug: 'user0/mathematics' },
          { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, slug: 'user0/calculus' },
        { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 1, slug: 'user0/natural-science' },
      { nestedSetIndex: 0, nestedSetNextSibling: 4, depth: 0, slug: 'user1' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, slug: 'user1/natural-science' },
        { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 1, slug: 'user1/mathematics' },
          { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 2, slug: 'user1/calculus' },
    ])
  })
})

it('api: article tree render=false', async () => {
  // This is what we have to do on mass upload with ourbigbook --web
  // in order to handle circular references without having one massive
  // server-side operation.
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)
    for (const render of [false, true]) {
      article = createArticleArg({ i: 0, titleSource: 'Mathematics', bodySource: '<calculus>' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { render }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Calculus', bodySource: '<mathematics>' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }, { render }))
      assertStatus(status, data)
    }
  })
})

it('api: article tree: updateNestedSetIndex=false and /article/update-nested-set', async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)

    // New articles with updateNestedSetIndex=false

      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user0' },
      ])

      // user.nestedSetNeedsUpdate starts off as false.
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.nestedSetNeedsUpdate, false)

      article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { updateNestedSetIndex: false }))
      assertStatus(status, data)
      assert.strictEqual(data.nestedSetNeedsUpdate, true)

      // user.nestedSetNeedsUpdate becomes true after the nested set is updated
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.nestedSetNeedsUpdate, true)

      await assertNestedSets(sequelize, [
        { nestedSetIndex: null, nestedSetNextSibling: null, depth: null, to_id_index: 0, slug: 'user0/mathematics' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user0' },
      ])

      article = createArticleArg({ i: 0, titleSource: 'Calculus' })
      ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/mathematics', updateNestedSetIndex: false }))
      assertStatus(status, data)
      assert.strictEqual(data.nestedSetNeedsUpdate, true)

      await assertNestedSets(sequelize, [
        { nestedSetIndex: null, nestedSetNextSibling: null, depth: null, to_id_index: 0, slug: 'user0/calculus' },
        { nestedSetIndex: null, nestedSetNextSibling: null, depth: null, to_id_index: 0, slug: 'user0/mathematics' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user0' },
      ])

      ;({data, status} = await test.webApi.articleUpdatedNestedSet('user0'))
      assertStatus(status, data)

      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
        { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, to_id_index: 0, slug: 'user0/calculus' },
      ])

      // user.nestedSetNeedsUpdate becomes false after the nested se is updated
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.nestedSetNeedsUpdate, false)

    // Update existing articles with updateNestedSetIndex=false

      article = createArticleArg({ i: 0, titleSource: 'Calculus' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { updateNestedSetIndex: false }))
      assertStatus(status, data)
      assert.strictEqual(data.nestedSetNeedsUpdate, true)

      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/mathematics' },
        { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 2, to_id_index: 0, slug: 'user0/calculus' },
      ])

      ;({data, status} = await test.webApi.articleUpdatedNestedSet('user0'))
      assertStatus(status, data)

      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/calculus' },
        { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/mathematics' },
      ])

    // nestedSetNeedsUpdate is false when there are no tree changes.
    article = createArticleArg({ i: 0, titleSource: 'Calculus', bodySource: 'Hacked' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    assert.strictEqual(data.nestedSetNeedsUpdate, false)

    // nestedSetNeedsUpdate is true when there are tree changes and render=false.
    article = createArticleArg({ i: 0, titleSource: 'Mathematics', bodySource: 'Hacked' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { render: false }))
    assertStatus(status, data)
    assert.strictEqual(data.nestedSetNeedsUpdate, true)

    // nestedSetNeedsUpdate is false when there are no tree changes and render=false.
    // TODO: false would be better here. But would require a bit of refactoring, and wouldn't
    // help much on the CLI, so lazy now. For now it always returns "true" when render=false.
    article = createArticleArg({ i: 0, titleSource: 'Mathematics', bodySource: 'Hacked 2' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { render: false }))
    assertStatus(status, data)
    assert.strictEqual(data.nestedSetNeedsUpdate, true)

    // Move math up with a full render.
    article = createArticleArg({ i: 0, titleSource: 'Mathematics', bodySource: 'Hacked 2' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    ;({data, status} = await test.webApi.articleUpdatedNestedSet('user0'))
    assertStatus(status, data)

    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 3, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 2, depth: 1, to_id_index: 0, slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 3, depth: 1, to_id_index: 1, slug: 'user0/calculus' },
    ])

    // nestedSetNeedsUpdate is false when there are tree changes, but we are updating the index;
    article = createArticleArg({ i: 0, titleSource: 'Calculus', bodySource: 'Hacked 2' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    assert.strictEqual(data.nestedSetNeedsUpdate, false)
  })
})

it('api: child articles inherit scope from parent', async () => {
  // This is what we have to do on mass upload with ourbigbook --web
  // in order to handle circular references without having one massive
  // server-side operation.
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)

    article = createArticleArg({ i: 0, titleSource: 'Mathematics', bodySource: '{scope}' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)

    article = createArticleArg({ i: 0, titleSource: 'Calculus', bodySource: '{scope}' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
    assertStatus(status, data)

    article = createArticleArg({ i: 0, titleSource: 'Derivative' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics/calculus' }))
    assertStatus(status, data)

    article = createArticleArg({ i: 0, titleSource: 'Physics', bodySource: `<mathematics/calculus>

<mathematics/calculus/derivative>
` })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    assert_xpath("//x:a[@href='/user0/mathematics/calculus' and text()='calculus']", data.articles[0].render)
    assert_xpath("//x:a[@href='/user0/mathematics/calculus/derivative' and text()='derivative']", data.articles[0].render)
  })
})

it('api: synonym rename', async () => {
  // This is what we have to do on mass upload with ourbigbook --web
  // in order to handle circular references without having one massive
  // server-side operation.
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    const user1 = await test.createUserApi(1)
    test.loginUser(user)

    // Create a basic hierarchy.

      article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
      ;({data, status} = await createOrUpdateArticleApi(test, article))
      assertStatus(status, data)

      // Has Calculus as previous sibling.
      article = createArticleArg({ i: 0, titleSource: 'Algebra' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Calculus', bodySource: '\\Image[http://jpg]{title=My image}\n' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Integral' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Fundamental theorem of calculus' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/integral' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Derivative', bodySource: '<Calculus>\n' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Chain rule' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/derivative' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Limit' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus' }))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Limit of a series' })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/limit' }))
      assertStatus(status, data)

    // Add some metadata to our article of interest.

    ;({data, status} = await test.webApi.issueCreate('user0/calculus',
      { titleSource: 'Calculus issue 0' }
    ))
    assertStatus(status, data)

    test.loginUser(user1)
    ;({data, status} = await test.webApi.articleLike('user0/calculus'))
    assertStatus(status, data)
    test.loginUser(user)

    // Sanity checks

      ;({data, status} = await test.webApi.issue('user0/calculus', 1))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Calculus issue 0')

      // Current tree state:
      // * 0 user0/Index
      //  * 1 Mathematics
      //    * 2 Calculus
      //      * 3 Limit
      //        * 4 Limit of a series
      //      * 5 Derivative
      //        * 6 Chain rule
      //      * 7 Integral
      //        * 8 Fundamental theorem of calculus
      //    * 9 Algebra
      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 10, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 10, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
        { nestedSetIndex: 2, nestedSetNextSibling: 9,  depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
        { nestedSetIndex: 3, nestedSetNextSibling: 5,  depth: 3, to_id_index: 0,    slug: 'user0/limit' },
        { nestedSetIndex: 4, nestedSetNextSibling: 5,  depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series' },
        { nestedSetIndex: 5, nestedSetNextSibling: 7,  depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
        { nestedSetIndex: 6, nestedSetNextSibling: 7,  depth: 4, to_id_index: 0,    slug: 'user0/chain-rule' },
        { nestedSetIndex: 7, nestedSetNextSibling: 9,  depth: 3, to_id_index: 2,    slug: 'user0/integral' },
        { nestedSetIndex: 8, nestedSetNextSibling: 9,  depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus' },
        { nestedSetIndex: 9, nestedSetNextSibling: 10, depth: 2, to_id_index: 1,    slug: 'user0/algebra' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1,  depth: 0, to_id_index: null, slug: 'user1' },
      ])

      // Sanity check that the parent and previous sibling are correct.

      ;({data, status} = await test.webApi.article('user0/derivative', { 'include-parent': true }))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Derivative')
      assert.strictEqual(data.parentId, '@user0/calculus')

      ;({data, status} = await test.webApi.article('user0/algebra', { 'include-parent': true }))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Algebra')
      assert.strictEqual(data.previousSiblingId, '@user0/calculus')

      // Sanity check scores

      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.username, 'user0')
      assert.strictEqual(data.score, 1)

      ;({data, status} = await test.webApi.article('user0/calculus'))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Calculus')
      assert.strictEqual(data.score, 1)

    // Add calculus-2 as a synonym of calculus without changing title.

      article = createArticleArg({
        i: 0,
        titleSource: 'Calculus',
        bodySource: `= Calculus 2
{synonym}

\\Image[http://jpg]{title=My image}
`
      })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

      // Current tree state:
      // * 0 user0/Index
      //  * 1 Mathematics
      //    * 2 Calculus (Calculus 2)
      //      * 3 Limit
      //        * 4 Limit of a series
      //      * 5 Derivative
      //        * 6 Chain rule
      //      * 7 Integral
      //        * 8 Fundamental theorem of calculus
      //    * 9 Algebra
      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 10, depth: 0, to_id_index: null, slug: 'user0' },
        { nestedSetIndex: 1, nestedSetNextSibling: 10, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
        { nestedSetIndex: 2, nestedSetNextSibling: 9,  depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
        { nestedSetIndex: 3, nestedSetNextSibling: 5,  depth: 3, to_id_index: 0,    slug: 'user0/limit' },
        { nestedSetIndex: 4, nestedSetNextSibling: 5,  depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series' },
        { nestedSetIndex: 5, nestedSetNextSibling: 7,  depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
        { nestedSetIndex: 6, nestedSetNextSibling: 7,  depth: 4, to_id_index: 0,    slug: 'user0/chain-rule' },
        { nestedSetIndex: 7, nestedSetNextSibling: 9,  depth: 3, to_id_index: 2,    slug: 'user0/integral' },
        { nestedSetIndex: 8, nestedSetNextSibling: 9,  depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus' },
        { nestedSetIndex: 9, nestedSetNextSibling: 10, depth: 2, to_id_index: 1,    slug: 'user0/algebra' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1,  depth: 0, to_id_index: null, slug: 'user1' },
      ])

      // The synonym exists as a redirect.
      ;({data, status} = await test.webApi.articleRedirects({ id: 'user0/calculus-2' }))
      assertStatus(status, data)
      assert.strictEqual(data.redirects['user0/calculus-2'], 'user0/calculus')

      // synonym does not break parentId and previousSibling

      ;({data, status} = await test.webApi.article('user0/derivative', { 'include-parent': true }))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Derivative')
      assert.strictEqual(data.parentId, '@user0/calculus')

      ;({data, status} = await test.webApi.article('user0/algebra', { 'include-parent': true }))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Algebra')
      assert.strictEqual(data.previousSiblingId, '@user0/calculus')

      // webApi.article( uses Article.getArticles, just double check
      // with the Article.getARticle (singular) version.
      const algebra = await test.sequelize.models.Article.getArticle({
        includeParentAndPreviousSibling: true,
        sequelize,
        slug: 'user0/algebra',
      })
      assert.strictEqual(algebra.parentId.idid, '@user0/mathematics')
      assert.strictEqual(algebra.previousSiblingId.idid, '@user0/calculus')

      // Issues are not broken by adding the synonym.
      ;({data, status} = await test.webApi.issue('user0/calculus', 1))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Calculus issue 0')

    // Add a link to the new synonym.
    article = createArticleArg({ i: 0, titleSource: 'Derivative', bodySource: '<Calculus>\n\n<Calculus 2>\n' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus', previousSiblingId: '@user0/limit' }))
    assertStatus(status, data)
    // TODO Links to synonym header from have fragment
    // https://docs.ourbigbook.com/todo/links-to-synonym-header-have-fragment
    assert_xpath("//x:a[@href='/user0/calculus' and text()='Calculus']", data.articles[0].render)
    assert_xpath("//x:a[@href='/user0/calculus' and text()='Calculus 2']", data.articles[0].render)

    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 10, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 10, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 9,  depth: 2, to_id_index: 0,    slug: 'user0/calculus' },
      { nestedSetIndex: 3, nestedSetNextSibling: 5,  depth: 3, to_id_index: 0,    slug: 'user0/limit' },
      { nestedSetIndex: 4, nestedSetNextSibling: 5,  depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series' },
      { nestedSetIndex: 5, nestedSetNextSibling: 7,  depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
      { nestedSetIndex: 6, nestedSetNextSibling: 7,  depth: 4, to_id_index: 0,    slug: 'user0/chain-rule' },
      { nestedSetIndex: 7, nestedSetNextSibling: 9,  depth: 3, to_id_index: 2,    slug: 'user0/integral' },
      { nestedSetIndex: 8, nestedSetNextSibling: 9,  depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus' },
      { nestedSetIndex: 9, nestedSetNextSibling: 10, depth: 2, to_id_index: 1,    slug: 'user0/algebra' },
      { nestedSetIndex: 0, nestedSetNextSibling: 1,  depth: 0, to_id_index: null, slug: 'user1' },
    ])

    // Add a tag to the new synonym.
    article = createArticleArg({ i: 0, titleSource: 'Integral', bodySource: '{tag=Calculus 2}\n' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/calculus', previousSiblingId: '@user0/derivative' }))
    assertStatus(status, data)

    // Sanity: the File object exists.
    {
      const file = await sequelize.models.File.findOne({ where: { path: '@user0/calculus.bigb' } })
      assert.notStrictEqual(file, null)
    }

    // Rename Calculus to Calculus 3
    article = createArticleArg({
      i: 0,
      titleSource: 'Calculus 3',
      bodySource: `= Calculus
{synonym}

= Calculus 2
{synonym}

\\Image[http://jpg]{title=My image}
`
    })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/mathematics' }))
    assertStatus(status, data)

    // The File object was removed.
    {
      const file = await sequelize.models.File.findOne({ where: { path: '@user0/calculus.bigb' } })
      assert.strictEqual(file, null)
    }

    // Current tree state:
    // * 0 user0/Index
    //  * 1 Mathematics
    //    * 2 Calculus 3 (Calculus, Calculus 2)
    //      * 3 Limit
    //        * 4 Limit of a series
    //      * 5 Derivative
    //        * 6 Chain rule
    //      * 7 Integral
    //        * 8 Fundamental theorem of calculus
    //    * 9 Algebra
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 10, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 10, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 9,  depth: 2, to_id_index: 0,    slug: 'user0/calculus-3' },
      { nestedSetIndex: 3, nestedSetNextSibling: 5,  depth: 3, to_id_index: 0,    slug: 'user0/limit' },
      { nestedSetIndex: 4, nestedSetNextSibling: 5,  depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series' },
      { nestedSetIndex: 5, nestedSetNextSibling: 7,  depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
      { nestedSetIndex: 6, nestedSetNextSibling: 7,  depth: 4, to_id_index: 0,    slug: 'user0/chain-rule' },
      { nestedSetIndex: 7, nestedSetNextSibling: 9,  depth: 3, to_id_index: 2,    slug: 'user0/integral' },
      { nestedSetIndex: 8, nestedSetNextSibling: 9,  depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus' },
      { nestedSetIndex: 9, nestedSetNextSibling: 10, depth: 2, to_id_index: 1,    slug: 'user0/algebra' },
      { nestedSetIndex: 0, nestedSetNextSibling: 1,  depth: 0, to_id_index: null, slug: 'user1' },
    ])

    // Check that the latest name exists as the main one.
    ;({data, status} = await test.webApi.article('user0/calculus-3'))
    assertStatus(status, data)
    assert.strictEqual(data.titleRender, 'Calculus 3')
    assert.strictEqual(data.file.bodySource, `= Calculus
{synonym}

= Calculus 2
{synonym}

\\Image[http://jpg]{title=My image}
`
    )
    // The score is zeroed on rename to prevent rename spam.
    assert.strictEqual(data.score, 0)

    // The user score is reduce accordingly.
    ;({data, status} = await test.webApi.user('user0'))
    assertStatus(status, data)
    assert.strictEqual(data.username, 'user0')
    assert.strictEqual(data.score, 0)

    // Check that the metadata is now associated to the article with the new main title.

    ;({data, status} = await test.webApi.issue('user0/calculus-3', 1))
    assertStatus(status, data)
    assert.strictEqual(data.titleRender, 'Calculus issue 0')

    // Check that the previous name now exists as a redirect.

    ;({data, status} = await test.webApi.articleRedirects({ id: 'user0/calculus' }))
    assertStatus(status, data)
    assert.strictEqual(data.redirects['user0/calculus'], 'user0/calculus-3')

    ;({data, status} = await test.webApi.articleRedirects({ id: 'user0/calculus-2' }))
    assertStatus(status, data)
    assert.strictEqual(data.redirects['user0/calculus-2'], 'user0/calculus-3')

    // Children of the renamed article now point to the new parent,
    // Previous sibling is also updated.

    ;({data, status} = await test.webApi.article('user0/derivative', { 'include-parent': true }))
    assertStatus(status, data)
    assert.strictEqual(data.titleRender, 'Derivative')
    assert.strictEqual(data.parentId, '@user0/calculus-3')

    ;({data, status} = await test.webApi.article('user0/algebra'))
    assertStatus(status, data)
    assert.strictEqual(data.titleRender, 'Algebra')
    // TODO https://docs.ourbigbook.com/todo/fix-parentid-and-previoussiblingid-on-articles-api
    //assert.strictEqual(data.previousSiblingId, '@user0/calculus-3')

    {
      const algebra = await sequelize.models.Article.getArticle({
        includeParentAndPreviousSibling: true,
        slug: 'user0/algebra',
        sequelize
      })
      assert.strictEqual(algebra.parentId.idid, '@user0/mathematics')
      assert.strictEqual(algebra.previousSiblingId.idid, '@user0/calculus-3')
    }

    // Only the main Article retains a File object.
    assert.notStrictEqual(await sequelize.models.File.findOne({ where: { path: '@user0/calculus-3.bigb' } }), null)
    assert.strictEqual(await sequelize.models.File.findOne({ where: { path: '@user0/calculus.bigb' } }), null)
    assert.strictEqual(await sequelize.models.File.findOne({ where: { path: '@user0/calculus-2.bigb' } }), null)

    // extract_ids without render of header with synonym does not blow up.
    // Yes, everything breaks everything.
    article = createArticleArg({ i: 0, titleSource: 'Calculus 3', bodySource: `= Calculus
{synonym}

= Calculus 2
{synonym}

\\Image[http://jpg]{title=My image}
`
    })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { render: false }))
    assertStatus(status, data)

    // Current tree state:
    // * 0 user0/Index
    //  * 1 Mathematics
    //    * 2 Calculus 3 (Calculus, Calculus 2)
    //      * 3 Limit
    //        * 4 Limit of a series
    //      * 5 Derivative
    //        * 6 Chain rule
    //      * 7 Integral
    //        * 8 Fundamental theorem of calculus
    //    * 9 Algebra
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 10, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 10, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 9,  depth: 2, to_id_index: 0,    slug: 'user0/calculus-3' },
      { nestedSetIndex: 3, nestedSetNextSibling: 5,  depth: 3, to_id_index: 0,    slug: 'user0/limit' },
      { nestedSetIndex: 4, nestedSetNextSibling: 5,  depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series' },
      { nestedSetIndex: 5, nestedSetNextSibling: 7,  depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
      { nestedSetIndex: 6, nestedSetNextSibling: 7,  depth: 4, to_id_index: 0,    slug: 'user0/chain-rule' },
      { nestedSetIndex: 7, nestedSetNextSibling: 9,  depth: 3, to_id_index: 2,    slug: 'user0/integral' },
      { nestedSetIndex: 8, nestedSetNextSibling: 9,  depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus' },
      { nestedSetIndex: 9, nestedSetNextSibling: 10, depth: 2, to_id_index: 1,    slug: 'user0/algebra' },
      { nestedSetIndex: 0, nestedSetNextSibling: 1,  depth: 0, to_id_index: null, slug: 'user1' },
    ])

    // Adding synonym to index is fine.
    article = createArticleArg({ i: 0, titleSource: 'Index', bodySource: '= Index 2\n{synonym}' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)

    // Current tree state:
    // * 0 user0/Index (Index 2)
    //  * 1 Mathematics
    //    * 2 Calculus 3 (Calculus, Calculus 2)
    //      * 3 Limit
    //        * 4 Limit of a series
    //      * 5 Derivative
    //        * 6 Chain rule
    //      * 7 Integral
    //        * 8 Fundamental theorem of calculus
    //    * 9 Algebra
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 10, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 10, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 9,  depth: 2, to_id_index: 0,    slug: 'user0/calculus-3' },
      { nestedSetIndex: 3, nestedSetNextSibling: 5,  depth: 3, to_id_index: 0,    slug: 'user0/limit' },
      { nestedSetIndex: 4, nestedSetNextSibling: 5,  depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series' },
      { nestedSetIndex: 5, nestedSetNextSibling: 7,  depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
      { nestedSetIndex: 6, nestedSetNextSibling: 7,  depth: 4, to_id_index: 0,    slug: 'user0/chain-rule' },
      { nestedSetIndex: 7, nestedSetNextSibling: 9,  depth: 3, to_id_index: 2,    slug: 'user0/integral' },
      { nestedSetIndex: 8, nestedSetNextSibling: 9,  depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus' },
      { nestedSetIndex: 9, nestedSetNextSibling: 10, depth: 2, to_id_index: 1,    slug: 'user0/algebra' },
      { nestedSetIndex: 0, nestedSetNextSibling: 1,  depth: 0, to_id_index: null, slug: 'user1' },
    ])

    // Renaming index via synonyms is not allowed.
    article = createArticleArg({ i: 0, titleSource: 'Index 3', bodySource: '= Index\n{synonym}\n\n= Index 2\n{synonym}\n' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assert.strictEqual(status, 422)

    // Current tree state:
    // * 0 user0/Index (Index 2)
    //  * 1 Mathematics
    //    * 2 Calculus 3 (Calculus, Calculus 2)
    //      * 3 Limit
    //        * 4 Limit of a series
    //      * 5 Derivative
    //        * 6 Chain rule
    //      * 7 Integral
    //        * 8 Fundamental theorem of calculus
    //    * 9 Algebra
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 10, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 10, depth: 1, to_id_index: 0,    slug: 'user0/mathematics' },
      { nestedSetIndex: 2, nestedSetNextSibling: 9,  depth: 2, to_id_index: 0,    slug: 'user0/calculus-3' },
      { nestedSetIndex: 3, nestedSetNextSibling: 5,  depth: 3, to_id_index: 0,    slug: 'user0/limit' },
      { nestedSetIndex: 4, nestedSetNextSibling: 5,  depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series' },
      { nestedSetIndex: 5, nestedSetNextSibling: 7,  depth: 3, to_id_index: 1,    slug: 'user0/derivative' },
      { nestedSetIndex: 6, nestedSetNextSibling: 7,  depth: 4, to_id_index: 0,    slug: 'user0/chain-rule' },
      { nestedSetIndex: 7, nestedSetNextSibling: 9,  depth: 3, to_id_index: 2,    slug: 'user0/integral' },
      { nestedSetIndex: 8, nestedSetNextSibling: 9,  depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus' },
      { nestedSetIndex: 9, nestedSetNextSibling: 10, depth: 2, to_id_index: 1,    slug: 'user0/algebra' },
      { nestedSetIndex: 0, nestedSetNextSibling: 1,  depth: 0, to_id_index: null, slug: 'user1' },
    ])

    // Article merge

      // Add user1 metadata to user0/derivative
      test.loginUser(user1)

      // Like derivative.
      ;({data, status} = await test.webApi.articleLike('user0/derivative'))
      assertStatus(status, data)

      // Create issue.
      ;({data, status} = await test.webApi.issueCreate('user0/derivative',
        { titleSource: 'Derivative issue 0' }
      ))
      assertStatus(status, data)

      test.loginUser(user)

      // Sanity check that user score is now up to 1
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.username, 'user0')
      assert.strictEqual(data.score, 1)

      // Sanity check that the issue is visible.
      ;({data, status} = await test.webApi.issue('user0/derivative', 1))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Derivative issue 0')

      // Sanity: the File object exists
      {
        const file = await sequelize.models.File.findOne({ where: { path: '@user0/derivative.bigb' } })
        assert.notStrictEqual(file, null)
      }

      // Rename derivative to calculus-3, triggering an article merge
      // of user0/derivative to user/calculus-3.
      article = createArticleArg({
        i: 0,
        titleSource: 'Calculus 3',
        bodySource: `= Calculus
{synonym}

= Calculus 2
{synonym}

= Derivative
{synonym}

\\Image[http://jpg]{title=My image}
`,
      })
      ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: undefined }))
      assertStatus(status, data)

      // The File object was removed.
      {
        const file = await sequelize.models.File.findOne({ where: { path: '@user0/derivative.bigb' } })
        assert.strictEqual(file, null)
      }

      // Current tree state:
      // * 0 user0/Index
      //  * 1 Mathematics
      //    * 2 Calculus 3 (Calculus, Calculus 2, Derivative)
      //      * 3 Limit
      //        * 4 Limit of a series
      //      * 5 Integral
      //        * 6 Fundamental theorem of calculus
      //      * 7 Chain rule
      //    * 8 Algebra
      await assertNestedSets(sequelize, [
        { nestedSetIndex: 0, nestedSetNextSibling: 9, depth: 0, to_id_index: null, slug: 'user0', parentId: null },
        { nestedSetIndex: 1, nestedSetNextSibling: 9, depth: 1, to_id_index: 0,    slug: 'user0/mathematics', parentId: '@user0' },
        { nestedSetIndex: 2, nestedSetNextSibling: 8, depth: 2, to_id_index: 0,    slug: 'user0/calculus-3', parentId: '@user0/mathematics' },
        { nestedSetIndex: 3, nestedSetNextSibling: 5, depth: 3, to_id_index: 0,    slug: 'user0/limit', parentId: '@user0/calculus-3' },
        { nestedSetIndex: 4, nestedSetNextSibling: 5, depth: 4, to_id_index: 0,    slug: 'user0/limit-of-a-series', parentId: '@user0/limit' },
        { nestedSetIndex: 5, nestedSetNextSibling: 7, depth: 3, to_id_index: 1,    slug: 'user0/integral', parentId: '@user0/calculus-3' },
        { nestedSetIndex: 6, nestedSetNextSibling: 7, depth: 4, to_id_index: 0,    slug: 'user0/fundamental-theorem-of-calculus', parentId: '@user0/integral' },
        { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 3, to_id_index: 2,    slug: 'user0/chain-rule', parentId: '@user0/calculus-3' },
        { nestedSetIndex: 8, nestedSetNextSibling: 9, depth: 2, to_id_index: 1,    slug: 'user0/algebra', parentId: '@user0/mathematics' },
        { nestedSetIndex: 0, nestedSetNextSibling: 1, depth: 0, to_id_index: null, slug: 'user1', parentId: null },
      ])

      // user0/derivative redirects to user0/calculus-3
      ;({data, status} = await test.webApi.articleRedirects({ id: 'user0/derivative' }))
      assertStatus(status, data)
      assert.strictEqual(data.redirects['user0/derivative'], 'user0/calculus-3')

      // user0/chain-rule, previously a child of user0/derivative, is reparented
      // to the new parent user0/calculus-3
      {
        const article = await sequelize.models.Article.getArticle({
          includeParentAndPreviousSibling: true,
          slug: 'user0/chain-rule',
          sequelize
        })
        assert.strictEqual(article.parentId.idid, '@user0/calculus-3')
        assert.strictEqual(article.previousSiblingId.idid, '@user0/integral')
      }

      // Issue 1 of user0/derivative is migrated as issue 2 of user/calculus-3,
      // following the pre-existing issue 1.
      ;({data, status} = await test.webApi.issue('user0/calculus-3', 1))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Calculus issue 0')
      ;({data, status} = await test.webApi.issue('user0/calculus-3', 2))
      assertStatus(status, data)
      assert.strictEqual(data.titleRender, 'Derivative issue 0')

      // user0 score is back down to 0 since likes of merged articles are deleted to prevent spam.
      ;({data, status} = await test.webApi.user('user0'))
      assertStatus(status, data)
      assert.strictEqual(data.username, 'user0')
      assert.strictEqual(data.score, 0)

      // Creating a new article with a synonym does not blow up
      article = createArticleArg({
        i: 0,
        titleSource: 'Geometry',
        bodySource: `= Geometry 2
{synonym}
`,
      })
      ;({data, status} = await createOrUpdateArticleApi(test, article, {
        parentId: '@user0/mathematics',
        previousSiblingId: '@user0/algebra',
      }))
      assertStatus(status, data)

      // Current tree state:
      // * 0 user0/Index
      //  * 1 Mathematics
      //    * 2 Calculus 3 (Calculus, Calculus 2, Derivative)
      //      * 3 Limit
      //        * 4 Limit of a series
      //      * 5 Integral
      //        * 6 Fundamental theorem of calculus
      //      * 7 Chain rule
      //    * 8 Algebra
      //    * 9 Geometry (Geometry 2)

      // Creating a new article with a synonym with render: false does not blow up.
      article = createArticleArg({
        i: 0,
        titleSource: 'Number theory',
        bodySource: `= Number theory 2
{synonym}
`,
      })
      ;({data, status} = await createOrUpdateArticleApi(test, article, {
        parentId: '@user0/mathematics',
        previousSiblingId: '@user0/geometry',
        render: false,
      }))
      assertStatus(status, data)
      ;({data, status} = await createOrUpdateArticleApi(test, article, {
        parentId: '@user0/mathematics',
        previousSiblingId: '@user0/geometry',
        render: true,
      }))
      assertStatus(status, data)

      // Current tree state:
      // * 0 user0/Index
      //  * 1 Mathematics
      //    * 2 Calculus 3 (Calculus, Calculus 2, Derivative)
      //      * 3 Limit
      //        * 4 Limit of a series
      //      * 5 Integral
      //        * 6 Fundamental theorem of calculus
      //      * 7 Chain rule
      //    * 8 Algebra
      //    * 9 Geometry (Geometry 2)
      //    * 10 Number theory (Number theory 2)

      // Creating a new article with id and a synonym does not blow up.
      article = createArticleArg({
        i: 0,
        titleSource: 'Ł',
        bodySource: `{id=Ł}
{parent=Polish letter}
{title2=wa}
{title2=wo}

= L with a stroke
{synonym}
`,
      })
      assertStatus(status, data)
  })
})

it('api: uppercase article IDs are forbidden', async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)

    // The only way to currently obtain them on toplevel article is with the path: argument.
    // id= does nothing on web as of writing as it gets overridden by path:.
    article = createArticleArg({ i: 0, titleSource: 'Aa' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { path: 'bB' }))
    assert.strictEqual(status, 422)

    // Similar for synonym subobjects.
    // TODO: Forbid uppercase IDs on web and CLI by default
    //article = createArticleArg({ i: 0, titleSource: 'Aa', bodySource: '= Bb\n{synonym}\n{id=cC}\n' })
    //;({data, status} = await createOrUpdateArticleApi(test, article))
    //assert.strictEqual(status, 422)

    // Similar for other subobjects.
    // TODO: Forbid uppercase IDs on web and CLI by default
    //article = createArticleArg({ i: 0, titleSource: 'Aa', bodySource: '\\Image[tmp.png]{id=cC}' })
    //;({data, status} = await createOrUpdateArticleApi(test, article))
    //assert.strictEqual(status, 422)

    // Uppercase is allowed with {file} however.
    article = createArticleArg({ i: 0, titleSource: 'path/to/main.S', bodySource: '{file}' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { path: '_file/path/to/main.S' }))
    assertStatus(status, data)
    ;({data, status} = await test.webApi.article('user0/_file/path/to/main.S'))
    assertStatus(status, data)
    assert.notStrictEqual(data, undefined)

    await models.normalize({
      check: true,
      sequelize,
      whats: ['nested-set'],
    })
  })
})

it('api: hideArticleDates', async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)

    // New articles created with hideArticleDates=false don't have the dummy date.
    article = createArticleArg({ i: 0, titleSource: 'Before' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    assert.notStrictEqual(data.articles[0].createdAt, config.hideArticleDatesDate)
    assert.notStrictEqual(data.articles[0].updatedAt, config.hideArticleDatesDate)

    // Set hideArticleDates to true.
    ;({data, status} = await test.webApi.userUpdate('user0', {
      hideArticleDates: true,
    }))

    // New articles created after hideArticleDates=true have the dummy date.
    article = createArticleArg({ i: 0, titleSource: 'After' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    assert.strictEqual(data.articles[0].createdAt, config.hideArticleDatesDate)
    assert.strictEqual(data.articles[0].updatedAt, config.hideArticleDatesDate)

    // Updates change the createdAt and updatedAt dates of existing articles.
    article = createArticleArg({ i: 0, titleSource: 'Before' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    // TODO would be slightly better if this were also reset. However it appears 
    // that bulkCreate doesn't set createdAt even if if is passed explicitly on
    // updateOnDuplicate.
    assert.notStrictEqual(data.articles[0].createdAt, config.hideArticleDatesDate)
    assert.strictEqual(data.articles[0].updatedAt, config.hideArticleDatesDate)
  })
})

it('api: editor/fetch-files', async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)

    // Create articles

      article = createArticleArg({ i: 0, titleSource: 'Mathematics' })
      ;({data, status} = await createArticleApi(test, article))
      assertStatus(status, data)

      article = createArticleArg({ i: 0, titleSource: 'Calculus' })
      ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/mathematics' }))
      assertStatus(status, data)

    // Fetch and check some files.
    ;({data, status} =  await test.webApi.editorFetchFiles([ '@user0/mathematics.bigb', '@user0/calculus.bigb' ]))
    assertStatus(status, data)
    console.error();
    assertRows(data.files, [
      { path: '@user0/calculus.bigb', toplevel_id: '@user0/calculus' },
      { path: '@user0/mathematics.bigb', toplevel_id: '@user0/mathematics' },
    ])
  })
})

it('api: ourbigbook LaTeX macros are defined', async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)
    article = createArticleArg({
      i: 0,
      titleSource: 'Mathematics',
      bodySource: `$$
\\abs{x}
$$
`,
    })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)
  })
})

it(`api: topic links dont have the domain name`, async () => {
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    test.loginUser(user)
    article = createArticleArg({
      i: 0,
      titleSource: 'Mathematics',
      bodySource: `<#My Topic>
`,
    })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)
    assert_xpath(`//x:div[@class='p']//x:a[@href='/go/topic/my-topic' and text()='My Topic']`, data.articles[0].render)

    article = createArticleArg({
      i: 0,
      titleSource: 'Index',
      bodySource: `<#My Topic>
`,
    })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)
    assert_xpath(`//x:div[@class='p']//x:a[@href='/go/topic/my-topic' and text()='My Topic']`, data.articles[0].render)
  })
})

it('api: parent and child to unrelated synonyms with updateNestedSetIndex', async () => {
  // Attempt to reproduce: https://docs.ourbigbook.com/todo/5
  await testApp(async (test) => {
    let data, status, article
    const sequelize = test.sequelize
    const user = await test.createUserApi(0)
    // Create a second user and index to ensure that the nested set indexes are independent for each user.
    // Because of course we didn't do this when originally implementing.
    test.loginUser(user)

    article = createArticleArg({ i: 0, titleSource: 'h2' })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)
    article = createArticleArg({ i: 0, titleSource: 'h2-2' })
    ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/h2' }))
    assertStatus(status, data)
    article = createArticleArg({ i: 0, titleSource: 'h1' })
    ;({data, status} = await createArticleApi(test, article))
    assertStatus(status, data)
    article = createArticleArg({ i: 0, titleSource: 'h1-2' })
    ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/h1' }))
    assertStatus(status, data)
    article = createArticleArg({ i: 0, titleSource: 'h1-1' })
    ;({data, status} = await createArticleApi(test, article, { parentId: '@user0/h1' }))
    assertStatus(status, data)
    article = createArticleArg({ i: 0, titleSource: 'h1-1-1' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/h1-1' }))
    assertStatus(status, data)
    article = createArticleArg({ i: 0, titleSource: 'h1-2-1' })
    ;({data, status} = await createOrUpdateArticleApi(test, article, { parentId: '@user0/h1-2' }))
    assertStatus(status, data)
    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 8, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 6, depth: 1, to_id_index: 0,    slug: 'user0/h1' },
      { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 2, to_id_index: 0,    slug: 'user0/h1-1' },
      { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/h1-1-1' },
      { nestedSetIndex: 4, nestedSetNextSibling: 6, depth: 2, to_id_index: 1,    slug: 'user0/h1-2' },
      { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 3, to_id_index: 0,    slug: 'user0/h1-2-1' },
      { nestedSetIndex: 6, nestedSetNextSibling: 8, depth: 1, to_id_index: 1,    slug: 'user0/h2' },
      { nestedSetIndex: 7, nestedSetNextSibling: 8, depth: 2, to_id_index: 0,    slug: 'user0/h2-2' },
    ])

    article = createArticleArg({ i: 0, titleSource: 'h1-2', bodySource: '= h2-2\n{synonym}' })
    ;({data, status} = await createOrUpdateArticleApi(test, article))
    assertStatus(status, data)

    await assertNestedSets(sequelize, [
      { nestedSetIndex: 0, nestedSetNextSibling: 7, depth: 0, to_id_index: null, slug: 'user0' },
      { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, to_id_index: 0,    slug: 'user0/h1' },
      { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 2, to_id_index: 0,    slug: 'user0/h1-1' },
      { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/h1-1-1' },
      { nestedSetIndex: 4, nestedSetNextSibling: 6, depth: 2, to_id_index: 1,    slug: 'user0/h1-2' },
      { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 3, to_id_index: 0,    slug: 'user0/h1-2-1' },
      { nestedSetIndex: 6, nestedSetNextSibling: 8, depth: 1, to_id_index: 1,    slug: 'user0/h2' },
    ])

    //article = createArticleArg({ i: 0, titleSource: 'h1-1', bodySource: '= h2\n{synonym}' })
    //;({data, status} = await createOrUpdateArticleApi(test, article))
    //assertStatus(status, data)

    //await assertNestedSets(sequelize, [
    //  { nestedSetIndex: 0, nestedSetNextSibling: 6, depth: 0, to_id_index: null, slug: 'user0' },
    //  { nestedSetIndex: 1, nestedSetNextSibling: 3, depth: 1, to_id_index: 0,    slug: 'user0/h1' },
    //  { nestedSetIndex: 2, nestedSetNextSibling: 4, depth: 2, to_id_index: 0,    slug: 'user0/h1-1' },
    //  { nestedSetIndex: 3, nestedSetNextSibling: 4, depth: 3, to_id_index: 0,    slug: 'user0/h1-1-1' },
    //  { nestedSetIndex: 4, nestedSetNextSibling: 6, depth: 2, to_id_index: 1,    slug: 'user0/h1-2' },
    //  { nestedSetIndex: 5, nestedSetNextSibling: 6, depth: 3, to_id_index: 0,    slug: 'user0/h1-2-1' },
    //])
  })
})