1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-07-19 05:09:41 +02:00

Beautiful urls (#18)

* Added uri property to Page model

* Added aliases collection

* Added routing form aliases

* Fixed redirect after page creation

* Added abiltity to support few pages with same title

* Added ability to change uri manually

* Changed hash function

* Changed uri parsing

* Removed pages controller promise

* Modified page's tests

* Added tests for alias model

* Added tests for aliases

* Escaping special characters

* Added missed files

* Fixed bugs related to translation

* Fixed parent page link

* Added server validation for uri

* Changed css properties order

* Made uri property of page be optional

* Prevented alias creation from empty uri

* Moved alias types to model
This commit is contained in:
DorofeevMark 2019-01-25 02:23:00 +03:00 committed by Peter Savchenko
parent ed69336481
commit d872e78339
28 changed files with 807 additions and 45 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,26 @@
const Alias = require('../models/alias');
/**
* @class Aliases
* @classdesc Aliases controller
*/
class Aliases {
/**
* @static
* Find and return entity with given alias
*
* @param {string} aliasName - alias name of entity
* @returns {Promise<Alias>}
*/
static async get(aliasName) {
const alias = await Alias.get(aliasName);
if (!alias.id) {
throw new Error('Entity with given alias does not exist');
}
return alias;
}
}
module.exports = Aliases;

View file

@ -1,4 +1,5 @@
const Model = require('../models/page'); const Model = require('../models/page');
const Alias = require('../models/alias');
/** /**
* @class Pages * @class Pages
@ -85,7 +86,18 @@ class Pages {
const page = new Model(data); const page = new Model(data);
return page.save(); const insertedPage = await page.save();
if (insertedPage.uri) {
const alias = new Alias({
id: insertedPage._id,
type: Alias.types.PAGE
}, insertedPage.uri);
alias.save();
}
return insertedPage;
} catch (validationError) { } catch (validationError) {
throw new Error(validationError); throw new Error(validationError);
} }
@ -132,13 +144,33 @@ class Pages {
*/ */
static async update(id, data) { static async update(id, data) {
const page = await Model.get(id); const page = await Model.get(id);
const previousUri = page.uri;
if (!page._id) { if (!page._id) {
throw new Error('Page with given id does not exist'); throw new Error('Page with given id does not exist');
} }
if (data.uri && !data.uri.match(/^[a-z0-9'-]+$/i)) {
throw new Error('Uri has unexpected characters');
}
page.data = data; page.data = data;
return page.save(); const updatedPage = await page.save();
if (updatedPage.uri !== previousUri) {
if (updatedPage.uri) {
const alias = new Alias({
id: updatedPage._id,
type: Alias.types.PAGE
}, updatedPage.uri);
alias.save();
}
Alias.markAsDeprecated(previousUri);
}
return updatedPage;
} }
/** /**

View file

@ -29,6 +29,7 @@ export default class Writing {
saveButton: null, saveButton: null,
parentIdSelector: null, parentIdSelector: null,
putAboveIdSelector: null, putAboveIdSelector: null,
uriInput: null
}; };
} }
@ -63,6 +64,7 @@ export default class Writing {
}); });
this.nodes.parentIdSelector = moduleEl.querySelector('[name="parent"]'); this.nodes.parentIdSelector = moduleEl.querySelector('[name="parent"]');
this.nodes.putAboveIdSelector = moduleEl.querySelector('[name="above"]'); this.nodes.putAboveIdSelector = moduleEl.querySelector('[name="above"]');
this.nodes.uriInput = moduleEl.querySelector('[name="uri-input"]');
}; };
/** /**
@ -86,6 +88,15 @@ export default class Writing {
const editorData = await this.editor.save(); const editorData = await this.editor.save();
const firstBlock = editorData.blocks.length ? editorData.blocks[0] : null; const firstBlock = editorData.blocks.length ? editorData.blocks[0] : null;
const title = firstBlock && firstBlock.type === 'header' ? firstBlock.data.text : null; const title = firstBlock && firstBlock.type === 'header' ? firstBlock.data.text : null;
let uri = '';
if (this.nodes.uriInput && this.nodes.uriInput.value) {
if (this.nodes.uriInput.value.match(/^[a-z0-9'-]+$/i)) {
uri = this.nodes.uriInput.value;
} else {
throw new Error('Uri has unexpected characters');
}
}
if (!title) { if (!title) {
throw new Error('Entry should start with Header'); throw new Error('Entry should start with Header');
@ -100,6 +111,7 @@ export default class Writing {
return { return {
parent: this.nodes.parentIdSelector.value, parent: this.nodes.parentIdSelector.value,
putAbovePageId: putAbovePageId, putAbovePageId: putAbovePageId,
uri: uri,
body: editorData body: editorData
}; };
} }
@ -124,7 +136,7 @@ export default class Writing {
response = await response.json(); response = await response.json();
if (response.success) { if (response.success) {
document.location = '/page/' + response.result._id; window.location.pathname = response.result.uri ? response.result.uri : '/page/' + response.result._id;
} else { } else {
alert(response.error); alert(response.error);
console.log('Validation failed:', response.error); console.log('Validation failed:', response.error);

View file

@ -1,13 +1,13 @@
.docs-header { .docs-header {
font-size: 15.8px;
border-bottom: 1px solid var(--color-line-gray);
line-height: 50px;
display: flex; display: flex;
padding: 0 var(--layout-padding-horisontal); padding: 0 var(--layout-padding-horisontal);
border-bottom: 1px solid var(--color-line-gray);
font-size: 15.8px;
line-height: 50px;
a { a {
text-decoration: none;
display: inline-block; display: inline-block;
text-decoration: none;
} }
&__logo { &__logo {

View file

@ -66,8 +66,8 @@
*/ */
.block-code { .block-code {
padding: 20px !important; padding: 20px !important;
font-size: 13px;
border-radius: 3px; border-radius: 3px;
font-size: 13px;
border: 1px solid var(--color-line-gray); border: 1px solid var(--color-line-gray);
font-family: Menlo,Monaco,Consolas,Courier New,monospace; font-family: Menlo,Monaco,Consolas,Courier New,monospace;
line-height: 1.7em; line-height: 1.7em;

View file

@ -1,12 +1,12 @@
.writing-header { .writing-header {
display: flex; display: flex;
padding: 15px 0;
margin-top: calc(-1 * var(--layout-padding-vertical));
position: sticky; position: sticky;
top: 0; top: 0;
padding: 15px 0;
margin-top: calc(-1 * var(--layout-padding-vertical));
background: #fff; background: #fff;
z-index: 2;
box-shadow: 0 3px 10px #fff; box-shadow: 0 3px 10px #fff;
z-index: 2;
&__save { &__save {
@apply --button; @apply --button;
@ -22,3 +22,13 @@
} }
} }
} }
.uri-input {
box-sizing: border-box;
width: 100%;
padding: 10px 12px;
border-radius: 3px;
border: 1px solid rgba(201, 201, 204, 0.48);
box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06);
outline: none;
}

View file

@ -3,6 +3,7 @@
--color-text-second: #7B7E89; --color-text-second: #7B7E89;
--color-line-gray: #E8E8EB; --color-line-gray: #E8E8EB;
--color-link-active: #388AE5; --color-link-active: #388AE5;
--color-gray-border: rgba(var(--color-line-gray), 0.48);
/** /**
* Site layout sizes * Site layout sizes
@ -14,10 +15,10 @@
--button { --button {
display: inline-block; display: inline-block;
padding: 9px 15px;
border-radius: 3px;
background: var(--color-link-active); background: var(--color-link-active);
color: #fff; color: #fff;
border-radius: 3px;
padding: 9px 15px;
font-size: 14px; font-size: 14px;
line-height: 1em; line-height: 1em;
text-decoration: none; text-decoration: none;

129
src/models/alias.js Normal file
View file

@ -0,0 +1,129 @@
const {aliases: aliasesDb} = require('../utils/database/index');
const binaryMD5 = require('../utils/crypto');
/**
* @typedef {Object} AliasData
* @property {string} _id - alias id
* @property {string} hash - alias binary hash
* @property {string} type - entity type
* @property {boolean} deprecated - indicate if alias deprecated
* @property {string} id - entity id
*
*/
/**
* @class Alias
* @property {string} _id - alias id
* @property {string} hash - alias binary hash
* @property {string} type - entity type
* @property {boolean} deprecated - indicate if alias deprecated
* @property {string} id - entity title
*/
class Alias {
/**
* Return Alias types
*
* @returns {Object}
*/
static get types() {
return {
PAGE: 'page'
};
};
/**
* Find and return alias with given alias
* @param {string} aliasName - alias of entity
* @returns {Promise<Alias>}
*/
static async get(aliasName) {
const hash = binaryMD5(aliasName);
let data = await aliasesDb.findOne({hash: hash, deprecated: false});
if (!data) {
data = await aliasesDb.findOne({hash: hash});
}
return new Alias(data);
}
/**
* @constructor
*
* @param {AliasData} data
* @param {string} aliasName - alias of entity
*/
constructor(data = {}, aliasName = '') {
if (data === null) {
data = {};
}
if (data._id) {
this._id = data._id;
}
if (aliasName) {
this.hash = binaryMD5(aliasName);
}
this.data = data;
}
/**
* Save or update alias data in the database
*
* @returns {Promise<Alias>}
*/
async save() {
if (!this._id) {
const insertedRow = await aliasesDb.insert(this.data);
this._id = insertedRow._id;
} else {
await aliasesDb.update({_id: this._id}, this.data);
}
return this;
}
/**
* Set AliasData object fields to internal model fields
*
* @param {AliasData} aliasData
*/
set data(aliasData) {
const {id, type, hash, deprecated} = aliasData;
this.id = id || this.id;
this.type = type || this.type;
this.hash = hash || this.hash;
this.deprecated = deprecated || false;
}
/**
* Return AliasData object
*
* @returns {AliasData}
*/
get data() {
return {
_id: this._id,
id: this.id,
type: this.type,
hash: this.hash,
deprecated: this.deprecated
};
}
/**
* Mark alias as deprecated
* @param {string} aliasName - alias of entity
* @returns {Promise<Alias>}
*/
static async markAsDeprecated(aliasName) {
const alias = await Alias.get(aliasName);
alias.deprecated = true;
return alias.save();
}
}
module.exports = Alias;

View file

@ -1,9 +1,11 @@
const {pages: db} = require('../utils/database/index'); const {pages: pagesDb} = require('../utils/database/index');
const translateString = require('../utils/translation');
/** /**
* @typedef {Object} PageData * @typedef {Object} PageData
* @property {string} _id - page id * @property {string} _id - page id
* @property {string} title - page title * @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body * @property {*} body - page body
* @property {string} parent - id of parent page * @property {string} parent - id of parent page
*/ */
@ -14,6 +16,7 @@ const {pages: db} = require('../utils/database/index');
* *
* @property {string} _id - page id * @property {string} _id - page id
* @property {string} title - page title * @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body * @property {*} body - page body
* @property {string} _parent - id of parent page * @property {string} _parent - id of parent page
*/ */
@ -24,7 +27,18 @@ class Page {
* @returns {Promise<Page>} * @returns {Promise<Page>}
*/ */
static async get(_id) { static async get(_id) {
const data = await db.findOne({_id}); const data = await pagesDb.findOne({_id});
return new Page(data);
}
/**
* Find and return model of page with given uri
* @param {string} uri - page uri
* @returns {Promise<Page>}
*/
static async getByUri(uri) {
const data = await pagesDb.findOne({uri});
return new Page(data); return new Page(data);
} }
@ -36,7 +50,7 @@ class Page {
* @returns {Promise<Page[]>} * @returns {Promise<Page[]>}
*/ */
static async getAll(query = {}) { static async getAll(query = {}) {
const docs = await db.find(query); const docs = await pagesDb.find(query);
return Promise.all(docs.map(doc => new Page(doc))); return Promise.all(docs.map(doc => new Page(doc)));
} }
@ -64,10 +78,11 @@ class Page {
* @param {PageData} pageData * @param {PageData} pageData
*/ */
set data(pageData) { set data(pageData) {
const {body, parent} = pageData; const {body, parent, uri} = pageData;
this.body = body || this.body; this.body = body || this.body;
this.title = this.extractTitleFromBody(); this.title = this.extractTitleFromBody();
this.uri = uri || '';
this._parent = parent || this._parent; this._parent = parent || this._parent;
} }
@ -80,6 +95,7 @@ class Page {
return { return {
_id: this._id, _id: this._id,
title: this.title, title: this.title,
uri: this.uri,
body: this.body, body: this.body,
parent: this._parent parent: this._parent
}; };
@ -95,6 +111,21 @@ class Page {
return headerBlock ? headerBlock.data.text : ''; return headerBlock ? headerBlock.data.text : '';
} }
/**
* Transform title for uri
* @return {string}
*/
transformTitleToUri() {
return translateString(this.title
.replace(/&nbsp;/g, ' ')
.replace(/[^a-zA-Z0-9А-Яа-яЁё ]/g, ' ')
.replace(/ +/g, ' ')
.trim()
.toLowerCase()
.split(' ')
.join('-'));
}
/** /**
* Link given page as parent * Link given page as parent
* *
@ -110,7 +141,7 @@ class Page {
* @returns {Promise<Page>} * @returns {Promise<Page>}
*/ */
get parent() { get parent() {
return db.findOne({_id: this._parent}) return pagesDb.findOne({_id: this._parent})
.then(data => new Page(data)); .then(data => new Page(data));
} }
@ -120,7 +151,7 @@ class Page {
* @returns {Promise<Page[]>} * @returns {Promise<Page[]>}
*/ */
get children() { get children() {
return db.find({parent: this._id}) return pagesDb.find({parent: this._id})
.then(data => data.map(page => new Page(page))); .then(data => data.map(page => new Page(page)));
} }
@ -130,12 +161,14 @@ class Page {
* @returns {Promise<Page>} * @returns {Promise<Page>}
*/ */
async save() { async save() {
this.uri = await this.composeUri(this.uri);
if (!this._id) { if (!this._id) {
const insertedRow = await db.insert(this.data); const insertedRow = await pagesDb.insert(this.data);
this._id = insertedRow._id; this._id = insertedRow._id;
} else { } else {
await db.update({_id: this._id}, this.data); await pagesDb.update({_id: this._id}, this.data);
} }
return this; return this;
@ -147,13 +180,37 @@ class Page {
* @returns {Promise<Page>} * @returns {Promise<Page>}
*/ */
async destroy() { async destroy() {
await db.remove({_id: this._id}); await pagesDb.remove({_id: this._id});
delete this._id; delete this._id;
return this; return this;
} }
/**
* Find and return available uri
*
* @returns {Promise<string>}
*/
async composeUri(uri) {
let pageWithSameUriCount = 0;
if (!this._id) {
uri = this.transformTitleToUri();
}
if (uri) {
let pageWithSameUri = await Page.getByUri(uri);
while (pageWithSameUri._id && pageWithSameUri._id !== this._id) {
pageWithSameUriCount++;
pageWithSameUri = await Page.getByUri(uri + `-${pageWithSameUriCount}`);
}
}
return pageWithSameUriCount ? uri + `-${pageWithSameUriCount}` : uri;
}
/** /**
* Return readable page data * Return readable page data
* *

35
src/routes/aliases.js Normal file
View file

@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const Aliases = require('../controllers/aliases');
const Pages = require('../controllers/pages');
const Alias = require('../models/alias');
/**
* GET /*
*
* Return document with given alias
*/
router.get('*', async (req, res) => {
try {
const alias = await Aliases.get(req.originalUrl.slice(1)); // Cuts first '/' character
switch (alias.type) {
case Alias.types.PAGE: {
let page = await Pages.get(alias.id);
let pageParent = await page.parent;
res.render('pages/page', {
page, pageParent
});
}
}
} catch (err) {
res.status(400).json({
success: false,
error: err.message
});
}
});
module.exports = router;

View file

@ -80,7 +80,7 @@ router.post('/page/:id', multer.any(), async (req, res) => {
const {id} = req.params; const {id} = req.params;
try { try {
const {title, body, parent, putAbovePageId} = req.body; const {title, body, parent, putAbovePageId, uri} = req.body;
let page = await Pages.get(id); let page = await Pages.get(id);
if (page._parent !== parent) { if (page._parent !== parent) {
@ -91,7 +91,7 @@ router.post('/page/:id', multer.any(), async (req, res) => {
} }
} }
page = await Pages.update(id, {title, body, parent}); page = await Pages.update(id, {title, body, parent, uri});
res.json({ res.json({
success: true, success: true,
result: page result: page

View file

@ -3,6 +3,7 @@ const router = express.Router();
const home = require('./home'); const home = require('./home');
const pages = require('./pages'); const pages = require('./pages');
const aliases = require('./aliases');
const api = require('./api'); const api = require('./api');
const pagesMiddleware = require('./middlewares/pages'); const pagesMiddleware = require('./middlewares/pages');
@ -10,5 +11,6 @@ const pagesMiddleware = require('./middlewares/pages');
router.use('/', pagesMiddleware, home); router.use('/', pagesMiddleware, home);
router.use('/', pagesMiddleware, pages); router.use('/', pagesMiddleware, pages);
router.use('/api', api); router.use('/api', api);
router.use('/', aliases);
module.exports = router; module.exports = router;

12
src/utils/crypto.js Normal file
View file

@ -0,0 +1,12 @@
const crypto = require('crypto');
/**
* Create binary md5
* @param stringToHash - string to hash
* @returns {string} - binary hash of argument
*/
module.exports = function binaryMD5(stringToHash) {
return crypto.createHash('md5')
.update(stringToHash)
.digest('binary');
};

View file

@ -0,0 +1,6 @@
const Datastore = require('nedb');
const config = require('../../../config');
const db = new Datastore({filename: `./${config.database}/aliases.db`, autoload: true});
module.exports = db;

View file

@ -1,4 +1,5 @@
const pages = require('./pages'); const pages = require('./pages');
const aliases = require('./aliases');
const pagesOrder = require('./pagesOrder'); const pagesOrder = require('./pagesOrder');
/** /**
@ -144,5 +145,6 @@ class Database {
module.exports = { module.exports = {
class: Database, class: Database,
pages: new Database(pages), pages: new Database(pages),
aliases: new Database(aliases),
pagesOrder: new Database(pagesOrder) pagesOrder: new Database(pagesOrder)
}; };

78
src/utils/translation.js Normal file
View file

@ -0,0 +1,78 @@
const translationTable = {
'а': 'a',
'б': 'b',
'в': 'v',
'г': 'g',
'д': 'd',
'е': 'e',
'ж': 'g',
'з': 'z',
'и': 'i',
'й': 'y',
'к': 'k',
'л': 'l',
'м': 'm',
'н': 'n',
'о': 'o',
'п': 'p',
'р': 'r',
'с': 's',
'т': 't',
'у': 'u',
'ф': 'f',
'ы': 'i',
'э': 'e',
'А': 'A',
'Б': 'B',
'В': 'V',
'Г': 'G',
'Д': 'D',
'Е': 'E',
'Ж': 'G',
'З': 'Z',
'И': 'I',
'Й': 'Y',
'К': 'K',
'Л': 'L',
'М': 'M',
'Н': 'N',
'О': 'O',
'П': 'P',
'Р': 'R',
'С': 'S',
'Т': 'T',
'У': 'U',
'Ф': 'F',
'Ы': 'I',
'Э': 'E',
'ё': 'yo',
'х': 'h',
'ц': 'ts',
'ч': 'ch',
'ш': 'sh',
'щ': 'shch',
'ъ': "''",
'ь': "'",
'ю': 'yu',
'я': 'ya',
'Ё': 'YO',
'Х': 'H',
'Ц': 'TS',
'Ч': 'CH',
'Ш': 'SH',
'Щ': 'SHCH',
'Ъ': "''",
'Ь': "'",
'Ю': 'YU',
'Я': 'YA'
};
/**
* Function to translate string
*
* @param string - string to translate
* @returns {string} - translated string
*/
module.exports = function translateString(string) {
return string.replace(/[А-яёЁ]/g, (char) => translationTable[char] || char);
};

View file

@ -1,14 +1,24 @@
<div class="docs-aside"> <div class="docs-aside">
{% for firstLevelPage in menu %} {% for firstLevelPage in menu %}
<section class="docs-aside__section"> <section class="docs-aside__section">
<a class="docs-aside__section-title" href="/page/{{ firstLevelPage._id }}"> <a class="docs-aside__section-title"
{% if firstLevelPage.uri %}
href="/{{ firstLevelPage.uri }}"
{% else %}
href="/page/{{ firstLevelPage._id }}"
{% endif %}>
{{ firstLevelPage.title }} {{ firstLevelPage.title }}
</a> </a>
{% if firstLevelPage.children is not empty %} {% if firstLevelPage.children is not empty %}
<ul class="docs-aside__section-list"> <ul class="docs-aside__section-list">
{% for child in firstLevelPage.children %} {% for child in firstLevelPage.children %}
<li> <li>
<a href="/page/{{ child._id }}"> <a
{% if child.uri %}
href="/{{ child.uri }}"
{% else %}
href="/page/{{ child._id }}"
{% endif %}>
{{ child.title }} {{ child.title }}
</a> </a>
</li> </li>

View file

@ -11,7 +11,12 @@
</li> </li>
{% for option in config.menu %} {% for option in config.menu %}
<li> <li>
<a href="{{option.uri}}"> <a
{% if child.uri %}
href="{{ option.uri }}"
{% else %}
href="/page/{{ option._id }}"
{% endif %}>
{{ option.title }} {{ option.title }}
</a> </a>
</li> </li>

View file

@ -21,7 +21,7 @@
{% set currentPageId = page._id %} {% set currentPageId = page._id %}
{% endif %} {% endif %}
<select name="parent"> <select name="parent">
<option value="0">Root</option> <option value="0">Root</option>
{% for _page in pagesAvailable %} {% for _page in pagesAvailable %}
{% if _page._id != currentPageId %} {% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}> <option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
@ -29,7 +29,7 @@
</option> </option>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
{% if parentsChildrenOrdered is not empty %} {% if parentsChildrenOrdered is not empty %}
@ -48,5 +48,10 @@
Save Save
</span> </span>
</header> </header>
{% if page is not empty %}
<main>
<input type="text" class="uri-input" name="uri-input" placeholder="Uri(Optional)" value="{{ page.uri }}">
</main>
{% endif %}
</section> </section>
{% endblock %} {% endblock %}

View file

@ -7,7 +7,12 @@
Documentation Documentation
</a> </a>
{% if page._parent %} {% if page._parent %}
<a href="/page/{{ page._parent }}" class="page__header-nav"> <a class="page__header-nav"
{% if pageParent.uri %}
href="/{{ pageParent.uri }}"
{% else %}
href="/page/{{ pageParent._id }}"
{% endif %}>
Parent {{ pageParent.title }} Parent {{ pageParent.title }}
</a> </a>
{% endif %} {% endif %}

139
test/models/alias.js Normal file
View file

@ -0,0 +1,139 @@
const {expect} = require('chai');
const fs = require('fs');
const path = require('path');
const config = require('../../config');
const Alias = require('../../src/models/alias');
const binaryMD5 = require('../../src/utils/crypto');
const {aliases} = require('../../src/utils/database');
describe('Alias model', () => {
after(() => {
const pathToDB = path.resolve(__dirname, '../../', config.database, './aliases.db');
if (fs.existsSync(pathToDB)) {
fs.unlinkSync(pathToDB);
}
});
it('Working with empty model', async () => {
let alias = new Alias();
expect(alias.data).to.be.a('object');
let {data} = alias;
expect(data._id).to.be.undefined;
expect(data.hash).to.be.undefined;
expect(data.type).to.be.undefined;
expect(data.deprecated).to.be.false;
expect(data.id).to.be.undefined;
alias = new Alias();
data = alias.data;
expect(data._id).to.be.undefined;
expect(data.hash).to.be.undefined;
expect(data.type).to.be.undefined;
expect(data.deprecated).to.be.false;
expect(data.id).to.be.undefined;
const initialData = {
_id: 'alias_id',
type: Alias.types.PAGE,
id: 'page_id'
};
const aliasName = 'alias name';
alias = new Alias(initialData, aliasName);
data = alias.data;
expect(data._id).to.equal(initialData._id);
expect(data.hash).to.equal(binaryMD5(aliasName));
expect(data.type).to.equal(initialData.type);
expect(data.deprecated).to.equal(false);
const update = {
type: Alias.types.PAGE,
id: 'page_id',
hash: binaryMD5('another test hash'),
deprecated: true
};
alias.data = update;
data = alias.data;
expect(data._id).to.equal(initialData._id);
expect(data.type).to.equal(update.type);
expect(data.hash).to.equal(update.hash);
expect(data.deprecated).to.equal(update.deprecated);
});
it('Static get method', async () => {
const initialData = {
type: Alias.types.PAGE,
id: 'page_id'
};
const aliasName = 'alias name';
const alias = new Alias(initialData, aliasName);
const savedAlias = await alias.save();
const foundAlias = await Alias.get(aliasName);
const {data} = foundAlias;
expect(data._id).to.equal(savedAlias._id);
expect(data.hash).to.equal(binaryMD5(aliasName));
expect(data.type).to.equal(initialData.type);
expect(data.deprecated).to.equal(false);
});
it('Saving, updating and deleting model in the database', async () => {
const initialData = {
type: Alias.types.PAGE,
id: 'page_id'
};
const aliasName = 'alias name';
const alias = new Alias(initialData, aliasName);
const savedAlias = await alias.save();
expect(savedAlias._id).not.be.undefined;
expect(savedAlias.hash).to.equal(binaryMD5(aliasName));
expect(savedAlias.type).to.equal(initialData.type);
expect(savedAlias.id).to.equal(initialData.id);
expect(savedAlias.deprecated).to.equal(false);
const insertedAlias = await aliases.findOne({_id: savedAlias._id});
expect(insertedAlias._id).to.equal(savedAlias._id);
expect(insertedAlias.hash).to.equal(savedAlias.hash);
expect(insertedAlias.type).to.equal(savedAlias.type);
expect(insertedAlias.id).to.equal(savedAlias.id);
expect(insertedAlias.deprecated).to.equal(savedAlias.deprecated);
const updateData = {
type: Alias.types.PAGE,
id: 'page_id',
hash: binaryMD5('another test hash'),
deprecated: true
};
alias.data = updateData;
await alias.save();
expect(alias._id).to.equal(insertedAlias._id);
const updatedAlias = await aliases.findOne({_id: alias._id});
expect(updatedAlias._id).to.equal(savedAlias._id);
expect(updatedAlias.hash).to.equal(updateData.hash);
expect(updatedAlias.type).to.equal(updateData.type);
expect(updatedAlias.id).to.equal(updateData.id);
expect(updatedAlias.deprecated).to.equal(updateData.deprecated);
});
});

View file

@ -4,8 +4,21 @@ const path = require('path');
const config = require('../../config'); const config = require('../../config');
const Page = require('../../src/models/page'); const Page = require('../../src/models/page');
const {pages} = require('../../src/utils/database'); const {pages} = require('../../src/utils/database');
const translateString = require('../../src/utils/translation');
describe('Page model', () => { describe('Page model', () => {
const transformToUri = (string) => {
return translateString(string
.replace(/&nbsp;/g, ' ')
.replace(/[^a-zA-Z0-9А-Яа-яЁё ]/g, ' ')
.replace(/ +/g, ' ')
.trim()
.toLowerCase()
.split(' ')
.join('-'));
};
after(() => { after(() => {
const pathToDB = path.resolve(__dirname, '../../', config.database, './pages.db'); const pathToDB = path.resolve(__dirname, '../../', config.database, './pages.db');
@ -23,6 +36,7 @@ describe('Page model', () => {
expect(data._id).to.be.undefined; expect(data._id).to.be.undefined;
expect(data.title).to.be.empty; expect(data.title).to.be.empty;
expect(data.uri).to.be.empty;
expect(data.body).to.be.undefined; expect(data.body).to.be.undefined;
expect(data.parent).to.be.undefined; expect(data.parent).to.be.undefined;
@ -32,6 +46,7 @@ describe('Page model', () => {
expect(data._id).to.be.undefined; expect(data._id).to.be.undefined;
expect(data.title).to.be.empty; expect(data.title).to.be.empty;
expect(data.uri).to.be.empty;
expect(data.body).to.be.undefined; expect(data.body).to.be.undefined;
expect(data.parent).to.be.undefined; expect(data.parent).to.be.undefined;
@ -57,11 +72,13 @@ describe('Page model', () => {
expect(data._id).to.equal(initialData._id); expect(data._id).to.equal(initialData._id);
expect(data.title).to.equal(initialData.body.blocks[0].data.text); expect(data.title).to.equal(initialData.body.blocks[0].data.text);
expect(data.uri).to.be.empty;
expect(data.body).to.deep.equal(initialData.body); expect(data.body).to.deep.equal(initialData.body);
expect(data.parent).to.be.undefined; expect(data.parent).to.be.undefined;
expect(json._id).to.equal(initialData._id); expect(json._id).to.equal(initialData._id);
expect(json.title).to.equal(initialData.body.blocks[0].data.text); expect(json.title).to.equal(initialData.body.blocks[0].data.text);
expect(json.title).to.equal(initialData.body.blocks[0].data.text);
expect(json.body).to.deep.equal(initialData.body); expect(json.body).to.deep.equal(initialData.body);
expect(json.parent).to.be.undefined; expect(json.parent).to.be.undefined;
@ -85,6 +102,7 @@ describe('Page model', () => {
expect(data._id).to.equal(initialData._id); expect(data._id).to.equal(initialData._id);
expect(data.title).to.equal(update.body.blocks[0].data.text); expect(data.title).to.equal(update.body.blocks[0].data.text);
expect(data.uri).to.be.empty;
expect(data.body).to.equal(update.body); expect(data.body).to.equal(update.body);
expect(data.parent).to.be.undefined; expect(data.parent).to.be.undefined;
}); });
@ -96,7 +114,7 @@ describe('Page model', () => {
{ {
type: 'header', type: 'header',
data: { data: {
text: 'Page header' text: 'New page header'
} }
} }
] ]
@ -108,6 +126,7 @@ describe('Page model', () => {
expect(savedPage._id).not.be.undefined; expect(savedPage._id).not.be.undefined;
expect(savedPage.title).to.equal(initialData.body.blocks[0].data.text); expect(savedPage.title).to.equal(initialData.body.blocks[0].data.text);
expect(savedPage.uri).to.equal(transformToUri(initialData.body.blocks[0].data.text));
expect(savedPage.body).to.equal(initialData.body); expect(savedPage.body).to.equal(initialData.body);
expect(page._id).not.be.undefined; expect(page._id).not.be.undefined;
@ -115,6 +134,7 @@ describe('Page model', () => {
expect(insertedPage._id).to.equal(page._id); expect(insertedPage._id).to.equal(page._id);
expect(insertedPage.title).to.equal(page.title); expect(insertedPage.title).to.equal(page.title);
expect(insertedPage.uri).to.equal(page.uri);
expect(insertedPage.body).to.deep.equal(page.body); expect(insertedPage.body).to.deep.equal(page.body);
const updateData = { const updateData = {
@ -127,7 +147,8 @@ describe('Page model', () => {
} }
} }
] ]
} },
uri: 'updated-uri'
}; };
page.data = updateData; page.data = updateData;
@ -139,6 +160,7 @@ describe('Page model', () => {
expect(updatedPage._id).to.equal(savedPage._id); expect(updatedPage._id).to.equal(savedPage._id);
expect(updatedPage.title).to.equal(updateData.body.blocks[0].data.text); expect(updatedPage.title).to.equal(updateData.body.blocks[0].data.text);
expect(updatedPage.uri).to.equal(updateData.uri);
expect(updatedPage.body).to.deep.equal(updateData.body); expect(updatedPage.body).to.deep.equal(updateData.body);
await page.destroy(); await page.destroy();
@ -150,6 +172,39 @@ describe('Page model', () => {
expect(removedPage).to.be.null; expect(removedPage).to.be.null;
}); });
it('Handle multiple page creation with the same uri', async () => {
const initialData = {
body: {
blocks: [
{
type: 'header',
data: {
text: 'New page header'
}
}
]
}
};
const firstPage = new Page(initialData);
let firstSavedPage = await firstPage.save();
const secondPage = new Page(initialData);
let secondSavedPage = await secondPage.save();
expect(secondSavedPage.uri).to.equal(transformToUri(initialData.body.blocks[0].data.text) + '-1');
const newUri = 'new-uri';
firstPage.data = {...firstPage.data, uri: newUri};
firstSavedPage = await firstPage.save();
expect(firstSavedPage.uri).to.equal(newUri);
const thirdPage = new Page(initialData);
let thirdSavedPage = await thirdPage.save();
expect(thirdSavedPage.uri).to.equal(transformToUri(initialData.body.blocks[0].data.text));
});
it('Static get method', async () => { it('Static get method', async () => {
const initialData = { const initialData = {
body: { body: {
@ -173,6 +228,7 @@ describe('Page model', () => {
expect(data._id).to.equal(savedPage._id); expect(data._id).to.equal(savedPage._id);
expect(data.title).to.equal(initialData.body.blocks[0].data.text); expect(data.title).to.equal(initialData.body.blocks[0].data.text);
expect(data.uri).to.equal(transformToUri(initialData.body.blocks[0].data.text));
expect(data.body).to.deep.equal(initialData.body); expect(data.body).to.deep.equal(initialData.body);
await page.destroy(); await page.destroy();
@ -213,6 +269,7 @@ describe('Page model', () => {
expect(foundPages.length).to.equal(2); expect(foundPages.length).to.equal(2);
foundPages.forEach((page, i) => { foundPages.forEach((page, i) => {
expect(page.title).to.equal(pagesToSave[i].body.blocks[0].data.text); expect(page.title).to.equal(pagesToSave[i].body.blocks[0].data.text);
expect(page.uri).to.equal(transformToUri(pagesToSave[i].body.blocks[0].data.text));
expect(page.body).to.deep.equal(pagesToSave[i].body); expect(page.body).to.deep.equal(pagesToSave[i].body);
}); });
}); });
@ -257,6 +314,7 @@ describe('Page model', () => {
expect(testedParent._id).to.equal(parentId); expect(testedParent._id).to.equal(parentId);
expect(testedParent.title).to.equal(parent.body.blocks[0].data.text); expect(testedParent.title).to.equal(parent.body.blocks[0].data.text);
expect(testedParent.uri).to.equal(transformToUri(parent.body.blocks[0].data.text));
expect(testedParent.body).to.deep.equal(parent.body); expect(testedParent.body).to.deep.equal(parent.body);
const children = await parent.children; const children = await parent.children;
@ -267,6 +325,7 @@ describe('Page model', () => {
expect(testedChild._id).to.equal(childId); expect(testedChild._id).to.equal(childId);
expect(testedChild.title).to.equal(child.body.blocks[0].data.text); expect(testedChild.title).to.equal(child.body.blocks[0].data.text);
expect(testedChild.uri).to.equal(transformToUri(child.body.blocks[0].data.text));
expect(testedChild.body).to.deep.equal(child.body); expect(testedChild.body).to.deep.equal(child.body);
expect(testedChild._parent).to.equal(child._parent); expect(testedChild._parent).to.equal(child._parent);
expect(testedChild._parent).to.equal(parent._id); expect(testedChild._parent).to.equal(parent._id);

58
test/rest/aliases.js Normal file
View file

@ -0,0 +1,58 @@
const {app} = require('../../bin/www');
const fs = require('fs');
const path = require('path');
const config = require('../../config');
const chai = require('chai');
const chaiHTTP = require('chai-http');
const {expect} = chai;
chai.use(chaiHTTP);
describe('Aliases REST: ', () => {
let agent;
before(async () => {
agent = chai.request.agent(app);
});
after(async () => {
const pathToDB = path.resolve(__dirname, '../../', config.database, './pages.db');
if (fs.existsSync(pathToDB)) {
fs.unlinkSync(pathToDB);
}
const pathToAliasDB = path.resolve(__dirname, '../../', config.database, './aliases.db');
if (fs.existsSync(pathToAliasDB)) {
fs.unlinkSync(pathToAliasDB);
}
});
it('Finding page with alias', async () => {
const body = {
blocks: [
{
type: 'header',
data: {
text: 'Test header'
}
}
]
};
const put = await agent
.put('/api/page')
.send({body});
expect(put).to.have.status(200);
expect(put).to.be.json;
const {result: {uri}} = put.body;
const get = await agent.get(`/${uri}`);
expect(get).to.have.status(200);
});
});

View file

@ -1,6 +1,7 @@
const {app} = require('../../bin/www'); const {app} = require('../../bin/www');
const model = require('../../src/models/page'); const model = require('../../src/models/page');
const PageOrder = require('../../src/models/pageOrder'); const PageOrder = require('../../src/models/pageOrder');
const translateString = require('../../src/utils/translation');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@ -13,6 +14,16 @@ chai.use(chaiHTTP);
describe('Pages REST: ', () => { describe('Pages REST: ', () => {
let agent; let agent;
const transformToUri = (string) => {
return translateString(string
.replace(/&nbsp;/g, ' ')
.replace(/[^a-zA-Z0-9А-Яа-яЁё ]/g, ' ')
.replace(/ +/g, ' ')
.trim()
.toLowerCase()
.split(' ')
.join('-'));
};
before(async () => { before(async () => {
agent = chai.request.agent(app); agent = chai.request.agent(app);
@ -35,9 +46,9 @@ describe('Pages REST: ', () => {
text: 'Page header' text: 'Page header'
} }
} }
], ]
}; };
const parent = 0; const parent = 0;
const res = await agent const res = await agent
.put('/api/page') .put('/api/page')
.send({body, parent}); .send({body, parent});
@ -50,6 +61,7 @@ describe('Pages REST: ', () => {
expect(success).to.be.true; expect(success).to.be.true;
expect(result._id).to.be.a('string'); expect(result._id).to.be.a('string');
expect(result.title).to.equal(body.blocks[0].data.text); expect(result.title).to.equal(body.blocks[0].data.text);
expect(result.uri).to.equal(transformToUri(body.blocks[0].data.text));
expect(result.body).to.deep.equal(body); expect(result.body).to.deep.equal(body);
const createdPage = await model.get(result._id); const createdPage = await model.get(result._id);
@ -57,13 +69,14 @@ describe('Pages REST: ', () => {
expect(createdPage).not.be.null; expect(createdPage).not.be.null;
expect(createdPage._id).to.equal(result._id); expect(createdPage._id).to.equal(result._id);
expect(createdPage.title).to.equal(body.blocks[0].data.text); expect(createdPage.title).to.equal(body.blocks[0].data.text);
expect(createdPage.uri).to.equal(transformToUri(body.blocks[0].data.text));
expect(createdPage.body).to.deep.equal(body); expect(createdPage.body).to.deep.equal(body);
const pageOrder = await PageOrder.get('' + (createdPage.data.parent || 0)); const pageOrder = await PageOrder.get('' + (createdPage.data.parent || 0));
expect(pageOrder.order).to.be.an('array'); expect(pageOrder.order).to.be.an('array');
await createdPage.destroy(); await createdPage.destroy();
await pageOrder.destroy() await pageOrder.destroy();
}); });
it('Page data validation on create', async () => { it('Page data validation on create', async () => {
@ -115,6 +128,7 @@ describe('Pages REST: ', () => {
expect(foundPage._id).to.equal(_id); expect(foundPage._id).to.equal(_id);
expect(foundPage.title).to.equal(body.blocks[0].data.text); expect(foundPage.title).to.equal(body.blocks[0].data.text);
expect(foundPage.uri).to.equal(transformToUri(body.blocks[0].data.text));
expect(foundPage.body).to.deep.equal(body); expect(foundPage.body).to.deep.equal(body);
await pageOrder.destroy(); await pageOrder.destroy();
@ -164,10 +178,11 @@ describe('Pages REST: ', () => {
} }
] ]
}; };
const updatedUri = 'updated-uri';
res = await agent res = await agent
.post(`/api/page/${_id}`) .post(`/api/page/${_id}`)
.send({body: updatedBody}); .send({body: updatedBody, uri: updatedUri});
expect(res).to.have.status(200); expect(res).to.have.status(200);
expect(res).to.be.json; expect(res).to.be.json;
@ -178,6 +193,8 @@ describe('Pages REST: ', () => {
expect(result._id).to.equal(_id); expect(result._id).to.equal(_id);
expect(result.title).not.equal(body.blocks[0].data.text); expect(result.title).not.equal(body.blocks[0].data.text);
expect(result.title).to.equal(updatedBody.blocks[0].data.text); expect(result.title).to.equal(updatedBody.blocks[0].data.text);
expect(result.uri).not.equal(transformToUri(body.blocks[0].data.text));
expect(result.uri).to.equal(updatedUri);
expect(result.body).not.equal(body); expect(result.body).not.equal(body);
expect(result.body).to.deep.equal(updatedBody); expect(result.body).to.deep.equal(updatedBody);
@ -187,6 +204,8 @@ describe('Pages REST: ', () => {
expect(updatedPage._id).to.equal(_id); expect(updatedPage._id).to.equal(_id);
expect(updatedPage.title).not.equal(body.blocks[0].data.text); expect(updatedPage.title).not.equal(body.blocks[0].data.text);
expect(updatedPage.title).to.equal(updatedBody.blocks[0].data.text); expect(updatedPage.title).to.equal(updatedBody.blocks[0].data.text);
expect(updatedPage.uri).not.equal(transformToUri(body.blocks[0].data.text));
expect(updatedPage.uri).to.equal(updatedUri);
expect(updatedPage.body).not.equal(body); expect(updatedPage.body).not.equal(body);
expect(updatedPage.body).to.deep.equal(updatedBody); expect(updatedPage.body).to.deep.equal(updatedBody);
@ -194,6 +213,65 @@ describe('Pages REST: ', () => {
await updatedPage.destroy(); await updatedPage.destroy();
}); });
it('Handle multiple page creation with the same uri', async () => {
const body = {
blocks: [
{
type: 'header',
data: {
text: 'Page header'
}
}
]
};
let res = await agent
.put('/api/page')
.send({body});
expect(res).to.have.status(200);
expect(res).to.be.json;
const {result: {_id}} = res.body;
res = await agent
.put('/api/page')
.send({body: body});
expect(res).to.have.status(200);
expect(res).to.be.json;
const {success: secondPageSuccess, result: secondPageResult} = res.body;
expect(secondPageSuccess).to.be.true;
expect(secondPageResult.title).to.equal(body.blocks[0].data.text);
expect(secondPageResult.uri).to.equal(transformToUri(body.blocks[0].data.text) + '-1');
expect(secondPageResult.body).to.deep.equal(body);
const newFirstPageUri = 'New-uri';
res = await agent
.post(`/api/page/${_id}`)
.send({body: body, uri: newFirstPageUri});
expect(res).to.have.status(200);
expect(res).to.be.json;
res = await agent
.put('/api/page')
.send({body: body});
expect(res).to.have.status(200);
expect(res).to.be.json;
const {success: thirdPageSuccess, result: thirdPageResult} = res.body;
expect(thirdPageSuccess).to.be.true;
expect(thirdPageResult.title).to.equal(body.blocks[0].data.text);
expect(thirdPageResult.uri).to.equal(transformToUri(body.blocks[0].data.text));
expect(thirdPageResult.body).to.deep.equal(body);
});
it('Updating page with not existing id', async () => { it('Updating page with not existing id', async () => {
const res = await agent const res = await agent
.post('/api/page/not-existing-id') .post('/api/page/not-existing-id')
@ -223,7 +301,7 @@ describe('Pages REST: ', () => {
{ {
type: 'header', type: 'header',
data: { data: {
text: 'Page header' text: 'Page header to be deleted'
} }
} }
] ]
@ -249,6 +327,7 @@ describe('Pages REST: ', () => {
expect(success).to.be.true; expect(success).to.be.true;
expect(result._id).to.be.undefined; expect(result._id).to.be.undefined;
expect(result.title).to.equal(body.blocks[0].data.text); expect(result.title).to.equal(body.blocks[0].data.text);
expect(result.uri).to.equal(transformToUri(body.blocks[0].data.text));
expect(result.body).to.deep.equal(body); expect(result.body).to.deep.equal(body);
const deletedPage = await model.get(_id); const deletedPage = await model.get(_id);