-
Notifications
You must be signed in to change notification settings - Fork 204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use the new /api/links
resource
#356
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
/** | ||
* Reducer for storing a "links" object in the Redux state store. | ||
* | ||
* The links object is initially null, and can only be updated by completely | ||
* replacing it with a new links object. | ||
* | ||
* Used by serviceUrl. | ||
*/ | ||
|
||
'use strict'; | ||
|
||
/** Return the initial links. */ | ||
function init() { return {links: null}; } | ||
|
||
/** Return updated links based on the given current state and action object. */ | ||
function updateLinks(state, action) { return {links: action.newLinks}; } | ||
|
||
/** Return an action object for updating the links to the given newLinks. */ | ||
function updateLinksAction(newLinks) { | ||
return { type: 'UPDATE_LINKS', newLinks: newLinks }; | ||
} | ||
|
||
module.exports = { | ||
init: init, | ||
update: { UPDATE_LINKS: updateLinks }, | ||
actions: { updateLinks: updateLinksAction }, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
'use strict'; | ||
|
||
var links = require('../links'); | ||
|
||
var init = links.init; | ||
var update = links.update.UPDATE_LINKS; | ||
var action = links.actions.updateLinks; | ||
|
||
describe('sidebar.reducers.links', function() { | ||
describe('#init()', function() { | ||
it('returns a null links object', function() { | ||
assert.deepEqual(init(), {links: null}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since |
||
}); | ||
}); | ||
|
||
describe('#update.UPDATE_LINKS()', function() { | ||
it('returns the given newLinks as the links object', function() { | ||
assert.deepEqual( | ||
update('CURRENT_STATE', {newLinks: 'NEW_LINKS'}), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since tests partly serve as documentation, I prefer that test data should look like the real data, even if simplified. eg. If a function with a string argument expects a URL, the test should pass a URL, even if not strictly required for the test to pass. In this case, the easiest way to do that would be to pass There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that tests partially serve as documentation. But here what I'm attempting to document is that what the I could use an object here instead of a string in order to be consistent with what Similarly the current value which I agree that it's unfortunate that, because the tests use strings here, from reading the tests it might look as if the links reducer is only for storing strings when in fact it's for storing any kind of value and in practice is currently only used to store an object. This is case where in Python you would use a sentinel object rather than a string as a clear way to say this to the reader that this is an opaque value. I don't think any such thing exists in JavaScript so I fell back on all caps strings hoping that would be clear enough. Even though currently it's only used for storing the links templates, this links reducer could easily be generalised into a "store one opaque value in Redux and be able to update that value by completely replacing it with a new value" reducer, like Do you think that makes sense? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fact that the code doesn't care about the shape of You're also assuming that we keep |
||
{links: 'NEW_LINKS'}); | ||
}); | ||
}); | ||
|
||
describe('#actions.updateLinks()', function() { | ||
it('returns an UPDATE_LINKS action object for the given newLinks', function() { | ||
assert.deepEqual( | ||
action('NEW_LINKS'), | ||
{ type: 'UPDATE_LINKS', newLinks: 'NEW_LINKS' }); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,166 @@ | ||
'use strict'; | ||
|
||
var serviceUrl = require('../service-url'); | ||
var proxyquire = require('proxyquire'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some comment as with |
||
|
||
describe('serviceUrl', function () { | ||
var service; | ||
/** Return a fake annotationUI object. */ | ||
function fakeAnnotationUI() { | ||
var links = null; | ||
return { | ||
updateLinks: function(newLinks) { | ||
links = newLinks; | ||
}, | ||
getState: function() { | ||
return {links: links}; | ||
}, | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This
This is actually the contract of a standard JavaScript object: > var annotationUI = {}
> annotationUI.links
undefined
> annotationUI.links = 'foo'
> annotationUI.links
"foo" I wonder if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This works for simple actions but not for the case where one action updates multiple parts of the state. For example, the process of logging out might: 1) Update the user's session, 2) Clear the set of annotations, 3) Remove any unsaved drafts. We don't actually have any actions that really do this yet, but it is a common pattern. btw. What you are describing sounds a bit like MobX which is a library that lets you turn ordinary objects into "observable" ones that trigger arbitrary side-effects then properties are modified. |
||
} | ||
|
||
beforeEach(function () { | ||
service = serviceUrl({serviceUrl: 'https://test.hypothes.is/'}); | ||
function createServiceUrl(linksPromise) { | ||
var replaceURLParams = sinon.stub().returns( | ||
{url: 'EXPANDED_URL', params: {}} | ||
); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TBQH I wouldn't have bothered mocking On the other hand, a) the mock's outputs here don't even resemble the real one, so the test loses some value as documentation. b) the mock's outputs don't depend on the inputs, which creates additional ways for the code under test to do the wrong thing but yet still pass the test. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't think this is true (I don't think you can actually change I'm not sure that the test here does lose any value as documentation. All I suppose I could have used There is of course a trade-off that, as you point out, while isolating the I fall down on the side of isolating the tests here, rather than integrating them, for a couple of reasons. One is that isolated tests provide feedback on the design of your code by revealing how deeply and complexly the module under test is coupled to its dependencies. If code is tightly coupled you will end up having to create complex mocks. I think that a couple of the mocks I had to create here to write isolated tests for The second reason is that it scales better. Across a large test suite when you make a lot of small decisions to integrate the tests for a lot of modules with a lot of their dependencies, then you end up in a situation where running the entire test suite is slow, where a bug in one part of the code causes a cascade of test failures all over the test suite making debugging difficult, and where the design of the code in terms of the coupling, interfaces and contracts of modules is bad because you haven't been getting design feedback from isolated tests. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Those are sound principles but I'm not convinced they really apply in this case. There is a spectrum of choices when writing tests between injecting absolutely every dependency that your code has and injecting none at all. Both extremes are obviously bad. Injecting a dependency always has a cost in terms of making the code or the tests a bit more complex and less straightforward, as well as differences in behaviour between the injected dependency and the real one. I usually take the view that pure helper functions which don't involve any substantial computation are not worth injecting most of the time. But in the interests of forward progress, I'm going to ah. disagree and commit. Let's settle this over a beer in SF 😉 |
||
|
||
var serviceUrlFactory = proxyquire('../service-url', { | ||
'./util/url-util': { replaceURLParams: replaceURLParams }, | ||
}); | ||
|
||
var annotationUI = fakeAnnotationUI(); | ||
|
||
var store = { | ||
links: sinon.stub().returns(linksPromise), | ||
}; | ||
|
||
return { | ||
annotationUI: annotationUI, | ||
store: store, | ||
serviceUrl: serviceUrlFactory(annotationUI, store), | ||
replaceURLParams: replaceURLParams, | ||
}; | ||
} | ||
|
||
describe('links', function () { | ||
|
||
beforeEach(function() { | ||
sinon.stub(console, 'warn'); | ||
}); | ||
|
||
afterEach(function () { | ||
console.warn.restore(); | ||
}); | ||
|
||
context('before the API response has been received', function() { | ||
var serviceUrl; | ||
var store; | ||
|
||
beforeEach(function() { | ||
// Create a serviceUrl function with an unresolved Promise that will | ||
// never be resolved - it never receives the links from store.links(). | ||
var parts = createServiceUrl(new Promise(function() {})); | ||
|
||
serviceUrl = parts.serviceUrl; | ||
store = parts.store; | ||
}); | ||
|
||
it('sends one API request for the links at boot time', function() { | ||
assert.calledOnce(store.links); | ||
assert.isTrue(store.links.calledWithExactly()); | ||
}); | ||
|
||
it('returns an empty string for any link', function() { | ||
assert.equal(serviceUrl('foo'), ''); | ||
}); | ||
|
||
it('returns an empty string even if link params are given', function() { | ||
assert.equal(serviceUrl('foo', {bar: 'bar'}), ''); | ||
}); | ||
}); | ||
|
||
it('returns route URLs', function () { | ||
assert.equal(service('help'), 'https://test.hypothes.is/docs/help'); | ||
context('if the API request fails', function() { | ||
it('just keeps returning empty strings for URLs', function() { | ||
var linksPromise = Promise.reject(new Error('Oops')); | ||
|
||
var serviceUrl = createServiceUrl(linksPromise).serviceUrl; | ||
|
||
assert.equal(serviceUrl('second_link'), ''); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assert is being executed before the Promise handlers inside |
||
}); | ||
}); | ||
|
||
it('expands route parameters', function () { | ||
assert.equal(service('user', {user: 'jim'}), | ||
'https://test.hypothes.is/u/jim'); | ||
context('after the API response has been received', function() { | ||
var annotationUI; | ||
var linksPromise; | ||
var replaceURLParams; | ||
var serviceUrl; | ||
|
||
beforeEach(function() { | ||
// The links Promise that store.links() will return. | ||
linksPromise = Promise.resolve({ | ||
first_link: 'http://example.com/first_page/:foo', | ||
second_link: 'http://example.com/second_page', | ||
}); | ||
|
||
var parts = createServiceUrl(linksPromise); | ||
|
||
annotationUI = parts.annotationUI; | ||
serviceUrl = parts.serviceUrl; | ||
replaceURLParams = parts.replaceURLParams; | ||
}); | ||
|
||
it('updates annotationUI with the real links', function() { | ||
return linksPromise.then(function(links) { | ||
assert.deepEqual(annotationUI.getState(), {links: links}); | ||
}); | ||
}); | ||
|
||
it('calls replaceURLParams with the path and given params', function() { | ||
return linksPromise.then(function() { | ||
var params = {foo: 'bar'}; | ||
|
||
serviceUrl('first_link', params); | ||
|
||
assert.calledOnce(replaceURLParams); | ||
assert.deepEqual( | ||
replaceURLParams.args[0], | ||
['http://example.com/first_page/:foo', params]); | ||
}); | ||
}); | ||
|
||
it('passes an empty params object to replaceURLParams if no params are given', function() { | ||
return linksPromise.then(function() { | ||
serviceUrl('first_link'); | ||
|
||
assert.calledOnce(replaceURLParams); | ||
assert.deepEqual(replaceURLParams.args[0][1], {}); | ||
}); | ||
}); | ||
|
||
it('returns the expanded URL from replaceURLParams', function() { | ||
return linksPromise.then(function() { | ||
var renderedUrl = serviceUrl('first_link'); | ||
|
||
assert.equal(renderedUrl, 'EXPANDED_URL'); | ||
}); | ||
}); | ||
|
||
it("throws an error if it doesn't have the requested link", function() { | ||
return linksPromise.then(function() { | ||
assert.throws( | ||
function() { serviceUrl('madeUpLinkName'); }, | ||
Error, 'Unknown link madeUpLinkName'); | ||
}); | ||
}); | ||
|
||
it('throws an error if replaceURLParams returns unused params', function() { | ||
var params = {'unused_param_1': 'foo', 'unused_param_2': 'bar'}; | ||
replaceURLParams.returns({ | ||
url: 'EXPANDED_URL', | ||
params: params, | ||
}); | ||
|
||
return linksPromise.then(function() { | ||
assert.throws( | ||
function() { serviceUrl('first_link', params); }, | ||
Error, 'Unknown link parameters: unused_param_1, unused_param_2'); | ||
}); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a nicer way of describing the module than the other modules use (which don't really have a clear scheme). It would be a good idea to make them consistent with this in future (not as part of this PR!)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I think so too