add api sdk

This commit is contained in:
Pavel Mineev
2022-08-20 12:58:40 -05:00
committed by Umputun
parent 75427df7de
commit b47b6c4f56
33 changed files with 2796 additions and 131 deletions
+3 -2
View File
@@ -8,16 +8,17 @@ on:
paths:
- ".github/workflows/ci-build.yml"
- "backend/**"
- "frontend/**"
- "frontend/apps/**"
- ".dockerignore"
- "docker-init.sh"
- "Dockerfile"
- "!**.md"
- "!frontend/packages/**"
pull_request:
paths:
- ".github/workflows/ci-build.yml"
- "backend/**"
- "frontend/**"
- "frontend/apps/**"
- ".dockerignore"
- "docker-init.sh"
- "Dockerfile"
+153
View File
@@ -0,0 +1,153 @@
name: "@remark42/api"
on:
push:
branches:
- master
paths:
- ".github/workflows/ci-frontend-api.yml"
- "frontend/packages/**"
- "!**.md"
pull_request:
paths:
- ".github/workflows/ci-frontend-api.yml"
- "frontend/packages/**"
- "!**.md"
jobs:
type-check:
name: Type check
runs-on: ubuntu-latest
strategy:
matrix:
node: [16.15.1]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- name: Install pnpm
uses: pnpm/action-setup@v2.0.1
id: pnpm-install
with:
version: 7
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm i
working-directory: ./frontend
- name: Run type check
run: pnpm type-check:api
working-directory: ./frontend
lint:
name: Lint
runs-on: ubuntu-latest
strategy:
matrix:
node: [16.15.1]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- name: Install pnpm
uses: pnpm/action-setup@v2.0.1
id: pnpm-install
with:
version: 7
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm i
working-directory: ./frontend
- name: Run linters
run: pnpm lint:api
working-directory: ./frontend/
test:
name: Tests & Coverage
runs-on: ubuntu-latest
strategy:
matrix:
node: [16.15.1]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- name: Install pnpm
uses: pnpm/action-setup@v2.0.1
id: pnpm-install
with:
version: 7
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm i
working-directory: ./frontend
- name: Test & Coverage
run: pnpm coverage:api
working-directory: ./frontend
- name: Submit coverage
run: ${{ github.workspace }}/frontend/apps/remark42/node_modules/.bin/codecov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+7 -2
View File
@@ -10,10 +10,15 @@
],
"scripts": {
"prepare": "if [ -z \"$CI\" ]; then cd .. && husky install frontend/.husky; else echo \"Skip Husky Hooks\"; fi",
"lint-staged": "lint-staged"
"lint-staged": "lint-staged",
"test:api": "turbo run test --filter=@remark42/api",
"coverage:api": "turbo run coverage --filter=@remark42/api",
"type-check:api": "turbo run type-check --filter=@remark42/api",
"lint:api": "turbo run lint --filter=@remark42/api"
},
"devDependencies": {
"husky": "^8.0.1",
"lint-staged": "^13.0.3"
"lint-staged": "^13.0.3",
"turbo": "^1.4.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
dist
coverage
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:@typescript-eslint/strict',
'plugin:prettier/recommended',
],
overrides: [],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.eslint.json',
},
plugins: ['@typescript-eslint'],
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
coverage
dist
**/*.js
**/*.d.ts
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
'./**/*.ts': ['pnpm lint-staged:lint'],
}
+17
View File
@@ -0,0 +1,17 @@
coverage/**
dist/**
node_modules/**
src/**
test/**
.editorconfig
.eslintrc.cjs
.prettierrc
files.txt
jest.config.ts
pnpm-lock.yaml
postpublish.sh
prepublish.sh
tsconfig.common.json
tsconfig.dev.json
tsconfig.json
+1
View File
@@ -0,0 +1 @@
16.17.0
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"printWidth": 100,
"quoteProps": "consistent",
"singleQuote": true
}
+11
View File
@@ -0,0 +1,11 @@
# @remark42/api
Implementation of API methods for Remark42
## Development
- If you don't have `pnpm` installed run `npm i -g pnpm`
- Install dependencies `pnpm i`
- Run development mode with `pnpm run dev`
- Build lib with `pnpm run build`
- Run tests with `pnpm run test`
+134
View File
@@ -0,0 +1,134 @@
import type { ClientParams, User } from './index'
import { API_BASE } from '../consts'
import { createFetcher } from '../lib/fetcher'
export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m'
export interface BlockUserResponse {
block: boolean
site_id: string
user_id: string
}
export function createAdminClient({ siteId, baseUrl }: ClientParams) {
const fetcher = createFetcher(siteId, `${baseUrl}${API_BASE}`)
async function toggleUserVerification(id: string, verified: 0 | 1): Promise<void> {
return fetcher.put(`/verify/${id}`, { verified })
}
async function toggleCommentPin(id: string, pinned: 0 | 1): Promise<void> {
return fetcher.put(`/pin/${id}`, { pinned })
}
async function toggleCommenting(url: string, ro: 0 | 1): Promise<void> {
return fetcher.put('/readonly', { url, ro })
}
async function toggleUserBlock(id: string, ttl?: BlockTTL): Promise<BlockUserResponse> {
const params = ttl
? {
block: 1,
ttl: ttl === 'permanently' ? 0 : ttl,
}
: { block: 0 }
return fetcher.put<BlockUserResponse>(`/user/${id}`, params)
}
/**
* Request list of blocked users
*/
async function getBlockedUsers(): Promise<User[]> {
return fetcher.get<User[]>('/blocked')
}
/**
* Block user from commenting
* @param id user ID
* @param ttl block duration
*/
async function blockUser(id: string, ttl: BlockTTL): Promise<BlockUserResponse> {
return toggleUserBlock(id, ttl)
}
/**
* Unblock user from commenting
* @param id user ID
*/
async function unblockUser(id: string): Promise<BlockUserResponse> {
return toggleUserBlock(id)
}
/**
* Mark user as verified
* @param id user ID
*/
async function verifyUser(id: string): Promise<void> {
return toggleUserVerification(id, 1)
}
/**
* Mark user as unverified
* @param id user ID
*/
async function unverifyUser(id: string): Promise<void> {
return toggleUserVerification(id, 0)
}
/**
* Approve request to remove user data
* @param token token from email
*/
async function approveRemovingRequest(token: string): Promise<void> {
return fetcher.get('/deleteme', { token })
}
/**
* Mark comment as pinned
* @param id comment ID
*/
async function pinComment(id: string): Promise<void> {
return toggleCommentPin(id, 1)
}
/**
* Mark comment as unpinned
* @param id comment ID
*/
async function unpinComment(id: string): Promise<void> {
return toggleCommentPin(id, 0)
}
/**
* Remove comment
* @param url page URL
* @param id comment ID
*/
async function removeComment(url: string, id: string): Promise<void> {
return fetcher.delete(`/comment/${id}`, { url })
}
/**
* Enable commenting on a page
* @param url page URL
*/
async function enableCommenting(url: string) {
return toggleCommenting(url, 1)
}
/**
* Disable commenting on a page
* @param url page URL
*/
async function disableCommenting(url: string) {
return toggleCommenting(url, 0)
}
return {
getBlockedUsers,
blockUser,
unblockUser,
verifyUser,
unverifyUser,
approveRemovingRequest,
pinComment,
unpinComment,
removeComment,
enableCommenting,
disableCommenting,
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { ClientParams, User } from './index'
import { createFetcher } from '../lib/fetcher'
export function createAuthClient({ siteId, baseUrl }: ClientParams) {
const fetcher = createFetcher(siteId, `${baseUrl}/auth`)
async function anonymous(user: string): Promise<User> {
return fetcher.get<User>('/anonymous/login', { user, aud: siteId })
}
async function email(email: string, username: string): Promise<(token: string) => Promise<User>> {
const EMAIL_SIGNIN_ENDPOINT = '/email/login'
await fetcher.get<undefined>(EMAIL_SIGNIN_ENDPOINT, { address: email, user: username })
return function tokenVerification(token: string): Promise<User> {
return fetcher.get<User>(EMAIL_SIGNIN_ENDPOINT, { token })
}
}
async function telegram() {
const TELEGRAM_SIGNIN_ENDPOINT = '/telegram/login'
const { bot, token } = await fetcher.get<{ bot: string; token: string }>(
TELEGRAM_SIGNIN_ENDPOINT
)
return {
bot,
token,
verify() {
return fetcher.get(TELEGRAM_SIGNIN_ENDPOINT, { token })
},
}
}
async function logout(): Promise<void> {
return fetcher.get<void>('/logout')
}
return {
anonymous,
email,
telegram,
logout,
}
}
+55
View File
@@ -0,0 +1,55 @@
import { createAdminClient } from './admin'
import { createAuthClient } from './auth'
import { createPublicClient } from './public'
export interface User {
id: string
name: string
/** url to avatar */
picture: string
admin: boolean
block: boolean
verified: boolean
/** subscription to email notification */
email_subscription?: boolean
/** users with Patreon auth can have paid status */
paid_sub?: boolean
}
export type OAuthProvider =
| 'facebook'
| 'twitter'
| 'google'
| 'yandex'
| 'github'
| 'microsoft'
| 'patreon'
| 'telegram'
| 'dev'
export type FormProvider = 'email' | 'anonymous'
export type Provider = OAuthProvider | FormProvider
export interface ClientParams {
siteId: string
baseUrl: string
}
export interface Client {
admin: ReturnType<typeof createAdminClient>
auth: ReturnType<typeof createAuthClient>
public: ReturnType<typeof createPublicClient>
}
let client: Client | undefined
export function createClient(params: ClientParams): Client {
if (client === undefined) {
client = {
auth: createAuthClient(params),
admin: createAdminClient(params),
public: createPublicClient(params),
}
}
return client
}
+153
View File
@@ -0,0 +1,153 @@
import type { ClientParams, Provider, User } from './index'
import { createFetcher } from '../lib/fetcher'
import { API_BASE } from '../consts'
export interface Config {
version: string
auth_providers: Provider[]
edit_duration: number
max_comment_size: number
admins: string[]
admin_email: string
low_score: number
critical_score: number
positive_score: boolean
readonly_age: number
max_image_size: number
simple_view: boolean
anon_vote: boolean
email_notifications: boolean
telegram_bot_username: string
emoji_enabled: boolean
}
export interface Comment {
/** comment id */
id: string
/** parent id */
pid: string
/** comment text, after md processing */
text: string
/** original comment text */
orig?: string
user: User
locator: {
/** site id */
site: string
/** page url */
url: string
}
score: number
voted_ips: { Timestamp: string; Value: boolean }[]
/**
* vote delta,
* if user hasn't voted delta will be 0,
* -1/+1 for downvote/upvote
*/
vote: 0 | 1 | -1
/** comment controversy */
controversy?: number
/** pointer to have empty default in json response */
edit?: {
time: string
summary: string
}
/** timestamp */
time: string
pin?: boolean
delete?: boolean
/** page title */
title?: string
}
export interface CommentsTree {
comment: Comment
replies: Comment[]
}
export interface CommentPayload {
title?: string
pid?: string
text: string
}
export type Sort = '-active' | '+active'
export interface GetUserCommentsParams {
url: string
sort?: Sort
limit?: number
skip?: number
}
export type Vote = -1 | 1
export function createPublicClient({ siteId: site, baseUrl }: ClientParams) {
const fetcher = createFetcher(site, `${baseUrl}${API_BASE}`)
/**
* Get server config
*/
async function getConfig(): Promise<Config> {
return fetcher.get('/config')
}
/**
* Get current authorized user
*/
async function getUser(): Promise<User | null> {
return fetcher.get<User | null>('/user').catch(() => null)
}
/**
* Get comments
*/
async function getComments(url: string): Promise<CommentsTree>
async function getComments(params: GetUserCommentsParams): Promise<Comment[]>
async function getComments(
params: string | GetUserCommentsParams
): Promise<Comment[] | CommentsTree> {
if (typeof params === 'string') {
return fetcher.get('/comments', { url: params })
}
return fetcher.get<CommentsTree>('/find', { ...params, format: 'tree' })
}
/**
* Add new comment
*/
async function addComment(url: string, payload: CommentPayload): Promise<Comment> {
const locator = { site, url }
return fetcher.post('/comment', {}, { ...payload, locator })
}
/**
* Update comment
*/
async function updateComment(url: string, id: string, text: string): Promise<Comment> {
return fetcher.put(`/comment/${id}`, { url }, { text })
}
/**
* Remove comment on a page
*/
async function removeComment(url: string, id: string): Promise<void> {
return fetcher.put(`/comment/${id}`, { url }, { delete: true })
}
/**
* Vote for a comment
*/
async function vote(url: string, id: string, vote: Vote): Promise<{ id: string; vote: number }> {
return fetcher.put<{ id: string; vote: number }>(`/vote/${id}`, { url, vote })
}
return {
getConfig,
getUser,
getComments,
addComment,
updateComment,
removeComment,
vote,
}
}
+11
View File
@@ -0,0 +1,11 @@
/** Base path to API */
export const API_BASE = '/api/v1'
/** Header name for JWT token */
export const JWT_HEADER = 'X-JWT'
/** Header name for XSRF token */
export const XSRF_HEADER = 'X-XSRF-TOKEN'
/** Cookie field with XSRF token */
export const XSRF_COOKIE = 'XSRF-TOKEN'
+4
View File
@@ -0,0 +1,4 @@
export * from './clients'
export * from './clients/admin'
export * from './clients/auth'
export * from './clients/public'
+11
View File
@@ -0,0 +1,11 @@
export function getCookie(name: string): string | undefined {
const matches = document.cookie.match(
new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`)
)
if (matches === null) {
return
}
return decodeURIComponent(matches[1])
}
+94
View File
@@ -0,0 +1,94 @@
import { JWT_HEADER, XSRF_COOKIE, XSRF_HEADER } from '../consts'
import { getCookie } from '../lib/cookies'
export type QueryParams = Record<string, string | number | string[] | number[] | undefined>
export type Payload = BodyInit | Record<string, unknown> | null
export type BodylessMethod = <T>(url: string, query?: QueryParams) => Promise<T>
export type BodyMethod = <T>(url: string, query?: QueryParams, body?: Payload) => Promise<T>
export interface Client {
get: BodylessMethod
put: BodyMethod
post: BodyMethod
delete: BodylessMethod
}
/** JWT token received from server and will be send by each request, if it present */
let token: string | undefined
export const createFetcher = (site: string, baseUrl: string): Client => {
const client = {
get: <T>(uri: string, query?: QueryParams): Promise<T> => request<T>('get', uri, query),
put: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
request<T>('put', uri, query, body),
post: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
request<T>('post', uri, query, body),
delete: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
request<T>('delete', uri, query, body),
}
/**
* Fetcher is abstraction on top of fetch
*
* @method - a string to set http method
* @uri uri to API endpoint
* @query - collection of query params. They will be concatenated to URL. `siteId` will be added automatically.
* @body - data for sending to the server. If you pass object it will be stringified. If you pass form data it will be sent as is. Content type headers will be added automatically.
*/
async function request<T>(
method: string,
uri: string,
query: QueryParams = {},
body?: Payload
): Promise<T> {
const searchParams = new URLSearchParams({ site, ...query })
searchParams.sort()
const url = `${baseUrl}${uri}?${searchParams.toString()}`
const headers = new Headers()
const params: RequestInit = { method, headers }
// Save token in memory and pass it into headers in case if storing cookies is disabled
if (token) {
headers.set(JWT_HEADER, token)
}
// An HTTP header cannot be empty.
// Although some webservers allow this (nginx, Apache), others answer 400 Bad Request (lighttpd).
const xsrfToken = getCookie(XSRF_COOKIE)
if (typeof xsrfToken === 'string') {
headers.set(XSRF_HEADER, xsrfToken)
}
if (typeof body === 'object' && body !== null && !(body instanceof FormData)) {
headers.set('Content-Type', 'application/json')
params.body = JSON.stringify(body)
} else {
params.body = body
}
return fetch(url, params).then<T>((res) => {
if ([401, 403].includes(res.status)) {
token = undefined
return Promise.reject('Unauthorized')
}
token = res.headers.get(JWT_HEADER) ?? token
return res
.text()
.catch(Object)
.then((data: string) => {
if (res.status < 200 || res.status > 299) {
return Promise.reject(data)
}
try {
return JSON.parse(data) as T
} catch (e) {
return data as unknown as T
}
})
})
}
return client
}
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@remark42/api",
"version": "0.6.0-alpha.11",
"description": "Implementation of API methods for Remark42",
"repository": {
"type": "git",
"url": "git+https://github.com/umputun/remark42.git#master"
},
"bugs": {
"url": "https://github.com/umputun/remark42/issues"
},
"homepage": "https://github.com/umputun/remark42/tree/master/frontend/packages/api#readme",
"keywords": [
"remark42",
"comments"
],
"author": "Paul Mineev",
"license": "MIT",
"scripts": {
"dev": "tsc -w --incremental",
"build:cjs": "tsc --module CommonJS",
"build:esm": "tsc --module ESNext",
"build": "npm-run-all cleanup --parallel build:*",
"postpublish": "pnpm run cleanup",
"cleanup": "rm -rf *.js *.d.ts **/*.js **/*.d.ts *.tsbuildinfo",
"test": "vitest",
"coverage": "vitest run --coverage",
"lint": "eslint --ext .cjs,.mjs,.ts --max-warnings=0",
"type-check": "tsc --noEmit",
"lint-staged:lint": "eslint --fix --ext .cjs,.mjs,.ts"
},
"devDependencies": {
"@types/node": "^18.0.5",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",
"@vitest/coverage-c8": "^0.22.1",
"eslint": "^8.18.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"jsdom": "^20.0.0",
"msw": "^0.44.1",
"npm-run-all": "^4.1.5",
"prettier": "^2.7.1",
"ts-node": "^10.9.1",
"tslib": "^2.4.0",
"typescript": "^4.7.4",
"vitest": "^0.22.1",
"whatwg-fetch": "^3.6.2"
},
"type": "module",
"files": [
"./*.js",
"./*.d.ts",
"./clients/*.d.ts",
"./lib/*.d.ts"
]
}
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { BlockTTL, createAdminClient } from '../../clients/admin'
interface Context {
client: ReturnType<typeof createAdminClient>
}
describe<Context>('Admin Client', (adminClient) => {
beforeEach<Context>((ctx) => {
ctx.client = createAdminClient({ siteId: 'mysite', baseUrl: '/remark42' })
})
adminClient('should return list of blocked users', async ({ client }) => {
const data = [{ id: 1 }, { id: 2 }]
mockEndpoint('/remark42/api/v1/blocked', { body: data })
await expect(client.getBlockedUsers()).resolves.toEqual(data)
})
const ttlCases: [BlockTTL, string][] = [
['permanently', '0'],
['1440m', '1440m'],
['43200m', '43200m'],
]
ttlCases.forEach(([ttl, expected]) => {
adminClient(`should block user with ttl: ${ttl}`, async ({ client }) => {
const data = { block: true, site_id: 'remark42', user_id: '1' }
const ref = mockEndpoint('/remark42/api/v1/user/1', { method: 'put', body: data })
await expect(client.blockUser('1', ttl)).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('ttl')).toBe(expected)
})
})
adminClient('should unblock user', async ({ client }) => {
const data = { block: false, site_id: 'remark42', user_id: '1' }
const ref = mockEndpoint('/remark42/api/v1/user/1', { method: 'put', body: data })
await expect(client.unblockUser('1')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('block')).toBe('0')
})
adminClient('should mark user as verified', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/verify/1', { method: 'put' })
await client.verifyUser('1')
expect(ref.req.url.searchParams.get('verified')).toBe('1')
})
adminClient('should mark user as unverified', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/verify/1', { method: 'put' })
await client.unverifyUser('1')
expect(ref.req.url.searchParams.get('verified')).toBe('0')
})
adminClient('should approve removing request', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/deleteme')
await client.approveRemovingRequest('token')
expect(ref.req.url.searchParams.get('token')).toBe('token')
})
adminClient('should pin comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/pin/1', { method: 'put' })
await client.pinComment('1')
expect(ref.req.url.searchParams.get('pinned')).toBe('1')
})
adminClient('should unpin comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/pin/1', { method: 'put' })
await client.unpinComment('1')
expect(ref.req.url.searchParams.get('pinned')).toBe('0')
})
adminClient('should remove comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'delete' })
const url = '/post/1'
await client.removeComment(url, '1')
expect(ref.req.url.searchParams.get('url')).toBe(url)
})
adminClient('should enable commenting on a page', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/readonly', { method: 'put' })
const url = '/post/1'
await client.enableCommenting(url)
expect(ref.req.url.searchParams.get('ro')).toBe('1')
expect(ref.req.url.searchParams.get('url')).toBe(url)
})
adminClient('should disable commenting on a page', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/readonly', { method: 'put' })
const url = '/post/1'
await client.disableCommenting('/post/1')
expect(ref.req.url.searchParams.get('ro')).toBe('0')
expect(ref.req.url.searchParams.get('url')).toBe(url)
})
})
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { createAuthClient } from '../../clients/auth'
interface Content {
client: ReturnType<typeof createAuthClient>
}
describe<Content>('Auth Client', (authClient) => {
beforeEach<Content>((ctx) => {
ctx.client = createAuthClient({ siteId: 'mysite', baseUrl: '/remark42' })
})
authClient('should authorize as anonymouse', async ({ client }) => {
const data = { id: 1 }
const ref = mockEndpoint('/remark42/auth/anonymous/login', { body: data })
await expect(client.anonymous('username')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('aud')).toBe('mysite')
expect(ref.req.url.searchParams.get('user')).toBe('username')
})
authClient('should authorize with email', async ({ client }) => {
const data = { id: 1 }
const ref = mockEndpoint('/remark42/auth/email/login', { body: data })
const tokenVerification = await client.email('username@example.com', 'username')
expect(ref.req.url.searchParams.get('address')).toBe('username@example.com')
expect(ref.req.url.searchParams.get('user')).toBe('username')
await expect(tokenVerification('token')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('token')).toBe('token')
})
authClient('should authorize with telegram', async ({ client }) => {
const data = {
bot: 'remark42bot',
token: 'token',
}
const user = { id: 1 }
mockEndpoint('/remark42/auth/telegram/login', { body: data })
const telegramAuth = await client.telegram()
expect(telegramAuth.bot).toBe(data.bot)
expect(telegramAuth.token).toBe(data.token)
const ref = mockEndpoint('/remark42/auth/telegram/login', { body: user })
await expect(telegramAuth.verify()).resolves.toEqual(user)
expect(ref.req.url.searchParams.get('token')).toBe('token')
})
authClient('should logout', async ({ client }) => {
mockEndpoint('/remark42/auth/logout')
await expect(client.logout()).resolves.toBe('')
})
})
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { createClient } from '../..'
describe('Client', () => {
it('should create a client', () => {
const params = { siteId: 'mysite', baseUrl: '/remark42' }
const client = createClient(params)
expect(client).toBeDefined()
expect(client.admin).toBeDefined()
expect(client.auth).toBeDefined()
expect(client.public).toBeDefined()
})
})
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { createPublicClient, GetUserCommentsParams, Vote } from '../../clients/public'
interface Context {
client: ReturnType<typeof createPublicClient>
}
describe<Context>('Public Client', (publicClient) => {
beforeEach<Context>((ctx) => {
ctx.client = createPublicClient({ siteId: 'mysite', baseUrl: '/remark42' })
})
publicClient('getConfig: should return config', async ({ client }) => {
const data = { x: 1, y: 2 }
mockEndpoint('/remark42/api/v1/config', { body: data })
await expect(client.getConfig()).resolves.toEqual(data)
})
publicClient('getComments: should return page comments', async ({ client }) => {
const data = { post: { id: '1' }, node: [{ id: 1 }] }
const ref = mockEndpoint('/remark42/api/v1/comments', { body: data })
await expect(client.getComments('/post/1')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('url')).toBe('/post/1')
})
const commentRequestCases: GetUserCommentsParams[] = [
{ url: '' },
{ url: '' },
{ url: '', limit: 10 },
{ url: '', skip: 10 },
{ url: '', skip: 10, limit: 0 },
]
commentRequestCases.forEach((params) => {
publicClient(
`getComments: should return user comments with params: ${JSON.stringify(params)}`,
async ({ client }) => {
const data = [{ id: 1 }, { id: 2 }]
const ref = mockEndpoint('/remark42/api/v1/find', { body: data })
await expect(client.getComments(params)).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('limit')).toBe(
params.limit === undefined ? null : `${params.limit}`
)
expect(ref.req.url.searchParams.get('skip')).toBe(
params.skip === undefined ? null : `${params.skip}`
)
}
)
})
publicClient('addComment: should add comment', async ({ client }) => {
const data = { id: '1', text: 'test' }
const ref = mockEndpoint('/remark42/api/v1/comment', { method: 'post', body: data })
await expect(client.addComment('/post/my-first-post', { text: 'test' })).resolves.toEqual(data)
await expect(ref.req.json()).resolves.toEqual({
text: data.text,
locator: {
site: 'mysite',
url: '/post/my-first-post',
},
})
})
publicClient('updateComment: should update comment', async ({ client }) => {
const data = { id: 1, body: 'test' }
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'put', body: data })
await expect(client.updateComment('/post/my-first-post', '1', 'test')).resolves.toEqual(data)
await expect(ref.req.json()).resolves.toEqual({ text: 'test' })
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
})
publicClient('should remove comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'put' })
await expect(client.removeComment('/post/my-first-post', '1')).resolves.toBe('')
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
})
const voteRequestCases: { vote: Vote; value: string }[] = [
{ vote: 1, value: 'upvote' },
{ vote: -1, value: 'downvote' },
]
voteRequestCases.forEach(({ vote, value }) => {
publicClient(`vote: should ${value} for comment`, async ({ client }) => {
const data = { id: 1, vote: 2 }
const ref = mockEndpoint('/remark42/api/v1/vote/1', { method: 'put', body: data })
await expect(client.vote('/post/my-first-post', '1', vote)).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
expect(ref.req.url.searchParams.get('vote')).toBe(`${vote}`)
})
})
const userCases = [null, { id: '1', username: 'user' }]
userCases.forEach((user) => {
publicClient('should return user', async ({ client }) => {
mockEndpoint('/remark42/api/v1/user', { body: user })
await expect(client.getUser()).resolves.toEqual(user)
})
})
})
@@ -0,0 +1,6 @@
import { test, expect } from 'vitest'
import { createClient } from '..'
test('create client', () => {
expect(() => createClient({ siteId: 'site', baseUrl: '' })).not.toThrow()
})
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { JWT_HEADER, XSRF_COOKIE, XSRF_HEADER } from '../../consts'
import { Client, createFetcher } from '../../lib/fetcher'
interface Context {
client: Client
}
describe<Context>('Fetcher', (fetcher) => {
beforeEach<Context>((ctx) => {
ctx.client = createFetcher('remark42', '')
})
fetcher('get', async ({ client }) => {
const ref = mockEndpoint('/test')
await client.get('/test')
expect(ref.req.method).toBe('GET')
})
fetcher('post', async ({ client }) => {
const ref = mockEndpoint('/test', { method: 'post' })
await client.post('/test')
expect(ref.req.method).toBe('POST')
})
fetcher('put', async ({ client }) => {
const ref = mockEndpoint('/test', { method: 'put' })
await client.put('/test')
expect(ref.req.method).toBe('PUT')
})
fetcher('delete', async ({ client }) => {
const ref = mockEndpoint('/test', { method: 'delete' })
await client.delete('/test')
expect(ref.req.method).toBe('DELETE')
})
fetcher('should send json', async ({ client }) => {
const data = { name: 'test' }
const ref = mockEndpoint('/test', { method: 'post', body: data })
await expect(client.post('/test', {}, data)).resolves.toStrictEqual(data)
await expect(ref.req.json()).resolves.toStrictEqual(data)
expect(ref.req.headers.get('Content-Type')).toBe('application/json')
})
fetcher('should send text', async ({ client }) => {
const data = 'text'
const ref = mockEndpoint('/test', { method: 'post', body: data })
await expect(client.post('/test', {}, data)).resolves.toBe(data)
await expect(ref.req.text()).resolves.toBe(data)
expect(ref.req.headers.get('Content-Type')).toMatch('text/plain')
})
fetcher('should send query', async ({ client }) => {
const ref = mockEndpoint('/test')
await expect(client.get('/test', { x: 1, p: 2 })).resolves.toBe('')
expect(ref.req.url.searchParams.get('x')).toBe('1')
expect(ref.req.url.searchParams.get('p')).toBe('2')
})
fetcher('should sort query params', async ({ client }) => {
const ref = mockEndpoint('/test')
await expect(client.get('/test', { x: 1, p: 2 })).resolves.toBe('')
expect(ref.req.url.search).toBe('?p=2&site=remark42&x=1')
})
fetcher(
'should set active token and then clean it on unauthorized response',
async ({ client }) => {
let ref = mockEndpoint('/user', { headers: { [JWT_HEADER]: 'token' } })
// token should be saved
await client.get('/user')
// the first call should be without token
expect(ref.req.headers.get(JWT_HEADER)).toBe(null)
// the second call should be with token
await client.get('/user')
// check if the second call was with token
expect(ref.req.headers.get(JWT_HEADER)).toBe('token')
// unauthorized response should clean token
ref = mockEndpoint('/user', { status: 401 })
// the third call should be with token but token should be cleaned after it
await expect(client.get('/user')).rejects.toBe('Unauthorized')
// the fourth call should be without token
await expect(client.get('/user')).rejects.toBe('Unauthorized')
// check if the fourth call was with token
expect(ref.req.headers.get(JWT_HEADER)).toBe(null)
}
)
fetcher('should add XSRF header if we have it in cookies', async ({ client }) => {
const ref = mockEndpoint('/user')
Object.defineProperty(document, 'cookie', {
writable: true,
value: `${XSRF_COOKIE}=token`,
})
await client.get('/user')
expect(ref.req.headers.get(XSRF_HEADER)).toBe('token')
})
fetcher('should throw error on api response with status code 400', async ({ client }) => {
mockEndpoint('/user', { status: 400 })
await expect(client.get('/user')).rejects.toBe('')
})
})
+15
View File
@@ -0,0 +1,15 @@
import 'whatwg-fetch'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './test-utils'
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
+36
View File
@@ -0,0 +1,36 @@
import { rest, RestRequest } from 'msw'
import { setupServer } from 'msw/node'
export const server = setupServer()
interface RequestRef {
req: RestRequest
}
export function mockEndpoint(
url: string,
params: {
method?: 'get' | 'put' | 'post' | 'delete'
body?: number | string | null | Record<string, unknown> | unknown[]
status?: number
headers?: Record<string, string | string[]>
} = {}
): RequestRef {
const { body, method = 'get', status = 200, headers } = params
const result = { req: {} } as RequestRef
server.use(
rest[method](url, (req, res, ctx) => {
const transformers = [ctx.status(status), ctx.json(body)]
if (headers) {
transformers.push(ctx.set(headers))
}
result.req = req
return res(...transformers)
})
)
return result
}
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts"],
"exclude": [".turbo", "node_modules"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2016",
"lib": ["DOM"],
"moduleResolution": "node",
"baseUrl": "./",
"declaration": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["index.ts", "clients", "lib"],
"exclude": ["tests", "coverage"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts'],
},
})
+1512 -127
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,2 +1,3 @@
packages:
- "apps/*"
- "packages/*"