summary refs log tree commit diff
path: root/syweb/webclient/test
diff options
context:
space:
mode:
Diffstat (limited to 'syweb/webclient/test')
-rw-r--r--syweb/webclient/test/README51
-rw-r--r--syweb/webclient/test/e2e/home.spec.js16
-rw-r--r--syweb/webclient/test/karma.conf.js107
-rw-r--r--syweb/webclient/test/protractor.conf.js18
-rw-r--r--syweb/webclient/test/unit/commands-service.spec.js143
-rw-r--r--syweb/webclient/test/unit/event-handler-service.spec.js117
-rw-r--r--syweb/webclient/test/unit/filters.spec.js635
-rw-r--r--syweb/webclient/test/unit/matrix-service.spec.js504
-rw-r--r--syweb/webclient/test/unit/model-service.spec.js30
-rw-r--r--syweb/webclient/test/unit/notification-service.spec.js78
-rw-r--r--syweb/webclient/test/unit/recents-service.spec.js153
-rw-r--r--syweb/webclient/test/unit/register-controller.spec.js84
-rw-r--r--syweb/webclient/test/unit/user-controller.spec.js57
13 files changed, 1993 insertions, 0 deletions
diff --git a/syweb/webclient/test/README b/syweb/webclient/test/README
new file mode 100644
index 0000000000..e7ed4eaa87
--- /dev/null
+++ b/syweb/webclient/test/README
@@ -0,0 +1,51 @@
+Testing is done using Karma.
+
+
+UNIT TESTING
+============
+
+Requires the following:
+ - npm/nodejs
+ - phantomjs
+
+Requires the following node packages:
+ - npm install jasmine
+ - npm install karma
+ - npm install karma-jasmine
+ - npm install karma-phantomjs-launcher
+ - npm install karma-junit-reporter
+
+Make sure you're in this directory so it can find the config file and run:
+  karma start
+
+You should see all the tests pass.
+
+
+E2E TESTING
+===========
+
+npm install protractor
+
+
+Setting up e2e tests (only if you don't have a selenium server to run the tests
+on. If you do, edit the config to point to that url):
+
+  webdriver-manager update
+  webdriver-manager start
+
+  Create a file "environment-protractor.js" in this directory and type:
+    module.exports = {
+        seleniumAddress: 'http://localhost:4444/wd/hub',
+        baseUrl: "http://localhost:8008",
+        username: "YOUR_TEST_USERNAME",
+        password: "YOUR_TEST_PASSWORD"
+    }
+
+Running e2e tests:
+  protractor protractor.conf.js
+
+NOTE: This will create a public room on the target home server.
+
+
+
+
diff --git a/syweb/webclient/test/e2e/home.spec.js b/syweb/webclient/test/e2e/home.spec.js
new file mode 100644
index 0000000000..470237d557
--- /dev/null
+++ b/syweb/webclient/test/e2e/home.spec.js
@@ -0,0 +1,16 @@
+var env = require("../environment-protractor.js");
+
+describe("home page", function() {
+
+    beforeEach(function() {
+        ptor = protractor.getInstance();
+        // FIXME we use longpoll on the event stream, and I can't get $interval
+        // playing nicely with it. Patches welcome to fix this.
+        ptor.ignoreSynchronization = true;
+    }); 
+
+    it("should have a title", function() {
+        browser.get(env.baseUrl);
+        expect(browser.getTitle()).toEqual("[matrix]");
+    });
+});
diff --git a/syweb/webclient/test/karma.conf.js b/syweb/webclient/test/karma.conf.js
new file mode 100644
index 0000000000..37a9eaf1c1
--- /dev/null
+++ b/syweb/webclient/test/karma.conf.js
@@ -0,0 +1,107 @@
+// Karma configuration
+// Generated on Thu Sep 18 2014 14:25:57 GMT+0100 (BST)
+
+module.exports = function(config) {
+  config.set({
+
+    // base path that will be used to resolve all patterns (eg. files, exclude)
+    basePath: '',
+
+
+    // frameworks to use
+    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+    frameworks: ['jasmine'],
+
+
+    // list of files / patterns to load in the browser
+    // XXX: Order is important, and doing /js/angular* makes the tests not run :/
+    files: [
+      '../js/jquery*',
+      '../js/angular.js',
+      '../js/angular-mocks.js',
+      '../js/angular-route.js',
+      '../js/angular-animate.js',
+      '../js/angular-sanitize.js',
+      '../js/jquery.peity.min.js',
+      '../js/angular-peity.js',
+      '../js/ng-infinite-scroll-matrix.js',
+      '../js/ui-bootstrap*',
+      '../js/elastic.js',  
+      '../login/**/*.js',
+      '../room/**/*.js',
+      '../components/**/*.js',
+      '../user/**/*.js',
+      '../home/**/*.js',
+      '../recents/**/*.js',
+      '../settings/**/*.js',
+      '../app.js',
+      '../app*',
+      './unit/**/*.js'
+    ],
+
+    plugins: [
+        'karma-*',
+    ],
+
+
+    // list of files to exclude
+    exclude: [
+    ],
+
+
+    // preprocess matching files before serving them to the browser
+    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
+    preprocessors: {
+      '../login/**/*.js': 'coverage', 
+      '../room/**/*.js': 'coverage',
+      '../components/**/*.js': 'coverage',
+      '../user/**/*.js': 'coverage',
+      '../home/**/*.js': 'coverage',
+      '../recents/**/*.js': 'coverage',
+      '../settings/**/*.js': 'coverage',
+      '../app.js': 'coverage'
+    },
+
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    reporters: ['progress', 'junit', 'coverage'],
+    junitReporter: {
+        outputFile: 'test-results.xml',
+        suite: ''
+    },
+
+    coverageReporter: {
+        type: 'cobertura',
+        dir: 'coverage/',
+        file: 'coverage.xml'
+    },
+
+    // web server port
+    port: 9876,
+
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_DEBUG,
+
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: true,
+
+
+    // start these browsers
+    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+    browsers: ['PhantomJS'],
+
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    singleRun: true
+  });
+};
diff --git a/syweb/webclient/test/protractor.conf.js b/syweb/webclient/test/protractor.conf.js
new file mode 100644
index 0000000000..76ae7b712b
--- /dev/null
+++ b/syweb/webclient/test/protractor.conf.js
@@ -0,0 +1,18 @@
+var env = require("./environment-protractor.js");
+exports.config = {
+    seleniumAddress: env.seleniumAddress,
+    specs: ['e2e/*.spec.js'],
+    onPrepare: function() {
+        browser.driver.get(env.baseUrl);
+        browser.driver.findElement(by.id("user_id")).sendKeys(env.username);
+        browser.driver.findElement(by.id("password")).sendKeys(env.password);
+        browser.driver.findElement(by.id("login")).click();
+
+        // wait till the login is done, detect via url change
+        browser.driver.wait(function() {
+            return browser.driver.getCurrentUrl().then(function(url) {
+                return !(/login/.test(url))
+            });
+        });
+    }
+}
diff --git a/syweb/webclient/test/unit/commands-service.spec.js b/syweb/webclient/test/unit/commands-service.spec.js
new file mode 100644
index 0000000000..142044f153
--- /dev/null
+++ b/syweb/webclient/test/unit/commands-service.spec.js
@@ -0,0 +1,143 @@
+describe('CommandsService', function() {
+    var scope;
+    var roomId = "!dlwifhweu:localhost";
+    
+    var testPowerLevelsEvent, testMatrixServicePromise;
+    
+    var matrixService = { // these will be spyed on by jasmine, hence stub methods
+        setDisplayName: function(args){},
+        kick: function(args){},
+        ban: function(args){},
+        unban: function(args){},
+        setUserPowerLevel: function(args){}
+    };
+    
+    var modelService = {
+        getRoom: function(roomId) {
+            return {
+                room_id: roomId,
+                current_room_state: {
+                    events: {
+                        "m.room.power_levels": testPowerLevelsEvent
+                    },
+                    state: function(type, key) {
+                        return key ? this.events[type+key] : this.events[type];
+                    }
+                }
+            };
+        }
+    };
+    
+    
+    // helper function for asserting promise outcomes
+    NOTHING = "[Promise]";
+    RESOLVED = "[Resolved promise]";
+    REJECTED = "[Rejected promise]";
+    var expectPromise = function(promise, expects) {
+        var value = NOTHING;
+        promise.then(function(result) {
+            value = RESOLVED;
+        }, function(fail) {
+            value = REJECTED;
+        });
+        scope.$apply();
+        expect(value).toEqual(expects);
+    };
+
+    // setup the service and mocked dependencies
+    beforeEach(function() {
+        
+        // set default mock values
+        testPowerLevelsEvent = {
+            content: {
+                default: 50
+            },
+            user_id: "@foo:bar",
+            room_id: roomId
+        }
+        
+        // mocked dependencies
+        module(function ($provide) {
+          $provide.value('matrixService', matrixService);
+          $provide.value('modelService', modelService);
+        });
+        
+        // tested service
+        module('commandsService');
+    });
+    
+    beforeEach(inject(function($rootScope, $q) {
+        scope = $rootScope;
+        testMatrixServicePromise = $q.defer();
+    }));
+
+    it('should reject a no-arg "/nick".', inject(
+    function(commandsService) {
+        var promise = commandsService.processInput(roomId, "/nick");
+        expectPromise(promise, REJECTED);
+    }));
+    
+    it('should be able to set a /nick with multiple words.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'setDisplayName').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/nick Bob Smith");
+        expect(matrixService.setDisplayName).toHaveBeenCalledWith("Bob Smith");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /kick a user without a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'kick').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/kick @bob:matrix.org");
+        expect(matrixService.kick).toHaveBeenCalledWith(roomId, "@bob:matrix.org", undefined);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /kick a user with a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'kick').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/kick @bob:matrix.org he smells");
+        expect(matrixService.kick).toHaveBeenCalledWith(roomId, "@bob:matrix.org", "he smells");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /ban a user without a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'ban').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/ban @bob:matrix.org");
+        expect(matrixService.ban).toHaveBeenCalledWith(roomId, "@bob:matrix.org", undefined);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /ban a user with a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'ban').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/ban @bob:matrix.org he smells");
+        expect(matrixService.ban).toHaveBeenCalledWith(roomId, "@bob:matrix.org", "he smells");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /unban a user.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'unban').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/unban @bob:matrix.org");
+        expect(matrixService.unban).toHaveBeenCalledWith(roomId, "@bob:matrix.org");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /op a user.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'setUserPowerLevel').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/op @bob:matrix.org 50");
+        expect(matrixService.setUserPowerLevel).toHaveBeenCalledWith(roomId, "@bob:matrix.org", 50, testPowerLevelsEvent);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /deop a user.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'setUserPowerLevel').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/deop @bob:matrix.org");
+        expect(matrixService.setUserPowerLevel).toHaveBeenCalledWith(roomId, "@bob:matrix.org", undefined, testPowerLevelsEvent);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+});
diff --git a/syweb/webclient/test/unit/event-handler-service.spec.js b/syweb/webclient/test/unit/event-handler-service.spec.js
new file mode 100644
index 0000000000..2a4dc3b5a5
--- /dev/null
+++ b/syweb/webclient/test/unit/event-handler-service.spec.js
@@ -0,0 +1,117 @@
+describe('EventHandlerService', function() {
+    var scope;
+    
+    var modelService = {};
+
+    // setup the service and mocked dependencies
+    beforeEach(function() {
+        // dependencies
+        module('matrixService');
+        module('notificationService');
+        module('mPresence');
+        
+        // cleanup mocked methods
+        modelService = {};
+        
+        // mocked dependencies
+        module(function ($provide) {
+          $provide.value('modelService', modelService);
+        });
+        
+        // tested service
+        module('eventHandlerService');
+    });
+    
+    beforeEach(inject(function($rootScope) {
+        scope = $rootScope;
+    }));
+
+    it('should be able to get the number of joined users in a room', inject(
+    function(eventHandlerService) {
+        var roomId = "!foo:matrix.org";
+        // set mocked data
+        modelService.getRoom = function(roomId) {
+            return {
+                room_id: roomId,
+                current_room_state: {
+                    members: {
+                        "@adam:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@adam:matrix.org"
+                            }
+                        },
+                        "@beth:matrix.org": {
+                            event: {
+                                content: { membership: "invite" },
+                                user_id: "@beth:matrix.org"
+                            }
+                        },
+                        "@charlie:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@charlie:matrix.org"
+                            }
+                        },
+                        "@danice:matrix.org": {
+                            event: {
+                                content: { membership: "leave" },
+                                user_id: "@danice:matrix.org"
+                            }
+                        }
+                    }
+                }
+            };
+        }
+        
+        var num = eventHandlerService.getUsersCountInRoom(roomId);
+        expect(num).toEqual(2);
+    }));
+    
+    it('should be able to get a users power level', inject(
+    function(eventHandlerService) {
+        var roomId = "!foo:matrix.org";
+        // set mocked data
+        modelService.getRoom = function(roomId) {
+            return {
+                room_id: roomId,
+                current_room_state: {
+                    members: {
+                        "@adam:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@adam:matrix.org"
+                            }
+                        },
+                        "@beth:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@beth:matrix.org"
+                            }
+                        }
+                    },
+                    s: {
+                        "m.room.power_levels": {
+                            content: {
+                                "@adam:matrix.org": 90,
+                                "default": 50
+                            }
+                        }
+                    },
+                    state: function(type, key) { 
+                        return key ? this.s[type+key] : this.s[type]
+                    }
+                }
+            };
+        };
+        
+        var num = eventHandlerService.getUserPowerLevel(roomId, "@beth:matrix.org");
+        expect(num).toEqual(50);
+        
+        num = eventHandlerService.getUserPowerLevel(roomId, "@adam:matrix.org");
+        expect(num).toEqual(90);
+        
+        num = eventHandlerService.getUserPowerLevel(roomId, "@unknown:matrix.org");
+        expect(num).toEqual(50);
+    }));
+});
diff --git a/syweb/webclient/test/unit/filters.spec.js b/syweb/webclient/test/unit/filters.spec.js
new file mode 100644
index 0000000000..c6253aad96
--- /dev/null
+++ b/syweb/webclient/test/unit/filters.spec.js
@@ -0,0 +1,635 @@
+describe('mRoomName filter', function() {
+    var filter, mRoomName, mUserDisplayName;
+    
+    var roomId = "!weufhewifu:matrix.org";
+    
+    // test state values (f.e. test)
+    var testUserId, testAlias, testDisplayName, testOtherDisplayName, testRoomState;
+    
+    // mocked services which return the test values above.
+    var matrixService = {
+        config: function() {
+            return {
+                user_id: testUserId
+            };
+        }
+    };
+    
+    var modelService = {
+        getRoom: function(room_id) {
+            return {
+                current_room_state: testRoomState
+            };
+        },
+        
+        getRoomIdToAliasMapping: function(room_id) {
+            return testAlias;
+        },
+    };
+    
+    beforeEach(function() {
+        // inject mocked dependencies
+        module(function ($provide) {
+            $provide.value('matrixService', matrixService);
+            $provide.value('modelService', modelService);
+        });
+        
+        module('matrixFilter');
+        
+        // angular resolves dependencies with the same name via a 'last wins'
+        // rule, hence we need to have this mock filter impl AFTER module('matrixFilter')
+        // so it clobbers the actual mUserDisplayName implementation.
+        module(function ($filterProvider) {
+            // provide a fake filter
+            $filterProvider.register('mUserDisplayName', function() {
+                return function(user_id, room_id) {
+                    if (user_id === testUserId) {
+                        return testDisplayName;
+                    }
+                    return testOtherDisplayName;
+                };
+            });
+        });
+    });
+    
+    
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        mRoomName = filter("mRoomName");
+        
+        // purge the previous test values
+        testUserId = undefined;
+        testAlias = undefined;
+        testDisplayName = undefined;
+        testOtherDisplayName = undefined;
+        
+        // mock up a stub room state
+        testRoomState = {
+            s:{}, // internal; stores the state events
+            state: function(type, key) {
+                // accessor used by filter
+                return key ? this.s[type+key] : this.s[type];
+            },
+            members: {}, // struct used by filter
+            
+            // test helper methods
+            setJoinRule: function(rule) {
+                this.s["m.room.join_rules"] = {
+                    content: {
+                        join_rule: rule
+                    }
+                };
+            },
+            setRoomName: function(name) {
+                this.s["m.room.name"] = {
+                    content: {
+                        name: name
+                    }
+                };
+            },
+            setMember: function(user_id, membership, inviter_user_id) {
+                if (!inviter_user_id) {
+                    inviter_user_id = user_id;
+                }
+                this.s["m.room.member" + user_id] = {
+                    event: {
+                        content: {
+                            membership: membership
+                        },
+                        state_key: user_id,
+                        user_id: inviter_user_id 
+                    }
+                };
+                this.members[user_id] = this.s["m.room.member" + user_id];
+            }
+        };
+    }));
+    
+    /**** ROOM NAME ****/
+    
+    it("should show the room name if one exists for private (invite join_rules) rooms.", function() {
+        var roomName = "The Room Name";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("invite");
+        testRoomState.setRoomName(roomName);
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomName);
+    });
+    
+    it("should show the room name if one exists for public (public join_rules) rooms.", function() {
+        var roomName = "The Room Name";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setRoomName(roomName);
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomName);
+    });
+    
+    /**** ROOM ALIAS ****/
+    
+    it("should show the room alias if one exists for private (invite join_rules) rooms if a room name doesn't exist.", function() {
+        testAlias = "#thealias:matrix.org";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("invite");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testAlias);
+    });
+    
+    it("should show the room alias if one exists for public (public join_rules) rooms if a room name doesn't exist.", function() {
+        testAlias = "#thealias:matrix.org";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testAlias);
+    });
+    
+    /**** ROOM ID ****/
+    
+    it("should show the room ID for public (public join_rules) rooms if a room name and alias don't exist.", function() {
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomId);
+    });
+    
+    it("should show the room ID for private (invite join_rules) rooms if a room name and alias don't exist and there are >2 members.", function() {
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember("@alice:matrix.org", "join");
+        testRoomState.setMember("@bob:matrix.org", "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomId);
+    });
+    
+    /**** SELF-CHAT ****/
+    
+    it("should show your display name for private (invite join_rules) rooms if a room name and alias don't exist and it is a self-chat.", function() {
+        testUserId = "@me:matrix.org";
+        testDisplayName = "Me";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testDisplayName);
+    });
+    
+    it("should show your user ID for private (invite join_rules) rooms if a room name and alias don't exist and it is a self-chat and they don't have a display name set.", function() {
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testUserId);
+    });
+    
+    /**** ONE-TO-ONE CHAT ****/
+    
+    it("should show the other user's display name for private (invite join_rules) rooms if a room name and alias don't exist and it is a 1:1-chat.", function() {
+        testUserId = "@me:matrix.org";
+        otherUserId = "@alice:matrix.org";
+        testOtherDisplayName = "Alice";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember("@alice:matrix.org", "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testOtherDisplayName);
+    });
+    
+    it("should show the other user's ID for private (invite join_rules) rooms if a room name and alias don't exist and it is a 1:1-chat and they don't have a display name set.", function() {
+        testUserId = "@me:matrix.org";
+        otherUserId = "@alice:matrix.org";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember("@alice:matrix.org", "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(otherUserId);
+    });
+    
+    /**** INVITED TO ROOM ****/
+    
+    it("should show the other user's display name for private (invite join_rules) rooms if you are invited to it.", function() {
+        testUserId = "@me:matrix.org";
+        testDisplayName = "Me";
+        otherUserId = "@alice:matrix.org";
+        testOtherDisplayName = "Alice";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember(otherUserId, "join");
+        testRoomState.setMember(testUserId, "invite");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testOtherDisplayName);
+    });
+    
+    it("should show the other user's ID for private (invite join_rules) rooms if you are invited to it and the inviter doesn't have a display name.", function() {
+        testUserId = "@me:matrix.org";
+        testDisplayName = "Me";
+        otherUserId = "@alice:matrix.org";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember(otherUserId, "join");
+        testRoomState.setMember(testUserId, "invite");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(otherUserId);
+    });
+});
+
+describe('duration filter', function() {
+    var filter, durationFilter;
+    
+    beforeEach(module('matrixWebClient'));
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        durationFilter = filter("duration");
+    }));
+    
+    it("should represent 15000 ms as '15s'", function() {
+        var output = durationFilter(15000);
+        expect(output).toEqual("15s");
+    });
+    
+    it("should represent 60000 ms as '1m'", function() {
+        var output = durationFilter(60000);
+        expect(output).toEqual("1m");
+    });
+    
+    it("should represent 65000 ms as '1m'", function() {
+        var output = durationFilter(65000);
+        expect(output).toEqual("1m");
+    });
+    
+    it("should represent 10 ms as '0s'", function() {
+        var output = durationFilter(10);
+        expect(output).toEqual("0s");
+    });
+    
+    it("should represent 4m as '4m'", function() {
+        var output = durationFilter(1000*60*4);
+        expect(output).toEqual("4m");
+    });
+    
+    it("should represent 4m30s as '4m'", function() {
+        var output = durationFilter(1000*60*4 + 1000*30);
+        expect(output).toEqual("4m");
+    });
+    
+    it("should represent 2h as '2h'", function() {
+        var output = durationFilter(1000*60*60*2);
+        expect(output).toEqual("2h");
+    });
+    
+    it("should represent 2h35m as '2h'", function() {
+        var output = durationFilter(1000*60*60*2 + 1000*60*35);
+        expect(output).toEqual("2h");
+    });
+});
+
+describe('orderMembersList filter', function() {
+    var filter, orderMembersList;
+    
+    beforeEach(module('matrixWebClient'));
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        orderMembersList = filter("orderMembersList");
+    }));
+    
+    it("should sort a single entry", function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 50,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([{
+                id: "@a:example.com",
+                last_active_ago: 50,
+                last_updated: 1415266943964
+        }]);
+    });
+    
+    it("should sort by taking last_active_ago into account", function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            "@b:example.com": {
+                last_active_ago: 50,
+                last_updated: 1415266943964
+            },
+            "@c:example.com": {
+                last_active_ago: 99999,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@b:example.com",
+                last_active_ago: 50,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: 99999,
+                last_updated: 1415266943964
+            },
+        ]);
+    });
+    
+    it("should sort by taking last_updated into account", function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            "@b:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266900000
+            },
+            "@c:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            },
+            {
+                id: "@b:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266900000
+            },
+        ]);
+    });
+    
+    it("should sort by taking last_updated and last_active_ago into account", 
+    function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            },
+            "@b:example.com": {
+                last_active_ago: 100000,
+                last_updated: 1415266943900
+            },
+            "@c:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@c:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            },
+            {
+                id: "@b:example.com",
+                last_active_ago: 100000,
+                last_updated: 1415266943900
+            },
+        ]);
+    });
+    
+    // SYWEB-26 comment
+    it("should sort members who do not have last_active_ago value at the end of the list", 
+    function() {
+        // single undefined entry
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            "@b:example.com": {
+                last_active_ago: 100000,
+                last_updated: 1415266943964
+            },
+            "@c:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@b:example.com",
+                last_active_ago: 100000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964
+            },
+        ]);
+    });
+    
+    it("should sort multiple members who do not have last_active_ago according to presence", 
+    function() {
+        // single undefined entry
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "unavailable"
+            },
+            "@b:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "online"
+            },
+            "@c:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "offline"
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@b:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "online"
+            },
+            {
+                id: "@a:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "unavailable"
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "offline"
+            },
+        ]);
+    });
+});
+describe('mUserDisplayName filter', function() {
+    var filter, mUserDisplayName;
+    
+    var roomId = "!weufhewifu:matrix.org";
+    
+    // test state values (f.e. test)
+    var testUser_displayname, testUser_user_id;
+    var testSelf_displayname, testSelf_user_id;
+    var testRoomState;
+    
+    // mocked services which return the test values above.
+    var matrixService = {
+        config: function() {
+            return {
+                user_id: testSelf_user_id
+            };
+        }
+    };
+    
+    var modelService = {
+        getRoom: function(room_id) {
+            return {
+                current_room_state: testRoomState
+            };
+        },
+        
+        getUser: function(user_id) {
+            return {
+                event: {
+                    content: {
+                        displayname: testUser_displayname
+                    },
+                    event_id: "wfiuhwf@matrix.org",
+                    user_id: testUser_user_id
+                }
+            };
+        },
+        
+        getMember: function(room_id, user_id) {
+            return testRoomState.members[user_id];
+        }
+    };
+    
+    beforeEach(function() {
+        // inject mocked dependencies
+        module(function ($provide) {
+            $provide.value('matrixService', matrixService);
+            $provide.value('modelService', modelService);
+        });
+        
+        module('matrixFilter');
+    });
+    
+    
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        mUserDisplayName = filter("mUserDisplayName");
+        
+        // purge the previous test values
+        testSelf_displayname = "Me"; 
+        testSelf_user_id = "@me:matrix.org";
+        testUser_displayname = undefined; 
+        testUser_user_id = undefined;
+        
+        // mock up a stub room state
+        testRoomState = {
+            s:{}, // internal; stores the state events
+            state: function(type, key) {
+                // accessor used by filter
+                return key ? this.s[type+key] : this.s[type];
+            },
+            members: {}, // struct used by filter
+            
+            // test helper methods
+            setMember: function(user_id, displayname, membership, inviter_user_id) {
+                if (!inviter_user_id) {
+                    inviter_user_id = user_id;
+                }
+                if (!membership) {
+                    membership = "join";
+                }
+                this.s["m.room.member" + user_id] = {
+                    event: {
+                        content: {
+                            displayname: displayname,
+                            membership: membership
+                        },
+                        state_key: user_id,
+                        user_id: inviter_user_id 
+                    }
+                };
+                this.members[user_id] = this.s["m.room.member" + user_id];
+            }
+        };
+    }));
+    
+    it("should show the display name of a user in a room if they have set one.", function() {
+        testUser_displayname = "Tom Scott";
+        testUser_user_id = "@tymnhk:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_displayname);
+    });
+    
+    it("should show the user_id of a user in a room if they have no display name.", function() {
+        testUser_user_id = "@mike:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_user_id);
+    });
+    
+    it("should still show the displayname of a user in a room if they are not a member of the room but there exists a User entry for them.", function() {
+        testUser_user_id = "@alice:matrix.org";
+        testUser_displayname = "Alice M";
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_displayname);
+    });
+    
+    it("should disambiguate users with the same displayname with their user id.", function() {
+        testUser_displayname = "Reimu";
+        testSelf_displayname = "Reimu";
+        testUser_user_id = "@reimu:matrix.org";
+        testSelf_user_id = "@xreimux:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_displayname + " (" + testUser_user_id + ")");
+    });
+    
+    it("should wrap user IDs after the : if the wrap flag is set.", function() {
+        testUser_user_id = "@mike:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId, true);
+        expect(output).toEqual("@mike :matrix.org");
+    });
+});
+
diff --git a/syweb/webclient/test/unit/matrix-service.spec.js b/syweb/webclient/test/unit/matrix-service.spec.js
new file mode 100644
index 0000000000..4959f2395d
--- /dev/null
+++ b/syweb/webclient/test/unit/matrix-service.spec.js
@@ -0,0 +1,504 @@
+describe('MatrixService', function() {
+    var scope, httpBackend;
+    var BASE = "http://example.com";
+    var PREFIX = "/_matrix/client/api/v1";
+    var URL = BASE + PREFIX;
+    var roomId = "!wejigf387t34:matrix.org";
+    
+    var CONFIG = {
+        access_token: "foobar",
+        homeserver: BASE
+    };
+    
+    beforeEach(module('matrixService'));
+
+    beforeEach(inject(function($rootScope, $httpBackend) {
+        httpBackend = $httpBackend;
+        scope = $rootScope;
+    }));
+
+    afterEach(function() {
+        httpBackend.verifyNoOutstandingExpectation();
+        httpBackend.verifyNoOutstandingRequest();
+    });
+
+    it('should be able to POST /createRoom with an alias', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var alias = "flibble";
+        matrixService.create(alias).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(URL + "/createRoom?access_token=foobar",
+            {
+                room_alias_name: alias
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+
+    it('should be able to GET /initialSync', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var limit = 15;
+        matrixService.initialSync(limit).then(function(response) {
+            expect(response.data).toEqual([]);
+        });
+
+        httpBackend.expectGET(
+            URL + "/initialSync?access_token=foobar&limit=15")
+            .respond([]);
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /rooms/$roomid/state', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.roomState(roomId).then(function(response) {
+            expect(response.data).toEqual([]);
+        });
+
+        httpBackend.expectGET(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/state?access_token=foobar")
+            .respond([]);
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /join', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.joinAlias(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/join/" + encodeURIComponent(roomId) + 
+            "?access_token=foobar",
+            {})
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/join', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.join(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/join?access_token=foobar",
+            {})
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/invite', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var inviteUserId = "@user:example.com";
+        matrixService.invite(roomId, inviteUserId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/invite?access_token=foobar",
+            {
+                user_id: inviteUserId
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/leave', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.leave(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/leave?access_token=foobar",
+            {})
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/ban', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@example:example.com";
+        var reason = "Because.";
+        matrixService.ban(roomId, userId, reason).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/ban?access_token=foobar",
+            {
+                user_id: userId,
+                reason: reason
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /directory/room/$alias', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var alias = "#test:example.com";
+        var roomId = "!wefuhewfuiw:example.com";
+        matrixService.resolveRoomAlias(alias).then(function(response) {
+            expect(response.data).toEqual({
+                room_id: roomId
+            });
+        });
+
+        httpBackend.expectGET(
+            URL + "/directory/room/" + encodeURIComponent(alias) +
+                    "?access_token=foobar")
+            .respond({
+                room_id: roomId
+            });
+        httpBackend.flush();
+    }));
+    
+    it('should be able to send m.room.name', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var name = "Room Name";
+        matrixService.setName(roomId, name).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/state/m.room.name?access_token=foobar",
+            {
+                name: name
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to send m.room.topic', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var topic = "A room topic can go here.";
+        matrixService.setTopic(roomId, topic).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/state/m.room.topic?access_token=foobar",
+            {
+                topic: topic
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to send generic state events without a state key', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventType = "com.example.events.test";
+        var content = {
+            testing: "1 2 3"
+        };
+        matrixService.sendStateEvent(roomId, eventType, content).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + "/state/" + 
+            encodeURIComponent(eventType) + "?access_token=foobar",
+            content)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    // TODO: Skipped since the webclient is purposefully broken so as not to
+    // 500 matrix.org
+    xit('should be able to send generic state events with a state key', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventType = "com.example.events.test:special@characters";
+        var content = {
+            testing: "1 2 3"
+        };
+        var stateKey = "version:1";
+        matrixService.sendStateEvent(roomId, eventType, content, stateKey).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + "/state/" + 
+            encodeURIComponent(eventType) + "/" + encodeURIComponent(stateKey)+
+            "?access_token=foobar",
+            content)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT generic events ', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventType = "com.example.events.test";
+        var txnId = "42";
+        var content = {
+            testing: "1 2 3"
+        };
+        matrixService.sendEvent(roomId, eventType, txnId, content).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + "/send/" + 
+            encodeURIComponent(eventType) + "/" + encodeURIComponent(txnId)+
+            "?access_token=foobar",
+            content)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT text messages ', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var body = "ABC 123";
+        matrixService.sendTextMessage(roomId, body).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            new RegExp(URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/send/m.room.message/(.*)" +
+            "?access_token=foobar"),
+            {
+                body: body,
+                msgtype: "m.text"
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT emote messages ', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var body = "ABC 123";
+        matrixService.sendEmoteMessage(roomId, body).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            new RegExp(URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/send/m.room.message/(.*)" +
+            "?access_token=foobar"),
+            {
+                body: body,
+                msgtype: "m.emote"
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST redactions', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventId = "fwefwexample.com";
+        matrixService.redactEvent(roomId, eventId).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/redact/" + encodeURIComponent(eventId) +
+            "?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /directory/room/$alias', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var alias = "#test:example.com";
+        var roomId = "!wefuhewfuiw:example.com";
+        matrixService.resolveRoomAlias(alias).then(function(response) {
+            expect(response.data).toEqual({
+                room_id: roomId
+            });
+        });
+
+        httpBackend.expectGET(
+            URL + "/directory/room/" + encodeURIComponent(alias) +
+                    "?access_token=foobar")
+            .respond({
+                room_id: roomId
+            });
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /rooms/$roomid/members', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!wefuhewfuiw:example.com";
+        matrixService.getMemberList(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(
+            URL + "/rooms/" + encodeURIComponent(roomId) +
+                    "/members?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to paginate a room', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!wefuhewfuiw:example.com";
+        var from = "3t_44e_54z";
+        var limit = 20;
+        matrixService.paginateBackMessages(roomId, from, limit).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(
+            URL + "/rooms/" + encodeURIComponent(roomId) +
+                    "/messages?access_token=foobar&dir=b&from="+
+                    encodeURIComponent(from)+"&limit="+limit)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /publicRooms', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.publicRooms().then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(
+            new RegExp(URL + "/publicRooms(.*)"))
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /profile/$userid/displayname', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@foo:example.com";
+        matrixService.getDisplayName(userId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(URL + "/profile/" + encodeURIComponent(userId) +
+            "/displayname?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /profile/$userid/avatar_url', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@foo:example.com";
+        matrixService.getProfilePictureUrl(userId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(URL + "/profile/" + encodeURIComponent(userId) +
+            "/avatar_url?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT /profile/$me/avatar_url', inject(
+    function(matrixService) {
+        var testConfig = angular.copy(CONFIG);
+        testConfig.user_id = "@bob:example.com";
+        matrixService.setConfig(testConfig);
+        var url = "http://example.com/mypic.jpg";
+        matrixService.setProfilePictureUrl(url).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPUT(URL + "/profile/" + 
+            encodeURIComponent(testConfig.user_id) +
+            "/avatar_url?access_token=foobar",
+            {
+                avatar_url: url
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT /profile/$me/displayname', inject(
+    function(matrixService) {
+        var testConfig = angular.copy(CONFIG);
+        testConfig.user_id = "@bob:example.com";
+        matrixService.setConfig(testConfig);
+        var displayname = "Bob Smith";
+        matrixService.setDisplayName(displayname).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPUT(URL + "/profile/" + 
+            encodeURIComponent(testConfig.user_id) +
+            "/displayname?access_token=foobar",
+            {
+                displayname: displayname
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to login with password', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@bob:example.com";
+        var password = "monkey";
+        matrixService.login(userId, password).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPOST(new RegExp(URL+"/login(.*)"),
+            {
+                user: userId,
+                password: password,
+                type: "m.login.password"
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT presence status', inject(
+    function(matrixService) {
+        var testConfig = angular.copy(CONFIG);
+        testConfig.user_id = "@bob:example.com";
+        matrixService.setConfig(testConfig);
+        var status = "unavailable";
+        matrixService.setUserPresence(status).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPUT(URL+"/presence/"+
+            encodeURIComponent(testConfig.user_id)+
+            "/status?access_token=foobar",
+            {
+                presence: status
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+});
diff --git a/syweb/webclient/test/unit/model-service.spec.js b/syweb/webclient/test/unit/model-service.spec.js
new file mode 100644
index 0000000000..e2fa8ceba3
--- /dev/null
+++ b/syweb/webclient/test/unit/model-service.spec.js
@@ -0,0 +1,30 @@
+describe('ModelService', function() {
+
+    // setup the dependencies
+    beforeEach(function() {
+        // dependencies
+        module('matrixService');
+        
+        // tested service
+        module('modelService');
+    });
+
+    it('should be able to get a member in a room', inject(
+    function(modelService) {
+        var roomId = "!wefiohwefuiow:matrix.org";
+        var userId = "@bob:matrix.org";
+        
+        modelService.getRoom(roomId).current_room_state.storeStateEvent({
+            type: "m.room.member",
+            id: "fwefw:matrix.org",
+            user_id: userId,
+            state_key: userId,
+            content: {
+                membership: "join"
+            }
+        });
+        
+        var user = modelService.getMember(roomId, userId);
+        expect(user.event.state_key).toEqual(userId);
+    }));
+});
diff --git a/syweb/webclient/test/unit/notification-service.spec.js b/syweb/webclient/test/unit/notification-service.spec.js
new file mode 100644
index 0000000000..4205ca0969
--- /dev/null
+++ b/syweb/webclient/test/unit/notification-service.spec.js
@@ -0,0 +1,78 @@
+describe('NotificationService', function() {
+
+    var userId = "@ali:matrix.org";
+    var displayName = "Alice M";
+    var bingWords = ["coffee","foo(.*)bar"]; // literal and wildcard
+
+    beforeEach(function() {
+        module('notificationService');
+    });
+    
+    // User IDs
+    
+    it('should bing on a user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello @ali:matrix.org, how are you?")).toEqual(true);
+    }));
+    
+    it('should bing on a partial user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello @ali, how are you?")).toEqual(true);
+    }));
+    
+    it('should bing on a case-insensitive user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello @AlI:matrix.org, how are you?")).toEqual(true);
+    }));
+    
+    // Display names
+    
+    it('should bing on a display name.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello Alice M, how are you?")).toEqual(true);
+    }));
+    
+    it('should bing on a case-insensitive display name.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello ALICE M, how are you?")).toEqual(true);
+    }));
+    
+    // Bing words
+    
+    it('should bing on a bing word.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "I really like coffee")).toEqual(true);
+    }));
+    
+    it('should bing on case-insensitive bing words.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Coffee is great")).toEqual(true);
+    }));
+    
+    it('should bing on wildcard (.*) bing words.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "It was foomahbar I think.")).toEqual(true);
+    }));
+    
+    // invalid
+    
+    it('should gracefully handle bad input.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, { "foo": "bar" })).toEqual(false);
+    }));
+    
+    it('should gracefully handle just a user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, undefined, 
+        undefined, "Hello @ali:matrix.org, how are you?")).toEqual(true);
+    }));
+});
diff --git a/syweb/webclient/test/unit/recents-service.spec.js b/syweb/webclient/test/unit/recents-service.spec.js
new file mode 100644
index 0000000000..a2f9ecbaf8
--- /dev/null
+++ b/syweb/webclient/test/unit/recents-service.spec.js
@@ -0,0 +1,153 @@
+describe('RecentsService', function() {
+    var scope;
+    var MSG_EVENT = "__test__";
+    
+    var testEventContainsBingWord, testIsLive, testEvent;
+    
+    var eventHandlerService = {
+        MSG_EVENT: MSG_EVENT,
+        eventContainsBingWord: function(event) {
+            return testEventContainsBingWord;
+        }
+    };
+
+    // setup the service and mocked dependencies
+    beforeEach(function() {
+        
+        // set default mock values
+        testEventContainsBingWord = false;
+        testIsLive = true;
+        testEvent = {
+            content: {
+                body: "Hello world",
+                msgtype: "m.text"
+            },
+            user_id: "@alfred:localhost",
+            room_id: "!fl1bb13:localhost",
+            event_id: "fwuegfw@localhost"
+        }
+        
+        // mocked dependencies
+        module(function ($provide) {
+          $provide.value('eventHandlerService', eventHandlerService);
+        });
+        
+        // tested service
+        module('recentsService');
+    });
+    
+    beforeEach(inject(function($rootScope) {
+        scope = $rootScope;
+    }));
+
+    it('should start with no unread messages.', inject(
+    function(recentsService) {
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+    
+    it('should NOT add an unread message to the room currently selected.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId(testEvent.room_id);
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+    
+    it('should add an unread message to the room NOT currently selected.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+    }));
+    
+    it('should add an unread message and an unread bing message if a message contains a bing word.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        
+        var bing = {};
+        bing[testEvent.room_id] = testEvent;
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+    }));
+    
+    it('should clear both unread and unread bing messages when markAsRead is called.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        
+        var bing = {};
+        bing[testEvent.room_id] = testEvent;
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+        
+        recentsService.markAsRead(testEvent.room_id);
+        
+        unread[testEvent.room_id] = 0;
+        bing[testEvent.room_id] = undefined;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+    }));
+    
+    it('should not add messages as unread if they are not live.', inject(
+    function(recentsService) {
+        testIsLive = false;
+        
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+    
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+    
+    it('should increment the unread message count.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+    
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        unread[testEvent.room_id] = 2;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+    }));
+    
+    it('should set the bing event to the latest message to contain a bing word.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+    
+        var nextEvent = angular.copy(testEvent);
+        nextEvent.content.body = "Goodbye cruel world.";
+        nextEvent.event_id = "erfuerhfeaaaa@localhost";
+        scope.$broadcast(MSG_EVENT, nextEvent, testIsLive);
+        
+        var bing = {};
+        bing[testEvent.room_id] = nextEvent;
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+    }));
+    
+    it('should do nothing when marking an unknown room ID as read.', inject(
+    function(recentsService) {
+        recentsService.markAsRead("!someotherroomid:localhost");
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+});
diff --git a/syweb/webclient/test/unit/register-controller.spec.js b/syweb/webclient/test/unit/register-controller.spec.js
new file mode 100644
index 0000000000..b5c7842358
--- /dev/null
+++ b/syweb/webclient/test/unit/register-controller.spec.js
@@ -0,0 +1,84 @@
+describe("RegisterController ", function() {
+    var rootScope, scope, ctrl, $q, $timeout;
+    var userId = "@foo:bar";
+    var displayName = "Foo";
+    var avatarUrl = "avatar.url";
+    
+    window.webClientConfig = {
+        useCapatcha: false
+    };
+    
+    // test vars
+    var testRegisterData, testFailRegisterData;
+    
+    
+    // mock services
+    var matrixService = {
+        config: function() {
+            return {
+                user_id: userId
+            }
+        },
+        setConfig: function(){},
+        register: function(mxid, password, threepidCreds, useCaptcha) {
+            var d = $q.defer();
+            if (testFailRegisterData) {
+                d.reject({
+                    data: testFailRegisterData
+                });
+            }
+            else {
+                d.resolve({
+                    data: testRegisterData
+                });
+            }
+            return d.promise;
+        }
+    };
+    
+    var eventStreamService = {};
+    
+    beforeEach(function() {
+        module('matrixWebClient');
+        
+        // reset test vars
+        testRegisterData = undefined;
+        testFailRegisterData = undefined;
+    });
+
+    beforeEach(inject(function($rootScope, $injector, $location, $controller, _$q_, _$timeout_) {
+            $q = _$q_;
+            $timeout = _$timeout_;
+            scope = $rootScope.$new();
+            rootScope = $rootScope;
+            routeParams = {
+                user_matrix_id: userId
+            };
+            ctrl = $controller('RegisterController', {
+                '$scope': scope,
+                '$rootScope': $rootScope, 
+                '$location': $location,
+                'matrixService': matrixService,
+                'eventStreamService': eventStreamService
+            });
+        })
+    );
+
+    // SYWEB-109
+    it('should display an error if the HS rejects the username on registration', function() {
+        var prevFeedback = angular.copy(scope.feedback);
+    
+        testFailRegisterData = {
+            errcode: "M_UNKNOWN",
+            error: "I am rejecting you."
+        };
+    
+        scope.account.pwd1 = "password";
+        scope.account.pwd2 = "password";
+        scope.account.desired_user_id = "bob";
+        scope.register(); // this depends on the result of a deferred
+        rootScope.$digest(); // which is delivered after the digest
+        
+        expect(scope.feedback).not.toEqual(prevFeedback);
+    });
+});
diff --git a/syweb/webclient/test/unit/user-controller.spec.js b/syweb/webclient/test/unit/user-controller.spec.js
new file mode 100644
index 0000000000..798cc4de48
--- /dev/null
+++ b/syweb/webclient/test/unit/user-controller.spec.js
@@ -0,0 +1,57 @@
+describe("UserCtrl", function() {
+    var scope, ctrl, matrixService, routeParams, $q, $timeout;
+    var userId = "@foo:bar";
+    var displayName = "Foo";
+    var avatarUrl = "avatar.url";
+    
+    beforeEach(module('matrixWebClient'));
+
+    beforeEach(function() {
+
+        inject(function($rootScope, $injector, $controller, _$q_, _$timeout_) {
+            $q = _$q_;
+            $timeout = _$timeout_;
+
+            matrixService = {
+                config: function() {
+                    return {
+                        user_id: userId
+                    };
+                },
+
+                getDisplayName: function(uid) {
+                    var d = $q.defer();
+                    d.resolve({
+                        data: {
+                            displayname: displayName
+                        }
+                    });
+                    return d.promise;
+                },
+
+                getProfilePictureUrl: function(uid) {
+                    var d = $q.defer();
+                    d.resolve({
+                        data: {
+                            avatar_url: avatarUrl
+                        }
+                    });
+                    return d.promise;
+                }
+            };
+            scope = $rootScope.$new();
+            routeParams = {
+                user_matrix_id: userId
+            };
+            ctrl = $controller('UserController', {
+                '$scope': scope, 
+                '$routeParams': routeParams, 
+                'matrixService': matrixService
+            });
+        });
+    });
+
+    it('should display your user id', function() {
+        expect(scope.user_id).toEqual(userId);
+    });
+});