Move suggest code and rewrite tests (#2886)
The suggest code was an exception to our usual organization pattern. There was a service test, but it's not a service. The code would sometimes regress because it wasn't being tested all the time. This makes them no longer run as service tests, which is good because they run as part of every build. Some of them are smaller-bracket tests which is good too, because it will make them easier to test, especially as this code grows. I'd have liked to keep using frisby for the ones that make requests to the server, though I ran into some issues with sequencing of setup that I think will require upstream changes.
This commit is contained in:
145
services/suggest.integration.js
Normal file
145
services/suggest.integration.js
Normal file
@@ -0,0 +1,145 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const got = require('got')
|
||||
const Camp = require('camp')
|
||||
const portfinder = require('portfinder')
|
||||
const { setRoutes } = require('./suggest')
|
||||
const GithubApiProvider = require('./github/github-api-provider')
|
||||
const serverSecrets = require('../lib/server-secrets')
|
||||
|
||||
describe('GitHub badge suggestions', function() {
|
||||
const githubApiBaseUrl = process.env.GITHUB_URL || 'https://api.github.com'
|
||||
|
||||
let token, apiProvider
|
||||
before(function() {
|
||||
token = serverSecrets.gh_token
|
||||
if (!token) {
|
||||
throw Error('The integration tests require a gh_token to be set')
|
||||
}
|
||||
apiProvider = new GithubApiProvider({
|
||||
baseUrl: githubApiBaseUrl,
|
||||
globalToken: token,
|
||||
withPooling: false,
|
||||
})
|
||||
})
|
||||
|
||||
let port, baseUrl
|
||||
before(async function() {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
let camp
|
||||
before(async function() {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
})
|
||||
after(async function() {
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const origin = 'https://example.test'
|
||||
before(function() {
|
||||
setRoutes([origin], apiProvider, camp)
|
||||
})
|
||||
|
||||
context('with an existing project', function() {
|
||||
it('returns the expected suggestions', async function() {
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://github.com/atom/atom'
|
||||
)}`,
|
||||
{
|
||||
json: true,
|
||||
}
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
suggestions: [
|
||||
{
|
||||
title: 'GitHub issues',
|
||||
link: 'https://github.com/atom/atom/issues',
|
||||
path: '/github/issues/atom/atom',
|
||||
},
|
||||
{
|
||||
title: 'GitHub forks',
|
||||
link: 'https://github.com/atom/atom/network',
|
||||
path: '/github/forks/atom/atom',
|
||||
},
|
||||
{
|
||||
title: 'GitHub stars',
|
||||
link: 'https://github.com/atom/atom/stargazers',
|
||||
path: '/github/stars/atom/atom',
|
||||
},
|
||||
{
|
||||
title: 'GitHub license',
|
||||
path: '/github/license/atom/atom',
|
||||
link: 'https://github.com/atom/atom/blob/master/LICENSE.md',
|
||||
},
|
||||
{
|
||||
title: 'Twitter',
|
||||
link:
|
||||
'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fatom%2Fatom',
|
||||
path: '/twitter/url/https/github.com/atom/atom',
|
||||
queryParams: {
|
||||
style: 'social',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('with a non-existent project', function() {
|
||||
it('returns the expected suggestions', async function() {
|
||||
this.timeout(5000)
|
||||
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://github.com/badges/not-a-real-project'
|
||||
)}`,
|
||||
{
|
||||
json: true,
|
||||
}
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
suggestions: [
|
||||
{
|
||||
title: 'GitHub issues',
|
||||
link: 'https://github.com/badges/not-a-real-project/issues',
|
||||
path: '/github/issues/badges/not-a-real-project',
|
||||
},
|
||||
{
|
||||
title: 'GitHub forks',
|
||||
link: 'https://github.com/badges/not-a-real-project/network',
|
||||
path: '/github/forks/badges/not-a-real-project',
|
||||
},
|
||||
{
|
||||
title: 'GitHub stars',
|
||||
link: 'https://github.com/badges/not-a-real-project/stargazers',
|
||||
path: '/github/stars/badges/not-a-real-project',
|
||||
},
|
||||
{
|
||||
title: 'GitHub license',
|
||||
path: '/github/license/badges/not-a-real-project',
|
||||
link: 'https://github.com/badges/not-a-real-project',
|
||||
},
|
||||
{
|
||||
title: 'Twitter',
|
||||
link:
|
||||
'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fbadges%2Fnot-a-real-project',
|
||||
path: '/twitter/url/https/github.com/badges/not-a-real-project',
|
||||
queryParams: {
|
||||
style: 'social',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
162
services/suggest.js
Normal file
162
services/suggest.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// Suggestion API
|
||||
//
|
||||
// eg. /$suggest/v1?url=https://github.com/badges/shields
|
||||
//
|
||||
// This endpoint is called from frontend/components/suggestion-and-search.js.
|
||||
|
||||
'use strict'
|
||||
|
||||
const { URL } = require('url')
|
||||
const request = require('request')
|
||||
|
||||
function twitterPage(url) {
|
||||
if (url.protocol === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const schema = url.protocol.slice(0, -1)
|
||||
const host = url.host
|
||||
const path = url.pathname
|
||||
return {
|
||||
title: 'Twitter',
|
||||
link: `https://twitter.com/intent/tweet?text=Wow:&url=${encodeURIComponent(
|
||||
url.href
|
||||
)}`,
|
||||
path: `/twitter/url/${schema}/${host}${path}`,
|
||||
queryParams: { style: 'social' },
|
||||
}
|
||||
}
|
||||
|
||||
function githubIssues(user, repo) {
|
||||
const repoSlug = `${user}/${repo}`
|
||||
return {
|
||||
title: 'GitHub issues',
|
||||
link: `https://github.com/${repoSlug}/issues`,
|
||||
path: `/github/issues/${repoSlug}`,
|
||||
}
|
||||
}
|
||||
|
||||
function githubForks(user, repo) {
|
||||
const repoSlug = `${user}/${repo}`
|
||||
return {
|
||||
title: 'GitHub forks',
|
||||
link: `https://github.com/${repoSlug}/network`,
|
||||
path: `/github/forks/${repoSlug}`,
|
||||
}
|
||||
}
|
||||
|
||||
function githubStars(user, repo) {
|
||||
const repoSlug = `${user}/${repo}`
|
||||
return {
|
||||
title: 'GitHub stars',
|
||||
link: `https://github.com/${repoSlug}/stargazers`,
|
||||
path: `/github/stars/${repoSlug}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function githubLicense(githubApiProvider, user, repo) {
|
||||
const repoSlug = `${user}/${repo}`
|
||||
|
||||
let link = `https://github.com/${repoSlug}`
|
||||
|
||||
const { buffer } = await githubApiProvider.requestAsPromise(
|
||||
request,
|
||||
`/repos/${repoSlug}/license`
|
||||
)
|
||||
try {
|
||||
const data = JSON.parse(buffer)
|
||||
if ('html_url' in data) {
|
||||
link = data.html_url
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return {
|
||||
title: 'GitHub license',
|
||||
path: `/github/license/${repoSlug}`,
|
||||
link,
|
||||
}
|
||||
}
|
||||
|
||||
async function findSuggestions(githubApiProvider, url) {
|
||||
let promises = []
|
||||
if (url.hostname === 'github.com') {
|
||||
const userRepo = url.pathname.slice(1).split('/')
|
||||
const user = userRepo[0]
|
||||
const repo = userRepo[1]
|
||||
promises = promises.concat([
|
||||
githubIssues(user, repo),
|
||||
githubForks(user, repo),
|
||||
githubStars(user, repo),
|
||||
githubLicense(githubApiProvider, user, repo),
|
||||
])
|
||||
}
|
||||
promises.push(twitterPage(url))
|
||||
|
||||
const suggestions = await Promise.all(promises)
|
||||
|
||||
return suggestions.filter(b => b != null)
|
||||
}
|
||||
|
||||
// data: {url}, JSON-serializable object.
|
||||
// end: function(json), with json of the form:
|
||||
// - suggestions: list of objects of the form:
|
||||
// - title: string
|
||||
// - link: target as a string URL.
|
||||
// - path: shields image URL path.
|
||||
// - queryParams: Object containing query params (Optional)
|
||||
function setRoutes(allowedOrigin, githubApiProvider, server) {
|
||||
server.ajax.on('suggest/v1', (data, end, ask) => {
|
||||
// The typical dev and production setups are cross-origin. However, in
|
||||
// Heroku deploys and some self-hosted deploys these requests may come from
|
||||
// the same host. Chrome does not send an Origin header on same-origin
|
||||
// requests, but Firefox does.
|
||||
//
|
||||
// It would be better to solve this problem using some well-tested
|
||||
// middleware.
|
||||
const origin = ask.req.headers.origin
|
||||
if (origin) {
|
||||
let host
|
||||
try {
|
||||
host = new URL(origin).hostname
|
||||
} catch (e) {
|
||||
ask.res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||
end({ err: 'Disallowed' })
|
||||
return
|
||||
}
|
||||
|
||||
if (host !== ask.req.headers.host) {
|
||||
if (allowedOrigin.includes(origin)) {
|
||||
ask.res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
} else {
|
||||
ask.res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||
end({ err: 'Disallowed' })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(data.url)
|
||||
} catch (e) {
|
||||
end({ err: `${e}` })
|
||||
return
|
||||
}
|
||||
|
||||
findSuggestions(githubApiProvider, url)
|
||||
// This interacts with callback code and can't use async/await.
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
.then(suggestions => {
|
||||
end({ suggestions })
|
||||
})
|
||||
.catch(err => {
|
||||
end({ suggestions: [], err })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findSuggestions,
|
||||
githubLicense,
|
||||
setRoutes,
|
||||
}
|
||||
150
services/suggest.spec.js
Normal file
150
services/suggest.spec.js
Normal file
@@ -0,0 +1,150 @@
|
||||
'use strict'
|
||||
|
||||
const Camp = require('camp')
|
||||
const { expect } = require('chai')
|
||||
const got = require('got')
|
||||
const nock = require('nock')
|
||||
const portfinder = require('portfinder')
|
||||
const { setRoutes, githubLicense } = require('./suggest')
|
||||
const GithubApiProvider = require('./github/github-api-provider')
|
||||
|
||||
describe('Badge suggestions', function() {
|
||||
const githubApiBaseUrl = 'https://api.github.test'
|
||||
const apiProvider = new GithubApiProvider({
|
||||
baseUrl: githubApiBaseUrl,
|
||||
globalToken: 'fake-token',
|
||||
withPooling: false,
|
||||
})
|
||||
|
||||
describe('GitHub license', function() {
|
||||
context('When html_url included in response', function() {
|
||||
it('Should link to it', async function() {
|
||||
const scope = nock(githubApiBaseUrl)
|
||||
.get('/repos/atom/atom/license')
|
||||
.reply(200, {
|
||||
html_url: 'https://github.com/atom/atom/blob/master/LICENSE.md',
|
||||
license: {
|
||||
key: 'mit',
|
||||
name: 'MIT License',
|
||||
spdx_id: 'MIT',
|
||||
url: 'https://api.github.com/licenses/mit',
|
||||
node_id: 'MDc6TGljZW5zZTEz',
|
||||
},
|
||||
})
|
||||
|
||||
expect(await githubLicense(apiProvider, 'atom', 'atom')).to.deep.equal({
|
||||
title: 'GitHub license',
|
||||
path: '/github/license/atom/atom',
|
||||
link: 'https://github.com/atom/atom/blob/master/LICENSE.md',
|
||||
})
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
|
||||
context('When html_url not included in response', function() {
|
||||
it('Should link to the repo', async function() {
|
||||
const scope = nock(githubApiBaseUrl)
|
||||
.get('/repos/atom/atom/license')
|
||||
.reply(200, {
|
||||
license: { key: 'mit' },
|
||||
})
|
||||
|
||||
expect(await githubLicense(apiProvider, 'atom', 'atom')).to.deep.equal({
|
||||
title: 'GitHub license',
|
||||
path: '/github/license/atom/atom',
|
||||
link: 'https://github.com/atom/atom',
|
||||
})
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scoutcamp integration', function() {
|
||||
let port, baseUrl
|
||||
before(async function() {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
let camp
|
||||
before(async function() {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
})
|
||||
after(async function() {
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const origin = 'https://example.test'
|
||||
before(function() {
|
||||
setRoutes([origin], apiProvider, camp)
|
||||
})
|
||||
|
||||
context('without an origin header', function() {
|
||||
it('returns the expected suggestions', async function() {
|
||||
const scope = nock(githubApiBaseUrl)
|
||||
.get('/repos/atom/atom/license')
|
||||
.reply(200, {
|
||||
html_url: 'https://github.com/atom/atom/blob/master/LICENSE.md',
|
||||
license: {
|
||||
key: 'mit',
|
||||
name: 'MIT License',
|
||||
spdx_id: 'MIT',
|
||||
url: 'https://api.github.com/licenses/mit',
|
||||
node_id: 'MDc6TGljZW5zZTEz',
|
||||
},
|
||||
})
|
||||
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
||||
'https://github.com/atom/atom'
|
||||
)}`,
|
||||
{
|
||||
json: true,
|
||||
}
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.deep.equal({
|
||||
suggestions: [
|
||||
{
|
||||
title: 'GitHub issues',
|
||||
link: 'https://github.com/atom/atom/issues',
|
||||
path: '/github/issues/atom/atom',
|
||||
},
|
||||
{
|
||||
title: 'GitHub forks',
|
||||
link: 'https://github.com/atom/atom/network',
|
||||
path: '/github/forks/atom/atom',
|
||||
},
|
||||
{
|
||||
title: 'GitHub stars',
|
||||
link: 'https://github.com/atom/atom/stargazers',
|
||||
path: '/github/stars/atom/atom',
|
||||
},
|
||||
{
|
||||
title: 'GitHub license',
|
||||
path: '/github/license/atom/atom',
|
||||
link: 'https://github.com/atom/atom/blob/master/LICENSE.md',
|
||||
},
|
||||
{
|
||||
title: 'Twitter',
|
||||
link:
|
||||
'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fatom%2Fatom',
|
||||
path: '/twitter/url/https/github.com/atom/atom',
|
||||
queryParams: {
|
||||
style: 'social',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// These tests are for the badge-suggestion endpoint in lib/suggest.js. This
|
||||
// endpoint is called from frontend/components/suggestion-and-search.js.
|
||||
|
||||
const { ServiceTester } = require('..')
|
||||
const { invalidJSON } = require('../response-fixtures')
|
||||
|
||||
const t = (module.exports = new ServiceTester({
|
||||
id: 'suggest',
|
||||
title: 'suggest',
|
||||
pathPrefix: '/$suggest',
|
||||
}))
|
||||
|
||||
t.create('issues, forks, stars and twitter')
|
||||
.get(`/v1?url=${encodeURIComponent('https://github.com/atom/atom')}`)
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'GitHub issues',
|
||||
link: 'https://github.com/atom/atom/issues',
|
||||
path: '/github/issues/atom/atom',
|
||||
})
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'GitHub forks',
|
||||
link: 'https://github.com/atom/atom/network',
|
||||
path: '/github/forks/atom/atom',
|
||||
})
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'GitHub stars',
|
||||
link: 'https://github.com/atom/atom/stargazers',
|
||||
path: '/github/stars/atom/atom',
|
||||
})
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'Twitter',
|
||||
link:
|
||||
'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fatom%2Fatom',
|
||||
path: '/twitter/url/https/github.com/atom/atom',
|
||||
queryParams: {
|
||||
style: 'social',
|
||||
},
|
||||
})
|
||||
|
||||
t.create('license')
|
||||
.get(`/v1?url=${encodeURIComponent('https://github.com/atom/atom')}`)
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'GitHub license',
|
||||
link: 'https://github.com/atom/atom/blob/master/LICENSE.md',
|
||||
path: '/github/license/atom/atom',
|
||||
})
|
||||
|
||||
t.create('license for non-existing project')
|
||||
.get(`/v1?url=${encodeURIComponent('https://github.com/atom/atom')}`)
|
||||
.intercept(nock =>
|
||||
nock('https://api.github.com')
|
||||
.get(/\/repos\/atom\/atom\/license/)
|
||||
.reply(404)
|
||||
)
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'GitHub license',
|
||||
link: 'https://github.com/atom/atom',
|
||||
path: '/github/license/atom/atom',
|
||||
})
|
||||
|
||||
t.create('license when json response is invalid')
|
||||
.get(`/v1?url=${encodeURIComponent('https://github.com/atom/atom')}`)
|
||||
.intercept(nock =>
|
||||
nock('https://api.github.com')
|
||||
.get(/\/repos\/atom\/atom\/license/)
|
||||
.reply(invalidJSON)
|
||||
)
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'GitHub license',
|
||||
link: 'https://github.com/atom/atom',
|
||||
path: '/github/license/atom/atom',
|
||||
})
|
||||
|
||||
t.create('license when html_url not found in GitHub api response')
|
||||
.get(`/v1?url=${encodeURIComponent('https://github.com/atom/atom')}`)
|
||||
.intercept(nock =>
|
||||
nock('https://api.github.com')
|
||||
.get(/\/repos\/atom\/atom\/license/)
|
||||
.reply(200, {
|
||||
license: 'MIT',
|
||||
})
|
||||
)
|
||||
.expectJSON('suggestions.?', {
|
||||
title: 'GitHub license',
|
||||
link: 'https://github.com/atom/atom',
|
||||
path: '/github/license/atom/atom',
|
||||
})
|
||||
Reference in New Issue
Block a user