[Pkg-javascript-commits] [node-lastfm] 01/02: Imported Upstream version 0.9.2

Andrew Kelley andrewrk-guest at moszumanska.debian.org
Wed Jul 9 20:45:51 UTC 2014


This is an automated email from the git hooks/post-receive script.

andrewrk-guest pushed a commit to branch master
in repository node-lastfm.

commit 62ec487b97e3fefdf9ad3533a7a667df4f8a1804
Author: Andrew Kelley <superjoe30 at gmail.com>
Date:   Wed Jul 9 20:40:07 2014 +0000

    Imported Upstream version 0.9.2
---
 .gitignore                                  |   2 +
 History.md                                  | 100 ++++++
 LICENSE                                     |  19 ++
 README.md                                   | 346 ++++++++++++++++++++
 Roadmap.md                                  |  17 +
 config.example.js                           |   4 +
 lastfm-repl.js                              |  41 +++
 lib/lastfm/index.js                         |  35 ++
 lib/lastfm/lastfm-base.js                   |  45 +++
 lib/lastfm/lastfm-info.js                   |  43 +++
 lib/lastfm/lastfm-request.js                | 144 +++++++++
 lib/lastfm/lastfm-session.js                | 113 +++++++
 lib/lastfm/lastfm-update.js                 | 104 ++++++
 lib/lastfm/recenttracks-stream.js           | 111 +++++++
 package.json                                |  34 ++
 tests/TestData.js                           |  36 +++
 tests/common.js                             |  16 +
 tests/fakes.js                              |  39 +++
 tests/index.js                              |  10 +
 tests/lastfm-base-test.js                   |  78 +++++
 tests/lastfm-info-test.js                   | 118 +++++++
 tests/lastfm-info-track-test.js             |  35 ++
 tests/lastfm-node-test.js                   |  43 +++
 tests/lastfm-read-test.js                   | 382 ++++++++++++++++++++++
 tests/lastfm-recenttracks-stream-test.js    | 324 +++++++++++++++++++
 tests/lastfm-request-test.js                | 186 +++++++++++
 tests/lastfm-session-deprecatedflow-test.js | 254 +++++++++++++++
 tests/lastfm-session-test.js                | 277 ++++++++++++++++
 tests/lastfm-update-test.js                 | 485 ++++++++++++++++++++++++++++
 tests/tests.sh                              |  16 +
 30 files changed, 3457 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c794590
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+config.js
+/node_modules
diff --git a/History.md b/History.md
new file mode 100644
index 0000000..613fc19
--- /dev/null
+++ b/History.md
@@ -0,0 +1,100 @@
+# Changelog
+
+## 0.9.1
+* Updated package information for Debian packaging (andrewrk)
+
+## 0.9.0
+* Add automatic retries to session authorisation.
+* Deprecated session.authorise() in favour of supplying a token at creation.
+
+## 0.8.4
+* Added some user functions to list of signed methods (maxkueng)
+* Added some library functions to list of write methods (maxkueng)
+
+## 0.8.3
+* Fix issue where undefined mbid in track object would cause scrobble
+  to fail. (maxkueng)
+* Fix issue where all undefined and null parameters would cause signatures
+  to fail.
+* Use http.request instead of deprecated http.createClient. (xhochy)
+* lastfm-node now requires node v0.4.10 and above.
+
+## 0.8.1
+* Automatically set album parameter from track details when available.
+* Experimental REPL.
+
+## 0.8.0
+
+### Breaking changes
+* Removed old handler options which were deprecated in 0.6.0.
+
+### New features
+* Scrobble request which return error codes 11, 16 or 29 are automatically retried.
+
+## 0.7.0
+
+### Breaking changes
+* The success event on `lastfm.request` now emits parsed JSON rather than raw text.
+* `RecentTracksStream.isStreaming` has been removed in favour of `RecentTracksStream.isStreaming()`.
+
+## 0.6.3
+
+* Fixed bug where success/error handlers on lastfm.info and lastfm.update were being fired twice.
+* Configurable user-agent string.
+* LastFmUpdate can now accept any parameter. This will be passed through to Last.Fm. This allows artist/track info to be supplied without having to use the track object from RecentTrackStream.
+
+## 0.6.2
+
+Fixed path issue (regression)
+
+## 0.6.1
+
+* Reinstated LastFmSession.authorise() handler options which were accidentallly removed rather than deprecated. Thanks to Vytautas Jakutis for the spot.
+* Fixed LastFmSession.authorise() documentation (Vytautas Jakutis)
+
+## 0.6.0
+
+### New features
+
+* Added request() method. Provides low-level support for entire Last.Fm API.
+* Event handler options on RecentTrackStream, LastFmUpdate and LastFmInfo have been deprecated and replaced with more generic `handlers` option. These will be removed soon.
+
+## 0.5.1
+
+* More accurate error reporting from RecentTrackParser
+* v0.4.x support
+
+## 0.5.0
+
+* Renamed lastfm.readRequest/writeRequest to read/write.
+* Replace response reader with LastFmRequest.
+
+## 0.4.4
+
+* Handles response errors (timeouts, etc) when communicating with Last.fm
+* Fixed bug where unexpected data in LastFmInfo would crash application.
+
+## 0.4.3
+
+* Fixed bug where RecentTrackStream was working with null tracks.
+
+## 0.4.2
+
+* Added session.isAuthorised()
+
+## 0.4.1
+
+* Added slightly more descriptive errors to RecentTracksParser.
+
+## 0.4.0
+
+### Breaking changes
+
+* `LastFmSession` is no longer responsible for sending update. Use `lastfm.update()`  instead.
+
+    lastfm.update("nowplaying", session, { track: track });
+
+### New Features
+
+* Access getInfo API calls through `lastfm.info()`. See README for example.
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6a32a52
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2011, James Scott and Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b3c3e6a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,346 @@
+# lastfm-node
+
+Read and write to users recent plays on Last.fm.
+
+## Installation
+
+    npm install lastfm
+
+## Usage
+
+    var LastFmNode = require('lastfm').LastFmNode;
+  
+    var lastfm = new LastFmNode({
+      api_key: 'apikey',    // sign-up for a key at http://www.last.fm/api
+      secret: 'secret',
+      useragent: 'appname/vX.X MyApp' // optional. defaults to lastfm-node.
+    });
+
+## Documentation
+
+### LastFmRequest
+
+    lastfm.request(method, options);
+
+Returns a `LastFmRequest` instance.
+
+Send request to Last.fm. Requests automatically include the API key and are signed and/or sent via POST as described in the Last.fm API documentation.
+
+Methods:
+
+Accepts any Last.fm API method name, eg "artist.getInfo". 
+
+Options:
+
+All options are passed through to Last.fm with the exception of the following.
+
+- *write*
+
+        Force request to act as a write method. Write methods are signed and sent via POST. Useful for new methods not yet recognised by lastfm-node.
+
+- *signed*
+
+        Force request to be signed. See Last.fm API docs for signature details. Useful for new methods not yet recognised by lastfm-node.
+
+- *handlers*
+
+        Default event handlers to attach to the request object on creation.
+
+Events:
+
+- *success(json)*
+
+        JSON response from Last.fm
+
+- *error(error)*
+
+        Ruh-roh. Either a error returned by Last.fm or a transmission error.
+
+### RecentTracksStream
+
+    lastfm.stream(username);
+
+Returns: a `RecentTracksStream` instance
+
+Methods:
+
+- *start()*
+
+        Start streaming recent track info.
+
+- *stop()*
+
+        Stop streaming recent track info.
+
+- *isStreaming()*
+
+        Boolean. True is nowplaying/recent track data is being actively fetched.
+
+- *on(event, listener)*
+
+        Adds a listener for the specified event.
+
+- *removeListener(event, listener)*
+
+        Removes the listener for the specified event.
+
+Options:
+
+- *autostart*
+
+        Start streaming automatically. Defaults to false.
+
+- *handlers*
+
+        Default event handlers to attach to the request object on creation.
+
+Events:
+
+- *lastPlayed(track)*
+
+        The user's last scrobbled track.
+
+- *nowPlaying(track)*
+
+        Track the user is currently listening to.
+
+- *scrobbled(track)*
+        
+        Now playing track has been scrobbled.
+
+- *stoppedPlaying(track)*
+
+        User stopped listening to current track.
+
+- *error(error)*
+
+        Ruh-roh.
+
+### LastFmSession
+
+    lastfm.session(options);
+
+Returns: a `LastFmSession` instance.
+
+If the user and session key are already known supply these in the options. Otherwise supply a token for authorisation. When a token is supplied the session will be authorised with Last.fm. If the user has not yet approved the token (desktop application flow) then authorisation will be automatically retried.
+
+See the last.fm API documentation for more info on Last.fm authorisation flow.
+
+Options:
+
+- *user*
+
+        User name, if known.
+
+- *key*
+
+        Session key, if known.
+
+- *token*
+
+        Token supplied by auth.getToken or web flow callback.
+
+- *retryInterval*
+
+        Time in milliseconds to leave between retries. Defaults to 10 seconds.
+
+- *handlers*
+
+        Default event handlers to attach to the session object on creation.
+
+
+Public properties:
+
+- *user*
+
+        The username of the Last.fm user associated with the session.
+
+- *key*
+
+        The session key. Either passed in or generated using authorise().
+
+Methods:
+
+- *authorise(token, [options])*
+
+        Deprecated. Use lastfm.session({ token: token }) instead.
+        Authorises user with Last.fm api. See last.fm documentation. Options argument has handlers property that has default event handlers to attach to the LastFmSession instance.
+
+- *on(event, handler)*
+
+        Adds a listener for the specified event.
+
+- *removeListener(event, handler)*
+
+        Removes the listener for the specified event.
+
+- *isAuthorised()*
+
+        Returns true if the session has been authorised or a key was specified in the constructor.
+
+- *cancel()*
+
+        Prevent any further authorisation retries. Only applies if token supplied.
+
+Events:
+
+- *success(session)*
+
+        Authorisation of session was successful.
+        Note: Only emitted if a token was supplied in options. Username/key combinations supplied in options are assumed to be valid.
+
+- *authorised(session)*
+
+        Deprecated: Use success instead.
+        Authorisation of session was successful.
+
+- *retrying(retry)*
+
+       Authorisation request was not successful but will be retried after a delay. Retry object contains the following properties:  
+       `delay` - The time in milliseconds before the request will be retried.  
+       `error` - The error code returned by the Last.fm API.  
+       `message` - The error message returned by the Last.fm API.
+
+- *error(track, error)*
+
+        The authorisation was not successful and will not be retried.
+
+### LastFmUpdate
+
+    lastfm.update(method, session, options);
+
+Returns a `LastFmUpdate` instance. 
+
+Valid methods are 'nowplaying' and 'scrobble'.
+
+An authorised `LastFmSession` instance is required to make a successful update.
+
+If a scrobble request receives an 11 (service offline), 16 (temporarily unavailable) or 29 (rate limit exceeded) error code from Last.fm then the request is automatically retried until it is permanently rejected or accepted. The first retry attempt is made after 10 seconds with subsequent requests delayed by 30 seconds, 1 minute, 5 minutes, 15 minutes and then every 30 minutes.
+
+Options:
+
+Accepts all parameters used by track.updateNowPlaying and user.scrobble (see Last.Fm API) as well as:
+
+- *track*
+    
+        Track for nowplaying and scrobble requests. Uses same format as returned by `RecentTracksStream` events.
+
+- *timestamp*
+
+        Required for scrobble requests. Timestamp is in unix time (seconds since 01-01-1970 and is in UTC time).
+
+- *handlers*
+
+        Default event handlers to attach to the request object on creation.
+
+Events:
+
+- *success(track)*
+
+       Update request was successful. 
+
+- *retrying(retry)*
+
+       Scrobble request was not successful but will be retried after a delay. Retry object contains the following properties:  
+       `delay` - The time in milliseconds before the request will be retried.  
+       `error` - The error code returned by the Last.fm API.  
+       `message` - The error message returned by the Last.fm API.
+
+- *error(track, error)*
+
+        Ruh-roh.
+
+### LastFmInfo
+
+    lastfm.info(itemtype, [options]);
+
+Returns: a `LastFmInfo` instance.
+
+Gets extended info about specified item.
+
+Public properties:
+
+- *itemtype*
+
+        Any Last.fm item with a getInfo method. eg user, track, artist, etc.
+
+Options:
+
+- *handlers*
+
+        Event handlers to attach to object at creation.
+
+- *various*
+
+       Params as specified in Last.fm API, eg user: "username"
+
+Special cases:
+
+When requesting track info the `track` param can be either the track name or a track object as returned by `RecentTracksStream`.
+
+
+## Example
+
+    var LastFmNode = require('lastfm').LastFmNode;
+    
+    var lastfm = new LastFmNode({
+      api_key: 'abc',
+      secret: 'secret'
+    });
+
+    var trackStream = lastfm.stream('username');
+    
+    trackStream.on('lastPlayed', function(track) {
+      console.log('Last played: ' + track.name);
+    });
+    
+    trackStream.on('nowPlaying', function(track) {
+      console.log('Now playing: ' + track.name);
+    });
+
+    trackStream.on('scrobbled', function(track) {
+      console.log('Scrobbled: ' + track.name);
+    });
+
+    trackStream.on('stoppedPlaying', function(track) {
+      console.log('Stopped playing: ' + track.name);
+    });
+
+    trackStream.on('error', function(error) {
+      console.log('Error: '  + error.message);
+    });
+
+    trackStream.start();
+
+    var session = lastfm.session({
+       token: token,
+       handlers: {
+          success: function(session) {
+             lastfm.update('nowplaying', session, { track: track } );
+             lastfm.update('scrobble', session, { track: track, timestamp: 12345678 });
+          }
+       }
+    });
+
+    var request = lastfm.request("artist.getInfo", {
+        artist: "The Mae Shi",
+        handlers: {
+            success: function(data) {
+                console.log("Success: " + data);
+            },
+            error: function(error) {
+                console.log("Error: " + error.message);
+            }
+        }
+    });
+
+## Influences
+
+Heavily drawn from technoweenie's twitter-node  
+http://github.com/technoweenie/twitter-node
+
+## Contributors
+
+* Garret Wilkin (garrettwilkin) - http://geethink.com
+* Uwe L. Korn (xhochy) - http://xhochy.com
+* Max Kueng (maxkueng) - http://maxkueng.com
diff --git a/Roadmap.md b/Roadmap.md
new file mode 100644
index 0000000..7f1bb3c
--- /dev/null
+++ b/Roadmap.md
@@ -0,0 +1,17 @@
+# Roadmap
+
+## 0.7.0 (complete)
+* Responses from lastfm.request are automatically converted to JSON.
+
+## 0.8.0
+* Failed scrobbles are automatically retried when appropriate.
+
+## 0.9.0
+* Caching support. Memory and reddis with other datastores to follow.
+
+## 1.0.0
+* Finalise API.
+* Tidy JSON responses.
+
+## 1.1.0
+* Radio API/playlist support.
diff --git a/config.example.js b/config.example.js
new file mode 100644
index 0000000..1af48f4
--- /dev/null
+++ b/config.example.js
@@ -0,0 +1,4 @@
+module.exports = {
+    api_key: '',
+    secret: ''
+};
diff --git a/lastfm-repl.js b/lastfm-repl.js
new file mode 100644
index 0000000..17c5df9
--- /dev/null
+++ b/lastfm-repl.js
@@ -0,0 +1,41 @@
+var LastFmNode = require('./lib/lastfm').LastFmNode,
+    repl = require('repl'),
+    config = require('./config'),
+    _ = require('underscore');
+
+var echoHandler = function() {
+    _(arguments).each(function(arg) {
+        console.log(arg);
+    });
+};
+
+var errorHandler = function(error) {
+    console.log('Error: ' + error.message);
+};
+
+var quietHandler = function() { };
+
+var _echoHandlers = {
+    error: errorHandler,
+    success: echoHandler,
+    lastPlayed: echoHandler,
+    nowPlaying: echoHandler,
+    scrobbled: echoHandler,
+    stoppedPlaying: echoHandler
+};
+
+var _quietHandlers = {
+    error: errorHandler,
+    success: quietHandler,
+    lastPlayed: quietHandler,
+    nowPlaying: quietHandler,
+    scrobbled: quietHandler,
+    stoppedPlaying: quietHandler
+}
+
+var lastfm = new LastFmNode(config);
+
+var context = repl.start().context;
+context.lastfm = lastfm;
+context._echoHandlers = _echoHandlers;
+context._quietHandlers = _quietHandlers;
diff --git a/lib/lastfm/index.js b/lib/lastfm/index.js
new file mode 100644
index 0000000..2dc0bc6
--- /dev/null
+++ b/lib/lastfm/index.js
@@ -0,0 +1,35 @@
+var RecentTracksStream = require("./recenttracks-stream"),
+    LastFmSession = require("./lastfm-session"),
+    LastFmUpdate = require("./lastfm-update"),
+    LastFmInfo = require("./lastfm-info"),
+    LastFmRequest = require("./lastfm-request");
+
+var LastFmNode = exports.LastFmNode = function(options) {
+  options = options || {};
+  this.url = "/2.0";
+  this.host = options.host || "ws.audioscrobbler.com";
+  this.format = "json";
+  this.secret = options.secret;
+  this.api_key = options.api_key;
+  this.useragent = options.useragent || "lastfm-node";
+};
+
+LastFmNode.prototype.request = function(method, params) {
+  return new LastFmRequest(this, method, params);
+};
+
+LastFmNode.prototype.stream = function(user, options) {
+  return new RecentTracksStream(this, user, options);
+};
+
+LastFmNode.prototype.session = function(user, key) {
+  return new LastFmSession(this, user, key);
+};
+
+LastFmNode.prototype.info = function(type, options) {
+  return new LastFmInfo(this, type, options);
+};
+
+LastFmNode.prototype.update = function(method, session, options) {
+  return new LastFmUpdate(this, method, session, options);
+};
diff --git a/lib/lastfm/lastfm-base.js b/lib/lastfm/lastfm-base.js
new file mode 100644
index 0000000..052a62b
--- /dev/null
+++ b/lib/lastfm/lastfm-base.js
@@ -0,0 +1,45 @@
+var EventEmitter = require("events").EventEmitter
+  , _ = require("underscore");
+
+var LastFmBase = function() {
+  EventEmitter.call(this);
+};
+
+LastFmBase.prototype = Object.create(EventEmitter.prototype);
+
+LastFmBase.prototype.registerHandlers = function(handlers) {
+  if (typeof handlers !== "object") {
+    return;
+  }
+  
+  var that = this;
+  _(handlers).each(function(value, key) {
+    that.on(key, value);
+  });
+};
+
+var defaultBlacklist = ["error", "success", "handlers"];
+LastFmBase.prototype.filterParameters = function(parameters, blacklist) {
+  var filteredParams = {};
+  _(parameters).each(function(value, key) {
+    if (isBlackListed(key)) {
+      return;
+    }
+    filteredParams[key] = value;
+  });
+  return filteredParams;
+  
+  function isBlackListed(name) {
+    return _(defaultBlacklist).include(name) || _(blacklist).include(name);
+  }
+};
+
+LastFmBase.prototype.scheduleCallback = function(callback, delay) {
+  return setTimeout(callback, delay);
+};
+
+LastFmBase.prototype.cancelCallback = function(identifier) {
+  clearTimeout(identifier);
+};
+
+module.exports = LastFmBase;
diff --git a/lib/lastfm/lastfm-info.js b/lib/lastfm/lastfm-info.js
new file mode 100644
index 0000000..6bb3609
--- /dev/null
+++ b/lib/lastfm/lastfm-info.js
@@ -0,0 +1,43 @@
+var LastFmBase = require("./lastfm-base");
+
+var LastFmInfo = function(lastfm, type, options) {
+  var that = this;
+  options = options || {};
+  LastFmBase.call(this);
+
+  registerEventHandlers(options);
+  requestInfo(type, options);
+
+  function registerEventHandlers(options) {
+    that.registerHandlers(options.handlers);
+  }
+
+  function requestInfo(type, options) {
+    if (!type) {
+      that.emit("error", new Error("Item type not specified"));
+      return;
+    }
+
+    var params = that.filterParameters(options)
+      , method = type + ".getinfo"
+      , request = lastfm.request(method, params);
+    request.on("success", success);
+    request.on("error", error);
+  }
+
+  function success(response) {
+    if (response[type]) {
+      that.emit("success", response[type]);
+      return;
+    }
+    that.emit("error", new Error("Unexpected error"));
+  }
+  
+  function error(error) {
+    that.emit("error", error);
+  }
+};
+
+LastFmInfo.prototype = Object.create(LastFmBase.prototype);
+
+module.exports = LastFmInfo;
diff --git a/lib/lastfm/lastfm-request.js b/lib/lastfm/lastfm-request.js
new file mode 100644
index 0000000..dfbb612
--- /dev/null
+++ b/lib/lastfm/lastfm-request.js
@@ -0,0 +1,144 @@
+if (global.GENTLY_HIJACK) require = GENTLY_HIJACK.hijack(require);
+
+var http = require("http")
+  , querystring = require('querystring')
+  , _ = require("underscore")
+  , crypto = require("crypto")
+  , LastFmBase = require("./lastfm-base");
+
+var WRITE_METHODS = ["album.addtags", "album.removetag", "album.share",
+        "artist.addtags", "artist.removetag", "artist.share", "artist.shout",
+        "event.attend", "event.share", "event.shout",
+        "library.addalbum", "library.addartist", "library.addtrack",
+        "library.removealbum", "library.removeartist", "library.removetrack", "library.removescrobble",
+        "playlist.addtrack", "playlist.create",
+        "radio.tune",
+        "track.addtags", "track.ban", "track.love", "track.removetag",
+        "track.scrobble", "track.share", "track.unban", "track.unlove",
+        "track.updatenowplaying",
+        "user.shout"],
+    SIGNED_METHODS = ["auth.getmobilesession", "auth.getsession", "auth.gettoken",
+        "radio.getplaylist",
+        "user.getrecentstations", "user.getrecommendedartists", "user.getrecommendedevents"];
+
+var LastFmRequest = module.exports = function(lastfm, method, params) {
+  var that = this;
+  LastFmBase.call(this);
+  params = params || {};
+
+  that.registerHandlers(params.handlers);
+
+  sendRequest(lastfm.host, lastfm.url, params);
+
+  function sendRequest(host, url, params) {
+    var httpVerb = isWriteRequest() ? "POST" : "GET"
+    var requestParams = buildRequestParams(params);
+    var data = querystring.stringify(requestParams);
+    if (httpVerb == "GET") {
+      url += "?" + data;
+    }
+    var options = {
+        host: host,
+        port: 80,
+        path: url,
+        method: httpVerb,
+        headers: requestHeaders(httpVerb, host, data)
+    };
+    var req = http.request(options, chunkedResponse);
+    req.on("error", function(error) {
+        that.emit("error", error);
+    });
+    if (httpVerb == "POST") {
+        req.write(data);
+    }
+    req.end()
+  }
+
+  function buildRequestParams(params) {
+    var requestParams = that.filterParameters(params, ["signed", "write"]);
+    requestParams.method = method;
+    requestParams.api_key = requestParams.api_key || lastfm.api_key;
+    requestParams.format = requestParams.format || lastfm.format;
+    if (params.track && typeof params.track === "object") {
+      requestParams.artist = params.track.artist["#text"];
+      requestParams.track = params.track.name;
+      if (params.track.mbid) {
+        requestParams.mbid = params.track.mbid;
+      }
+      if (params.track.album) {
+        requestParams.album = requestParams.album || params.track.album["#text"];
+      }
+    }
+    if (requiresSignature()) {
+      requestParams.api_sig = createSignature(requestParams, lastfm.secret);
+    }
+    return requestParams;
+  }
+
+  function requiresSignature() {
+    return params.signed || isWriteRequest() || isSignedMethod(method);
+  }
+
+  function isWriteRequest() {
+    return params.write || isWriteMethod(method);
+  }
+
+  function isSignedMethod(method) {
+    return method && _(SIGNED_METHODS).include(method.toLowerCase());
+  }
+
+  function isWriteMethod(method) {
+    return method && _(WRITE_METHODS).include(method.toLowerCase());
+  }
+
+  function requestHeaders(httpVerb, host, data) {
+    var headers = {
+      "User-Agent": lastfm.useragent
+    };
+
+    if (httpVerb == "POST") {
+      headers["Content-Length"] = data.length;
+      headers["Content-Type"] = "application/x-www-form-urlencoded";
+    }
+
+    return headers;
+  }
+
+  function chunkedResponse(response) {
+    var data = "";
+    response.on("data", function(chunk) {
+        data += chunk.toString("utf8");
+    });
+    response.on("end", function() {
+      if (lastfm.format !== "json") {
+        that.emit("success", data);
+        return;
+      }
+      try {
+        var json = JSON.parse(data);
+        if (json.error) {
+          that.emit("error", json);
+          return;
+        }
+        that.emit("success", json);
+      }
+      catch(e) {
+        that.emit("error", e)
+      }
+    });
+  }
+
+  function createSignature(params, secret) {
+    var sig = "";
+    Object.keys(params).sort().forEach(function(key) {
+      if (key != "format") {
+        var value = typeof params[key] !== "undefined" && params[key] !== null ? params[key] : "";
+        sig += key + value;
+      }
+    });
+    sig += secret;
+    return crypto.createHash("md5").update(sig, "utf8").digest("hex");
+  }
+};
+
+LastFmRequest.prototype = Object.create(LastFmBase.prototype);
diff --git a/lib/lastfm/lastfm-session.js b/lib/lastfm/lastfm-session.js
new file mode 100644
index 0000000..b35a2a7
--- /dev/null
+++ b/lib/lastfm/lastfm-session.js
@@ -0,0 +1,113 @@
+var LastFmBase = require("./lastfm-base");
+
+var LastFmSession = function(lastfm, options, key) {
+  options = options || { };
+  var that = this,
+      retry = true;
+
+  LastFmBase.call(this);
+  if (typeof options !== "object") {
+    this.user = options || "";
+    this.key = key || "";
+  }
+  else {
+    this.user = options.user || "";
+    this.key = options.key || "";
+  }
+
+  if (options.token) {
+    authorise(options.token, options);
+  }
+
+  /**
+   * @deprecated
+   */
+  this.authorise = function(token, options) {
+    authorise(token, options);
+  };
+
+  this.isAuthorised = function() {
+    return isAuthorised();
+  }
+
+  this.cancel = function() {
+    retry = false;
+  }
+
+  function authorise(token, options) {
+    options = options || { };
+
+    registerEventHandlers(options);
+
+    validateToken(token, options);
+  }
+
+  function registerEventHandlers(options) {
+    that.registerHandlers(options.handlers);
+  }
+
+  function validateToken(token, options) {
+    options = options || { };
+    if (!token) {
+      that.emit("error", new Error("No token supplied"));
+      return;
+    }
+
+    var params = { token: token },
+        request = lastfm.request("auth.getsession", params);
+
+    request.on("success", authoriseSession);
+
+    request.on("error", function handleError(error) {
+      if (shouldBeRetried(error)) {
+        if (!retry) {
+          return;
+        }
+        var delay = options.retryInterval || 10000;
+        that.emit("retrying", {
+          error: error.error,
+          message: error.message,
+          delay: delay
+        });
+        that.scheduleCallback(function() {
+          validateToken(token, options);
+        }, delay);
+        return;
+      }
+      bubbleError(error);
+    });
+  }
+
+  function shouldBeRetried(error) {
+    return error.error == 14 ||
+        error.error == 16 ||
+        error.error == 11;
+  }
+
+  function isAuthorised() {
+    return that.key !== '';
+  }
+
+  function authoriseSession(result) {
+    if (!result.session) {
+      that.emit("error", new Error("Unexpected error"));
+      return;
+    }
+    setSessionDetails(result.session);
+    that.emit("authorised", that);
+    that.emit("success", that);
+  }
+
+  function setSessionDetails(session) {
+    that.user = session.name;
+    that.key = session.key;
+  }
+
+  function bubbleError(error) {
+    that.emit("error", error);
+  }
+};
+
+LastFmSession.prototype = Object.create(LastFmBase.prototype);
+
+module.exports = LastFmSession;
diff --git a/lib/lastfm/lastfm-update.js b/lib/lastfm/lastfm-update.js
new file mode 100644
index 0000000..e6a111c
--- /dev/null
+++ b/lib/lastfm/lastfm-update.js
@@ -0,0 +1,104 @@
+var _ = require("underscore")
+  , LastFmBase = require("./lastfm-base")
+  , retryOnErrors = [
+      11,                 // Service offline
+      16,                 // Temporarily unavailable
+      29                  // Rate limit exceeded
+    ]
+  , retrySchedule = [
+      10 * 1000,          // 10 seconds
+      30 * 1000,          // 30 seconds
+      60 * 1000,          // 1 minute
+      5 * 60 * 1000,      // 5 minutes
+      15 * 60 * 1000,     // 15 minutes
+      30 * 60 * 1000      // 30 minutes
+    ];
+
+var LastFmUpdate = function(lastfm, method, session, options) {
+  var that = this;
+  options = options || { };
+  LastFmBase.call(this);
+
+  registerEventHandlers(options);
+    
+  if (!session.isAuthorised()) {
+    this.emit("error", {
+      error: 4,
+      message: "Authentication failed"
+    });
+    return;
+  }
+  if (method !== "scrobble" && method !== "nowplaying") {
+    return;
+  }
+  update(method, options); 
+
+  function registerEventHandlers(options) {
+    that.registerHandlers(options.handlers);
+  }
+  
+  function update(method, options) {
+    if (method == "scrobble" && !options.timestamp) {
+      that.emit("error", {
+        error: 6,
+        message: "Invalid parameters - Timestamp is required for scrobbling"
+      });
+      return;
+    }
+
+    var retryCount = 0
+      , params = buildRequestParams(options)
+      , requestMethod = method == "scrobble" ? "track.scrobble" : "track.updateNowPlaying";
+    makeRequest();
+
+    function makeRequest() {
+      var request = lastfm.request(requestMethod, params);
+      request.on("error", errorCallback);
+      request.on("success", successCallback);
+    }
+
+    function successCallback(response) {
+      if (response) {
+        that.emit("success", options.track);
+      }
+    }
+
+    function errorCallback(error) {
+      if (shouldBeRetried(error)) {
+        var delay = delayFor(retryCount++)
+          , retry = {
+              error: error.error,
+              message: error.message,
+              delay: delay
+            };
+        that.emit("retrying", retry);
+        that.scheduleCallback(makeRequest, delay);
+        return;
+      }
+      bubbleError(error);
+    }
+
+    function shouldBeRetried(error) {
+      return method == "scrobble" && _(retryOnErrors).include(error.error)
+    }
+  }
+
+  function bubbleError(error) {
+    that.emit("error", error);
+  }
+
+  function buildRequestParams(params) {
+    var requestParams = that.filterParameters(params);
+    requestParams.sk = session.key;
+    return requestParams;
+  }
+
+  function delayFor(retryCount) {
+    var index = Math.min(retryCount, retrySchedule.length - 1);
+    return retrySchedule[index];
+  }
+}
+
+LastFmUpdate.prototype = Object.create(LastFmBase.prototype);
+
+module.exports = LastFmUpdate;
diff --git a/lib/lastfm/recenttracks-stream.js b/lib/lastfm/recenttracks-stream.js
new file mode 100644
index 0000000..132a2d1
--- /dev/null
+++ b/lib/lastfm/recenttracks-stream.js
@@ -0,0 +1,111 @@
+var LastFmBase = require("./lastfm-base");
+
+var RecentTracksStream = module.exports = function(lastfm, user, options) {
+  var that = this;
+  LastFmBase.call(this);
+  options = options || {};
+
+  var rate = 10
+    , isStreaming = false
+    , timeout
+    , lastPlay = null
+    , nowPlaying = null;
+
+  registerEventHandlers(options);
+
+  if (options.autostart) {
+    start();
+  }
+
+  this.start = function() {
+    start();
+  }
+
+  this.stop = function() {
+    stop();
+  }
+
+  this.isStreaming = function() {
+    return isStreaming;
+  }
+
+  function registerEventHandlers(options) {
+    that.registerHandlers(options.handlers);
+  }
+
+  function start() {
+    isStreaming = true;
+    check();
+
+    function check() {
+      var request = lastfm.request("user.getrecenttracks", {
+        user: user,
+        limit: 1
+      });
+      request.on("success", handleSuccess);
+      request.on("error", bubbleError);
+      if (isStreaming) {
+        timeout = that.scheduleCallback(check, rate * 1000);
+      }
+    }
+
+    function handleSuccess(data) {
+      if (!data || !data.recenttracks || !data.recenttracks.track) {
+        that.emit("error", new Error("Unexpected response"));
+        return;
+      }
+
+      var tracks = data.recenttracks.track;
+      if (tracks instanceof Array) {
+        processNowPlaying(tracks[0]);
+        processLastPlay(tracks[1]);
+        return;
+      }
+
+      var track = tracks;
+      if (track["@attr"] && track["@attr"]["nowplaying"]) {
+        processNowPlaying(track);
+        return;
+      }
+    
+      processLastPlay(track);
+      if (nowPlaying) {
+        that.emit("stoppedPlaying", nowPlaying);
+        nowPlaying = null;
+      }
+    }
+
+    function bubbleError(error) {
+      that.emit("error", error);
+    }
+  }
+
+  function processNowPlaying(track) {
+    var sameTrack = (nowPlaying && nowPlaying.name == track.name);
+    if (!sameTrack) {
+      nowPlaying = track;
+      that.emit("nowPlaying", track);
+    }
+  }
+
+  function processLastPlay(track) {
+    if (!lastPlay) {
+      lastPlay = track;
+      that.emit("lastPlayed", track);
+      return;
+    }
+
+    var sameTrack = (lastPlay.name == track.name);
+    if (!sameTrack) {
+      lastPlay = track;
+      that.emit("scrobbled", track);
+    }
+  }
+
+  function stop() {
+    that.cancelCallback(timeout);
+    isStreaming = false;
+  }
+};
+
+RecentTracksStream.prototype = Object.create(LastFmBase.prototype);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..05ab3bd
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+  "name": "lastfm",
+  "description": "Read and write to Last.fm",
+  "version": "0.9.2",
+  "author": "James Scott <jammus at gmail.com>",
+  "contributors": [
+    "Garrett Wilkin <garrett.wilkin at gmail.com> (http://geethink.com)",
+    "Uwe L. Korn <uwelk at xhochy.com> (http://xhochy.com/)",
+    "Max Kueng (http://maxkueng.com/)",
+    "Andrew Kelley <superjoe30 at gmail.com> (http://andrewkelley.me/)"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "http://github.com/jammus/lastfm-node.git"
+  },
+  "engine": [
+    "node >= 0.4.10"
+  ],
+  "directories": {
+    "test": "tests"
+  },
+  "dependencies": {
+    "underscore": "~1.6.0"
+  },
+  "devDependencies": {
+    "gently": ""
+  },
+  "main": "./lib/lastfm/index",
+  "bugs": {
+    "url": "https://github.com/jammus/lastfm-node/issues"
+  },
+  "homepage": "https://github.com/jammus/lastfm-node",
+  "license": "MIT"
+}
diff --git a/tests/TestData.js b/tests/TestData.js
new file mode 100644
index 0000000..e97d861
--- /dev/null
+++ b/tests/TestData.js
@@ -0,0 +1,36 @@
+var FakeData = exports.FakeData  = Object.create; 
+FakeData.SingleRecentTrack = '{\"recenttracks\":{\"track\":42}}';
+FakeData.UnknownObject = '{\"recentevents\":{\"event\":{}}}';
+FakeData.MultipleRecentsTracks = '{\"recenttracks\":{\"track\":[\"first\", \"second\"]}}';
+FakeData.Garbage = 'fi30i\ 32';
+
+var FakeTracks = exports.FakeTracks = Object.create;
+FakeTracks.LambAndTheLion = { name: 'Lamb and the Lion'};
+
+FakeTracks.RunToYourGrave_NP = {"artist":{"#text":"The Mae Shi","mbid":"8eb5c47c-4847-4c4b-a041-879d8fc5fbf3"},"name":"Run To Your Grave","streamable":"1","mbid":"","album":{"#text":"HILLYH","mbid":""},"url":"http:\/\/www.last.fm\/music\/The+Mae+Shi\/_\/Run+To+Your+Grave","image":[{"#text":"http:\/\/userserve-ak.last.fm\/serve\/34s\/4656031.jpg","size":"small"},{"#text":"http:\/\/userserve-ak.last.fm\/serve\/64s\/4656031.jpg","size":"medium"},{"#text":"http:\/\/userserve-ak.last.fm\/serv [...]
+
+FakeTracks.RunToYourGrave = {"artist":{"#text":"The Mae Shi","mbid":"8eb5c47c-4847-4c4b-a041-879d8fc5fbf3"},"name":"Run To Your Grave","streamable":"1","mbid":"fakembid","album":{"#text":"HILLYH","mbid":""},"url":"http:\/\/www.last.fm\/music\/The+Mae+Shi\/_\/Run+To+Your+Grave","image":[{"#text":"http:\/\/userserve-ak.last.fm\/serve\/34s\/4656031.jpg","size":"small"},{"#text":"http:\/\/userserve-ak.last.fm\/serve\/64s\/4656031.jpg","size":"medium"},{"#text":"http:\/\/userserve-ak.last.fm\ [...]
+
+FakeTracks.NowPlayingAndScrobbled = [{"artist":{"#text":"Super Tennis","mbid":""},"name":"Theme Song","streamable":"1","mbid":"","album":{"#text":"The Quiet Finale","mbid":""},"url":"http:\/\/www.last.fm\/music\/Super+Tennis\/_\/Theme+Song","image":[{"#text":"http:\/\/userserve-ak.last.fm\/serve\/34s\/41788301.jpg","size":"small"},{"#text":"http:\/\/userserve-ak.last.fm\/serve\/64s\/41788301.jpg","size":"medium"},{"#text":"http:\/\/userserve-ak.last.fm\/serve\/126\/41788301.jpg","size":" [...]
+
+FakeData.AuthorisationError = "{ \"error\" : 13, \"message\" : \"Signature is invalid\" }";
+
+FakeData.SuccessfulAuthorisation = "{ \"session\":  { \"name\": \"username\", \"key\": \"sessionkey\", \"subscriber\": \"0\"} }"; 
+
+FakeData.UpdateError = "{ \"error\" : 13, \"message\" : \"Invalid method signature supplied\" }";
+
+FakeData.UpdateNowPlayingSuccess = "{\"nowplaying\":{\"track\":{\"#text\":\"Run To Your Grave\",\"corrected\":\"0\"},\"artist\":{\"#text\":\"The Mai Shi\",\"corrected\":\"0\"},\"album\":{\"#text\":\"\",\"corrected\":\"0\"},\"albumArtist\":{\"#text\":\"\",\"corrected\":\"0\"},\"ignoredMessage\":{\"#text\":\"\",\"code\":\"0\"}}}";
+
+FakeData.ScrobbleSuccess= "{\"scrobbles\":{\"scrobble\":{\"track\":{\"#text\":\"Run To Your Grave\",\"corrected\":\"0\"},\"artist\":{\"#text\":\"The Mai Shi\",\"corrected\":\"0\"},\"album\":{\"#text\":\"\",\"corrected\":\"0\"},\"albumArtist\":{\"#text\":\"\",\"corrected\":\"0\"},\"timestamp\":\"1287180538\",\"ignoredMessage\":{\"#text\":\"\",\"code\":\"0\"}},\"@attr\":{\"accepted\":\"1\",\"ignored\":\"0\"}}}";
+
+FakeData.UserInfo = "{\"user\":{\"name\":\"jammus\",\"realname\":\"James\",\"image\":[{\"#text\":\"http:\/\/userserve-ak.last.fm\/serve\/34\/5208646.jpg\",\"size\":\"small\"},{\"#text\":\"http:\/\/userserve-ak.last.fm\/serve\/64\/5208646.jpg\",\"size\":\"medium\"},{\"#text\":\"http:\/\/userserve-ak.last.fm\/serve\/126\/5208646.jpg\",\"size\":\"large\"},{\"#text\":\"http:\/\/userserve-ak.last.fm\/serve\/252\/5208646.jpg\",\"size\":\"extralarge\"}],\"url\":\"http:\/\/www.last.fm\/user\/jam [...]
+
+FakeData.UnknownUser = "{\"error\":6,\"message\":\"No user with that name was found\"}";
+
+FakeData.Error = "{\"error\":1,\"message\":\"Error received\"}";
+
+FakeData.NotEnoughTrackInfo = "{\"error\":6,\"message\":\"You must supply either a track & artist name or a track mbid.\"}";
+
+FakeData.RunToYourGraveTrackInfo = "{\"track\":{\"id\":\"87962768\",\"name\":\"Run To Your Grave\",\"mbid\":\"\",\"url\":\"http:\/\/www.last.fm\/music\/The+Mae+Shi\/_\/Run+To+Your+Grave\",\"duration\":\"232000\",\"streamable\":{\"#text\":\"1\",\"fulltrack\":\"0\"},\"listeners\":\"38019\",\"playcount\":\"164628\",\"artist\":{\"name\":\"The Mae Shi\",\"mbid\":\"8eb5c47c-4847-4c4b-a041-879d8fc5fbf3\",\"url\":\"http:\/\/www.last.fm\/music\/The+Mae+Shi\"},\"album\":{\"artist\":\"The Mae Shi\" [...]
+
+FakeData.UnexpectedRecentTracks = "{\"recenttracks\":\"somthing\"}";
diff --git a/tests/common.js b/tests/common.js
new file mode 100644
index 0000000..b5e54ca
--- /dev/null
+++ b/tests/common.js
@@ -0,0 +1,16 @@
+var path = require("path");
+global.Gently = require("gently");
+global.GENTLY_HIJACK = new Gently();
+global.assert = require("assert");
+global.ntest = require('ntest');
+global.it = ntest.it;
+global.describe = ntest.describe;
+global.before = ntest.before;
+global.after = ntest.after;
+global.LastFmNode = require("../lib/lastfm").LastFmNode;
+global.FakeData = require("./TestData.js").FakeData;
+global.FakeTracks = require("./TestData.js").FakeTracks;
+if (process.setMaxListeners) {
+    process.setMaxListeners(900);
+}
+global.emptyFn = function() { };
diff --git a/tests/fakes.js b/tests/fakes.js
new file mode 100644
index 0000000..6e1e72e
--- /dev/null
+++ b/tests/fakes.js
@@ -0,0 +1,39 @@
+var EventEmitter = require('events').EventEmitter;
+
+var Client = exports.Client = function(port, host) {
+  EventEmitter.call(this);
+  this.port = port;
+  this.host = host;
+};
+
+Client.prototype = Object.create(EventEmitter.prototype);
+
+Client.prototype.request = function() {
+    return new ClientRequest();
+};
+
+var ClientRequest = exports.ClientRequest = function() {
+  EventEmitter.call(this);
+};
+
+ClientRequest.prototype = Object.create(EventEmitter.prototype);
+
+ClientRequest.prototype.write = function() {
+};
+
+ClientRequest.prototype.end = function() {
+};
+
+var ClientResponse = exports.ClientResponse = function() {
+  EventEmitter.call(this);
+};
+
+ClientResponse.prototype = Object.create(EventEmitter.prototype);
+
+var LastFmRequest = exports.LastFmRequest = function(connection, url) {
+  EventEmitter.call(this);
+  this.connection = connection;
+  this.url = url;
+};
+
+LastFmRequest.prototype = Object.create(EventEmitter.prototype);
diff --git a/tests/index.js b/tests/index.js
new file mode 100644
index 0000000..1e2ad63
--- /dev/null
+++ b/tests/index.js
@@ -0,0 +1,10 @@
+require("./lastfm-node-test.js");
+require("./lastfm-recenttracks-stream-test.js");
+require("./lastfm-session-test.js");
+require("./lastfm-session-deprecatedflow-test.js");
+require("./lastfm-info-test.js");
+require("./lastfm-read-test.js");
+require("./lastfm-info-track-test.js");
+require("./lastfm-update-test.js");
+require("./lastfm-request-test.js");
+require("./lastfm-base-test.js");
diff --git a/tests/lastfm-base-test.js b/tests/lastfm-base-test.js
new file mode 100644
index 0000000..5d392c4
--- /dev/null
+++ b/tests/lastfm-base-test.js
@@ -0,0 +1,78 @@
+require('./common');
+var LastFmBase = require("../lib/lastfm/lastfm-base");
+
+(function() {
+  describe("LastFmBase");
+
+  it("is an event emitter", function() {
+    var events = { expected: function(){} };
+    var gently = new Gently();
+    var lastfmBase = new LastFmBase();
+    gently.expect(events, "expected");
+    lastfmBase.on("test", function() {
+      events.expected();
+    });
+    lastfmBase.emit("test");
+  });
+})();
+
+(function() {
+  describe("LastFmBase.registerHandlers");
+
+  it("attaches events specified in handelers parameter", function() {
+    var handlers = {
+      error: function() { },
+      success: function() { },
+      anything: function() { }
+    };
+    var gently = new Gently();
+    gently.expect(handlers, "error");
+    gently.expect(handlers, "success");
+    gently.expect(handlers, "anything");
+    var lastfmBase = new LastFmBase();
+    lastfmBase.registerHandlers(handlers);
+    lastfmBase.emit("error");
+    lastfmBase.emit("success");
+    lastfmBase.emit("anything");
+  });
+})();
+
+(function() {
+  var lastfmBase, original;
+
+  describe("LastFmBase.filterParameters");
+
+  before(function() {
+    lastfmBase = new LastFmBase();
+    original = { one: 1, two: 2, three: 3 };
+  });
+
+  it("unfiltered object matches original object", function() {
+    var copy = lastfmBase.filterParameters(original);
+    assert.deepEqual(copy, original);
+  });
+
+  it("returns copy of original object", function() {
+    var copy = lastfmBase.filterParameters(original);
+    copy.four = 4;
+    assert.notDeepEqual(copy, original);
+  });
+
+  it("filteres out blacklisted parameters", function() {
+    var copy = lastfmBase.filterParameters(original, ["one", "three"]);
+    assert.equal(typeof copy.one, "undefined");
+    assert.equal(typeof copy.three, "undefined");
+    assert.equal(copy.two, 2);
+  });
+
+  it("automatically removed error, success, handler parameters", function() {
+    var copy = lastfmBase.filterParameters({
+        error: emptyFn,
+        success: emptyFn,
+        handlers: { }
+    });
+    assert.equal(typeof copy.error, "undefined");
+    assert.equal(typeof copy.success, "undefined");
+    assert.equal(typeof copy.handlers, "undefined");
+  });
+})();
diff --git a/tests/lastfm-info-test.js b/tests/lastfm-info-test.js
new file mode 100644
index 0000000..0f8a5f5
--- /dev/null
+++ b/tests/lastfm-info-test.js
@@ -0,0 +1,118 @@
+require("./common.js");
+var LastFmInfo = require("../lib/lastfm/lastfm-info");
+var LastFmRequest = require("../lib/lastfm/lastfm-request");
+var fakes = require("./fakes");
+
+(function() {
+describe("a new info instance")
+  var lastfm, gently;
+  before(function() {
+    lastfm = new LastFmNode();
+    gently = new Gently();
+  });
+
+  it("accepts listeners in handler options", function() {
+    var handlers = { error: function() {}, success: function() {} };
+    var options = { handlers: handlers };
+    gently.expect(handlers, "error");
+    gently.expect(handlers, "success");
+    var info = new LastFmInfo(lastfm, "", options);
+    info.emit("success");
+  });
+  
+  it("emits error if type not specified", function() {
+    var handlers = { error: function() {}};
+    gently.expect(handlers, "error", function(error) {
+      assert.equal("Item type not specified", error.message);
+    });
+    var info = new LastFmInfo(lastfm, "", { handlers: handlers });
+  });
+  
+  it("allows requests for user info", function() {
+    gently.expect(lastfm, "request", function() {
+      return new fakes.LastFmRequest();
+    });
+    var info = new LastFmInfo(lastfm, "user");
+  });
+
+  it("allows requests for track info", function() {
+    gently.expect(lastfm, "request", function() {
+      return new fakes.LastFmRequest();
+    });
+    var info = new LastFmInfo(lastfm, "track");
+  });
+
+  it("allows all [itemtype].getinfo calls", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal("event.getinfo", method);
+      return new fakes.LastFmRequest();
+    });
+    new LastFmInfo(lastfm, "event");
+  });
+  
+  it("passes through parameters", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal("username", params.user);
+      assert.equal("anything", params.arbitrary);
+      return new fakes.LastFmRequest();
+    });
+    new LastFmInfo(lastfm, "user", { user: "username", arbitrary: "anything" });
+  });
+
+  it("doesnt pass through callback parameters", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.ok(!params.error);
+      assert.ok(!params.success);
+      assert.ok(!params.handlers);
+      return new fakes.LastFmRequest();
+    });
+    new LastFmInfo(lastfm, "user", { handlers:  { error: function() {}, success: function() {} } });
+  });
+})();
+
+(function() {
+describe("when receiving data")
+  var gently, lastfm, request;
+  before(function() {
+    gently = new Gently();
+    lastfm = new LastFmNode();
+    request = new fakes.LastFmRequest();
+  });
+
+  it("emits error when receiving unexpected data", function() {
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+    new LastFmInfo(lastfm, "track", { handlers: {
+      error: gently.expect(function errorHandler(error) {
+        assert.equal("Unexpected error", error.message);
+      })
+    }});
+    request.emit("success", JSON.parse(FakeData.SuccessfulAuthorisation));
+  });
+
+  it("emits success with received data when matches expected type", function() {
+    gently.expect(lastfm, "request", function() {
+        return request;
+    });
+    new LastFmInfo(lastfm, "track", { handlers: {
+      success: gently.expect(function success(track) {
+        assert.equal("Run To Your Grave", track.name);
+        assert.equal("232000", track.duration);
+      })
+    }});
+    request.emit("success", JSON.parse(FakeData.RunToYourGraveTrackInfo));
+  });
+
+  it("bubbles up errors", function() {
+    gently.expect(lastfm, "request", function() {
+        return request;
+    });
+    var info = new LastFmInfo(lastfm, "track");
+    gently.expect(info, "emit", function(event, error) {
+      assert.equal("error", event);
+      assert.equal("Bubbled error", error.message);
+    });
+    request.emit("error", new Error("Bubbled error"));
+  });
+})();
diff --git a/tests/lastfm-info-track-test.js b/tests/lastfm-info-track-test.js
new file mode 100644
index 0000000..0f8e801
--- /dev/null
+++ b/tests/lastfm-info-track-test.js
@@ -0,0 +1,35 @@
+require("./common.js");
+
+var LastFmInfo = require("../lib/lastfm/lastfm-info")
+  , fakes = require("./fakes");
+
+(function() {
+  describe("a track info request");
+
+  before(function() {
+    this.gently = new Gently();
+    this.lastfm = new LastFmNode();
+  });
+
+  it("calls method track.getInfo", function() {
+    this.gently.expect(this.lastfm, "request", function(method, params) {
+      assert.equal("track.getinfo", method);
+      return new fakes.LastFmRequest();
+    });
+    new LastFmInfo(this.lastfm, "track", { mbid: "mbid" });
+  });
+
+  it("can accept artist, track name and mbid", function() {
+    this.gently.expect(this.lastfm, "request", function(method, params) {
+      assert.equal("The Mae Shi", params.artist);
+      assert.equal("Run To Your Grave", params.track);
+      assert.equal("1234567890", params.mbid);
+      return new fakes.LastFmRequest();
+    });
+    new LastFmInfo(this.lastfm, "track", {
+      artist: "The Mae Shi",
+      track: "Run To Your Grave",
+      mbid: "1234567890"
+    });
+  });
+})();
diff --git a/tests/lastfm-node-test.js b/tests/lastfm-node-test.js
new file mode 100644
index 0000000..a9e1a12
--- /dev/null
+++ b/tests/lastfm-node-test.js
@@ -0,0 +1,43 @@
+require("./common.js");
+
+var querystring = require("querystring");
+
+(function() {
+  var lastfm;
+
+  describe("default LastFmNode instance")
+    
+  before(function() {
+    lastfm = new LastFmNode();
+  })
+
+  it("has default host", function() {
+    assert.equal("ws.audioscrobbler.com", lastfm.host);
+  });
+})();
+
+(function() {
+  var lastfm;
+
+  describe("LastFmNode options")
+
+  before(function() {
+    lastfm = new LastFmNode({
+      api_key: "abcdef12345",
+      secret: "ghijk67890",
+      host: "test.audioscrobbler.com"
+    });
+  })
+
+  it("configures api key", function() {
+    assert.equal("abcdef12345", lastfm.api_key);
+  });
+
+  it("configures secret", function() {
+    assert.equal("ghijk67890", lastfm.secret);
+  });
+
+  it("configures host", function() {
+    assert.equal("test.audioscrobbler.com", lastfm.host);
+  });
+})();
diff --git a/tests/lastfm-read-test.js b/tests/lastfm-read-test.js
new file mode 100644
index 0000000..306d0bf
--- /dev/null
+++ b/tests/lastfm-read-test.js
@@ -0,0 +1,382 @@
+require('./common');
+var crypto = require("crypto");
+var _ = require("underscore");
+var querystring = require("querystring");
+var fakes = require("./fakes");
+var LastFmRequest = fakes.LastFmRequest;
+
+(function() {
+  var gently, lastfm;
+  var options, expectations;
+  var notExpected;
+
+  describe("a lastfm request");
+
+  before(function() {
+    gently = new Gently();
+    options = {};
+    expectations = {
+      pairs:[],
+      handlers:[]
+    };
+    notExpected = {
+      keys:[]
+    }
+    lastfm = new LastFmNode({
+      api_key: "key",
+      secret: "secret"
+    });
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+      verifyCreateClient(options.port, options.host);
+      var request = new fakes.ClientRequest();
+      if (options.method == "POST") {
+          gently.expect(request, "write", function(data) {
+              verifyRequest(options.method, options.path, options.headers, data);
+          });
+      } else {
+          verifyRequest(options.method, options.path, options.headers);
+      }
+      return request;
+    });
+  });
+
+  after(function() {
+    var request = doRequest();
+    verifyHandlers(request);
+  });
+
+  function verifyCreateClient(port, host) {
+    if (expectations.port) {
+      assert.equal(expectations.port, port);
+    }
+    if (expectations.host) {
+      assert.equal(expectations.host, host);
+    }
+  }
+
+  function verifyRequest(method, url, header, data) {
+    if (expectations.url) {
+      assert.equal(expectations.url, url);
+    }
+    var pairs = querystring.parse(data || url.substr("/2.0?".length));
+    _(Object.keys(expectations.pairs)).each(function(key) {
+        assert.equal(expectations.pairs[key], pairs[key]);
+    });
+    if (expectations.signed || expectations.signatureHash) {
+      assert.ok(pairs.api_sig);
+    }
+    else if (expectations.signed === false) {
+      assert.ok(!pairs.api_sig);
+    }
+    if (expectations.signatureHash) {
+      assert.equal(expectations.signatureHash, pairs.api_sig);
+    }
+    if (expectations.method) {
+      assert.equal(expectations.method, method);
+    }
+    _(notExpected.keys).each(function(key) {
+      assert.ok(!pairs[key]);
+    });
+    if (expectations.requestData) {
+      assert.ok(data);
+    }
+  }
+
+  function whenMethodIs(method) {
+    options.method = method;
+  }
+
+  function andParamsAre(params) {
+    options.params = params;
+  }
+
+  function expectHttpMethod(method) {
+    expectations.method = method;
+  }
+
+  function expectDataPair(key, value) {
+    expectations.pairs[key] = value;
+  }
+
+  function expectSignature() {
+    expectations.signed = true;
+  }
+
+  function expectUrl(url) {
+    expectations.url = url;
+  }
+
+  function expectSignatureHashOf(unhashed) {
+    var expectedHash = crypto.createHash("md5").update(unhashed, "utf8").digest("hex");
+    expectSignatureHashToBe(expectedHash);
+  };
+
+    this.expectSignatureHashToBe = function(hash) {
+      expectations.signatureHash = hash;
+    }
+
+  function expectRequestOnPort(port) {
+    expectations.port = port;
+  }
+
+  function expectRequestToHost(host) {
+    expectations.host = host;
+  }
+
+  function expectHandlerFor(event) {
+    expectations.handlers.push(event);
+  }
+
+  function expectRequestData() {
+    expectations.requestData = true;
+  }
+
+  function doNotExpectDataKey(key) {
+    notExpected.keys.push(key);
+  }
+
+  function doRequest() {
+    return lastfm.request(options.method, options.params);
+  }
+
+  function verifyHandlers(request) {
+    _(expectations.handlers).each(function(event) {
+      var listeners = request.listeners(event);
+      assert.equal(1, listeners.length, "No handler for event: " + event);
+    });
+  }
+
+  it("default to port 80", function() {
+    whenMethodIs("any.method");
+    expectRequestOnPort(80);
+  });
+
+  it("makes request to audioscrobbler", function() {
+    whenMethodIs("any.method");
+    expectRequestToHost("ws.audioscrobbler.com");
+  });
+
+  it("always requests as json", function() {
+    whenMethodIs("any.method");
+    expectDataPair("format", "json");
+  });
+
+  it("always passes api_key", function() {
+    whenMethodIs("any.method");
+    expectDataPair("api_key", "key");
+  });
+
+  it("defaults to get request", function() {
+    whenMethodIs("any.method");
+    expectHttpMethod("GET");
+  });
+
+  it("calls the method specified", function() {
+    whenMethodIs("user.getinfo");
+    expectDataPair("method", "user.getinfo");
+  });
+
+  it("passes through parameters", function() {
+    whenMethodIs("user.getinfo");
+    andParamsAre({ user: "jammus" });
+    expectDataPair("user", "jammus");
+  });
+
+  it("converts track object to separate parameters", function() {
+    whenMethodIs("any.method");
+    andParamsAre({
+      track: {
+        artist: { "#text": "The Mae Shi" },
+        name: "Run To Your Grave",
+        mbid: "1234567890"
+      }
+    });
+    expectDataPair("artist", "The Mae Shi");
+    expectDataPair("track", "Run To Your Grave");
+    expectDataPair("mbid", "1234567890");
+  });
+
+  it("converts track object album details to separate parameters", function() {
+    whenMethodIs("any.method");
+    andParamsAre({
+      track: {
+        artist: { "#text": "The Mae Shi" },
+        name: "Run To Your Grave",
+        album: { "#text": "HLLLYH" }
+      }
+    });
+    expectDataPair("album", "HLLLYH");
+  });
+
+  it("does not overwrite explicitly set album parameters", function() {
+    whenMethodIs("any.method");
+    andParamsAre({
+      track: {
+        artist: { "#text": "The Mae Shi" },
+        name: "Run To Your Grave",
+        album: { "#text": "HLLLYH" }
+      },
+      album: "Run To Your Grave"
+    });
+    expectDataPair("album", "Run To Your Grave");
+  });
+
+  it("doesn't include mbid if one isn't supplied", function() {
+    whenMethodIs("any.method");
+    andParamsAre({
+      track: {
+        artist: { "#text": "The Mae Shi" },
+        name: "Run To Your Grave"
+      }
+    });
+    expectDataPair("artist", "The Mae Shi");
+    expectDataPair("track", "Run To Your Grave");
+    doNotExpectDataKey("mbid");
+  });
+
+  it("does not pass through event handler parameters", function() {
+    whenMethodIs("any.method");
+    andParamsAre({ handlers: "handlers", error: "error", success: "success" });
+    doNotExpectDataKey("handlers");
+    doNotExpectDataKey("error");
+    doNotExpectDataKey("success");
+  });
+
+  it("auth.getsession has signature", function() {
+    whenMethodIs("auth.getsession");
+    expectSignature();
+  });
+
+  it("attaches handlers to returned request", function() {
+    whenMethodIs("any.method");
+    andParamsAre({ handlers: {
+        error: function() {console.log("errrors");},
+        success: function() {},
+        arbitrary: function() {},
+    }});
+    expectHandlerFor("error");
+    expectHandlerFor("success");
+    expectHandlerFor("arbitrary");
+  });
+
+  it("uses signed param to force signature", function() {
+    whenMethodIs("any.method");
+    andParamsAre({
+      signed: true
+    });
+    expectSignature();
+  });
+
+  it("signature hashes api_key, method and secret", function() {
+    whenMethodIs("auth.getsession");
+    expectSignatureHashOf("api_keykeymethodauth.getsessionsecret");
+  });
+
+  it("signature includes other parameters", function() {
+    whenMethodIs("auth.getsession");
+    andParamsAre({ foo: "bar" });
+    expectSignatureHashOf("api_keykeyfoobarmethodauth.getsessionsecret");
+  });
+
+  it("signature hashes all params alphabetically", function() {
+    whenMethodIs("auth.getsession");
+    andParamsAre({ foo : "bar", baz: "bash", flip : "flop" });
+    expectSignatureHashOf("api_keykeybazbashflipflopfoobarmethodauth.getsessionsecret");
+  });
+
+  it("signature hash ignores format parameter", function() {
+    whenMethodIs("auth.getsession");
+    andParamsAre({ format: "json" });
+    expectSignatureHashOf("api_keykeymethodauth.getsessionsecret");
+  });
+
+  it("signature hash ignores handlers parameter", function() {
+    whenMethodIs("auth.getsession");
+    andParamsAre({ handlers: "handlers" });
+    expectSignatureHashOf("api_keykeymethodauth.getsessionsecret");
+  });
+
+  it("signature hash ignores write parameter", function() {
+    whenMethodIs("auth.getsession");
+    andParamsAre({ write: true });
+    expectSignatureHashOf("api_keykeymethodauth.getsessionsecret");
+  });
+
+  it("signature hash ignores signed parameter", function() {
+    whenMethodIs("any.method");
+    andParamsAre({ signed: true });
+    expectSignatureHashOf("api_keykeymethodany.methodsecret");
+  });
+
+  it("signature hash handles high characters as expected by last.fm (utf8)", function() {
+    whenMethodIs("auth.getsession");
+    andParamsAre({ track: "Tony’s Theme (Remastered)" });
+    expectSignatureHashToBe("15f5159046bf1e76774b9dd46a4ed993");
+  });
+
+  it("signature hash treats undefined values as blank", function() {
+    whenMethodIs("any.method");
+    andParamsAre({ signed: true, track: 'Replicating Networks', artist: 'Rabbit Milk', albumArtist: undefined });
+    expectSignatureHashOf("albumArtistapi_keykeyartistRabbit Milkmethodany.methodtrackReplicating Networkssecret");
+  });
+
+  it("signature hash treats null values as blank", function() {
+    whenMethodIs("any.method");
+    andParamsAre({ signed: true, track: 'Replicating Networks', artist: 'Rabbit Milk', albumArtist: null });
+    expectSignatureHashOf("albumArtistapi_keykeyartistRabbit Milkmethodany.methodtrackReplicating Networkssecret");
+  });
+
+  it("write requests use post", function() {
+    whenMethodIs("any.method");
+    andParamsAre({ write: true });
+    expectHttpMethod("POST");
+  });
+
+  it("write requests don't use get parameters", function() {
+    whenMethodIs("any.method");
+    andParamsAre({ write: true });
+    expectUrl("/2.0");
+  });
+
+  it("write requests send data in request", function() {
+    whenMethodIs("any.method");
+    andParamsAre({
+      write: true,
+      foo: "bar"
+    });
+    expectRequestData();
+    expectDataPair("foo", "bar");
+  });
+
+  it("write requests are always signed", function() {
+    whenMethodIs("album.removeTag");
+    andParamsAre({
+      write: true
+    });
+    expectSignature();
+  });
+
+  _(["album.addTags", "album.removeTag", "album.share",
+    "artist.addTags", "artist.removeTag", "artist.share", "artist.shout",
+    "event.attend", "event.share", "event.shout",
+    "library.addAlbum", "library.addArtist", "library.addTrack",
+    "playlist.addTrack", "playlist.create",
+    "radio.tune",
+    "track.addTags", "track.ban", "track.love", "track.removeTag",
+    "track.scrobble", "track.share", "track.unban", "track.unlove",
+    "track.updateNowPlaying",
+    "user.shout"]).each(function(method) {
+    it(method + " is a write (post) request", function() {
+      whenMethodIs(method);
+      expectHttpMethod("POST");
+    });
+  });
+
+  _(["auth.getMobileSession", "auth.getSession", "auth.getToken",
+    "radio.getPlaylist"]).each(function(method) {
+    it(method + " is signed", function() {
+      whenMethodIs(method);
+      expectSignature();
+    });
+  });
+})();
diff --git a/tests/lastfm-recenttracks-stream-test.js b/tests/lastfm-recenttracks-stream-test.js
new file mode 100644
index 0000000..7cc41d5
--- /dev/null
+++ b/tests/lastfm-recenttracks-stream-test.js
@@ -0,0 +1,324 @@
+require("./common.js");
+
+var _ = require("underscore")
+  , RecentTracksStream = require("../lib/lastfm/recenttracks-stream")
+  , LastFmRequest = require("../lib/lastfm/lastfm-request")
+  , fakes = require("./fakes");
+
+(function() {
+  var gently, lastfm, trackStream;
+
+  describe("a new stream instance");
+
+  before(function() {
+    gently = new Gently();
+    lastfm = new LastFmNode();
+    trackStream = new RecentTracksStream(lastfm, "username");
+  });
+
+  it("accepts listeners", function() {
+    trackStream.addListener("event", function() {});
+  });
+
+  it("is not streaming", function() {
+    assert.ok(!trackStream.isStreaming());
+  });
+
+  it("event handlers can be specified in options", function() {
+    var handlers = {};
+   
+    gently.expect(handlers, "error");
+    gently.expect(handlers, "lastPlayed");
+    gently.expect(handlers, "nowPlaying");
+    gently.expect(handlers, "stoppedPlaying");
+    gently.expect(handlers, "scrobbled");
+
+    var trackStream = new RecentTracksStream(lastfm, "username", {
+      handlers: {
+        error: handlers.error,
+        lastPlayed: handlers.lastPlayed,
+        nowPlaying: handlers.nowPlaying,
+        stoppedPlaying: handlers.stoppedPlaying,
+        scrobbled: handlers.scrobbled
+      }
+    });
+
+    trackStream.emit("error");
+    trackStream.emit("lastPlayed");
+    trackStream.emit("nowPlaying");
+    trackStream.emit("stoppedPlaying");
+    trackStream.emit("scrobbled");
+  });
+})();
+
+(function() {
+  var requestEmits = [],
+      previousEmits = [];
+
+  function ifRequestHasPreviouslyEmit(emits) {
+    previousEmits = emits;
+  }
+
+  function whenRequestEmits(count, event, object) {
+    if (typeof count !== "number") {
+      object = event;
+      event = count;
+      count = 1;
+    }
+    if (typeof event !== "string") {
+      object = event;
+      event = "success";
+    }
+    requestEmits = [event, object, count];
+  }
+
+  function expectStreamToEmit(count, expectation) {
+    if (typeof count === "function") {
+      expectation = count;
+      count = 1;
+    }
+    var lastfm = new LastFmNode(),
+        connection = new fakes.Client(80, lastfm.host),
+        request = new fakes.LastFmRequest(),
+        gently = new Gently();
+
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+
+    var trackStream = new RecentTracksStream(lastfm, "username");
+    trackStream.start();
+    trackStream.stop();
+    for(var index = 0; index < previousEmits.length; index++) {
+      request.emit("success", previousEmits[index]);
+    }
+    gently.expect(trackStream, "emit", count, expectation);
+    for(var times = 0; times < requestEmits[2]; times++) {
+      request.emit(requestEmits[0], requestEmits[1]);
+    }
+  }
+
+  describe("An active stream");
+
+  before(function() {
+    previousEmits = [];
+    requestEmits = [];
+  });
+
+  it("bubbles errors", function() {
+    whenRequestEmits("error", { error: 1, message: "An error" });
+    expectStreamToEmit(function(event, error) {
+      assert.equal("error", event);
+      assert.equal("An error", error.message);
+    });
+  });
+
+  it("emits last played when track received", function() {
+    whenRequestEmits({ recenttracks: { track:
+      FakeTracks.LambAndTheLion
+    } });
+    expectStreamToEmit(function(event, track) {
+      assert.equal("lastPlayed", event);
+      assert.equal("Lamb and the Lion", track.name);
+    });
+  });
+
+  it("emits now playing if track flagged now playing", function() {
+    whenRequestEmits({
+      recenttracks: { track: FakeTracks.RunToYourGrave_NP }
+    });
+    expectStreamToEmit(function(event, track) {
+      assert.equal("nowPlaying", event);
+      assert.equal("Run To Your Grave", track.name);
+    });
+  });
+
+  it("emits now playing and last played if both received", function() {
+    var count = 0;
+    whenRequestEmits({
+      recenttracks: { track: FakeTracks.NowPlayingAndScrobbled }
+    });
+    expectStreamToEmit(2, function(event, track) {
+      if (count == 0) {
+          assert.equal("nowPlaying", event);
+          assert.equal("Theme Song", track.name);
+      }
+      else {
+          assert.equal("lastPlayed", event);
+          assert.equal("Over The Moon", track.name);
+      }
+      count++;
+    });
+  });
+
+  it("does not re-emit lastPlayed on receipt of same track", function() {
+    whenRequestEmits(2, {
+      recenttracks: { track: FakeTracks.LambAndTheLion }
+    });
+    expectStreamToEmit(1, function(event, track) {
+      assert.equal("lastPlayed", event);
+      assert.equal("Lamb and the Lion", track.name);
+    });
+  });
+
+  it("does not re-emit nowPlaying on receipt of same track", function() {
+    whenRequestEmits(2, {
+      recenttracks: { track: FakeTracks.RunToYourGrave_NP }
+    });
+    expectStreamToEmit(1, function(event, track) {
+      assert.equal("nowPlaying", event);
+      assert.equal("Run To Your Grave", track.name);
+    });
+  });
+
+  it("emits stoppedPlaying track when now playing stops", function() {
+    ifRequestHasPreviouslyEmit([
+      { recenttracks: { track: FakeTracks.RunToYourGrave } },
+      { recenttracks: { track: FakeTracks.RunToYourGrave_NP } }
+    ]);
+    whenRequestEmits({
+      recenttracks: { track: FakeTracks.RunToYourGrave }
+    });
+    expectStreamToEmit(function(event, track) {
+      assert.equal("stoppedPlaying", event);
+      assert.equal("Run To Your Grave", track.name);
+    });
+  });
+
+  it("emits scrobbled when last play changes", function() {
+    ifRequestHasPreviouslyEmit([
+      { recenttracks: { track: FakeTracks.LambAndTheLion } },
+      { recenttracks: { track: FakeTracks.RunToYourGrave_NP } }
+    ]);
+    whenRequestEmits({
+      recenttracks: { track: FakeTracks.RunToYourGrave }
+    });
+    expectStreamToEmit(function(event, track) {
+      assert.equal("scrobbled", event);
+      assert.equal("Run To Your Grave", track.name);
+    });
+  });
+  
+  it("emits nowPlaying when track same as lastPlayed", function() {
+    ifRequestHasPreviouslyEmit([
+      { recenttracks: { track: FakeTracks.RunToYourGrave } }
+    ]);
+    whenRequestEmits({
+      recenttracks: { track: FakeTracks.RunToYourGrave_NP }
+    });
+    expectStreamToEmit(function(event, track) {
+      assert.equal("nowPlaying", event);
+      assert.equal("Run To Your Grave", track.name);
+    });
+  });
+
+  it("emits error when unexpected item is received", function() {
+    whenRequestEmits({
+      something: "we've never seen before"
+    });
+    expectStreamToEmit(function(event, error) {
+      assert.equal("error", event);
+      assert.equal("Unexpected response", error.message);
+    });
+  });
+})();
+
+(function() {
+  var lastfm, gently, request;
+
+  describe("Streaming")
+
+  before(function() { 
+    lastfm = new LastFmNode();
+    gently = new Gently();
+    request = new fakes.LastFmRequest();
+  });
+
+  it("starts and stops streaming when requested", function() {
+    gently.expect(lastfm, "request", 1, function(method, params) {
+      return request;
+    });
+    var trackStream = new RecentTracksStream(lastfm);
+    trackStream.start();
+    trackStream.stop();
+    assert.ok(!trackStream.isStreaming());
+  });
+
+  it("starts automatically when autostart set to true", function() {
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+    var trackStream = new RecentTracksStream(lastfm, "username", { autostart: true} );
+    assert.ok(trackStream.isStreaming());
+    trackStream.stop();
+  });
+
+  it("calls user.getrecenttracks method for user", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal("user.getrecenttracks", method);
+      assert.equal("username", params.user);
+      return request;
+    });
+    var trackStream = new RecentTracksStream(lastfm, "username", { autostart: true} );
+    trackStream.stop();
+  });
+
+  it("only fetches most recent track", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal(1, params.limit);
+      return request;
+    });
+    var trackStream = new RecentTracksStream(lastfm, "username", { autostart: true} );
+    trackStream.stop();
+  });
+
+  it("bubbles up errors", function() {
+    var errorMessage = "Bubbled error";
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+    var trackStream = new RecentTracksStream(lastfm, "username", { autostart:true });
+    gently.expect(trackStream, "emit", function(event, error) {
+      assert.equal(errorMessage, error.message);
+    });
+    request.emit("error", new Error(errorMessage));
+    trackStream.stop();
+  });
+})();
+
+(function() {
+  var lastfm, gently;
+
+  describe("Streaming")
+
+  var tmpScheduleFn;
+  before(function() { 
+    tmpScheduleFn = RecentTracksStream.prototype.scheduleCallback;
+    lastfm = new LastFmNode();
+    gently = new Gently();
+  });
+
+  after(function() {
+    RecentTracksStream.prototype.scheduleCallback = tmpScheduleFn;
+  });
+
+  it("queries API every 10 seconds", function() {
+    var trackStream = new RecentTracksStream(lastfm, "username");
+    var count = 0;
+    RecentTracksStream.prototype.scheduleCallback = function(callback, delay) {
+      count++;
+      if (count === 10) {
+        trackStream.stop();
+      }
+      assert.ok(delay, 10000);
+      gently.expect(lastfm, "request", function(method, params) {
+        return new fakes.LastFmRequest();
+      });
+      callback();
+    };
+    gently.expect(lastfm, "request", function(method, params) {
+      return new fakes.LastFmRequest();
+    });
+    trackStream.start();
+  });
+})();
diff --git a/tests/lastfm-request-test.js b/tests/lastfm-request-test.js
new file mode 100644
index 0000000..749ffd8
--- /dev/null
+++ b/tests/lastfm-request-test.js
@@ -0,0 +1,186 @@
+require("./common");
+var _ = require("underscore"),
+    LastFmRequest = require("../lib/lastfm/lastfm-request"),
+    fakes = require("./fakes");
+
+(function() {
+  describe("a LastFm request")
+
+  var lastfm, connection, url, gently, request;
+
+  before(function() {
+    lastfm = new LastFmNode();
+    connection = new fakes.Client(80, lastfm.host);
+    request = new fakes.ClientRequest();
+    gently = new Gently();
+  });
+
+  it("creates a get request", function() {
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+      assert.equal("GET", options.method);
+      assert.equal(lastfm.host, options.host);
+      return request;
+    });
+    var lastfmRequest = new LastFmRequest(lastfm, "any.method");
+  });
+
+  it("ends the request", function() {
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function() {
+      return request;
+    });
+    gently.expect(request, "end");
+    var lastfmRequest = new LastFmRequest(lastfm);
+  });
+
+  it("defaults user agent to lastfm-node", function() {
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+      assert.equal("lastfm-node", options.headers["User-Agent"]);
+      return request;
+    });
+    var lastFmRequest = new LastFmRequest(lastfm, "any.method");
+  });
+
+  it("can specify user agent in lastfm options", function() {
+    var useragent = "custom-user-agent";
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+      assert.equal(useragent, options.headers["User-Agent"]);
+      return request;
+    });
+    var lastfm = new LastFmNode({ useragent: useragent });
+    var lastFmRequest = new LastFmRequest(lastfm, "any.method");
+  });
+
+  it("bubbles up connection errors", function() {
+    var message = "Bubbled error";
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+        return request;
+    });
+    var lastfmRequest = new LastFmRequest(lastfm, "any.method");
+    gently.expect(lastfmRequest, "emit", function(event, error) {
+      assert.equal("error", event);
+      assert.equal(message, error.message);
+    });
+    request.emit("error", new Error(message));
+  });
+})();
+
+(function() {
+  describe("a LastFm request with a body")
+
+  var lastfm, connection, url, gently, request, params;
+
+  before(function() {
+    lastfm = new LastFmNode();
+    connection = new fakes.Client(80, lastfm.host);
+    request = new fakes.ClientRequest();
+    gently = new Gently();
+    params = { foo:"bar" };
+  });
+
+  it("write parameter forces a post request", function() {
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+      assert.equal("POST", options.method);
+      assert.equal(lastfm.url, options.path);
+      assert.equal(lastfm.host, options.host);
+      return request;
+    });
+    params.write = true;
+    var lastFmRequest = new LastFmRequest(lastfm, "any.method", params);
+  });
+
+  it("post requests includes additional headers", function() {
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+      assert.ok(options.headers["Content-Length"]);
+      assert.equal("application/x-www-form-urlencoded", options.headers["Content-Type"]);
+      return request;
+    });
+    params.write = true;
+    var lastFmRequest = new LastFmRequest(lastfm, "any.method", params);
+  });
+
+  it("writes body to request", function() {
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function() {
+        return request;
+    });
+    gently.expect(request, "write", function(data) {
+        assert.ok(data);
+    });
+    params.write = true;
+    var lastFmRequest = new LastFmRequest(lastfm, "any.method", params);
+  });
+})();
+
+(function() {
+  var lastfm, connection, url, gently, request, receivedData;
+
+  describe("A Lastfm request which returns data")
+
+  before(function() {
+    lastfm = new LastFmNode();
+    connection = new fakes.Client(80, lastfm.host);
+    request = new fakes.ClientRequest();
+    gently = new Gently();
+  });
+
+  it("emits data as json", function() {
+    whenReceiving("{\"testdata\":\"received\"}");
+    expectRequestToEmit(function(event, data) {
+      assert.equal("success", event);
+      assert.equal("received", data.testdata);
+    });
+  });
+
+  it("emits error if received data cannot be parsed to json", function() {
+    whenReceiving("{\"testdata\"");
+    expectRequestToEmit(function(event, error) {
+      assert.equal("error", event);
+      assert.ok(error);
+    });
+  });
+
+  it("emits error if json response contains a lastfm error", function() {
+    whenReceiving("{\"error\": 2, \"message\": \"service does not exist\"}");
+    expectRequestToEmit(function(event, error) {
+      assert.equal("error", event);
+      assert.equal("service does not exist", error.message);
+    });
+  });
+
+  it("accepts data in chunks", function() {
+    whenReceiving(["{\"testda", "ta\":\"recei", "ved\"}"]);
+    expectRequestToEmit(function(event, data) {
+      assert.equal("success", event);
+      assert.equal("received", data.testdata);
+    });
+  });
+
+  it("does not covert to json if requested is different format", function() {
+    var xml = "<somexml />";
+    lastfm.format = "xml";
+    whenReceiving(xml);
+    expectRequestToEmit(function(event, data) {
+      assert.equal(xml, data);
+    });
+  });
+
+  function whenReceiving(data) {
+      if (data.constructor.name !== 'Array') {
+          data = [data];
+      }
+      receivedData = data;
+  }
+
+  function expectRequestToEmit(expectation) {
+    var response = new fakes.ClientResponse();
+    gently.expect(GENTLY_HIJACK.hijacked.http, "request", function(options, cb) {
+      cb(response);
+      return request;
+    });
+    var lastfmRequest = new LastFmRequest(lastfm);
+    gently.expect(lastfmRequest, "emit", expectation);
+    _(receivedData).each(function(data) {
+      response.emit("data", data);
+    });
+    response.emit("end");
+  }
+})();
diff --git a/tests/lastfm-session-deprecatedflow-test.js b/tests/lastfm-session-deprecatedflow-test.js
new file mode 100644
index 0000000..79bd2e4
--- /dev/null
+++ b/tests/lastfm-session-deprecatedflow-test.js
@@ -0,0 +1,254 @@
+require('./common.js');
+var LastFmSession = require('../lib/lastfm/lastfm-session');
+var fakes = require("./fakes");
+
+
+(function() {
+  describe("a new LastFmSession");
+  var session;
+
+  before(function() {
+     session = new LastFmSession(new LastFmNode());
+  });
+
+  it("has no session key", function() {
+    assert.ok(!session.key);
+  });
+
+  it("has no user", function() {
+    assert.ok(!session.user);
+  });
+
+  it("is not authorised", function() {
+    assert.ok(!session.isAuthorised());
+  });
+
+  it("can configure key and user", function() {
+    var session = new LastFmSession(new LastFmNode(), "user", "sessionkey");
+    assert.equal("user", session.user);
+    assert.equal("sessionkey", session.key);
+  });
+
+  it("is authorised when it has a key", function() {
+    var session = new LastFmSession(new LastFmNode(), "user", "sessionkey");
+    assert.ok(session.isAuthorised());
+  });
+})();
+
+(function() {
+  var readError, token, returndata, options, request, lastfm, session, gently;
+
+  function setupFixture() {
+    readError = "";
+    token = "";
+    returndata = null;
+    options = null;
+    request = new fakes.LastFmRequest();
+    lastfm = new LastFmNode();
+    session = new LastFmSession(lastfm);
+    gently = new Gently();
+    LastFmSession.prototype.scheduleCallback = emptyFn;
+  }
+
+  function expectError(message) {
+    gently.expect(session, "emit", function(event, error) {
+      assert.equal("error", event);
+      assert.equal(message, error.message);
+    });
+    doRequest();
+  }
+
+  function doRequest() {
+    session.authorise(token, options);
+    if (readError) {
+      request.emit("error", readError);
+    }
+    else {
+      request.emit("success", returndata);
+    }
+  }
+
+  function doNotExpectError() {
+    doRequest();
+  }
+
+  function expectRetry(retry) {
+    gently.expect(session, "emit", function(event, details) {
+      assert.equal("retrying", event);
+      if (retry) {
+        assert.deepEqual(details, retry);
+      }
+    });
+    doRequest();
+  }
+
+  function expectAuthorisation(assertions) {
+    gently.expect(session, "emit", function(event, emittedSession) {
+      assert.equal("authorised", event);
+      if (assertions) {
+        assertions(emittedSession);
+      }
+    });
+    session.authorise(token, options);
+    request.emit("success", returndata);
+  }
+
+  function whenReadRequestReturns(data) {
+    returndata = JSON.parse(data);
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+  }
+
+  function whenReadRequestThrowsError(code, message) {
+    readError = {error: code, message: message };
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+  }
+  
+  function andTokenIs(setToken) {
+    token = setToken;
+  }
+
+  function andOptionsAre(setOptions) {
+    options = setOptions;
+  }
+
+  describe("a LastFmSession authorisation request")
+  before(function() {
+     setupFixture();
+  });
+  
+  it("emits error when no token supplied", function() {
+    expectError("No token supplied");
+  });
+  
+  it("contains supplied token", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal("token", params.token);
+      return request;
+    });
+    session.authorise("token");
+  });
+  
+  it("uses getSession method", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal("auth.getsession", method);
+      return request;
+    });
+    session.authorise("token");
+  });
+  
+  describe("a completed LastFmSession authorisation request")
+  before(function() {
+     setupFixture();
+  });
+  
+  it("emits error when receiving unexpected return data", function() {
+    whenReadRequestReturns(FakeData.SingleRecentTrack);
+    andTokenIs("token");
+    expectError("Unexpected error");
+  });
+  
+  it("emits authorised when successful", function() {
+    whenReadRequestReturns(FakeData.SuccessfulAuthorisation);
+    andTokenIs("token");
+    expectAuthorisation();
+  });
+  
+  it("can have error handler specified with authorise call", function() {
+    var handler = { error: function(error) { } };
+    gently.expect(handler, "error", function(error) {
+      assert.equal("No token supplied", error.message); 
+    });
+    session.authorise("", { handlers: {
+      error: handler.error
+    }});
+  });
+  
+  it("updates session key and user when successful", function() {
+    whenReadRequestReturns(FakeData.SuccessfulAuthorisation);
+    andTokenIs("token");
+    expectAuthorisation(function(session) {
+      assert.equal("username", session.user);
+      assert.equal("sessionkey", session.key);
+      assert.ok(session.isAuthorised());
+    });
+  });
+
+  it("can have authorised handler specified with authorise call", function() {
+    var handler = { authorised: function(session) { } };
+    whenReadRequestReturns(FakeData.SuccessfulAuthorisation);
+    gently.expect(handler, "authorised");
+    session.authorise("token", { handlers: {
+      authorised: handler.authorised
+    }});
+    request.emit("success", returndata);
+  });
+
+  it("bubbles up errors", function() {
+    var errorMessage = "Bubbled error";
+    whenReadRequestThrowsError('any', errorMessage);
+    andTokenIs("token");
+    expectError(errorMessage);
+  });
+
+  it("does not bubble error when not yet authorised", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    doNotExpectError();
+  });
+
+  it("emits a retry event when not yet authorised", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    expectRetry({
+        error: 14,
+        message: "This token has not been authorised",
+        delay: 10000
+    });
+  });
+
+  it("schedules a another request 10 seconds later when retrying", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    LastFmSession.prototype.scheduleCallback = gently.expect(function(callback, delay) {
+      assert.equal(delay, 10000);
+    });
+    doRequest();
+  });
+
+  it("will retry on temporarily unavailable", function() {
+    whenReadRequestThrowsError(16, "There was a temporary error processing your request. Please try again.");
+    andTokenIs("token");
+    expectRetry();
+  });
+
+  it("will retry on service unavailable", function() {
+    whenReadRequestThrowsError(11, "Service temporarily unavailable.");
+    andTokenIs("token");
+    expectRetry();
+  });
+
+  it("can have the retry interval specified", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    andOptionsAre({ retryInterval: 15000 });
+    LastFmSession.prototype.scheduleCallback = gently.expect(function(callback, delay) {
+      assert.equal(delay, 15000);
+    });
+    doRequest();
+  });
+
+  it("user defined retry interval in retry event", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    andOptionsAre({ retryInterval: 15000 });
+    expectRetry({
+        error: 14,
+        message: "This token has not been authorised",
+        delay: 15000
+    });
+  });
+})();
diff --git a/tests/lastfm-session-test.js b/tests/lastfm-session-test.js
new file mode 100644
index 0000000..f8d89ee
--- /dev/null
+++ b/tests/lastfm-session-test.js
@@ -0,0 +1,277 @@
+require('./common.js');
+var LastFmSession = require('../lib/lastfm/lastfm-session');
+var fakes = require("./fakes");
+
+
+(function() {
+  describe("a new LastFmSession");
+  var session;
+
+  before(function() {
+     session = new LastFmSession(new LastFmNode());
+  });
+
+  it("has no session key", function() {
+    assert.ok(!session.key);
+  });
+
+  it("has no user", function() {
+    assert.ok(!session.user);
+  });
+
+  it("is not authorised", function() {
+    assert.ok(session.isAuthorised() === false);
+  });
+
+  it("can configure key and user", function() {
+    var session = new LastFmSession(new LastFmNode(), { user: "user", key: "sessionkey" });
+    assert.equal("user", session.user);
+    assert.equal("sessionkey", session.key);
+  });
+
+  it("is authorised when it has a key", function() {
+    var session = new LastFmSession(new LastFmNode(), { user: "user", key: "sessionkey" });
+    assert.ok(session.isAuthorised());
+  });
+})();
+
+(function() {
+  var readError, token, returndata, options, request, lastfm, session, gently;
+
+  function setupFixture() {
+    readError = "";
+    token = "";
+    returndata = null;
+    options = null;
+    request = new fakes.LastFmRequest();
+    lastfm = new LastFmNode();
+    gently = new Gently();
+    LastFmSession.prototype.scheduleCallback = emptyFn;
+  }
+
+  function expectError(message) {
+    gently.expect(session, "emit", function(event, error) {
+      assert.equal("error", event);
+      assert.equal(message, error.message);
+    });
+    doRequest();
+  }
+
+  function doRequest() {
+    if (readError) {
+      request.emit("error", readError);
+    }
+    else {
+      request.emit("success", returndata);
+    }
+  }
+
+  function doNotExpectError() {
+    doRequest();
+  }
+
+  function expectRetry(retry) {
+    gently.expect(session, "emit", function(event, details) {
+      assert.equal("retrying", event);
+      if (retry) {
+        assert.deepEqual(details, retry);
+      }
+    });
+    doRequest();
+  }
+
+  function doNotExpectRetry() {
+    session.on('retrying', function() {
+      assert.ok(false);
+    });
+    doRequest();
+  }
+
+  function expectAuthorisation(assertions) {
+    expectSuccess(assertions, "authorised");
+  }
+
+  function expectSuccess(assertions, expectedEvent) {
+    expectedEvent = expectedEvent || "success";
+    gently.expect(session, "emit", function(event, emittedSession) {
+      if (expectedEvent !== event) {
+        return;
+      }
+      assert.equal(expectedEvent, event);
+      if (assertions) {
+        assertions(emittedSession);
+      }
+    });
+    request.emit("success", returndata);
+  }
+
+  function whenReadRequestReturns(data) {
+    returndata = JSON.parse(data);
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+  }
+
+  function whenReadRequestThrowsError(code, message) {
+    readError = {error: code, message: message };
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+  }
+  
+  function andTokenIs(setToken) {
+    session = new LastFmSession(lastfm, { token: setToken });
+  }
+
+  function andOptionsAre(setOptions) {
+    session = new LastFmSession(lastfm, setOptions);
+  }
+
+  describe("a LastFmSession authorisation request")
+  before(function() {
+     setupFixture();
+  });
+  
+  it("happens when a token is provided in options", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+        assert.ok(true);
+        return request;
+    });
+    session = new LastFmSession(lastfm, { token: 'token' });
+  });
+  
+  it("contains supplied token", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal("token", params.token);
+      return request;
+    });
+    session = new LastFmSession(lastfm, { token: 'token' });
+  });
+  
+  it("uses getSession method", function() {
+    gently.expect(lastfm, "request", function(method, params) {
+      assert.equal("auth.getsession", method);
+      return request;
+    });
+    session = new LastFmSession(lastfm, { token: 'token' });
+  });
+  
+  describe("a completed LastFmSession authorisation request")
+  before(function() {
+     setupFixture();
+  });
+  
+  it("emits error when receiving unexpected return data", function() {
+    whenReadRequestReturns(FakeData.SingleRecentTrack);
+    andTokenIs("token");
+    expectError("Unexpected error");
+  });
+  
+  it("emits authorised when successful", function() {
+    whenReadRequestReturns(FakeData.SuccessfulAuthorisation);
+    andTokenIs("token");
+    expectAuthorisation();
+  });
+  
+  it("emits success when authorisation successful", function() {
+    whenReadRequestReturns(FakeData.SuccessfulAuthorisation);
+    andTokenIs("token");
+    expectSuccess();
+  });
+  
+  it("can have error handler specified in options", function() {
+    whenReadRequestThrowsError('any', 'error');
+    session = new LastFmSession(lastfm, { token: 'token', handlers: {
+      error: gently.expect(function error() { })
+    }});
+    doRequest();
+  });
+  
+  it("updates session key and user when successful", function() {
+    whenReadRequestReturns(FakeData.SuccessfulAuthorisation);
+    andTokenIs("token");
+    expectAuthorisation(function(session) {
+      assert.equal("username", session.user);
+      assert.equal("sessionkey", session.key);
+      assert.ok(session.isAuthorised());
+    });
+  });
+
+  it("can have authorised handler specified with authorise call", function() {
+    whenReadRequestReturns(FakeData.SuccessfulAuthorisation);
+    session = new LastFmSession(lastfm, { token: 'token', handlers: {
+      authorised: gently.expect(function error() { })
+    }});
+    request.emit("success", returndata);
+  });
+
+  it("bubbles up errors", function() {
+    var errorMessage = "Bubbled error";
+    whenReadRequestThrowsError('any', errorMessage);
+    andTokenIs("token");
+    expectError(errorMessage);
+  });
+
+  it("does not bubble error when not yet authorised", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    doNotExpectError();
+  });
+
+  it("emits a retry event when not yet authorised", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    expectRetry({
+        error: 14,
+        message: "This token has not been authorised",
+        delay: 10000
+    });
+  });
+
+  it("schedules a another request 10 seconds later when retrying", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    LastFmSession.prototype.scheduleCallback = gently.expect(function(callback, delay) {
+      assert.equal(delay, 10000);
+    });
+    doRequest();
+  });
+
+  it("will retry on temporarily unavailable", function() {
+    whenReadRequestThrowsError(16, "There was a temporary error processing your request. Please try again.");
+    andTokenIs("token");
+    expectRetry();
+  });
+
+  it("will retry on service unavailable", function() {
+    whenReadRequestThrowsError(11, "Service temporarily unavailable.");
+    andTokenIs("token");
+    expectRetry();
+  });
+
+  it("can have the retry interval specified", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andOptionsAre({ token: 'token', retryInterval: 15000 });
+    LastFmSession.prototype.scheduleCallback = gently.expect(function(callback, delay) {
+      assert.equal(delay, 15000);
+    });
+    doRequest();
+  });
+
+  it("user defined retry interval in retry event", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andOptionsAre({ token: 'token', retryInterval: 15000 });
+    expectRetry({
+        error: 14,
+        message: "This token has not been authorised",
+        delay: 15000
+    });
+  });
+
+  it("can cancel retries", function() {
+    whenReadRequestThrowsError(14, "This token has not been authorised");
+    andTokenIs("token");
+    session.cancel();
+    doNotExpectRetry();
+  });
+})();
diff --git a/tests/lastfm-update-test.js b/tests/lastfm-update-test.js
new file mode 100644
index 0000000..ba59f64
--- /dev/null
+++ b/tests/lastfm-update-test.js
@@ -0,0 +1,485 @@
+require('./common.js');
+var LastFmSession = require('../lib/lastfm/lastfm-session');
+var LastFmUpdate = require('../lib/lastfm/lastfm-update');
+var fakes = require("./fakes");
+
+(function() {
+  describe("new LastFmUpdate")
+    it("can have success and error handlers specified in option at creation", function() {
+      var gently = new Gently();
+      var lastfm = new LastFmNode();
+      var update = new LastFmUpdate(lastfm, "method", new LastFmSession(lastfm, "user", "key"), { handlers: {
+          error: gently.expect(function error() {}),
+          success: gently.expect(function success() {})
+      }});
+      update.emit("error");
+      update.emit("success");
+    });
+})();
+
+(function() {
+  var request, returndata, options, session, method, gently, lastfm, authorisedSession, errorCode, errorMessage, update;
+
+  function setupFixture() {
+    request = new fakes.LastFmRequest();
+    returndata;
+    options = {};
+    session = null;
+    method = "";
+    gently = new Gently();
+    lastfm = new LastFmNode();
+    authorisedSession = new LastFmSession(lastfm, "user", "key");
+    errorCode = -1;
+    errorMessage = null;
+    update = undefined;
+  }
+
+  function whenRequestReturns(data) {
+    errorCode = -1;
+    errorMessage = null;
+    returndata = JSON.parse(data);
+    request = new fakes.LastFmRequest();
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+  }
+
+  function whenRequestThrowsError(code, message) {
+    errorCode = code;
+    errorMessage = message;
+    request = new fakes.LastFmRequest();
+    gently.expect(lastfm, "request", function() {
+      return request;
+    });
+  }
+
+  function andOptionsAre(setOptions) {
+    options = setOptions;
+  }
+
+  function andMethodIs(setMethod) {
+    method = setMethod;
+  }
+
+  function andSessionIs(setSession) {
+    session = setSession;
+  }
+
+  function expectSuccess(assertions) {
+    var checkSuccess = function(track) {
+      if (assertions) {
+        assertions(track);
+      }
+    };
+    if (update) {
+      update.on("success", checkSuccess);
+    }
+    else {
+      options.handlers = options.handlers || {};
+      options.handlers.success = checkSuccess;
+    }
+    doUpdate();
+  }
+
+  function expectError(errorCode, expectedError) {
+    var checkError = function(error) {
+      if (errorCode || expectedError) {
+        assert.equal(expectedError, error.message);
+        assert.equal(errorCode, error.error);
+      }
+    };
+    if (update) {
+      update.on("error", checkError);
+    }
+    else {
+      options.handlers = options.handlers || {};
+      options.handlers.error = gently.expect(checkError);
+    }
+    doUpdate();
+  }
+
+  function doNotExpectError() {
+    options.handlers = options.handlers || {};
+    options.handlers.error = function checkNoErrorThrown(error) {
+      assert.ok(false);
+    };
+    doUpdate();
+  }
+
+  function expectRetry(callback) {
+    callback = callback || function() { };
+    if (update) {
+      gently.expect(update, "emit", function(event, retry) {
+        assert.equal(event, "retrying");
+        callback(retry);
+      });
+    }
+    else {
+      options.handlers = options.handlers || { };
+      options.handlers.retrying = gently.expect(callback);
+    }
+    doUpdate();
+  }
+
+  function doUpdate() {
+    update = update || new LastFmUpdate(lastfm, method, session, options);
+    if (errorMessage) {
+      request.emit("error", { error: errorCode, message: errorMessage });
+    }
+    else {
+      request.emit("success", returndata);
+    }
+  }
+
+  describe("update requests")
+    before(function() {
+      setupFixture();
+    });
+  
+    it("fail when the session is not authorised", function() {
+      var session = new LastFmSession()
+        , update = new LastFmUpdate(lastfm, "method", session, {
+            handlers: {
+              error: gently.expect(function(error) {
+                assert.equal(error.error, 4);
+                assert.equal(error.message, "Authentication failed");
+              })
+            }
+          });
+    });
+  
+  describe("nowPlaying updates")
+    before(function() {
+      setupFixture();
+    });
+  
+    it("uses updateNowPlaying method", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal("track.updateNowPlaying", method);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "nowplaying", authorisedSession, {
+        track: FakeTracks.RunToYourGrave
+      });
+    });
+    
+    it("sends required parameters", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal(FakeTracks.RunToYourGrave, params.track);
+        assert.equal("key", params.sk);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "nowplaying", authorisedSession, {
+        track: FakeTracks.RunToYourGrave
+      });
+    });
+  
+    it("emits success when updated", function() {
+      whenRequestReturns(FakeData.UpdateNowPlayingSuccess);
+      andMethodIs("nowplaying");
+      andSessionIs(authorisedSession);
+      andOptionsAre({
+        track: FakeTracks.RunToYourGrave
+      });
+      expectSuccess(function(track) {
+        assert.equal("Run To Your Grave", track.name);
+      });
+    });
+  
+    it("sends duration when supplied", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal(232000, params.duration);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "nowplaying", authorisedSession, {
+        track: FakeTracks.RunToYourGrave,
+        duration: 232000
+      });
+    });
+
+    it("can have artist and track string parameters supplied", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal("The Mae Shi", params.artist);
+        assert.equal("Run To Your Grave", params.track);
+        assert.equal("key", params.sk);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "nowplaying", authorisedSession, {
+        track: "Run To Your Grave",
+        artist: "The Mae Shi"
+      });
+    });
+  
+    it("bubbles up errors", function() {
+      var errorMessage = "Bubbled error";
+      whenRequestThrowsError(100, errorMessage);
+      andMethodIs("nowplaying");
+      andSessionIs(authorisedSession);
+      andOptionsAre({
+        track: FakeTracks.RunToYourGrave,
+        timestamp: 12345678
+      });
+      expectError(100, errorMessage);
+    });
+  
+  describe("a scrobble request")
+    before(function() {
+      setupFixture();
+    });
+  
+    it("emits error when no timestamp supplied", function() {
+      new LastFmUpdate(lastfm, "scrobble", authorisedSession, {
+        track: FakeTracks.RunToYourGrave,
+        handlers: {
+          error: gently.expect(function error(error) {
+            assert.equal(6, error.error);
+            assert.equal("Invalid parameters - Timestamp is required for scrobbling", error.message);
+          })
+        }
+      });
+    });
+    
+    it("uses scrobble method", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal("track.scrobble", method);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "scrobble", authorisedSession, {
+        track: FakeTracks.RunToYourGrave,
+        timestamp: 12345678
+      });
+    });
+  
+    it("sends required parameters", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal(FakeTracks.RunToYourGrave, params.track);
+        assert.equal("key", params.sk);
+        assert.equal(12345678, params.timestamp);
+        return request;
+      });
+  
+      new LastFmUpdate(lastfm, "scrobble", authorisedSession, {
+        track: FakeTracks.RunToYourGrave,
+        timestamp: 12345678
+      });
+    });
+  
+    it("emits success when updated", function() {
+      whenRequestReturns(FakeData.ScrobbleSuccess);
+      andMethodIs("scrobble");
+      andSessionIs(authorisedSession);
+      andOptionsAre({
+        track: FakeTracks.RunToYourGrave,
+        timestamp: 12345678
+      });
+      expectSuccess(function(track) {
+        assert.equal("Run To Your Grave", track.name);
+      });
+    });
+  
+    it("bubbles up errors", function() {
+      var errorMessage = "Bubbled error";
+      whenRequestThrowsError(100, errorMessage);
+      andMethodIs("scrobble");
+      andSessionIs(authorisedSession);
+      andOptionsAre({
+        track: FakeTracks.RunToYourGrave,
+        timestamp: 12345678
+      });
+      expectError(100, errorMessage);
+    });
+
+    it("can have artist and track string parameters supplied", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal("The Mae Shi", params.artist);
+        assert.equal("Run To Your Grave", params.track);
+        assert.equal("key", params.sk);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "scrobble", authorisedSession, {
+        track: "Run To Your Grave",
+        artist: "The Mae Shi",
+        timestamp: 12345678
+      });
+    });
+
+    it("can have arbitrary parameters supplied", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal("somevalue", params.arbitrary);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "scrobble", authorisedSession, {
+        track: "Run To Your Grave",
+        artist: "The Mae Shi",
+        timestamp: 12345678,
+        arbitrary: "somevalue"
+      });
+    });
+
+    it("does not include handler parameters", function() {
+      gently.expect(lastfm, "request", function(method, params) {
+        assert.equal(undefined, params.handlers);
+        assert.equal(undefined, params.error);
+        assert.equal(undefined, params.success);
+        return request;
+      });
+      new LastFmUpdate(lastfm, "scrobble", authorisedSession, {
+        track: "Run To Your Grave",
+        artist: "The Mae Shi",
+        timestamp: 12345678,
+        handlers: { success: function() { } },
+        success: function() { },
+        error: function() { }
+      });
+    });
+
+  var tmpFn;
+  describe("update retries")
+    before(function() {
+      tmpFn = LastFmUpdate.prototype.scheduleCallback;
+      LastFmUpdate.prototype.scheduleCallback = function(callback, delay) { };
+      setupFixture();
+      andMethodIs("scrobble");
+      andSessionIs(authorisedSession);
+      andOptionsAre({
+        track: FakeTracks.RunToYourGrave,
+        timestamp: 12345678
+      });
+    });
+  
+    after(function() {
+      LastFmUpdate.prototype.scheduleCallback = tmpFn;
+    });
+
+    it("a error which should trigger a retry does not bubble errors", function() {
+      whenRequestThrowsError(11, "Service Offline");
+      doNotExpectError();
+    });
+  
+    it("service offline triggers a retry", function() {
+      whenRequestThrowsError(11, "Service Offline");
+      expectRetry();
+    });
+  
+    it("rate limit exceeded triggers a retry", function() {
+      whenRequestThrowsError(29, "Rate limit exceeded");
+      expectRetry();
+    });
+  
+    it("temporarily unavailable triggers a retry", function() {
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      expectRetry();
+    });
+
+    it("nowplaying update never trigger retries", function() {
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      andMethodIs("nowplaying");
+      expectError();
+    });
+
+    it("first retry schedules a request after a 10 second delay", function() {
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      LastFmUpdate.prototype.scheduleCallback = gently.expect(function testSchedule(callback, delay) {
+          assert.equal(delay, 10000);
+      });
+      doUpdate();
+    });
+
+    function onNextRequests(callback, count) {
+      count = count || 1;
+      var gently = new Gently();
+      LastFmUpdate.prototype.scheduleCallback = gently.expect(count, callback);
+      doUpdate();
+    }
+
+    function lastRequest() {
+      LastFmUpdate.prototype.scheduleCallback = function() { };
+    }
+
+    function whenNextRequestThrowsError(request, code, message) {
+      whenRequestThrowsError(code, message);
+      request();
+    }
+
+    function whenNextRequestReturns(request, data) {
+      whenRequestReturns(data);
+      request();
+    }
+
+    it("retry triggers another request", function() {
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      onNextRequests(function(nextRequest) {
+        lastRequest();
+        whenNextRequestThrowsError(nextRequest, 16, "Temporarily unavailable");
+        expectRetry();
+      });
+    });
+
+    it("emits succes if retry is successful", function() {
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      onNextRequests(function(nextRequest) {
+        whenNextRequestReturns(nextRequest, FakeData.ScrobbleSuccess);
+        expectSuccess(function(track) {
+          assert.equal("Run To Your Grave", track.name);
+        });
+      });
+    });
+
+    it("emits succes if retry is non-retry error", function() {
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      onNextRequests(function(nextRequest) {
+        whenNextRequestThrowsError(nextRequest, 6, "Invalid parameter");
+        expectError(6, "Invalid parameter");
+      });
+    });
+
+    it("retrying events include error received and delay details", function() {
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      expectRetry(function(retry) {
+          assert.equal(retry.delay, 10000);
+          assert.equal(retry.error, 16);
+          assert.equal(retry.message, "Temporarily unavailable");
+      });
+    });
+
+    var retrySchedule = [
+      10 * 1000,
+      30 * 1000,
+      60 * 1000,
+      5 * 60 * 1000,
+      15 * 60 * 1000,
+      30 * 60 * 1000,
+      30 * 60 * 1000,
+      30 * 60 * 1000
+    ];
+
+    it("follows a retry schedule on subsequent failures", function() {
+      var count = 0;
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      onNextRequests(function(nextRequest, delay) {
+        var expectedDelay = retrySchedule[count++];
+        assert.equal(delay, expectedDelay);
+        if (count >= retrySchedule.length) {
+          lastRequest();
+        }
+        whenNextRequestThrowsError(nextRequest, 16, "Temporarily unavailable");
+        expectRetry();
+      }, retrySchedule.length);
+    });
+
+    it("includes delay in subsequent retry events", function() {
+      var count = 0;
+      whenRequestThrowsError(16, "Temporarily unavailable");
+      onNextRequests(function(nextRequest, delay) {
+        count++;
+        if (count >= retrySchedule.length) {
+          lastRequest();
+        }
+        var expectedDelay = retrySchedule[Math.min(count, retrySchedule.length - 1)];
+        whenNextRequestThrowsError(nextRequest, 16, "Temporarily unavailable");
+        expectRetry(function(retry) {
+          assert.equal(retry.delay, expectedDelay);
+        });
+      }, retrySchedule.length);
+    });
+})();
diff --git a/tests/tests.sh b/tests/tests.sh
new file mode 100755
index 0000000..e0827aa
--- /dev/null
+++ b/tests/tests.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+. ~/.nvm/nvm.sh
+nvm use v0.2.6
+node ./index.js
+nvm use v0.3.3
+node ./index.js
+nvm use v0.4.0
+node ./index.js
+nvm use v0.4.3
+node ./index.js
+nvm use v0.4.7
+node ./index.js
+nvm use v0.4.8
+node ./index.js
+nvm use v0.6.2
+node ./index.js

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/pkg-javascript/node-lastfm.git



More information about the Pkg-javascript-commits mailing list