mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-11-02 03:55:55 +01:00
Testing suite integration for openapi spec (#8263)
* feat: testing suite integration for openapi spec The testing suite now takes the openapi spec into account. It will check each route defined, make a call to it, and compare the response with the defined schema. Any mismatches will cause the test to fail. * fix(openapi): removed debug stuff from tests * fix(openapi): fixed some tests * fix(openapi): added additional check to tests, test fixes * fix(openapi): better tests, fixed spec errors * fix(openapi): bad conditional in test * fix: oops * fix(openapi): more tests fixing * fix(openapi): more tests * fix(openapi): fix some more tests * fix: verbose'd an info log * fix: topic pagination route returns schema-optimized pagination block * fix(openapi): more test/spec fixes * fix(openapi): accidentally sending in authenticated jar for anon routes * fix(openapi): more test/spec fixes * fix(openapi): more spec fixes * fix: timestampReadable Invalid Date * fix(openapi): more tests... almost there * fix(openapi): more tests fixing * fix(openapi): finally all tests passing * fix(openapi): added reverse test to compare response to spec ... and fixed all the tests that broke * fix: remove tests related to group covers, as route is gone * fix(openapi): broken test on travis * fix(openapi): broken test on travis * fix(openapi): broken test on travis * fix(openapi): object cache is not present for psql * fix: tests Co-authored-by: Barış Soner Uşaklı <barisusakli@gmail.com>
This commit is contained in:
@@ -109,6 +109,7 @@
|
|||||||
"email:sendmail:rateLimit": 2,
|
"email:sendmail:rateLimit": 2,
|
||||||
"email:sendmail:rateDelta": 1000,
|
"email:sendmail:rateDelta": 1000,
|
||||||
"hideFullname": 0,
|
"hideFullname": 0,
|
||||||
|
"hideEmail": 0,
|
||||||
"allowGuestHandles": 0,
|
"allowGuestHandles": 0,
|
||||||
"disableRecentCategoryFilter": 0,
|
"disableRecentCategoryFilter": 0,
|
||||||
"maximumRelatedTopics": 0,
|
"maximumRelatedTopics": 0,
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"prompt": "^1.0.0",
|
"prompt": "^1.0.0",
|
||||||
"redis": "3.0.2",
|
"redis": "3.0.2",
|
||||||
"request": "2.88.2",
|
"request": "2.88.2",
|
||||||
|
"request-promise-native": "^1.0.8",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"rss": "^1.2.2",
|
"rss": "^1.2.2",
|
||||||
"sanitize-html": "^1.23.0",
|
"sanitize-html": "^1.23.0",
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ Breadcrumbs:
|
|||||||
text:
|
text:
|
||||||
type: string
|
type: string
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
|
cid:
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- text
|
||||||
@@ -47,10 +47,7 @@ CommonProps:
|
|||||||
property:
|
property:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- name
|
|
||||||
- content
|
- content
|
||||||
- noEscape
|
|
||||||
- property
|
|
||||||
link:
|
link:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -69,30 +66,14 @@ CommonProps:
|
|||||||
required:
|
required:
|
||||||
- rel
|
- rel
|
||||||
- href
|
- href
|
||||||
- type
|
|
||||||
- sizes
|
|
||||||
widgets:
|
widgets:
|
||||||
type: object
|
type: object
|
||||||
description: Rendered widgets
|
description: Each widget area will have its own property in this object
|
||||||
properties:
|
additionalProperties:
|
||||||
header:
|
type: array
|
||||||
type: array
|
description: A collection of HTML snippets that are appended to each widget area
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
html:
|
html:
|
||||||
type: string
|
type: string
|
||||||
sidebar:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
html:
|
|
||||||
type: string
|
|
||||||
footer:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
html:
|
|
||||||
type: string
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
GroupObject:
|
GroupFullObject:
|
||||||
type: object
|
type: object
|
||||||
|
description: The response from an internal call to `Groups.get(<groupname>)`
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
@@ -14,7 +15,7 @@ GroupObject:
|
|||||||
type: number
|
type: number
|
||||||
description: Label text for the user badge
|
description: Label text for the user badge
|
||||||
userTitleEnabled:
|
userTitleEnabled:
|
||||||
type: boolean
|
type: number
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
description: The group description
|
description: The group description
|
||||||
@@ -73,3 +74,60 @@ GroupObject:
|
|||||||
type: boolean
|
type: boolean
|
||||||
isOwner:
|
isOwner:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
GroupDataObject:
|
||||||
|
type: object
|
||||||
|
description: The response from an internal call to `Groups.getGroupData(<groupname>, [])` with **explicitly** no fields passed in
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: The group name
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
description: URL-safe slug of the group name
|
||||||
|
createtime:
|
||||||
|
type: number
|
||||||
|
description: UNIX timestamp of the group's creation
|
||||||
|
userTitle:
|
||||||
|
type: number
|
||||||
|
description: Label text for the user badge
|
||||||
|
userTitleEnabled:
|
||||||
|
type: number
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: The group description
|
||||||
|
memberCount:
|
||||||
|
type: number
|
||||||
|
hidden:
|
||||||
|
type: number
|
||||||
|
system:
|
||||||
|
type: number
|
||||||
|
private:
|
||||||
|
type: number
|
||||||
|
disableJoinRequests:
|
||||||
|
type: number
|
||||||
|
disableLeave:
|
||||||
|
type: number
|
||||||
|
cover:url:
|
||||||
|
type: string
|
||||||
|
cover:thumb:url:
|
||||||
|
type: string
|
||||||
|
nameEncoded:
|
||||||
|
type: string
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
description: A custom override of the group's name, a friendly name
|
||||||
|
labelColor:
|
||||||
|
type: string
|
||||||
|
description: A six-character hexadecimal colour code
|
||||||
|
textColor:
|
||||||
|
type: string
|
||||||
|
description: A six-character hexadecimal colour code
|
||||||
|
icon:
|
||||||
|
type: string
|
||||||
|
description: A FontAwesome icon string
|
||||||
|
createtimeISO:
|
||||||
|
type: string
|
||||||
|
description: "`createtime` rendered as an ISO 8601 format"
|
||||||
|
cover:position:
|
||||||
|
type: string
|
||||||
@@ -34,10 +34,30 @@ Pagination:
|
|||||||
type: boolean
|
type: boolean
|
||||||
rel:
|
rel:
|
||||||
type: array
|
type: array
|
||||||
items: {}
|
description: A collection of objects used to build the link tags pointing to adjacent pages, if any.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
rel:
|
||||||
|
type: string
|
||||||
|
enum: [prev, next]
|
||||||
|
href:
|
||||||
|
type: string
|
||||||
|
description: A query string that points to the previous or next page
|
||||||
pages:
|
pages:
|
||||||
type: array
|
type: array
|
||||||
items: {}
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: number
|
||||||
|
description: The current page
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
description: If the page noted in this array is the current page
|
||||||
|
qs:
|
||||||
|
type: string
|
||||||
|
description: A query string that points to the page noted in this array
|
||||||
currentPage:
|
currentPage:
|
||||||
type: number
|
type: number
|
||||||
pageCount:
|
pageCount:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ PostsObject:
|
|||||||
removed, etc.)
|
removed, etc.)
|
||||||
picture:
|
picture:
|
||||||
type: string
|
type: string
|
||||||
|
nullable: true
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
icon:text:
|
icon:text:
|
||||||
@@ -79,6 +80,10 @@ PostsObject:
|
|||||||
type: number
|
type: number
|
||||||
description: The post id of the first post in this topic (also called the
|
description: The post id of the first post in this topic (also called the
|
||||||
"original post")
|
"original post")
|
||||||
|
teaserPid:
|
||||||
|
type: number
|
||||||
|
description: The post id of the teaser (the most recent post, depending on settings)
|
||||||
|
nullable: true
|
||||||
titleRaw:
|
titleRaw:
|
||||||
type: string
|
type: string
|
||||||
category:
|
category:
|
||||||
|
|||||||
253
public/openapi/components/schemas/TopicObject.yaml
Normal file
253
public/openapi/components/schemas/TopicObject.yaml
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
TopicObject:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tid:
|
||||||
|
type: number
|
||||||
|
description: A topic identifier
|
||||||
|
uid:
|
||||||
|
type: number
|
||||||
|
description: A user identifier
|
||||||
|
cid:
|
||||||
|
type: number
|
||||||
|
description: A category identifier
|
||||||
|
mainPid:
|
||||||
|
type: number
|
||||||
|
description: The post id of the first post in this topic (also called the
|
||||||
|
"original post")
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
timestamp:
|
||||||
|
type: number
|
||||||
|
lastposttime:
|
||||||
|
type: number
|
||||||
|
postcount:
|
||||||
|
type: number
|
||||||
|
viewcount:
|
||||||
|
type: number
|
||||||
|
teaserPid:
|
||||||
|
oneOf:
|
||||||
|
- type: number
|
||||||
|
- type: string
|
||||||
|
nullable: true
|
||||||
|
upvotes:
|
||||||
|
type: number
|
||||||
|
downvotes:
|
||||||
|
type: number
|
||||||
|
deleted:
|
||||||
|
type: number
|
||||||
|
locked:
|
||||||
|
type: number
|
||||||
|
pinned:
|
||||||
|
type: number
|
||||||
|
description: Whether or not this particular topic is pinned to the top of the
|
||||||
|
category
|
||||||
|
deleterUid:
|
||||||
|
type: number
|
||||||
|
titleRaw:
|
||||||
|
type: string
|
||||||
|
timestampISO:
|
||||||
|
type: string
|
||||||
|
description: An ISO 8601 formatted date string (complementing `timestamp`)
|
||||||
|
lastposttimeISO:
|
||||||
|
type: string
|
||||||
|
votes:
|
||||||
|
type: number
|
||||||
|
category:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
cid:
|
||||||
|
type: number
|
||||||
|
description: A category identifier
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
icon:
|
||||||
|
type: string
|
||||||
|
image:
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
imageClass:
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
bgColor:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
disabled:
|
||||||
|
type: number
|
||||||
|
user:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
uid:
|
||||||
|
type: number
|
||||||
|
description: A user identifier
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: A friendly name for a given user account
|
||||||
|
fullname:
|
||||||
|
type: string
|
||||||
|
userslug:
|
||||||
|
type: string
|
||||||
|
description: An URL-safe variant of the username (i.e. lower-cased, spaces
|
||||||
|
removed, etc.)
|
||||||
|
reputation:
|
||||||
|
type: number
|
||||||
|
postcount:
|
||||||
|
type: number
|
||||||
|
picture:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
signature:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
banned:
|
||||||
|
type: number
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
icon:text:
|
||||||
|
type: string
|
||||||
|
description: A single-letter representation of a username. This is used in the
|
||||||
|
auto-generated icon given to users without
|
||||||
|
an avatar
|
||||||
|
icon:bgColor:
|
||||||
|
type: string
|
||||||
|
description: A six-character hexadecimal colour code assigned to the user. This
|
||||||
|
value is used in conjunction with
|
||||||
|
`icon:text` for the user's auto-generated
|
||||||
|
icon
|
||||||
|
example: "#f44336"
|
||||||
|
banned_until_readable:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- uid
|
||||||
|
- username
|
||||||
|
- userslug
|
||||||
|
- reputation
|
||||||
|
- postcount
|
||||||
|
- picture
|
||||||
|
- signature
|
||||||
|
- banned
|
||||||
|
- status
|
||||||
|
- icon:text
|
||||||
|
- icon:bgColor
|
||||||
|
- banned_until_readable
|
||||||
|
teaser:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pid:
|
||||||
|
type: number
|
||||||
|
uid:
|
||||||
|
type: number
|
||||||
|
description: A user identifier
|
||||||
|
timestamp:
|
||||||
|
type: number
|
||||||
|
tid:
|
||||||
|
type: number
|
||||||
|
description: A topic identifier
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
timestampISO:
|
||||||
|
type: string
|
||||||
|
description: An ISO 8601 formatted date string (complementing `timestamp`)
|
||||||
|
user:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
uid:
|
||||||
|
type: number
|
||||||
|
description: A user identifier
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: A friendly name for a given user account
|
||||||
|
userslug:
|
||||||
|
type: string
|
||||||
|
description: An URL-safe variant of the username (i.e. lower-cased, spaces
|
||||||
|
removed, etc.)
|
||||||
|
picture:
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
icon:text:
|
||||||
|
type: string
|
||||||
|
description: A single-letter representation of a username. This is used in the
|
||||||
|
auto-generated icon given to users
|
||||||
|
without an avatar
|
||||||
|
icon:bgColor:
|
||||||
|
type: string
|
||||||
|
description: A six-character hexadecimal colour code assigned to the user. This
|
||||||
|
value is used in conjunction with
|
||||||
|
`icon:text` for the user's
|
||||||
|
auto-generated icon
|
||||||
|
example: "#f44336"
|
||||||
|
index:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
valueEscaped:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
bgColor:
|
||||||
|
type: string
|
||||||
|
score:
|
||||||
|
type: number
|
||||||
|
isOwner:
|
||||||
|
type: boolean
|
||||||
|
ignored:
|
||||||
|
type: boolean
|
||||||
|
unread:
|
||||||
|
type: boolean
|
||||||
|
bookmark:
|
||||||
|
nullable: true
|
||||||
|
type: number
|
||||||
|
unreplied:
|
||||||
|
type: boolean
|
||||||
|
icons:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: HTML injected into the theme
|
||||||
|
index:
|
||||||
|
type: number
|
||||||
|
thumb:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- tid
|
||||||
|
- uid
|
||||||
|
- cid
|
||||||
|
- mainPid
|
||||||
|
- title
|
||||||
|
- slug
|
||||||
|
- timestamp
|
||||||
|
- lastposttime
|
||||||
|
- postcount
|
||||||
|
- viewcount
|
||||||
|
- teaserPid
|
||||||
|
- upvotes
|
||||||
|
- downvotes
|
||||||
|
- deleted
|
||||||
|
- locked
|
||||||
|
- pinned
|
||||||
|
- deleterUid
|
||||||
|
- titleRaw
|
||||||
|
- timestampISO
|
||||||
|
- lastposttimeISO
|
||||||
|
- votes
|
||||||
|
- category
|
||||||
|
- user
|
||||||
|
- teaser
|
||||||
|
- tags
|
||||||
|
- isOwner
|
||||||
|
- ignored
|
||||||
|
- unread
|
||||||
|
- bookmark
|
||||||
|
- unreplied
|
||||||
|
- icons
|
||||||
|
- index
|
||||||
@@ -33,34 +33,41 @@ UserObject:
|
|||||||
type: string
|
type: string
|
||||||
description: A URL pointing to a picture to be used as the user's avatar
|
description: A URL pointing to a picture to be used as the user's avatar
|
||||||
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
||||||
|
nullable: true
|
||||||
fullname:
|
fullname:
|
||||||
type: string
|
type: string
|
||||||
example: Mr. Dragon Fruit Jr.
|
example: Mr. Dragon Fruit Jr.
|
||||||
location:
|
location:
|
||||||
type: string
|
type: string
|
||||||
example: 'Toronto, Canada'
|
example: 'Toronto, Canada'
|
||||||
|
nullable: true
|
||||||
birthday:
|
birthday:
|
||||||
type: string
|
type: string
|
||||||
description: A birthdate given in an ISO format parseable by the Date object
|
description: A birthdate given in an ISO format parseable by the Date object
|
||||||
example: 03/27/2020
|
example: 03/27/2020
|
||||||
|
nullable: true
|
||||||
website:
|
website:
|
||||||
type: string
|
type: string
|
||||||
example: 'https://example.org'
|
example: 'https://example.org'
|
||||||
|
nullable: true
|
||||||
aboutme:
|
aboutme:
|
||||||
type: string
|
type: string
|
||||||
example: |
|
example: |
|
||||||
This is a paragraph all about how my life got twist-turned upside-down
|
This is a paragraph all about how my life got twist-turned upside-down
|
||||||
and I'd like to take a minute and sit right here,
|
and I'd like to take a minute and sit right here,
|
||||||
to tell you all about how I because the administrator of NodeBB
|
to tell you all about how I became the administrator of NodeBB
|
||||||
|
nullable: true
|
||||||
signature:
|
signature:
|
||||||
type: string
|
type: string
|
||||||
example: |
|
example: |
|
||||||
This is an example signature
|
This is an example signature
|
||||||
It can span multiple lines.
|
It can span multiple lines.
|
||||||
|
nullable: true
|
||||||
uploadedpicture:
|
uploadedpicture:
|
||||||
type: string
|
type: string
|
||||||
example: /assets/profile/1-profileimg.png
|
example: /assets/profile/1-profileimg.png
|
||||||
description: 'In almost all cases, defer to "picture" instead. Use this if you need to specifically reference the picture uploaded to the forum.'
|
description: 'In almost all cases, defer to "picture" instead. Use this if you need to specifically reference the picture uploaded to the forum.'
|
||||||
|
nullable: true
|
||||||
profileviews:
|
profileviews:
|
||||||
type: number
|
type: number
|
||||||
description: The number of times this user's profile has been viewed
|
description: The number of times this user's profile has been viewed
|
||||||
@@ -98,18 +105,21 @@ UserObject:
|
|||||||
flags:
|
flags:
|
||||||
type: number
|
type: number
|
||||||
example: 0
|
example: 0
|
||||||
followercount:
|
nullable: true
|
||||||
|
followerCount:
|
||||||
type: number
|
type: number
|
||||||
example: 2
|
example: 2
|
||||||
followingcount:
|
followingCount:
|
||||||
type: number
|
type: number
|
||||||
example: 5
|
example: 5
|
||||||
'cover:url':
|
'cover:url':
|
||||||
type: string
|
type: string
|
||||||
example: /assets/profile/1-cover.png
|
example: /assets/profile/1-cover.png
|
||||||
|
nullable: true
|
||||||
'cover:position':
|
'cover:position':
|
||||||
type: string
|
type: string
|
||||||
example: 50.0301% 19.2464%
|
example: 50.0301% 19.2464%
|
||||||
|
nullable: true
|
||||||
groupTitle:
|
groupTitle:
|
||||||
type: string
|
type: string
|
||||||
example: '["administrators","Staff"]'
|
example: '["administrators","Staff"]'
|
||||||
@@ -140,7 +150,45 @@ UserObject:
|
|||||||
type: string
|
type: string
|
||||||
description: An ISO 8601 formatted date string representing the moment a ban will be lifted, or the words "Not Banned"
|
description: An ISO 8601 formatted date string representing the moment a ban will be lifted, or the words "Not Banned"
|
||||||
example: Not Banned
|
example: Not Banned
|
||||||
|
required:
|
||||||
|
- uid
|
||||||
|
- username
|
||||||
|
- userslug
|
||||||
|
- 'email:confirmed'
|
||||||
|
- joindate
|
||||||
|
- lastonline
|
||||||
|
- picture
|
||||||
|
- location
|
||||||
|
- birthday
|
||||||
|
- website
|
||||||
|
- aboutme
|
||||||
|
- signature
|
||||||
|
- uploadedpicture
|
||||||
|
- profileviews
|
||||||
|
- reputation
|
||||||
|
- postcount
|
||||||
|
- topiccount
|
||||||
|
- lastposttime
|
||||||
|
- banned
|
||||||
|
- 'banned:expire'
|
||||||
|
- status
|
||||||
|
- enum
|
||||||
|
- flags
|
||||||
|
- followerCount
|
||||||
|
- followingCount
|
||||||
|
- 'cover:url'
|
||||||
|
- 'cover:position'
|
||||||
|
- groupTitle
|
||||||
|
- groupTitleArray
|
||||||
|
- example
|
||||||
|
- 'icon:text'
|
||||||
|
- 'icon:bgColor'
|
||||||
|
- joindateISO
|
||||||
|
- lastonlineISO
|
||||||
|
- banned_until
|
||||||
|
- banned_until_readable
|
||||||
UserObjectFull:
|
UserObjectFull:
|
||||||
|
# accountHelpers.getUserDataByUserSlug
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
uid:
|
uid:
|
||||||
@@ -175,6 +223,7 @@ UserObjectFull:
|
|||||||
type: string
|
type: string
|
||||||
description: A URL pointing to a picture to be used as the user's avatar
|
description: A URL pointing to a picture to be used as the user's avatar
|
||||||
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
||||||
|
nullable: true
|
||||||
fullname:
|
fullname:
|
||||||
type: string
|
type: string
|
||||||
example: Mr. Dragon Fruit Jr.
|
example: Mr. Dragon Fruit Jr.
|
||||||
@@ -193,7 +242,7 @@ UserObjectFull:
|
|||||||
example: |
|
example: |
|
||||||
This is a paragraph all about how my life got twist-turned upside-down
|
This is a paragraph all about how my life got twist-turned upside-down
|
||||||
and I'd like to take a minute and sit right here,
|
and I'd like to take a minute and sit right here,
|
||||||
to tell you all about how I because the administrator of NodeBB
|
to tell you all about how I became the administrator of NodeBB
|
||||||
signature:
|
signature:
|
||||||
type: string
|
type: string
|
||||||
example: |
|
example: |
|
||||||
@@ -203,6 +252,7 @@ UserObjectFull:
|
|||||||
type: string
|
type: string
|
||||||
example: /assets/profile/1-profileimg.png
|
example: /assets/profile/1-profileimg.png
|
||||||
description: 'In almost all cases, defer to "picture" instead. Use this if you need to specifically reference the picture uploaded to the forum.'
|
description: 'In almost all cases, defer to "picture" instead. Use this if you need to specifically reference the picture uploaded to the forum.'
|
||||||
|
nullable: true
|
||||||
profileviews:
|
profileviews:
|
||||||
type: number
|
type: number
|
||||||
description: The number of times this user's profile has been viewed
|
description: The number of times this user's profile has been viewed
|
||||||
@@ -240,18 +290,21 @@ UserObjectFull:
|
|||||||
flags:
|
flags:
|
||||||
type: number
|
type: number
|
||||||
example: 0
|
example: 0
|
||||||
followercount:
|
nullable: true
|
||||||
|
followerCount:
|
||||||
type: number
|
type: number
|
||||||
example: 2
|
example: 2
|
||||||
followingcount:
|
followingCount:
|
||||||
type: number
|
type: number
|
||||||
example: 5
|
example: 5
|
||||||
'cover:url':
|
'cover:url':
|
||||||
type: string
|
type: string
|
||||||
example: /assets/profile/1-cover.png
|
example: /assets/profile/1-cover.png
|
||||||
|
nullable: true
|
||||||
'cover:position':
|
'cover:position':
|
||||||
type: string
|
type: string
|
||||||
example: 50.0301% 19.2464%
|
example: 50.0301% 19.2464%
|
||||||
|
nullable: true
|
||||||
groupTitle:
|
groupTitle:
|
||||||
type: string
|
type: string
|
||||||
example: '["administrators","Staff"]'
|
example: '["administrators","Staff"]'
|
||||||
@@ -332,7 +385,8 @@ UserObjectFull:
|
|||||||
type: boolean
|
type: boolean
|
||||||
groups:
|
groups:
|
||||||
type: array
|
type: array
|
||||||
items: {}
|
items:
|
||||||
|
$ref: ./GroupObject.yaml#/GroupFullObject
|
||||||
disableSignatures:
|
disableSignatures:
|
||||||
type: boolean
|
type: boolean
|
||||||
reputation:disabled:
|
reputation:disabled:
|
||||||
@@ -369,6 +423,12 @@ UserObjectFull:
|
|||||||
type: boolean
|
type: boolean
|
||||||
icon:
|
icon:
|
||||||
type: string
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- route
|
||||||
|
- name
|
||||||
|
- visibility
|
||||||
|
- public
|
||||||
sso:
|
sso:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -411,6 +471,7 @@ UserObjectSlim:
|
|||||||
type: string
|
type: string
|
||||||
description: A URL pointing to a picture to be used as the user's avatar
|
description: A URL pointing to a picture to be used as the user's avatar
|
||||||
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
||||||
|
nullable: true
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -437,6 +498,7 @@ UserObjectSlim:
|
|||||||
flags:
|
flags:
|
||||||
type: number
|
type: number
|
||||||
example: 0
|
example: 0
|
||||||
|
nullable: true
|
||||||
banned:
|
banned:
|
||||||
type: number
|
type: number
|
||||||
description: A Boolean representing whether a user is banned or not
|
description: A Boolean representing whether a user is banned or not
|
||||||
@@ -473,3 +535,74 @@ UserObjectSlim:
|
|||||||
example: Not Banned
|
example: Not Banned
|
||||||
administrator:
|
administrator:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
UserObjectACP:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
uid:
|
||||||
|
type: number
|
||||||
|
description: A user identifier
|
||||||
|
example: 1
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: A friendly name for a given user account
|
||||||
|
example: Dragon Fruit
|
||||||
|
userslug:
|
||||||
|
type: string
|
||||||
|
description: An URL-safe variant of the username (i.e. lower-cased, spaces removed, etc.)
|
||||||
|
example: dragon-fruit
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: Email address associated with the user account
|
||||||
|
example: dragonfruit@example.org
|
||||||
|
postcount:
|
||||||
|
type: number
|
||||||
|
example: 1000
|
||||||
|
joindate:
|
||||||
|
type: number
|
||||||
|
description: A UNIX timestamp representing the moment the user's account was created
|
||||||
|
example: 1585337827953
|
||||||
|
banned:
|
||||||
|
type: number
|
||||||
|
description: A Boolean representing whether a user is banned or not
|
||||||
|
example: 0
|
||||||
|
reputation:
|
||||||
|
type: number
|
||||||
|
description: The user's reputation score on the forum. Out-of-the-box, users gain/lose reputation points based on upvotes/downvotes, though plugins can alter the logic and criterion for awarding reputation points
|
||||||
|
example: 100
|
||||||
|
picture:
|
||||||
|
type: string
|
||||||
|
description: A URL pointing to a picture to be used as the user's avatar
|
||||||
|
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
||||||
|
nullable: true
|
||||||
|
flags:
|
||||||
|
type: number
|
||||||
|
example: 0
|
||||||
|
nullable: true
|
||||||
|
lastonline:
|
||||||
|
type: number
|
||||||
|
description: A UNIX timestamp representing the moment the user was last recorded online on this site
|
||||||
|
example: 1585337827953
|
||||||
|
'email:confirmed':
|
||||||
|
type: number
|
||||||
|
description: Whether the user has confirmed their email address or not
|
||||||
|
example: 1
|
||||||
|
'icon:text':
|
||||||
|
type: string
|
||||||
|
description: A single-letter representation of a username. This is used in the auto-generated icon given to users without an avatar
|
||||||
|
example: D
|
||||||
|
'icon:bgColor':
|
||||||
|
type: string
|
||||||
|
description: A six-character hexadecimal colour code assigned to the user. This value is used in conjunction with `icon:text` for the user's auto-generated icon
|
||||||
|
example: '#9c27b0'
|
||||||
|
joindateISO:
|
||||||
|
type: string
|
||||||
|
example: '2020-03-27T20:30:36.590Z'
|
||||||
|
lastonlineISO:
|
||||||
|
type: string
|
||||||
|
example: '2020-03-27T20:30:36.590Z'
|
||||||
|
banned_until_readable:
|
||||||
|
type: string
|
||||||
|
description: An ISO 8601 formatted date string representing the moment a ban will be lifted, or the words "Not Banned"
|
||||||
|
example: Not Banned
|
||||||
|
administrator:
|
||||||
|
type: boolean
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,7 @@ define('forum/topic/posts', [
|
|||||||
|
|
||||||
function updatePagination() {
|
function updatePagination() {
|
||||||
$.get(config.relative_path + '/api/topic/pagination/' + ajaxify.data.tid, { page: ajaxify.data.pagination.currentPage }, function (paginationData) {
|
$.get(config.relative_path + '/api/topic/pagination/' + ajaxify.data.tid, { page: ajaxify.data.pagination.currentPage }, function (paginationData) {
|
||||||
app.parseAndTranslate('partials/paginator', { pagination: paginationData }, function (html) {
|
app.parseAndTranslate('partials/paginator', paginationData, function (html) {
|
||||||
$('[component="pagination"]').after(html).remove();
|
$('[component="pagination"]').after(html).remove();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ consentController.get = async function (req, res, next) {
|
|||||||
const consented = await db.getObjectField('user:' + userData.uid, 'gdpr_consent');
|
const consented = await db.getObjectField('user:' + userData.uid, 'gdpr_consent');
|
||||||
userData.gdpr_consent = parseInt(consented, 10) === 1;
|
userData.gdpr_consent = parseInt(consented, 10) === 1;
|
||||||
userData.digest = {
|
userData.digest = {
|
||||||
frequency: meta.config.dailyDigestFreq,
|
frequency: meta.config.dailyDigestFreq || 'off',
|
||||||
enabled: meta.config.dailyDigestFreq !== 'off',
|
enabled: meta.config.dailyDigestFreq !== 'off',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ async function getProfileMenu(uid, callerUID) {
|
|||||||
id: 'info',
|
id: 'info',
|
||||||
route: 'info',
|
route: 'info',
|
||||||
name: '[[user:account_info]]',
|
name: '[[user:account_info]]',
|
||||||
|
icon: 'fa-info',
|
||||||
visibility: {
|
visibility: {
|
||||||
self: false,
|
self: false,
|
||||||
other: false,
|
other: false,
|
||||||
@@ -155,6 +156,7 @@ async function getProfileMenu(uid, callerUID) {
|
|||||||
id: 'sessions',
|
id: 'sessions',
|
||||||
route: 'sessions',
|
route: 'sessions',
|
||||||
name: '[[pages:account/sessions]]',
|
name: '[[pages:account/sessions]]',
|
||||||
|
icon: 'fa-group',
|
||||||
visibility: {
|
visibility: {
|
||||||
self: true,
|
self: true,
|
||||||
other: false,
|
other: false,
|
||||||
@@ -170,6 +172,7 @@ async function getProfileMenu(uid, callerUID) {
|
|||||||
id: 'consent',
|
id: 'consent',
|
||||||
route: 'consent',
|
route: 'consent',
|
||||||
name: '[[user:consent.title]]',
|
name: '[[user:consent.title]]',
|
||||||
|
icon: 'fa-thumbs-o-up',
|
||||||
visibility: {
|
visibility: {
|
||||||
self: true,
|
self: true,
|
||||||
other: false,
|
other: false,
|
||||||
@@ -190,6 +193,8 @@ async function getProfileMenu(uid, callerUID) {
|
|||||||
|
|
||||||
async function parseAboutMe(userData) {
|
async function parseAboutMe(userData) {
|
||||||
if (!userData.aboutme) {
|
if (!userData.aboutme) {
|
||||||
|
userData.aboutme = '';
|
||||||
|
userData.aboutmeParsed = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
userData.aboutme = validator.escape(String(userData.aboutme || ''));
|
userData.aboutme = validator.escape(String(userData.aboutme || ''));
|
||||||
|
|||||||
@@ -106,12 +106,12 @@ settingsController.get = async function (req, res, next) {
|
|||||||
|
|
||||||
userData.categoryWatchState = { [userData.settings.categoryWatchState]: true };
|
userData.categoryWatchState = { [userData.settings.categoryWatchState]: true };
|
||||||
|
|
||||||
userData.disableCustomUserSkins = meta.config.disableCustomUserSkins;
|
userData.disableCustomUserSkins = meta.config.disableCustomUserSkins || 0;
|
||||||
|
|
||||||
userData.allowUserHomePage = meta.config.allowUserHomePage;
|
userData.allowUserHomePage = meta.config.allowUserHomePage || 1;
|
||||||
|
|
||||||
userData.hideFullname = meta.config.hideFullname;
|
userData.hideFullname = meta.config.hideFullname || 0;
|
||||||
userData.hideEmail = meta.config.hideEmail;
|
userData.hideEmail = meta.config.hideEmail || 0;
|
||||||
|
|
||||||
userData.inTopicSearchAvailable = plugins.hasListeners('filter:topic.search');
|
userData.inTopicSearchAvailable = plugins.hasListeners('filter:topic.search');
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,17 @@ infoController.get = function (req, res) {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let port = nconf.get('port');
|
||||||
|
if (!Array.isArray(port) && !isNaN(parseInt(port, 10))) {
|
||||||
|
port = [port];
|
||||||
|
}
|
||||||
|
|
||||||
res.render('admin/development/info', {
|
res.render('admin/development/info', {
|
||||||
info: data,
|
info: data,
|
||||||
infoJSON: JSON.stringify(data, null, 4),
|
infoJSON: JSON.stringify(data, null, 4),
|
||||||
host: os.hostname(),
|
host: os.hostname(),
|
||||||
port: nconf.get('port'),
|
port: port,
|
||||||
nodeCount: data.length,
|
nodeCount: data.length,
|
||||||
timeout: timeoutMS,
|
timeout: timeoutMS,
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ categoryController.get = async function (req, res, next) {
|
|||||||
|
|
||||||
addTags(categoryData, res);
|
addTags(categoryData, res);
|
||||||
|
|
||||||
categoryData['feeds:disableRSS'] = meta.config['feeds:disableRSS'];
|
categoryData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
|
||||||
categoryData['reputation:disabled'] = meta.config['reputation:disabled'];
|
categoryData['reputation:disabled'] = meta.config['reputation:disabled'];
|
||||||
pageCount = Math.max(1, Math.ceil(categoryData.topic_count / userSettings.topicsPerPage));
|
pageCount = Math.max(1, Math.ceil(categoryData.topic_count / userSettings.topicsPerPage));
|
||||||
categoryData.pagination = pagination.create(currentPage, pageCount, req.query);
|
categoryData.pagination = pagination.create(currentPage, pageCount, req.query);
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ recentController.getData = async function (req, url, sort) {
|
|||||||
data.canPost = canPost;
|
data.canPost = canPost;
|
||||||
data.categories = categoryData.categories;
|
data.categories = categoryData.categories;
|
||||||
data.allCategoriesUrl = url + helpers.buildQueryString('', filter, '');
|
data.allCategoriesUrl = url + helpers.buildQueryString('', filter, '');
|
||||||
data.selectedCategory = categoryData.selectedCategory;
|
data.selectedCategory = categoryData.selectedCategory || null;
|
||||||
data.selectedCids = categoryData.selectedCids;
|
data.selectedCids = categoryData.selectedCids;
|
||||||
data['feeds:disableRSS'] = meta.config['feeds:disableRSS'];
|
data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
|
||||||
data.rssFeedUrl = nconf.get('relative_path') + '/' + url + '.rss';
|
data.rssFeedUrl = nconf.get('relative_path') + '/' + url + '.rss';
|
||||||
if (req.loggedIn) {
|
if (req.loggedIn) {
|
||||||
data.rssFeedUrl += '?uid=' + req.uid + '&token=' + rssToken;
|
data.rssFeedUrl += '?uid=' + req.uid + '&token=' + rssToken;
|
||||||
|
|||||||
@@ -32,10 +32,6 @@ tagsController.getTag = async function (req, res) {
|
|||||||
helpers.getCategoriesByStates(req.uid, '', states),
|
helpers.getCategoriesByStates(req.uid, '', states),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (Array.isArray(tids) && !tids.length) {
|
|
||||||
return res.render('tag', templateData);
|
|
||||||
}
|
|
||||||
|
|
||||||
templateData.categories = categoriesData.categories;
|
templateData.categories = categoriesData.categories;
|
||||||
|
|
||||||
templateData.topics = await topics.getTopics(tids, req.uid);
|
templateData.topics = await topics.getTopics(tids, req.uid);
|
||||||
|
|||||||
@@ -70,16 +70,12 @@ topicsController.get = async function getTopic(req, res, callback) {
|
|||||||
topics.modifyPostsByPrivilege(topicData, userPrivileges);
|
topics.modifyPostsByPrivilege(topicData, userPrivileges);
|
||||||
|
|
||||||
const hookData = await plugins.fireHook('filter:controllers.topic.get', { topicData: topicData, uid: req.uid });
|
const hookData = await plugins.fireHook('filter:controllers.topic.get', { topicData: topicData, uid: req.uid });
|
||||||
await Promise.all([
|
|
||||||
buildBreadcrumbs(hookData.topicData),
|
|
||||||
addTags(topicData, req, res),
|
|
||||||
]);
|
|
||||||
|
|
||||||
topicData.privileges = userPrivileges;
|
topicData.privileges = userPrivileges;
|
||||||
topicData.topicStaleDays = meta.config.topicStaleDays;
|
topicData.topicStaleDays = meta.config.topicStaleDays;
|
||||||
topicData['reputation:disabled'] = meta.config['reputation:disabled'];
|
topicData['reputation:disabled'] = meta.config['reputation:disabled'];
|
||||||
topicData['downvote:disabled'] = meta.config['downvote:disabled'];
|
topicData['downvote:disabled'] = meta.config['downvote:disabled'];
|
||||||
topicData['feeds:disableRSS'] = meta.config['feeds:disableRSS'];
|
topicData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
|
||||||
topicData.bookmarkThreshold = meta.config.bookmarkThreshold;
|
topicData.bookmarkThreshold = meta.config.bookmarkThreshold;
|
||||||
topicData.necroThreshold = meta.config.necroThreshold;
|
topicData.necroThreshold = meta.config.necroThreshold;
|
||||||
topicData.postEditDuration = meta.config.postEditDuration;
|
topicData.postEditDuration = meta.config.postEditDuration;
|
||||||
@@ -99,6 +95,11 @@ topicsController.get = async function getTopic(req, res, callback) {
|
|||||||
res.locals.linkTags.push(rel);
|
res.locals.linkTags.push(rel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
buildBreadcrumbs(hookData.topicData),
|
||||||
|
addTags(topicData, req, res),
|
||||||
|
]);
|
||||||
|
|
||||||
incrementViewCount(req, tid);
|
incrementViewCount(req, tid);
|
||||||
|
|
||||||
markAsRead(req, tid);
|
markAsRead(req, tid);
|
||||||
@@ -338,5 +339,5 @@ topicsController.pagination = async function (req, res, callback) {
|
|||||||
rel.href = nconf.get('url') + '/topic/' + topic.slug + rel.href;
|
rel.href = nconf.get('url') + '/topic/' + topic.slug + rel.href;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(paginationData);
|
res.json({ pagination: paginationData });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ userController.exportUploads = function (req, res, next) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
archive.pipe(output);
|
archive.pipe(output);
|
||||||
winston.info('[user/export/uploads] Collating uploads for uid ' + targetUid);
|
winston.verbose('[user/export/uploads] Collating uploads for uid ' + targetUid);
|
||||||
user.collateUploads(targetUid, archive, function (err) {
|
user.collateUploads(targetUid, archive, function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return next(err);
|
return next(err);
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ redisModule.init = function (callback) {
|
|||||||
winston.error('NodeBB could not connect to your Redis database. Redis returned the following error', err);
|
winston.error('NodeBB could not connect to your Redis database. Redis returned the following error', err);
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
require('./redis/promisify')(redisModule.client);
|
require('./redis/promisify')(redisModule.client);
|
||||||
|
|
||||||
callback();
|
callback();
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ Flags.get = async function (flagId) {
|
|||||||
|
|
||||||
const flagObj = {
|
const flagObj = {
|
||||||
state: 'open',
|
state: 'open',
|
||||||
|
assignee: null,
|
||||||
...base,
|
...base,
|
||||||
description: validator.escape(base.description),
|
description: validator.escape(base.description),
|
||||||
datetimeISO: utils.toISOString(base.datetime),
|
datetimeISO: utils.toISOString(base.datetime),
|
||||||
@@ -164,6 +165,7 @@ Flags.list = async function (filters, uid) {
|
|||||||
const userObj = await user.getUserFields(flagObj.uid, ['username', 'picture']);
|
const userObj = await user.getUserFields(flagObj.uid, ['username', 'picture']);
|
||||||
flagObj = {
|
flagObj = {
|
||||||
state: 'open',
|
state: 'open',
|
||||||
|
assignee: null,
|
||||||
...flagObj,
|
...flagObj,
|
||||||
reporter: {
|
reporter: {
|
||||||
username: userObj.username,
|
username: userObj.username,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ module.exports = function (Messaging) {
|
|||||||
messages = await Promise.all(messages.map(async (message) => {
|
messages = await Promise.all(messages.map(async (message) => {
|
||||||
if (message.system) {
|
if (message.system) {
|
||||||
message.content = validator.escape(String(message.content));
|
message.content = validator.escape(String(message.content));
|
||||||
|
message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(message.content));
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ admin.get = async function () {
|
|||||||
async function getAvailable() {
|
async function getAvailable() {
|
||||||
const core = require('../../install/data/navigation.json').map(function (item) {
|
const core = require('../../install/data/navigation.json').map(function (item) {
|
||||||
item.core = true;
|
item.core = true;
|
||||||
|
item.id = item.id || '';
|
||||||
|
item.properties = item.properties || { targetBlank: false };
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ module.exports = function (Posts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getTopicAndCategories(tids) {
|
async function getTopicAndCategories(tids) {
|
||||||
const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'postcount', 'mainPid']);
|
const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'postcount', 'mainPid', 'teaserPid']);
|
||||||
const cids = _.uniq(topicsData.map(topic => topic && topic.cid));
|
const cids = _.uniq(topicsData.map(topic => topic && topic.cid));
|
||||||
const categoriesData = await categories.getCategoriesFields(cids, ['cid', 'name', 'icon', 'slug', 'parentCid', 'bgColor', 'color', 'image', 'imageClass']);
|
const categoriesData = await categories.getCategoriesFields(cids, ['cid', 'name', 'icon', 'slug', 'parentCid', 'bgColor', 'color', 'image', 'imageClass']);
|
||||||
return { topics: topicsData, categories: categoriesData };
|
return { topics: topicsData, categories: categoriesData };
|
||||||
|
|||||||
@@ -106,4 +106,8 @@ function modifyTopic(topic, fields) {
|
|||||||
if (topic.hasOwnProperty('upvotes') && topic.hasOwnProperty('downvotes')) {
|
if (topic.hasOwnProperty('upvotes') && topic.hasOwnProperty('downvotes')) {
|
||||||
topic.votes = topic.upvotes - topic.downvotes;
|
topic.votes = topic.upvotes - topic.downvotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fields.includes('teaserPid') || !fields.length) {
|
||||||
|
topic.teaserPid = topic.teaserPid || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ Topics.getTopicsByTids = async function (tids, options) {
|
|||||||
if (topics[i]) {
|
if (topics[i]) {
|
||||||
topics[i].category = categoriesMap[topics[i].cid];
|
topics[i].category = categoriesMap[topics[i].cid];
|
||||||
topics[i].user = usersMap[topics[i].uid];
|
topics[i].user = usersMap[topics[i].uid];
|
||||||
topics[i].teaser = teasers[i];
|
topics[i].teaser = teasers[i] || null;
|
||||||
topics[i].tags = tags[i];
|
topics[i].tags = tags[i];
|
||||||
|
|
||||||
topics[i].isOwner = topics[i].uid === parseInt(uid, 10);
|
topics[i].isOwner = topics[i].uid === parseInt(uid, 10);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ module.exports = function (User) {
|
|||||||
banObj.user = usersData[index];
|
banObj.user = usersData[index];
|
||||||
banObj.until = parseInt(banObj.expire, 10);
|
banObj.until = parseInt(banObj.expire, 10);
|
||||||
banObj.untilReadable = new Date(banObj.until).toString();
|
banObj.untilReadable = new Date(banObj.until).toString();
|
||||||
banObj.timestampReadable = new Date(banObj.timestamp).toString();
|
banObj.timestampReadable = new Date(parseInt(banObj.timestamp, 10)).toString();
|
||||||
banObj.timestampISO = utils.toISOString(banObj.timestamp);
|
banObj.timestampISO = utils.toISOString(banObj.timestamp);
|
||||||
banObj.reason = validator.escape(String(banObj.reason || '')) || '[[user:info.banned-no-reason]]';
|
banObj.reason = validator.escape(String(banObj.reason || '')) || '[[user:info.banned-no-reason]]';
|
||||||
return banObj;
|
return banObj;
|
||||||
|
|||||||
216
test/api.js
216
test/api.js
@@ -3,18 +3,226 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const SwaggerParser = require('@apidevtools/swagger-parser');
|
const SwaggerParser = require('@apidevtools/swagger-parser');
|
||||||
|
const request = require('request-promise-native');
|
||||||
|
const nconf = require('nconf');
|
||||||
|
|
||||||
describe('Read API', () => {
|
const db = require('./mocks/databasemock');
|
||||||
let readApi;
|
const helpers = require('./helpers');
|
||||||
|
const user = require('../src/user');
|
||||||
|
const groups = require('../src/groups');
|
||||||
|
const categories = require('../src/categories');
|
||||||
|
const topics = require('../src/topics');
|
||||||
|
const plugins = require('../src/plugins');
|
||||||
|
const flags = require('../src/flags');
|
||||||
|
const messaging = require('../src/messaging');
|
||||||
|
|
||||||
|
describe('Read API', async () => {
|
||||||
|
let readApi = false;
|
||||||
|
const apiPath = path.resolve(__dirname, '../public/openapi/read.yaml');
|
||||||
|
let jar;
|
||||||
|
let setup = false;
|
||||||
|
const unauthenticatedRoutes = ['/api/login', '/api/register']; // Everything else will be called with the admin user
|
||||||
|
|
||||||
|
async function dummySearchHook(data) {
|
||||||
|
return [1];
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
plugins.unregisterHook('core', 'filter:search.query', dummySearchHook);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupData() {
|
||||||
|
if (setup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample users
|
||||||
|
const adminUid = await user.create({ username: 'admin', password: '123456', email: 'test@example.org' });
|
||||||
|
const unprivUid = await user.create({ username: 'unpriv', password: '123456', email: 'unpriv@example.org' });
|
||||||
|
await groups.join('administrators', adminUid);
|
||||||
|
|
||||||
|
// Create a category
|
||||||
|
const testCategory = await categories.create({ name: 'test' });
|
||||||
|
|
||||||
|
// Post a new topic
|
||||||
|
const testTopic = await topics.post({
|
||||||
|
uid: adminUid,
|
||||||
|
cid: testCategory.cid,
|
||||||
|
title: 'Test Topic',
|
||||||
|
content: 'Test topic content',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a sample flag
|
||||||
|
await flags.create('post', 1, unprivUid, 'sample reasons', Date.now());
|
||||||
|
|
||||||
|
// Create a new chat room
|
||||||
|
await messaging.newRoom(1, [2]);
|
||||||
|
|
||||||
|
// Attach a search hook so /api/search is enabled
|
||||||
|
plugins.registerHook('core', {
|
||||||
|
hook: 'filter:search.query',
|
||||||
|
method: dummySearchHook,
|
||||||
|
});
|
||||||
|
|
||||||
|
jar = await helpers.loginUser('admin', '123456');
|
||||||
|
setup = true;
|
||||||
|
}
|
||||||
|
|
||||||
it('should pass OpenAPI v3 validation', async () => {
|
it('should pass OpenAPI v3 validation', async () => {
|
||||||
const apiPath = path.resolve(__dirname, '../public/openapi/read.yaml');
|
|
||||||
try {
|
try {
|
||||||
readApi = await SwaggerParser.validate(apiPath);
|
await SwaggerParser.validate(apiPath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
assert.ifError(e);
|
assert.ifError(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readApi = await SwaggerParser.dereference(apiPath);
|
||||||
|
|
||||||
|
// Iterate through all documented paths, make a call to it, and compare the result body with what is defined in the spec
|
||||||
|
const paths = Object.keys(readApi.paths);
|
||||||
|
|
||||||
|
paths.forEach((path) => {
|
||||||
|
let schema;
|
||||||
|
let response;
|
||||||
|
let url;
|
||||||
|
const headers = {};
|
||||||
|
const qs = {};
|
||||||
|
|
||||||
|
function compare(schema, response, context) {
|
||||||
|
let required = [];
|
||||||
|
const additionalProperties = schema.hasOwnProperty('additionalProperties');
|
||||||
|
|
||||||
|
if (schema.allOf) {
|
||||||
|
schema = schema.allOf.reduce((memo, obj) => {
|
||||||
|
required = required.concat(obj.required ? obj.required : Object.keys(obj.properties));
|
||||||
|
memo = { ...memo, ...obj.properties };
|
||||||
|
return memo;
|
||||||
|
}, {});
|
||||||
|
} else if (schema.properties) {
|
||||||
|
required = schema.required || Object.keys(schema.properties);
|
||||||
|
schema = schema.properties;
|
||||||
|
} else {
|
||||||
|
// If schema contains no properties, check passes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the schema to the response
|
||||||
|
required.forEach((prop) => {
|
||||||
|
if (schema.hasOwnProperty(prop)) {
|
||||||
|
assert(response.hasOwnProperty(prop), '"' + prop + '" is a required property (path: ' + path + ', context: ' + context + ')');
|
||||||
|
|
||||||
|
// Don't proceed with type-check if the value could possibly be unset (nullable: true, in spec)
|
||||||
|
if (response[prop] === null && schema[prop].nullable === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Therefore, if the value is actually null, that's a problem (nullable is probably missing)
|
||||||
|
assert(response[prop] !== null, '"' + prop + '" was null, but schema does not specify it to be a nullable property (path: ' + path + ', context: ' + context + ')');
|
||||||
|
|
||||||
|
switch (schema[prop].type) {
|
||||||
|
case 'string':
|
||||||
|
assert.strictEqual(typeof response[prop], 'string', '"' + prop + '" was expected to be a string, but was ' + typeof response[prop] + ' instead (path: ' + path + ', context: ' + context + ')');
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
assert.strictEqual(typeof response[prop], 'boolean', '"' + prop + '" was expected to be a boolean, but was ' + typeof response[prop] + ' instead (path: ' + path + ', context: ' + context + ')');
|
||||||
|
break;
|
||||||
|
case 'object':
|
||||||
|
assert.strictEqual(typeof response[prop], 'object', '"' + prop + '" was expected to be an object, but was ' + typeof response[prop] + ' instead (path: ' + path + ', context: ' + context + ')');
|
||||||
|
compare(schema[prop], response[prop], context ? [context, prop].join('.') : prop);
|
||||||
|
break;
|
||||||
|
case 'array':
|
||||||
|
assert.strictEqual(Array.isArray(response[prop]), true, '"' + prop + '" was expected to be an array, but was ' + typeof response[prop] + ' instead (path: ' + path + ', context: ' + context + ')');
|
||||||
|
|
||||||
|
if (schema[prop].items) {
|
||||||
|
// Ensure the array items have a schema defined
|
||||||
|
assert(schema[prop].items.type || schema[prop].items.allOf, '"' + prop + '" is defined to be an array, but its items have no schema defined (path: ' + path + ', context: ' + context + ')');
|
||||||
|
|
||||||
|
// Compare types
|
||||||
|
if (schema[prop].items.type === 'object' || Array.isArray(schema[prop].items.allOf)) {
|
||||||
|
response[prop].forEach((res) => {
|
||||||
|
compare(schema[prop].items, res, context ? [context, prop].join('.') : prop);
|
||||||
|
});
|
||||||
|
} else if (response[prop].length) { // for now
|
||||||
|
response[prop].forEach((item) => {
|
||||||
|
assert.strictEqual(typeof item, schema[prop].items.type, '"' + prop + '" should have ' + schema[prop].items.type + ' items, but found ' + typeof items + ' instead (path: ' + path + ', context: ' + context + ')');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compare the response to the schema
|
||||||
|
Object.keys(response).forEach((prop) => {
|
||||||
|
if (additionalProperties) { // All bets are off
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(schema[prop], '"' + prop + '" was found in response, but is not defined in schema (path: ' + path + ', context: ' + context + ')');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOXO: fix -- premature exit for POST-only routes
|
||||||
|
if (!readApi.paths[path].get) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should have examples when parameters are present', () => {
|
||||||
|
const parameters = readApi.paths[path].get.parameters;
|
||||||
|
let testPath = path;
|
||||||
|
if (parameters) {
|
||||||
|
parameters.forEach((param) => {
|
||||||
|
assert(param.example !== null && param.example !== undefined, path + ' has parameters without examples');
|
||||||
|
|
||||||
|
switch (param.in) {
|
||||||
|
case 'path':
|
||||||
|
testPath = testPath.replace('{' + param.name + '}', param.example);
|
||||||
|
break;
|
||||||
|
case 'header':
|
||||||
|
headers[param.name] = param.example;
|
||||||
|
break;
|
||||||
|
case 'query':
|
||||||
|
qs[param.name] = param.example;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
url = nconf.get('url') + testPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve with a 200 when called', async () => {
|
||||||
|
await setupData();
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await request(url, {
|
||||||
|
jar: !unauthenticatedRoutes.includes(path) ? jar : undefined,
|
||||||
|
json: true,
|
||||||
|
headers: headers,
|
||||||
|
qs: qs,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
assert(!e, path + ' resolved with ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recursively iterate through schema properties, comparing type
|
||||||
|
it('response should match schema definition', () => {
|
||||||
|
const has200 = readApi.paths[path].get.responses['200'];
|
||||||
|
if (!has200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasJSON = has200.content && has200.content['application/json'];
|
||||||
|
if (hasJSON) {
|
||||||
|
schema = readApi.paths[path].get.responses['200'].content['application/json'].schema;
|
||||||
|
compare(schema, response, 'root');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO someday: text/csv, binary file type checking?
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Write API', () => {
|
describe('Write API', () => {
|
||||||
|
|||||||
@@ -1071,7 +1071,7 @@ describe('Topic\'s', function () {
|
|||||||
assert.ifError(err);
|
assert.ifError(err);
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert(body);
|
assert(body);
|
||||||
assert.deepEqual(body, {
|
assert.deepEqual(body.pagination, {
|
||||||
prev: { page: 1, active: false },
|
prev: { page: 1, active: false },
|
||||||
next: { page: 1, active: false },
|
next: { page: 1, active: false },
|
||||||
first: { page: 1, active: true },
|
first: { page: 1, active: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user