← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rharding/launchpad/lpclient_fix into lp:launchpad

 

Richard Harding has proposed merging lp:~rharding/launchpad/lpclient_fix into lp:launchpad with lp:~rharding/launchpad/garden_client as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1004248 in Launchpad itself: "hook to force page refresh on context url changing is not working"
  https://bugs.launchpad.net/launchpad/+bug/1004248

For more details, see:
https://code.launchpad.net/~rharding/launchpad/lpclient_fix/+merge/116338

= Summary =

Fixes bug #1004248 introduced by attempting to fix bug #911973. That fix only
considered named_post/get methods and not patch methods.


== Pre Implementation ==

Talked a bunch with Aaron and Deryck while working on issue and best way to
fix.


== Implementation Notes ==

The big fix is to only reassign the uri when the request method is not a
patch. #89 of the diff.

This required letting the wrapper method know what the method was so we pass
that along in the args.

Tests were added to verify that this is not altered during a patch, but is
during a named_get/post. See #776.

The test suite was updated to the new standard format and thus was indented
for the big line change there. No other tests were modified or adjusted.

Since the entry/resource lp_original_uri no longer makes sense, it's just
changed to uri. It's used in other methods that the instance makes so they
were updated to match.

There are a few other small drive by lints to fix spacing and such. See
#8/25/etc.

== Tests ==

xvfb-run ./bin/test -x -cvv --layer=YUITestLayer 


== LoC Qualification ==
Need 54 LoC

Worked on cleaning up the client.js, removing the original bug test that isn't
required and isn't run.

See:
https://code.launchpad.net/~rharding/launchpad/garden_client -33
https://code.launchpad.net/~rharding/launchpad/reportbug/+merge/115562 -8
https://code.launchpad.net/~rharding/launchpad/lpnames_yui35 -17
-- 
https://code.launchpad.net/~rharding/launchpad/lpclient_fix/+merge/116338
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rharding/launchpad/lpclient_fix into lp:launchpad.
=== modified file 'lib/lp/app/javascript/client.js'
--- lib/lp/app/javascript/client.js	2012-07-23 18:06:49 +0000
+++ lib/lp/app/javascript/client.js	2012-07-24 16:58:23 +0000
@@ -43,11 +43,11 @@
                               'lp:' + cache_name + ':' + name + ':changed';
                         var new_value_html = entry.getHTML(name);
                         var event = {
-                          'name': name,
-                          'old_value': old_value,
-                          'new_value': new_value,
-                          'new_value_html': new_value_html,
-                          'entry': entry
+                          name: name,
+                          old_value: old_value,
+                          new_value: new_value,
+                          new_value_html: new_value_html,
+                          entry: entry
                         };
                         Y.fire(field_updated_event_name, event);
                     }
@@ -66,17 +66,16 @@
         }
 
         if (fields_changed.length > 0) {
-          var event_name = 'lp:' + cache_name + ':changed';
-          var event_ = {
-            'fields_changed': fields_changed,
-            'entry': entry
-          };
-          Y.fire(event_name, event_);
+            var event_name = 'lp:' + cache_name + ':changed';
+            var event_ = {
+                fields_changed: fields_changed,
+                entry: entry
+            };
+            Y.fire(event_name, event_);
         }
     };
 
 
-
     var module = Y.namespace('lp.client');
 
     module.HTTP_CREATED = 201;
@@ -84,6 +83,9 @@
     module.HTTP_NOT_FOUND = 404;
 
     module.XHTML = 'application/xhtml+xml';
+    module.GET = 'get';
+    module.POST = 'post';
+    module.PATCH = 'patch';
 
     /* Log the normal attributes accessible via o[key], and if it is a
      * YUI node, log all of the attributes accessible via o.get(key).
@@ -328,7 +330,7 @@
         if (!entry) {
             return;
         }
-        var original_uri = entry.lp_original_uri;
+        var original_uri = entry.uri;
         var full_uri = module.get_absolute_uri(original_uri);
         var name;
         var cached_object;
@@ -349,14 +351,29 @@
 
     module.wrap_resource_on_success = function(ignore, response, args) {
         var client = args[0];
+        // The original uri of the caller.
         var uri = args[1];
-        var old_on_success = args[2];
+        var callback = args[2];
         var update_cache = args[3];
+        var method = args[4];
         var representation, wrapped;
-        if (old_on_success) {
+
+        if (callback) {
             var media_type = response.getResponseHeader('Content-Type');
             if (media_type.substring(0,16) === 'application/json') {
                 representation = Y.JSON.parse(response.responseText);
+
+                // If the object fetched has a self_link, make that the object's
+                // uri for use in other api methods off of that object.
+                // During a PATCH request the caller is the object. Leave the
+                // original_uri alone. Otherwise make the uri the object
+                // coming back.
+                if (Y.Lang.isValue(representation) &&
+                    Y.Lang.isValue(representation.self_link) &&
+                    method !== module.PATCH) {
+                    uri = representation.self_link;
+                }
+
                 // If the response contains a notification header, display the
                 // notifications.
                 var notifications = response.getResponseHeader(
@@ -364,18 +381,14 @@
                 if (notifications !== null && notifications !== "") {
                     module.display_notifications(notifications);
                 }
-                if (Y.Lang.isValue(representation) &&
-                    Y.Lang.isValue(representation.self_link)) {
-                    uri = representation.self_link;
-                }
                 wrapped = client.wrap_resource(uri, representation);
-                var result = old_on_success(wrapped);
+                var result = callback(wrapped);
                 if (update_cache) {
-                  module.update_cache(wrapped);
+                    module.update_cache(wrapped);
                 }
                 return result;
             } else {
-                return old_on_success(response.responseText);
+                return callback(response.responseText);
             }
         }
     };
@@ -501,7 +514,7 @@
         'init': function(client, representation, uri) {
             /* Initialize a resource with its representation and URI. */
             this.lp_client = client;
-            this.lp_original_uri = uri;
+            this.uri = uri;
             var key;
             for (key in representation) {
                 if (representation.hasOwnProperty(key)) {
@@ -548,15 +561,13 @@
 
         'named_get': function(operation_name, config) {
             /* Get the result of a named GET operation on this resource. */
-            return this.lp_client.named_get(this.lp_original_uri,
-                                            operation_name,
+            return this.lp_client.named_get(this.uri, operation_name,
                                             config);
         },
 
         'named_post': function(operation_name, config) {
             /* Trigger a named POST operation on this resource. */
-            return this.lp_client.named_post(this.lp_original_uri,
-                                             operation_name,
+            return this.lp_client.named_post(this.uri, operation_name,
                                              config);
         }
     };
@@ -588,14 +599,14 @@
            :param start: Where in the collection to start serving entries.
            :param size: How many entries to serve.
         */
-        return this.lp_client.get(this.lp_original_uri,
+        return this.lp_client.get(this.uri,
                                   {on: on, start: start, size: size});
     };
 
     module.Entry = function(client, representation, uri) {
         /* A single object from the Launchpad web service. */
         this.lp_client = client;
-        this.lp_original_uri = uri;
+        this.uri = uri;
         this.dirty_attributes = [];
         var entry = this;
 
@@ -644,21 +655,21 @@
     };
 
     module.Entry.prototype.getHTML = function(key) {
-      var lp_html = this.get('lp_html');
-      if (lp_html) {
-        // First look for the key.
-        var value = lp_html[key];
-        if (value === undefined) {
-          // now look for key_link
-          value = lp_html[key + '_link'];
-        }
-        if (value !== undefined) {
-          var result = Y.Node.create("<span/>");
-          result.setContent(value);
-          return result;
-        }
-      }
-      return null;
+        var lp_html = this.get('lp_html');
+        if (lp_html) {
+            // First look for the key.
+            var value = lp_html[key];
+            if (value === undefined) {
+                // now look for key_link
+                value = lp_html[key + '_link'];
+            }
+            if (value !== undefined) {
+                var result = Y.Node.create("<span/>");
+                result.setContent(value);
+                return result;
+            }
+        }
+        return null;
     };
 
     // The Launchpad client itself.
@@ -690,7 +701,8 @@
             var client = this;
             var y_config = {
                 on: on,
-                'arguments': [client, uri, old_on_success, update_cache],
+                'arguments': [
+                    client, uri, old_on_success, update_cache, module.GET],
                 'headers': headers,
                 data: data,
                 sync: this.sync
@@ -737,9 +749,8 @@
                                       { on: { success: old_on_success,
                                               failure: on.failure } });
                 }
-                return module.wrap_resource_on_success(undefined,
-                                                       response,
-                                                       args);
+                return module.wrap_resource_on_success(
+                    undefined, response, args, module.POST);
             };
             var client = this;
             var update_cache = false;
@@ -761,12 +772,12 @@
             var old_on_success = on.success;
             var update_cache = true;
             on.success = module.wrap_resource_on_success;
-            args = [this, uri, old_on_success, update_cache];
+            var args = [this, uri, old_on_success, update_cache, module.PATCH];
 
             var extra_headers = {
-                    "X-HTTP-Method-Override": "PATCH",
-                    "Content-Type": "application/json",
-                    "X-Content-Type-Override": "application/json"
+                "X-HTTP-Method-Override": "PATCH",
+                "Content-Type": "application/json",
+                "X-Content-Type-Override": "application/json"
             };
             var name;
             if (headers !== undefined) {
@@ -1027,14 +1038,12 @@
        }
    });
 
-
 }, "0.1", {
     requires: ["attribute", "base", "io", "querystring", "json-parse",
         "json-stringify", "lp"]
 });
 
 YUI.add('lp.client.plugins', function (Y) {
-
     /**
      * A collection of plugins to hook lp.client into widgets.
      *
@@ -1245,7 +1254,6 @@
             }
         }
     });
-
 }, "0.1", {
     requires: ["base", "plugin", "dump", "lazr.editor", "lp.client"]
 });

=== modified file 'lib/lp/app/javascript/tests/test_lp_client.html'
--- lib/lp/app/javascript/tests/test_lp_client.html	2012-03-27 04:36:24 +0000
+++ lib/lp/app/javascript/tests/test_lp_client.html	2012-07-24 16:58:23 +0000
@@ -3,7 +3,6 @@
 Copyright 2012 Canonical Ltd.  This software is licensed under the
 GNU Affero General Public License version 3 (see the file LICENSE).
 -->
-
 <html>
   <head>
       <title>Test client</title>
@@ -30,20 +29,15 @@
       <script type="text/javascript"
           src="../../../../../build/js/lp/app/lp.js"></script>
 
-
       <!-- The module under test. -->
       <script type="text/javascript" src="../client.js"></script>
 
-      <!-- Placeholder for any css asset for this module. -->
-      <!-- <link rel="stylesheet" href="../assets/client-core.css" /> -->
-
       <!-- The test suite. -->
       <script type="text/javascript" src="test_lp_client.js"></script>
 
     </head>
     <body class="yui3-skin-sam">
         <ul id="suites">
-            <!-- <li>lp.large_indicator.test</li> -->
             <li>lp.client.test</li>
         </ul>
         <div class="context-publication">

=== modified file 'lib/lp/app/javascript/tests/test_lp_client.js'
--- lib/lp/app/javascript/tests/test_lp_client.js	2012-06-26 17:16:34 +0000
+++ lib/lp/app/javascript/tests/test_lp_client.js	2012-07-24 16:58:23 +0000
@@ -1,522 +1,587 @@
-/* Copyright (c) 2011, Canonical Ltd. All rights reserved. */
-
-YUI().use('lp.testing.runner', 'lp.testing.mockio', 'test', 'console',
-          'lp.client', 'escape', function(Y) {
-
-var Assert = Y.Assert;  // For easy access to isTrue(), etc.
-
-var suite = new Y.Test.Suite("lp.client Tests");
-
-suite.add(new Y.Test.Case({
-    name: "lp.client",
-
-    setUp: function() {
-    },
-
-    test_normalize_uri: function() {
-        var normalize = Y.lp.client.normalize_uri;
-        Assert.areEqual(
-            normalize("http://www.example.com/api/devel/foo";),
-            "/api/devel/foo");
-        Assert.areEqual(
-            normalize("http://www.example.com/foo/bar";), "/foo/bar");
-        Assert.areEqual(
-            normalize("/foo/bar"), "/api/devel/foo/bar");
-        Assert.areEqual(
-            normalize("/api/devel/foo/bar"), "/api/devel/foo/bar");
-        Assert.areEqual(
-            normalize("foo/bar"), "/api/devel/foo/bar");
-        Assert.areEqual(
-            normalize("api/devel/foo/bar"), "/api/devel/foo/bar");
-    },
-
-    test_get_io_provider__default: function() {
-        var undefined_provider,
-            io_provider = Y.lp.client.get_io_provider(undefined_provider);
-        Assert.areSame(Y, io_provider);
-    },
-
-    test_get_io_provider__mockio: function() {
-        // If a mock provider is provided, it is picked as the io_provider.
-        var mockio = new Y.lp.testing.mockio.MockIo(),
-            io_provider = Y.lp.client.get_io_provider(mockio);
-        Assert.areSame(mockio, io_provider);
-    },
-
-    test_get_configured_io_provider__default: function() {
-        // If no io_provider is configured, Y is the io_provider.
-        var io_provider = Y.lp.client.get_configured_io_provider({});
-        Assert.areSame(Y, io_provider);
-    },
-
-    test_get_configured_io_provider__default_undefined: function() {
-        // If no configuration is provided, Y is the io_provider.
-        var io_provider = Y.lp.client.get_configured_io_provider();
-        Assert.areSame(Y, io_provider);
-    },
-
-    test_get_configured_io_provider__mockio: function() {
-        // If an io_provider is configured,  it is picked as the io_provider.
-        var mockio = new Y.lp.testing.mockio.MockIo(),
-            io_provider = Y.lp.client.get_configured_io_provider(
-                {io_provider: mockio});
-        Assert.areSame(mockio, io_provider);
-    },
-
-    test_get_configured_io_provider__different_key: function() {
-        // The io_provider can be stored with a different key.
-        var mockio = new Y.lp.testing.mockio.MockIo(),
-            io_provider = Y.lp.client.get_configured_io_provider(
-                {my_io: mockio}, 'my_io');
-        Assert.areSame(mockio, io_provider);
-    },
-
-    test_append_qs: function() {
-        var qs = "";
-        qs = Y.lp.client.append_qs(qs, "Pöllä", "Perelló");
-        Assert.areEqual(
-            "P%C3%83%C2%B6ll%C3%83%C2%A4=Perell%C3%83%C2%B3", qs,
-            'This tests is known to fail in Chrome, it is browser specific.');
-    },
-
-    test_append_qs_with_array: function() {
-        // append_qs() appends multiple arguments to the query string
-        // when a parameter value is an array.
-        var qs = "";
-        qs = Y.lp.client.append_qs(qs, "foo", ["bar", "baz"]);
-        Assert.areEqual("foo=bar&foo=baz", qs);
-        // All values in the array are encoded correctly too.
-        qs = Y.lp.client.append_qs(qs, "a&b", ["a+b"]);
-        Assert.areEqual("foo=bar&foo=baz&a%26b=a%2Bb", qs);
-    },
-
-    test_field_uri: function() {
-      var get_field_uri = Y.lp.client.get_field_uri;
-      Assert.areEqual(
-          get_field_uri("http://www.example.com/api/devel/foo";, "field"),
-          "/api/devel/foo/field");
-      Assert.areEqual(
-          get_field_uri("/no/slash", "field"),
-          "/api/devel/no/slash/field");
-      Assert.areEqual(
-          get_field_uri("/has/slash/", "field"),
-          "/api/devel/has/slash/field");
-    },
-    test_view_url: function() {
-        entry_repr = {web_link: 'http://example.com/context'};
-        var context = new Y.lp.client.Entry(null, entry_repr, null);
-        expected = '/context/+myview/++mynamespace++';
-        actual = Y.lp.client.get_view_url(context, '+myview', 'mynamespace');
-        Assert.areEqual(expected, actual);
-    },
-    test_get_form_url: function() {
-        entry_repr = {web_link: 'http://example.com/context'};
-        var context = new Y.lp.client.Entry(null, entry_repr, null);
-        expected = '/context/+myview/++form++';
-        actual = Y.lp.client.get_form_url(context, '+myview');
-        Assert.areEqual(expected, actual);
-    },
-    test_load_model: function(){
-        var mockio = new Y.lp.testing.mockio.MockIo();
-        Assert.areEqual(0, mockio.requests.length);
-        var mylist = [];
-        var config = {
-            io_provider: mockio,
-            on: {
-                success: Y.bind(mylist.push, mylist)
-            }
-        };
-        var entry_repr = {web_link: 'http://example.com/context'};
-        var client = new Y.lp.client.Launchpad();
-        var context = new Y.lp.client.Entry(client, entry_repr, null);
-        Y.lp.client.load_model(context, '+myview', config);
-        Assert.areEqual(
-            '/context/+myview/++model++', mockio.last_request.url);
-        mockio.success({
-            responseText:
-                '{"boolean": true, "entry": {"resource_type_link": "foo"}}',
-            responseHeaders: {'Content-Type': 'application/json'}
-        });
-        var result = mylist[0];
-        Assert.areSame(true, result.boolean);
-        Assert.isInstanceOf(Y.lp.client.Entry, result.entry);
-        Assert.areSame('foo', result.entry.get('resource_type_link'));
-    },
-    test_get_success_callback: function() {
-      var mockio = new Y.lp.testing.mockio.MockIo();
-      var mylist = [];
-      var client = new Y.lp.client.Launchpad({io_provider: mockio});
-      client.get('/people', {on:{success: Y.bind(mylist.push, mylist)}});
-      Assert.areEqual('/api/devel/people', mockio.last_request.url);
-      mockio.success({
-        responseText:
-        '{"entry": {"resource_type_link": "foo"}}',
-        responseHeaders: {'Content-Type': 'application/json'}
-      });
-      var result = mylist[0];
-      Assert.isInstanceOf(Y.lp.client.Entry, result.entry);
-      Assert.areSame('foo', result.entry.get('resource_type_link'));
-    },
-    test_get_failure_callback: function() {
-      var mockio = new Y.lp.testing.mockio.MockIo();
-      var mylist = [];
-      var client = new Y.lp.client.Launchpad({io_provider: mockio});
-      client.get(
-        '/people',
-        {on: {
-          failure: function(){
-            mylist.push(Array.prototype.slice.call(arguments));
-          }}});
-      mockio.failure({status: 503});
-      var result = mylist[0];
-      Assert.areSame(503, result[1].status);
-      Assert.areSame('/api/devel/people', result[2][1]);
-    },
-    test_named_post_success_callback: function() {
-      var mockio = new Y.lp.testing.mockio.MockIo();
-      var mylist = [];
-      var client = new Y.lp.client.Launchpad({io_provider: mockio});
-      client.named_post(
-        '/people', 'newTeam', {on:{success: Y.bind(mylist.push, mylist)}});
-      Assert.areEqual('/api/devel/people', mockio.last_request.url);
-      Assert.areEqual('ws.op=newTeam', mockio.last_request.config.data);
-      Assert.areEqual('POST', mockio.last_request.config.method);
-      mockio.success({
-        responseText:
-        '{"entry": {"resource_type_link": "foo"}}',
-        responseHeaders: {'Content-Type': 'application/json'}
-      });
-      var result = mylist[0];
-      Assert.isInstanceOf(Y.lp.client.Entry, result.entry);
-      Assert.areSame('foo', result.entry.get('resource_type_link'));
-    },
-    test_wrap_resource_nested_mapping: function() {
-        // wrap_resource produces mappings of plain object literals. These can
-        // be nested and have Entries in them.
-        var foo = {
-            baa: {},
-            bar: {
-                baz: {
-                    resource_type_link: 'qux'}
-            }
-        };
-        foo = new Y.lp.client.Launchpad().wrap_resource(null, foo);
-        Y.Assert.isInstanceOf(Y.lp.client.Entry, foo.bar.baz);
-    },
-    test_wrap_resource_nested_array: function() {
-        // wrap_resource produces arrays of array literals. These can
-        // be nested and have Entries in them.
-        var foo = [[{resource_type_link: 'qux'}]];
-        foo = new Y.lp.client.Launchpad().wrap_resource(null, foo);
-        Y.Assert.isInstanceOf(Y.lp.client.Entry, foo[0][0]);
-    },
-    test_wrap_resource_creates_array: function() {
-        // wrap_resource creates new arrays, rather than reusing the existing
-        // one.
-        var foo = ['a'];
-        var bar = new Y.lp.client.Launchpad().wrap_resource(null, foo);
-        Y.Assert.areNotSame(foo, bar);
-    },
-    test_wrap_resource_creates_mapping: function() {
-        // wrap_resource creates new mappings, rather than reusing the
-        // existing one.
-        var foo = {a: 'b'};
-        var bar = new Y.lp.client.Launchpad().wrap_resource(null, foo);
-        Y.Assert.areNotSame(foo, bar);
-    },
-    test_wrap_resource_null: function() {
-        // wrap_resource handles null correctly.
-        var foo = {
-            bar: null
-        };
-        foo = new Y.lp.client.Launchpad().wrap_resource(null, foo);
-        Y.Assert.isNull(foo.bar);
-    }
-}));
-
-suite.add(new Y.Test.Case({
-    name: "update cache",
-
-    setUp: function() {
-        window.LP = {
-          cache: {
-             context: {
+/* Copyright (c) 2011-2012 Canonical Ltd. All rights reserved. */
+YUI.add('lp.client.test', function (Y) {
+    var Assert = Y.Assert;
+    var tests = Y.namespace('lp.client.test');
+    tests.suite = new Y.Test.Suite('client Tests');
+    tests.suite.add(new Y.Test.Case({
+        name: "lp.client",
+
+        setUp: function() {
+        },
+
+        test_normalize_uri: function() {
+            var normalize = Y.lp.client.normalize_uri;
+            Assert.areEqual(
+                normalize("http://www.example.com/api/devel/foo";),
+                "/api/devel/foo");
+            Assert.areEqual(
+                normalize("http://www.example.com/foo/bar";), "/foo/bar");
+            Assert.areEqual(
+                normalize("/foo/bar"), "/api/devel/foo/bar");
+            Assert.areEqual(
+                normalize("/api/devel/foo/bar"), "/api/devel/foo/bar");
+            Assert.areEqual(
+                normalize("foo/bar"), "/api/devel/foo/bar");
+            Assert.areEqual(
+                normalize("api/devel/foo/bar"), "/api/devel/foo/bar");
+        },
+
+        test_get_io_provider__default: function() {
+            var undefined_provider,
+                io_provider = Y.lp.client.get_io_provider(undefined_provider);
+            Assert.areSame(Y, io_provider);
+        },
+
+        test_get_io_provider__mockio: function() {
+            // If a mock provider is provided, it is picked as the io_provider.
+            var mockio = new Y.lp.testing.mockio.MockIo(),
+                io_provider = Y.lp.client.get_io_provider(mockio);
+            Assert.areSame(mockio, io_provider);
+        },
+
+        test_get_configured_io_provider__default: function() {
+            // If no io_provider is configured, Y is the io_provider.
+            var io_provider = Y.lp.client.get_configured_io_provider({});
+            Assert.areSame(Y, io_provider);
+        },
+
+        test_get_configured_io_provider__default_undefined: function() {
+            // If no configuration is provided, Y is the io_provider.
+            var io_provider = Y.lp.client.get_configured_io_provider();
+            Assert.areSame(Y, io_provider);
+        },
+
+        test_get_configured_io_provider__mockio: function() {
+            // If an io_provider is configured,  it is picked as the
+            // io_provider.
+            var mockio = new Y.lp.testing.mockio.MockIo(),
+                io_provider = Y.lp.client.get_configured_io_provider(
+                    {io_provider: mockio});
+            Assert.areSame(mockio, io_provider);
+        },
+
+        test_get_configured_io_provider__different_key: function() {
+            // The io_provider can be stored with a different key.
+            var mockio = new Y.lp.testing.mockio.MockIo(),
+                io_provider = Y.lp.client.get_configured_io_provider(
+                    {my_io: mockio}, 'my_io');
+            Assert.areSame(mockio, io_provider);
+        },
+
+        test_append_qs: function() {
+            var qs = "";
+            qs = Y.lp.client.append_qs(qs, "Pöllä", "Perelló");
+            Assert.areEqual(
+                "P%C3%83%C2%B6ll%C3%83%C2%A4=Perell%C3%83%C2%B3", qs,
+                'This tests is known to fail in Chrome.');
+        },
+
+        test_append_qs_with_array: function() {
+            // append_qs() appends multiple arguments to the query string
+            // when a parameter value is an array.
+            var qs = "";
+            qs = Y.lp.client.append_qs(qs, "foo", ["bar", "baz"]);
+            Assert.areEqual("foo=bar&foo=baz", qs);
+            // All values in the array are encoded correctly too.
+            qs = Y.lp.client.append_qs(qs, "a&b", ["a+b"]);
+            Assert.areEqual("foo=bar&foo=baz&a%26b=a%2Bb", qs);
+        },
+
+        test_field_uri: function() {
+          var get_field_uri = Y.lp.client.get_field_uri;
+          Assert.areEqual(
+              get_field_uri("http://www.example.com/api/devel/foo";, "field"),
+              "/api/devel/foo/field");
+          Assert.areEqual(
+              get_field_uri("/no/slash", "field"),
+              "/api/devel/no/slash/field");
+          Assert.areEqual(
+              get_field_uri("/has/slash/", "field"),
+              "/api/devel/has/slash/field");
+        },
+        test_view_url: function() {
+            entry_repr = {web_link: 'http://example.com/context'};
+            var context = new Y.lp.client.Entry(null, entry_repr, null);
+            expected = '/context/+myview/++mynamespace++';
+            actual = Y.lp.client.get_view_url(
+                context, '+myview', 'mynamespace');
+            Assert.areEqual(expected, actual);
+        },
+        test_get_form_url: function() {
+            entry_repr = {web_link: 'http://example.com/context'};
+            var context = new Y.lp.client.Entry(null, entry_repr, null);
+            expected = '/context/+myview/++form++';
+            actual = Y.lp.client.get_form_url(context, '+myview');
+            Assert.areEqual(expected, actual);
+        },
+        test_load_model: function(){
+            var mockio = new Y.lp.testing.mockio.MockIo();
+            Assert.areEqual(0, mockio.requests.length);
+            var mylist = [];
+            var config = {
+                io_provider: mockio,
+                on: {
+                    success: Y.bind(mylist.push, mylist)
+                }
+            };
+            var entry_repr = {web_link: 'http://example.com/context'};
+            var client = new Y.lp.client.Launchpad();
+            var context = new Y.lp.client.Entry(client, entry_repr, null);
+            Y.lp.client.load_model(context, '+myview', config);
+            Assert.areEqual(
+                '/context/+myview/++model++', mockio.last_request.url);
+            mockio.success({
+                responseText:
+                    '{"boolean": true, "entry": {"resource_type_link": "foo"}}',
+                responseHeaders: {'Content-Type': 'application/json'}
+            });
+            var result = mylist[0];
+            Assert.areSame(true, result.boolean);
+            Assert.isInstanceOf(Y.lp.client.Entry, result.entry);
+            Assert.areSame('foo', result.entry.get('resource_type_link'));
+        },
+        test_get_success_callback: function() {
+          var mockio = new Y.lp.testing.mockio.MockIo();
+          var mylist = [];
+          var client = new Y.lp.client.Launchpad({io_provider: mockio});
+          client.get('/people', {on:{success: Y.bind(mylist.push, mylist)}});
+          Assert.areEqual('/api/devel/people', mockio.last_request.url);
+          mockio.success({
+            responseText:
+            '{"entry": {"resource_type_link": "foo"}}',
+            responseHeaders: {'Content-Type': 'application/json'}
+          });
+          var result = mylist[0];
+          Assert.isInstanceOf(Y.lp.client.Entry, result.entry);
+          Assert.areSame('foo', result.entry.get('resource_type_link'));
+        },
+        test_get_failure_callback: function() {
+          var mockio = new Y.lp.testing.mockio.MockIo();
+          var mylist = [];
+          var client = new Y.lp.client.Launchpad({io_provider: mockio});
+          client.get(
+            '/people',
+            {on: {
+              failure: function(){
+                mylist.push(Array.prototype.slice.call(arguments));
+              }}});
+          mockio.failure({status: 503});
+          var result = mylist[0];
+          Assert.areSame(503, result[1].status);
+          Assert.areSame('/api/devel/people', result[2][1]);
+        },
+        test_named_post_success_callback: function() {
+          var mockio = new Y.lp.testing.mockio.MockIo();
+          var mylist = [];
+          var client = new Y.lp.client.Launchpad({io_provider: mockio});
+          client.named_post(
+            '/people', 'newTeam', {on:{success: Y.bind(mylist.push, mylist)}});
+          Assert.areEqual('/api/devel/people', mockio.last_request.url);
+          Assert.areEqual('ws.op=newTeam', mockio.last_request.config.data);
+          Assert.areEqual('POST', mockio.last_request.config.method);
+          mockio.success({
+            responseText:
+            '{"entry": {"resource_type_link": "foo"}}',
+            responseHeaders: {'Content-Type': 'application/json'}
+          });
+          var result = mylist[0];
+          Assert.isInstanceOf(Y.lp.client.Entry, result.entry);
+          Assert.areSame('foo', result.entry.get('resource_type_link'));
+        },
+        test_wrap_resource_nested_mapping: function() {
+            // wrap_resource produces mappings of plain object literals. These
+            // can be nested and have Entries in them.
+            var foo = {
+                baa: {},
+                bar: {
+                    baz: {
+                        resource_type_link: 'qux'}
+                }
+            };
+            foo = new Y.lp.client.Launchpad().wrap_resource(null, foo);
+            Assert.isInstanceOf(Y.lp.client.Entry, foo.bar.baz);
+        },
+        test_wrap_resource_nested_array: function() {
+            // wrap_resource produces arrays of array literals. These can
+            // be nested and have Entries in them.
+            var foo = [[{resource_type_link: 'qux'}]];
+            foo = new Y.lp.client.Launchpad().wrap_resource(null, foo);
+            Assert.isInstanceOf(Y.lp.client.Entry, foo[0][0]);
+        },
+        test_wrap_resource_creates_array: function() {
+            // wrap_resource creates new arrays, rather than reusing the
+            // existing one.
+            var foo = ['a'];
+            var bar = new Y.lp.client.Launchpad().wrap_resource(null, foo);
+            Assert.areNotSame(foo, bar);
+        },
+        test_wrap_resource_creates_mapping: function() {
+            // wrap_resource creates new mappings, rather than reusing the
+            // existing one.
+            var foo = {a: 'b'};
+            var bar = new Y.lp.client.Launchpad().wrap_resource(null, foo);
+            Assert.areNotSame(foo, bar);
+        },
+        test_wrap_resource_null: function() {
+            // wrap_resource handles null correctly.
+            var foo = {
+                bar: null
+            };
+            foo = new Y.lp.client.Launchpad().wrap_resource(null, foo);
+            Assert.isNull(foo.bar);
+        }
+    }));
+
+    tests.suite.add(new Y.Test.Case({
+        name: "lp.wrap_on_success",
+        original_uri: 'http://launchpad.net/original_uri',
+        updated_uri: 'http://launchpad.net/object_uri',
+
+        setUp: function() {
+            window.LP = {};
+            this.called = false;
+        },
+
+        tearDown: function () {
+            delete window.LP;
+            delete this.called;
+        },
+
+        _gen_callback: function (uri) {
+            var that = this;
+            return function (wrapped) {
+                that.called = true;
+                Assert.areEqual(wrapped.uri, uri);
+            };
+        },
+
+        _fake_response: function () {
+            return {
+                responseText: Y.JSON.stringify({
+                    self_link: this.updated_uri,
+                    resource_type_link: 'object'
+                }),
+                getResponseHeader: function (key) {
+                    var headers = {'Content-Type': 'application/json'};
+                    return headers[key];
+                }
+            };
+        },
+
+        test_wrap_resource_patch_link: function () {
+            // wrap_resource_on_success will not modify the uri on a patch
+            // request and keep the original value.
+            var callback = this._gen_callback(this.original_uri);
+            Y.lp.client.wrap_resource_on_success(10, this._fake_response(), [
+                 new Y.lp.client.Launchpad(), this.original_uri, callback,
+                     true, 'patch']
+            );
+            Assert.isTrue(this.called);
+        },
+
+        test_wrap_resource_patch_named: function () {
+            // wrap_resource_on_success will modify the uri on a
+            // named_get/post methods to the value that came back in
+            // self_link.
+            var callback = this._gen_callback(this.updated_uri);
+            Y.lp.client.wrap_resource_on_success(10, this._fake_response(), [
+                new Y.lp.client.Launchpad(), this.original_uri, callback,
+                    true, 'post']
+            );
+            Assert.isTrue(this.called);
+        }
+    }));
+
+    tests.suite.add(new Y.Test.Case({
+        name: "update cache",
+
+        setUp: function() {
+            window.LP = {
+              cache: {
+                 context: {
+                  'first': "Hello",
+                  'second': true,
+                  'third': 42,
+                  'fourth': "Unaltered",
+                  'self_link': Y.lp.client.get_absolute_uri("a_self_link")
+                }
+              }};
+        },
+
+        tearDown: function() {
+            delete window.LP;
+        },
+
+        test_update_cache: function() {
+            // Make sure that the cached objects are in fact updated.
+            var entry_repr = {
+              'first': "World",
+              'second': false,
+              'third': 24,
+              'fourth': "Unaltered",
+              'self_link': Y.lp.client.get_absolute_uri("a_self_link")
+            };
+            var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
+            Y.lp.client.update_cache(entry);
+            Assert.areEqual("World", LP.cache.context.first);
+            Assert.areEqual(false, LP.cache.context.second);
+            Assert.areEqual(24, LP.cache.context.third);
+            Assert.areEqual("Unaltered", LP.cache.context.fourth);
+        },
+
+        test_getHTML: function() {
+            // Make sure that the getHTML method works as expected.
+            var entry_repr = {
               'first': "Hello",
-              'second': true,
-              'third': 42,
+              'second': "World",
+              'self_link': Y.lp.client.get_absolute_uri("a_self_link"),
+              'lp_html': {'first': "<p>Hello</p><p>World</p>"}
+            };
+            var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
+            Assert.areEqual(
+                "<p>Hello</p><p>World</p>",
+                entry.getHTML('first').get('innerHTML'));
+            // If there is no html representation, null is returned.
+            Assert.areEqual(null, entry.getHTML('second'));
+          },
+
+        test_update_cache_raises_events: function() {
+            // Check that the object changed event is raised.
+            var raised_event = null;
+            var handle = Y.on('lp:context:changed', function(e) {
+                raised_event = e;
+              });
+            var entry_repr = {
+              'first': "World",
+              'second': false,
+              'third': 24,
               'fourth': "Unaltered",
               'self_link': Y.lp.client.get_absolute_uri("a_self_link")
-            }
-          }};
-    },
-
-    tearDown: function() {
-        delete window.LP;
-    },
-
-    test_update_cache: function() {
-        // Make sure that the cached objects are in fact updated.
-        var entry_repr = {
-          'first': "World",
-          'second': false,
-          'third': 24,
-          'fourth': "Unaltered",
-          'self_link': Y.lp.client.get_absolute_uri("a_self_link")
-        };
-        var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
-        Y.lp.client.update_cache(entry);
-        Assert.areEqual("World", LP.cache.context.first);
-        Assert.areEqual(false, LP.cache.context.second);
-        Assert.areEqual(24, LP.cache.context.third);
-        Assert.areEqual("Unaltered", LP.cache.context.fourth);
-      },
-
-    test_getHTML: function() {
-        // Make sure that the getHTML method works as expected.
-        var entry_repr = {
-          'first': "Hello",
-          'second': "World",
-          'self_link': Y.lp.client.get_absolute_uri("a_self_link"),
-          'lp_html': {'first': "<p>Hello</p><p>World</p>"}
-        };
-        var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
-        Assert.areEqual(
-            "<p>Hello</p><p>World</p>",
-            entry.getHTML('first').get('innerHTML'));
-        // If there is no html representation, null is returned.
-        Assert.areEqual(null, entry.getHTML('second'));
-      },
-
-    test_update_cache_raises_events: function() {
-        // Check that the object changed event is raised.
-        var raised_event = null;
-        var handle = Y.on('lp:context:changed', function(e) {
-            raised_event = e;
-          });
-        var entry_repr = {
-          'first': "World",
-          'second': false,
-          'third': 24,
-          'fourth': "Unaltered",
-          'self_link': Y.lp.client.get_absolute_uri("a_self_link")
-        };
-        var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
-        Y.lp.client.update_cache(entry);
-        handle.detach();
-        Y.ArrayAssert.itemsAreEqual(
-            ['first','second','third'], raised_event.fields_changed);
-        Assert.areEqual(entry, raised_event.entry);
-      },
-
-    test_update_cache_raises_attribute_events: function() {
-        // Check that the object attribute changed events are raised.
-        var first_event = null;
-        var second_event = null;
-        var third_event = null;
-        var fourth_event = null;
-        var first_handle = Y.on('lp:context:first:changed', function(e) {
-            first_event = e;
-          });
-        var second_handle = Y.on('lp:context:second:changed', function(e) {
-            second_event = e;
-          });
-        var third_handle = Y.on('lp:context:third:changed', function(e) {
-            third_event = e;
-          });
-        var fourth_handle = Y.on('lp:context:fourth:changed', function(e) {
-            fourth_event = e;
-          });
-        var entry_repr = {
-          'first': "World<boo/>",
-          'second': false,
-          'third': 24,
-          'fourth': "Unaltered",
-          'self_link': Y.lp.client.get_absolute_uri("a_self_link"),
-          'lp_html': {'first': "<p>World html<boo/></p>"}
-        };
-        var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
-        Y.lp.client.update_cache(entry);
-        first_handle.detach();
-        second_handle.detach();
-        third_handle.detach();
-        fourth_handle.detach();
-
-        Assert.areEqual('first', first_event.name);
-        Assert.areEqual('Hello', first_event.old_value);
-        Assert.areEqual('World<boo/>', first_event.new_value);
-        Assert.areEqual(
-            '<p>World html<boo></boo></p>',
-            first_event.new_value_html.get('innerHTML'));
-        Assert.areEqual(entry, first_event.entry);
-
-        Assert.areEqual('second', second_event.name);
-        Assert.areEqual(true, second_event.old_value);
-        Assert.areEqual(false, second_event.new_value);
-        Assert.areEqual(entry, second_event.entry);
-
-        Assert.areEqual('third', third_event.name);
-        Assert.areEqual(42, third_event.old_value);
-        Assert.areEqual(24, third_event.new_value);
-        Assert.areEqual(entry, third_event.entry);
-
-        Assert.isNull(fourth_event);
-      },
-
-    test_update_cache_different_object: function() {
-        // Check that the object is not modified if the entry has a different
-        // link.
-        var entry_repr = {
-          'first': "World",
-          'second': false,
-          'third': 24,
-          'fourth': "Unaltered",
-          'self_link': Y.lp.client.get_absolute_uri("different_link")
-        };
-        var entry = new Y.lp.client.Entry(null, entry_repr, "different_link");
-        Y.lp.client.update_cache(entry);
-        Assert.areEqual("Hello", LP.cache.context.first);
-        Assert.areEqual(true, LP.cache.context.second);
-        Assert.areEqual(42, LP.cache.context.third);
-        Assert.areEqual("Unaltered", LP.cache.context.fourth);
-      }
-}));
-
-suite.add(new Y.Test.Case({
-    name: "lp.client.notifications",
-
-    setUp: function() {
-        this.client = new Y.lp.client.Launchpad();
-        this.args=[this.client, null, this._on_success, false];
-        this.response = new Y.lp.testing.mockio.MockHttpResponse();
-        this.response.setResponseHeader('Content-Type', 'application/json');
-    },
-
-    _on_success: function(entry) {
-    },
-
-    _checkNotificationNode: function(node_class, node_text) {
-        var node = Y.one('div#request-notifications div'+node_class);
-        Assert.areEqual(node_text, node.get("innerHTML"));
-    },
-
-    _checkNoNotificationNode: function(node_class) {
-        var node = Y.one('div#request-notifications div'+node_class);
-        Assert.isNull(node);
-    },
-
-    test_display_notifications: function() {
-        var notifications = '[ [10, "A debug"], [20, "An info"] ]';
-        this.response.setResponseHeader(
-                'X-Lazr-Notifications', notifications);
-        Y.lp.client.wrap_resource_on_success(null, this.response, this.args);
-        this._checkNotificationNode('.debug.message', 'A debug');
-        this._checkNotificationNode('.informational.message', 'An info');
-
-        // Any subsequent request should preserve existing notifications.
-        var new_notifications = '[ [30, "A warning"], [40, "An error"] ]';
-        this.response.setResponseHeader(
-                'X-Lazr-Notifications', new_notifications);
-        Y.lp.client.wrap_resource_on_success(null, this.response, this.args);
-        this._checkNotificationNode('.debug.message', 'A debug');
-        this._checkNotificationNode('.informational.message', 'An info');
-        this._checkNotificationNode('.warning.message', 'A warning');
-        this._checkNotificationNode('.error.message', 'An error');
-    },
-
-    test_remove_notifications: function() {
-        // Make some notifications that will be removed.
-        var notifications = '[ [10, "A debug"], [20, "An info"] ]';
-        this.response.setResponseHeader(
-                'X-Lazr-Notifications', notifications);
-        Y.lp.client.wrap_resource_on_success(null, this.response, this.args);
-
-        // If the notifications header is just the string "null", then the
-        // current notifications are removed.
-        this.response.setResponseHeader('X-Lazr-Notifications', "null");
-        Y.lp.client.wrap_resource_on_success(null, this.response, this.args);
-        this._checkNoNotificationNode('.debug.message');
-        this._checkNoNotificationNode('.informational.message');
-    },
-
-    test_notifications_not_removed: function() {
-        // Make some notifications that will be removed.
-        var notifications = '[ [10, "A debug"], [20, "An info"] ]';
-        this.response.setResponseHeader(
-                'X-Lazr-Notifications', notifications);
-        Y.lp.client.wrap_resource_on_success(null, this.response, this.args);
-
-        // If the response does not include a notifications header, then any
-        // pre-existing notifiactions are not removed.
-        this.response.setResponseHeader('X-Lazr-Notifications', null);
-        Y.lp.client.wrap_resource_on_success(null, this.response, this.args);
-        this._checkNotificationNode('.debug.message', 'A debug');
-        this._checkNotificationNode('.informational.message', 'An info');
-    }
-}));
-
-suite.add(new Y.Test.Case({
-    name: "lp.client.forms",
-
-    setUp: function() {
-        var form = Y.one("#testform");
-        this.error_handler = new Y.lp.client.FormErrorHandler({
-            form: form
-        });
-    },
-
-    tearDown: function() {
-        this.error_handler.clearFormErrors();
-    },
-
-    test_form_error_handler_ignores_other_responses: function() {
-        // Only XHR responses not containing validation data are ignored.
-        var result = this.error_handler.handleError(0, {
-            status: 400,
-            statusText: 'Not Validation'
-        });
-        Y.Assert.isFalse(result);
-    },
-
-    test_form_error_handler_handles_responses: function() {
-        // XHR responses containing validation data are processed.
-        var error_data = {
-            'error_summary': 'Some errors',
-            'form_wide_errors': ['Form error'],
-            errors: {'field.test': 'Field error'}
-        };
-        var result = this.error_handler.handleError(0, {
-            status: 400,
-            statusText: 'Validation',
-            responseText: Y.JSON.stringify(error_data)
-        });
-        Y.Assert.isTrue(result);
-        this._assert_error_rendering();
-    },
-
-    _assert_error_rendering: function() {
-        var label = Y.one('label[for="field.test"]');
-        var field_error = label.next('div').next('.message');
-        Y.Assert.isTrue(Y.one('#field_div').hasClass('error'),
-                       'Field div has class error');
-        Y.Assert.areEqual('Field error', field_error.getContent());
-        Y.all('.error.message').each(function(error_node) {
-            var error_message = error_node.getContent();
-            Y.Assert.isTrue(
-                error_message === '<p>Form error</p>' ||
-                error_message === 'Some errors',
-                'Each error message has the correct content.');
-        });
-    },
-
-    test_form_error_handler_renders_errors: function() {
-        // Form errors are rendered correctly.
-        this.error_handler.handleFormValidationError(
-            "Some errors", ["Form error"],
-            {'field.test': "Field error"});
-        this._assert_error_rendering();
-    }
-}));
-
-Y.lp.testing.Runner.run(suite);
-
+            };
+            var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
+            Y.lp.client.update_cache(entry);
+            handle.detach();
+            Y.ArrayAssert.itemsAreEqual(
+                ['first','second','third'], raised_event.fields_changed);
+            Assert.areEqual(entry, raised_event.entry);
+          },
+
+        test_update_cache_raises_attribute_events: function() {
+            // Check that the object attribute changed events are raised.
+            var first_event = null;
+            var second_event = null;
+            var third_event = null;
+            var fourth_event = null;
+            var first_handle = Y.on('lp:context:first:changed', function(e) {
+                first_event = e;
+              });
+            var second_handle = Y.on('lp:context:second:changed', function(e) {
+                second_event = e;
+              });
+            var third_handle = Y.on('lp:context:third:changed', function(e) {
+                third_event = e;
+              });
+            var fourth_handle = Y.on('lp:context:fourth:changed', function(e) {
+                fourth_event = e;
+              });
+            var entry_repr = {
+              'first': "World<boo/>",
+              'second': false,
+              'third': 24,
+              'fourth': "Unaltered",
+              'self_link': Y.lp.client.get_absolute_uri("a_self_link"),
+              'lp_html': {'first': "<p>World html<boo/></p>"}
+            };
+            var entry = new Y.lp.client.Entry(null, entry_repr, "a_self_link");
+            Y.lp.client.update_cache(entry);
+            first_handle.detach();
+            second_handle.detach();
+            third_handle.detach();
+            fourth_handle.detach();
+
+            Assert.areEqual('first', first_event.name);
+            Assert.areEqual('Hello', first_event.old_value);
+            Assert.areEqual('World<boo/>', first_event.new_value);
+            Assert.areEqual(
+                '<p>World html<boo></boo></p>',
+                first_event.new_value_html.get('innerHTML'));
+            Assert.areEqual(entry, first_event.entry);
+
+            Assert.areEqual('second', second_event.name);
+            Assert.areEqual(true, second_event.old_value);
+            Assert.areEqual(false, second_event.new_value);
+            Assert.areEqual(entry, second_event.entry);
+
+            Assert.areEqual('third', third_event.name);
+            Assert.areEqual(42, third_event.old_value);
+            Assert.areEqual(24, third_event.new_value);
+            Assert.areEqual(entry, third_event.entry);
+
+            Assert.isNull(fourth_event);
+          },
+
+        test_update_cache_different_object: function() {
+            // Check that the object is not modified if the entry has a
+            // different link.
+            var entry_repr = {
+              'first': "World",
+              'second': false,
+              'third': 24,
+              'fourth': "Unaltered",
+              'self_link': Y.lp.client.get_absolute_uri("different_link")
+            };
+            var entry = new Y.lp.client.Entry(
+                null, entry_repr, "different_link");
+            Y.lp.client.update_cache(entry);
+            Assert.areEqual("Hello", LP.cache.context.first);
+            Assert.areEqual(true, LP.cache.context.second);
+            Assert.areEqual(42, LP.cache.context.third);
+            Assert.areEqual("Unaltered", LP.cache.context.fourth);
+          }
+    }));
+
+    tests.suite.add(new Y.Test.Case({
+        name: "lp.client.notifications",
+
+        setUp: function() {
+            this.client = new Y.lp.client.Launchpad();
+            this.args=[this.client, null, this._on_success, false];
+            this.response = new Y.lp.testing.mockio.MockHttpResponse();
+            this.response.setResponseHeader('Content-Type', 'application/json');
+        },
+
+        _on_success: function(entry) {
+        },
+
+        _checkNotificationNode: function(node_class, node_text) {
+            var node = Y.one('div#request-notifications div'+node_class);
+            Assert.areEqual(node_text, node.get("innerHTML"));
+        },
+
+        _checkNoNotificationNode: function(node_class) {
+            var node = Y.one('div#request-notifications div'+node_class);
+            Assert.isNull(node);
+        },
+
+        test_display_notifications: function() {
+            var notifications = '[ [10, "A debug"], [20, "An info"] ]';
+            this.response.setResponseHeader(
+                    'X-Lazr-Notifications', notifications);
+            Y.lp.client.wrap_resource_on_success(
+                null, this.response, this.args);
+            this._checkNotificationNode('.debug.message', 'A debug');
+            this._checkNotificationNode('.informational.message', 'An info');
+
+            // Any subsequent request should preserve existing notifications.
+            var new_notifications = '[ [30, "A warning"], [40, "An error"] ]';
+            this.response.setResponseHeader(
+                    'X-Lazr-Notifications', new_notifications);
+            Y.lp.client.wrap_resource_on_success(
+                null, this.response, this.args);
+            this._checkNotificationNode('.debug.message', 'A debug');
+            this._checkNotificationNode('.informational.message', 'An info');
+            this._checkNotificationNode('.warning.message', 'A warning');
+            this._checkNotificationNode('.error.message', 'An error');
+        },
+
+        test_remove_notifications: function() {
+            // Make some notifications that will be removed.
+            var notifications = '[ [10, "A debug"], [20, "An info"] ]';
+            this.response.setResponseHeader(
+                    'X-Lazr-Notifications', notifications);
+            Y.lp.client.wrap_resource_on_success(
+                null, this.response, this.args);
+
+            // If the notifications header is just the string "null", then the
+            // current notifications are removed.
+            this.response.setResponseHeader('X-Lazr-Notifications', "null");
+            Y.lp.client.wrap_resource_on_success(
+                null, this.response, this.args);
+            this._checkNoNotificationNode('.debug.message');
+            this._checkNoNotificationNode('.informational.message');
+        },
+
+        test_notifications_not_removed: function() {
+            // Make some notifications that will be removed.
+            var notifications = '[ [10, "A debug"], [20, "An info"] ]';
+            this.response.setResponseHeader(
+                    'X-Lazr-Notifications', notifications);
+            Y.lp.client.wrap_resource_on_success(
+                null, this.response, this.args);
+
+            // If the response does not include a notifications header, then
+                // any pre-existing notifiactions are not removed.
+            this.response.setResponseHeader('X-Lazr-Notifications', null);
+            Y.lp.client.wrap_resource_on_success(
+                null, this.response, this.args);
+            this._checkNotificationNode('.debug.message', 'A debug');
+            this._checkNotificationNode('.informational.message', 'An info');
+        }
+    }));
+
+    tests.suite.add(new Y.Test.Case({
+        name: "lp.client.forms",
+
+        setUp: function() {
+            var form = Y.one("#testform");
+            this.error_handler = new Y.lp.client.FormErrorHandler({
+                form: form
+            });
+        },
+
+        tearDown: function() {
+            this.error_handler.clearFormErrors();
+        },
+
+        test_form_error_handler_ignores_other_responses: function() {
+            // Only XHR responses not containing validation data are ignored.
+            var result = this.error_handler.handleError(0, {
+                status: 400,
+                statusText: 'Not Validation'
+            });
+            Assert.isFalse(result);
+        },
+
+        test_form_error_handler_handles_responses: function() {
+            // XHR responses containing validation data are processed.
+            var error_data = {
+                'error_summary': 'Some errors',
+                'form_wide_errors': ['Form error'],
+                errors: {'field.test': 'Field error'}
+            };
+            var result = this.error_handler.handleError(0, {
+                status: 400,
+                statusText: 'Validation',
+                responseText: Y.JSON.stringify(error_data)
+            });
+            Assert.isTrue(result);
+            this._assert_error_rendering();
+        },
+
+        _assert_error_rendering: function() {
+            var label = Y.one('label[for="field.test"]');
+            var field_error = label.next('div').next('.message');
+            Assert.isTrue(Y.one('#field_div').hasClass('error'),
+                           'Field div has class error');
+            Assert.areEqual('Field error', field_error.getContent());
+            Y.all('.error.message').each(function(error_node) {
+                var error_message = error_node.getContent();
+                Assert.isTrue(
+                    error_message === '<p>Form error</p>' ||
+                    error_message === 'Some errors',
+                    'Each error message has the correct content.');
+            });
+        },
+
+        test_form_error_handler_renders_errors: function() {
+            // Form errors are rendered correctly.
+            this.error_handler.handleFormValidationError(
+                "Some errors", ["Form error"],
+                {'field.test': "Field error"});
+            this._assert_error_rendering();
+        }
+    }));
+}, '0.1', {
+    requires: ['test', 'lp.testing.helpers', 'console', 'lp.client',
+        'lp.testing.mockio', 'lp.client', 'escape']
 });

=== modified file 'lib/lp/app/javascript/tests/test_lp_client_integration.js'
--- lib/lp/app/javascript/tests/test_lp_client_integration.js	2012-01-10 14:24:19 +0000
+++ lib/lp/app/javascript/tests/test_lp_client_integration.js	2012-07-24 16:58:23 +0000
@@ -1,4 +1,5 @@
 YUI({
+
     base: '/+icing/yui/',
     filter: 'raw', combine: false, fetchCSS: false
 }).use('test',
@@ -127,20 +128,6 @@
     Y.Assert.areSame(1, config.result.total_size);
   },
 
-  test_named_get_uri: function() {
-    var data = serverfixture.setup(
-      this, 'create_product_with_milestone_and_login');
-    var client = new Y.lp.client.Launchpad({sync: true});
-    var config = makeTestConfig({parameters: {name: data.milestone.name}});
-    var product = new Y.lp.client.Entry(
-      client, data.product, data.product.self_link);
-    product.named_get('getMilestone', config);
-    Y.Assert.isTrue(config.successful, 'Getting milestone failed');
-    var milestone = config.result;
-    Y.Assert.isInstanceOf(Y.lp.client.Entry, milestone);
-    Y.Assert.areSame(data.milestone_self_link, milestone.lp_original_uri);
-  },
-
   test_named_post_integration: function() {
     var data = serverfixture.setup(this, 'create_bug_and_login');
     var client = new Y.lp.client.Launchpad({sync: true});
@@ -321,7 +308,7 @@
     var team = config.result;
     Y.Assert.isInstanceOf(Y.lp.client.Entry, team);
     Y.Assert.areEqual('My lpclient team', team.get('display_name'));
-    Y.Assert.isTrue(/\~newlpclientteam$/.test(team.lp_original_uri));
+    Y.Assert.isTrue(/\~newlpclientteam$/.test(team.uri));
   },
 
   test_collection_paged_named_get: function() {

=== modified file 'lib/lp/registry/javascript/tests/test_structural_subscription.js'
--- lib/lp/registry/javascript/tests/test_structural_subscription.js	2012-07-20 22:48:23 +0000
+++ lib/lp/registry/javascript/tests/test_structural_subscription.js	2012-07-24 16:58:23 +0000
@@ -234,7 +234,7 @@
             Y.one('body').appendChild(this.content_node);
 
             this.bug_filter = {
-                lp_original_uri:
+                uri:
                     '/api/devel/firefox/+subscription/mark/+filter/28'
             };
             this.form_data = {

=== modified file 'lib/lp/soyuz/javascript/lp_dynamic_dom_updater.js'
--- lib/lp/soyuz/javascript/lp_dynamic_dom_updater.js	2012-07-05 14:28:57 +0000
+++ lib/lp/soyuz/javascript/lp_dynamic_dom_updater.js	2012-07-24 16:58:23 +0000
@@ -266,8 +266,8 @@
             }
 
             // Finally, call the LP api method as required...
-            if (uri){
-                if (api_method_name){
+            if (uri) {
+                if (api_method_name) {
                     this.get("lp_client").named_get(uri,
                         api_method_name, this._lp_api_config);
                 }


Follow ups