*/ "should write status header": function test { var data = { data: { user: "cjno", message: "hi" } }; this.controller.post; this.sendRequestdata; test.okthis.res.writeHead.called; test.
Trang 1Unfortunately, this doesn’t play out exactly as planned The previous test, which also calls post, is now attempting to call addMessage on chatRoom, which is undefinedin that test We can fix the issue by moving the chatRoom stub into setUpas Listing 14.18 does
Listing 14.18 Sharing the chatRoom stub
function controllerSetUp() { /* */
this.controller.chatRoom = { addMessage: stub() };
}
All the tests go back to a soothing green, and we can turn our attention to the duplicated logic we just introduced in the second test In particular, both tests simulates sending a request with a body We can simplify the tests considerably by extracting this logic into the setup Listing 14.19 shows the updated tests
Listing 14.19 Cleaning up post tests
function controllerSetUp() { /* */
this.sendRequest = function (data) { var str = encodeURI(JSON.stringify(data));
this.req.emit("data", str.substring(0, str.length / 2));
this.req.emit("data", str.substring(str.length / 2));
this.req.emit("end");
};
} testCase(exports, "chatRoomController.post", { /* */
"should parse request body as JSON": function (test) { var data = { data: { user: "cjno", message: "hi" } };
JSON.parse = stub(data);
this.controller.post();
this.sendRequest(data);
test.equals(JSON.parse.args[0], JSON.stringify(data));
test.done();
}, /* */
});
Trang 2The cleaned up tests certainly are a lot easier to follow, and with the send-Requesthelper method, writing new tests that make requests will be easier as
well All tests pass and we can move on
14.2.4.3 Malicious Data
Notice that we are currently accepting messages completely unfiltered This can
lead to all kinds of scary situations, for instance consider the effects of the request
in Listing 14.20
Listing 14.20 Malicious request
{ "topic": "message",
"data": {
"user": "cjno",
"message":
"<script>window.location = 'http://hacked';</script>"
} }
Before deploying an application like the one we are currently building we should take care to not blindly accept any end user data unfiltered
14.2.5 Responding to Requests
When the controller has added the message, it should respond and close the
connec-tion In most web frameworks, output buffering and closing the connection happen
automatically behind the scenes The HTTP server support in Node, however, was
consciously designed with data streaming and long polling in mind For this reason,
data is never buffered, and connections are never closed until told to do so
http.ServerResponseobjects offer a few methods useful to output a re-sponse, namely writeHead, which writes the status code and response headers;
write, which writes a chunk to the response body; and finally end
14.2.5.1 Status Code
As there really isn’t much feedback to give the user when a message is added,
Listing 14.21 simply expects post to respond with an empty “201 Created.”
Listing 14.21 Expecting status code 201
function controllerSetUp() {
/* */
var res = this.res = { writeHead: stub() };
Trang 3/* */
} testCase(exports, "chatRoomController.post", { /* */
"should write status header": function (test) { var data = { data: { user: "cjno", message: "hi" } };
this.controller.post();
this.sendRequest(data);
test.ok(this.res.writeHead.called);
test.equals(this.res.writeHead.args[0], 201);
test.done();
} });
Listing 14.22 faces the challenge and makes the actual call to writeHead
Listing 14.22 Setting the response code
post: function () { /* */
this.request.addListener("end", function () { var data = JSON.parse(decodeURI(body)).data;
this.chatRoom.addMessage(data.user, data.message);
this.response.writeHead(201);
}.bind(this));
}
14.2.5.2 Closing the Connection
Once the headers have been written, we should make sure the connection is closed
Listing 14.23 shows the test
Listing 14.23 Expecting the response to be closed
function controllerSetUp() { /* */
var res = this.res = { writeHead: stub(), end: stub()
};
/* */
};
Trang 4testCase(exports, "chatRoomController.post", {
/* */
"should close connection": function (test) { var data = { data: { user: "cjno", message: "hi" } };
this.controller.post();
this.sendRequest(data);
test.ok(this.res.end.called);
test.done();
} });
The test fails, and Listing 14.24 shows the updated post method, which passes all the tests
Listing 14.24 Closing the response
post: function () {
/* */
this.request.addListener("end", function () { /* */
this.response.end();
}.bind(this));
}
That’s it for the post method It is now functional enough to properly handle well-formed requests In a real-world setting, however, I encourage more rigid input
verification and error handling Making the method more resilient is left as an
exercise
14.2.6 Taking the Application for a Spin
If we make a small adjustment to the server, we can now take the application for a
spin In the original listing, the server did not set up a chatRoom for the controller
To successfully run the application, update the server to match Listing 14.25
Listing 14.25 The final server
var http = require("http");
var url = require("url");
var crController = require("chapp/chat_room_controller");
var chatRoom = require("chapp/chat_room");
Trang 5var room = Object.create(chatRoom);
module.exports = http.createServer(function (req, res) {
if (url.parse(req.url).pathname == "/comet") { var controller = crController.create(req, res);
controller.chatRoom = room;
controller[req.method.toLowerCase()]();
} });
For this to work, we need to add a fake chatRoom module Save the contents
of Listing 14.26 to lib/chapp/chat_room.js
Listing 14.26 A fake chat room
var sys = require("sys");
var chatRoom = { addMessage: function (user, message) { sys.puts(user + ": " + message);
} };
module.exports = chatRoom;
Listing 14.27 shows how to use node-repl, an interactive Node shell, to encode some POST data and post it to the application using curl, the command line HTTP client Run it in another shell, and watch the output from the shell that
is running the application
Listing 14.27 Manually testing the app from the command line
$ node-repl node> var msg = { user:"cjno", message:"Enjoying Node.js" };
node> var data = { topic: "message", data: msg };
node> var encoded = encodeURI(JSON.stringify(data));
node> require("fs").writeFileSync("chapp.txt", encoded);
node> Ctrl-d
$ curl -d `cat chapp.txt` http://localhost:8000/comet
When you enter that last command, you should get an immediate response (i.e.,
it simply returns to your prompt) and the shell that is running the server should
output “cjno: Enjoying Node.js.” In Chapter 15, TDD and DOM Manipulation: The
Chat Client, we will build a proper frontend for the application.
Trang 614.3 Domain Model and Storage
The domain model of the chat application will consist of a single chatRoom object
for the duration of our exercise chatRoom will simply store messages in memory,
but we will design it following Node’s I/O conventions
14.3.1 Creating a Chat Room
As with the controller, we will rely on Object.create to create new objects
inheriting from chatRoom However, until proved otherwise, chatRoom does
not need an initializer, so we can simply create objects with Object.create
directly Should we decide to add an initializer at a later point, we must update the
places that create chat room objects in the tests, which should be a good motivator
to keep from duplicating the call
14.3.2 I/O in Node
Because the chatRoom interface will take the role as the storage backend, we
classify it as an I/O interface This means it should follow Node’s carefully thought
out conventions for asynchronous I/O, even if it’s just an in-memory store for now
Doing so allows us to very easily refactor to use a persistence mechanism, such as a
database or web service, at a later point
In Node, asynchronous interfaces accept an optional callback as their last ar-gument The first argument passed to the callback is always either null or an error
object This removes the need for a dedicated “errback” function Listing 14.28
shows an example using the file system module
Listing 14.28 Callback and errback convention in Node
var fs = require("fs");
fs.rename("./tetx.txt", "./text.txt", function (err) {
if (err) { throw err;
} // Renamed successfully, carry on });
This convention is used for all low-level system interfaces, and it will be our starting point as well
Trang 714.3.3 Adding Messages
As dictated by the controller using it, the chatRoom object should have an ad-dMessagemethod that accepts a username and a message
14.3.3.1 Dealing with Bad Data
For basic data consistency, the addMessage method should err if either the user-name or message is missing However, as an asynchronous I/O interface, it cannot simply throw exceptions Rather, we will expect errors to be passed as the first ar-gument to the callback registered with addMessage, as is the Node way Listing 14.29 shows the test for missing username Save it in test/chapp/chat_room_
test.js
Listing 14.29 addMessage should require username
var testCase = require("nodeunit").testCase;
var chatRoom = require("chapp/chat_room");
testCase(exports, "chatRoom.addMessage", {
"should require username": function (test) { var room = Object.create(chatRoom);
room.addMessage(null, "a message", function (err) { test.isNotNull(err);
test.inherits(err, TypeError);
test.done();
});
} });
The test fails as expected, and so we add a check on the user parameter, as Listing 14.30 shows
Listing 14.30 Checking the username
var chatRoom = { addMessage: function (user, message, callback) {
if (!user) { callback(new TypeError("user is null"));
} } };
Trang 8The test passes, and we can move on to checking the message The test in Listing 14.31 expects addMessage to require a message
Listing 14.31 addMessage should require message
"should require message": function (test) {
var room = Object.create(chatRoom);
room.addMessage("cjno", null, function (err) { test.isNotNull(err);
test.inherits(err, TypeError);
test.done();
});
}
The test introduces some duplication that we’ll deal with shortly First, Listing 14.32 makes the check that passes it
Listing 14.32 Checking the message
addMessage: function (user, message, callback) {
/* */
if (!message) { callback(new TypeError("message is null"));
} }
All the tests pass Listing 14.33 adds a setUp method to remove the duplicated creation of the chatRoom object
Listing 14.33 Adding a setUp method
testCase(exports, "chatRoom.addMessage", {
setUp: function () { this.room = Object.create(chatRoom);
}, /* */
});
As we decided previously, the callback should be optional, so Listing 14.34 adds
a test that expects the method not to fail when the callback is missing.
Trang 9Listing 14.34 Expecting addMessage not to require a callback
/* */
require("function-bind");
/* */
testCase(exports, "chatRoom.addMessage", { /* */
"should not require a callback": function (test) { test.noException(function () {
this.room.addMessage();
test.done();
}.bind(this));
} }
Once again we load the custom bind implementation to bind the anonymous callback to test.noException To pass the test we need to check that the callback is callable before calling it, as Listing 14.35 shows
Listing 14.35 Verifying that callback is callable before calling it
addMessage: function (user, message, callback) { var err = null;
if (!user) { err = new TypeError("user is null"); }
if (!message) { err = new TypeError("message is null"); }
if (typeof callback == "function") { callback(err);
} }
14.3.3.2 Successfully Adding Messages
We won’t be able to verify that messages are actually stored until we have a way
to retrieve them, but we should get some indication on whether or not adding the message was successful To do this we’ll expect the method to call the callback with
a message object The object should contain the data we passed in along with an id
The test can be seen in Listing 14.36
Trang 10Listing 14.36 Expecting addMessage to pass the created message
"should call callback with new object": function (test) {
var txt = "Some message";
this.room.addMessage("cjno", txt, function (err, msg) { test.isObject(msg);
test.isNumber(msg.id);
test.equals(msg.message, txt);
test.equals(msg.user, "cjno");
test.done();
});
}
Listing 14.37 shows an attempt at passing the test It calls the callback with an object and cheats the id by hard-coding it to 1
Listing 14.37 Passing the object to the callback
addMessage: function (user, message, callback) {
/* */
var data;
if (!err) { data = { id: 1, user: user, message: message };
}
if (typeof callback == "function") { callback(err, data);
} }
With this in place, the tests are back to green Next up, the id should be unique for every message Listing 14.38 shows the test
Listing 14.38 Expecting unique message ids
"should assign unique ids to messages": function (test) {
var user = "cjno";
this.room.addMessage(user, "a", function (err, msg1) { this.room.addMessage(user, "b", function (err, msg2) { test.notEquals(msg1.id, msg2.id);
test.done();
});
}.bind(this));
}
Trang 11The test exposes our cheat, so we need to find a better way to generate ids
Listing 14.39 uses a simple variable that is incremented each time a message is added
Listing 14.39 Assigning unique integer ids
var id = 0;
var chatRoom = { addMessage: function (user, message, callback) { /* */
if (!err) { data = { id: id++, user: user, message: message };
} /* */
} };
Tests are passing again You might worry that we’re not actually storing the
message anywhere That is a problem, but it’s not currently being addressed by the
test case To do so we must start testing message retrieval
14.3.4 Fetching Messages
In the next chapter we will interface with the chat backend using the comet-Clientfrom Chapter 13, Streaming Data with Ajax and Comet This means that
chatRoomneeds some way to retrieve all messages since some token We’ll add a getMessagesSincemethod that accepts an id and yields an array of messages
to the callback
14.3.4.1 The getMessagesSince Method
The initial test for this method in Listing 14.40 adds two messages, then tries to retrieve all messages since the id of the first This way we don’t program any as-sumptions about how the ids are generated into the tests
Listing 14.40 Testing message retrieval
testCase(exports, "chatRoom.getMessagesSince", {
"should get messages since given id": function (test) { var room = Object.create(chatRoom);
var user = "cjno";