aboutsummaryrefslogtreecommitdiffstats
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/core_configmanager_spec.lua31
-rw-r--r--spec/core_moduleapi_spec.lua76
-rw-r--r--spec/core_storagemanager_spec.lua332
-rw-r--r--spec/json/fail1.json1
-rw-r--r--spec/json/fail10.json1
-rw-r--r--spec/json/fail11.json1
-rw-r--r--spec/json/fail12.json1
-rw-r--r--spec/json/fail13.json1
-rw-r--r--spec/json/fail14.json1
-rw-r--r--spec/json/fail15.json1
-rw-r--r--spec/json/fail16.json1
-rw-r--r--spec/json/fail17.json1
-rw-r--r--spec/json/fail18.json1
-rw-r--r--spec/json/fail19.json1
-rw-r--r--spec/json/fail2.json1
-rw-r--r--spec/json/fail20.json1
-rw-r--r--spec/json/fail21.json1
-rw-r--r--spec/json/fail22.json1
-rw-r--r--spec/json/fail23.json1
-rw-r--r--spec/json/fail24.json1
-rw-r--r--spec/json/fail25.json1
-rw-r--r--spec/json/fail26.json1
-rw-r--r--spec/json/fail27.json2
-rw-r--r--spec/json/fail28.json2
-rw-r--r--spec/json/fail29.json1
-rw-r--r--spec/json/fail3.json1
-rw-r--r--spec/json/fail30.json1
-rw-r--r--spec/json/fail31.json1
-rw-r--r--spec/json/fail32.json1
-rw-r--r--spec/json/fail33.json1
-rw-r--r--spec/json/fail4.json1
-rw-r--r--spec/json/fail5.json1
-rw-r--r--spec/json/fail6.json1
-rw-r--r--spec/json/fail7.json1
-rw-r--r--spec/json/fail8.json1
-rw-r--r--spec/json/fail9.json1
-rw-r--r--spec/json/pass1.json58
-rw-r--r--spec/json/pass2.json1
-rw-r--r--spec/json/pass3.json6
-rw-r--r--spec/mod_bosh_spec.lua674
-rw-r--r--spec/muc_util_spec.lua35
-rw-r--r--spec/net_http_parser_spec.lua52
-rw-r--r--spec/net_http_server_spec.lua13
-rw-r--r--spec/net_websocket_frames_spec.lua56
-rw-r--r--spec/scansion/basic.scs18
-rw-r--r--spec/scansion/basic_message.scs174
-rw-r--r--spec/scansion/basic_roster.scs73
-rw-r--r--spec/scansion/issue1224.scs115
-rw-r--r--spec/scansion/issue505.scs79
-rw-r--r--spec/scansion/issue978-multi.scs111
-rw-r--r--spec/scansion/issue978.scs85
-rw-r--r--spec/scansion/mam_prefs_prep.scs36
-rw-r--r--spec/scansion/muc_affiliation_notify.scs137
-rw-r--r--spec/scansion/muc_mediated_invite.scs76
-rw-r--r--spec/scansion/muc_members_only_change.scs114
-rw-r--r--spec/scansion/muc_members_only_deregister.scs122
-rw-r--r--spec/scansion/muc_password.scs143
-rw-r--r--spec/scansion/muc_register.scs528
-rw-r--r--spec/scansion/muc_whois_anyone_member.scs101
-rw-r--r--spec/scansion/pep_nickname.scs72
-rw-r--r--spec/scansion/pep_publish_subscribe.scs210
-rw-r--r--spec/scansion/prosody.cfg.lua83
-rw-r--r--spec/scansion/pubsub_advanced.scs167
-rw-r--r--spec/scansion/pubsub_basic.scs104
-rw-r--r--spec/scansion/pubsub_config.scs205
-rw-r--r--spec/scansion/pubsub_createdelete.scs63
-rw-r--r--spec/scansion/vcard_temp.scs80
-rw-r--r--spec/utf8_sequences.txt52
-rw-r--r--spec/util_async_spec.lua616
-rw-r--r--spec/util_cache_spec.lua316
-rw-r--r--spec/util_dataforms_spec.lua427
-rw-r--r--spec/util_datetime_spec.lua76
-rw-r--r--spec/util_encodings_spec.lua41
-rw-r--r--spec/util_events_spec.lua212
-rw-r--r--spec/util_format_spec.lua14
-rw-r--r--spec/util_http_spec.lua87
-rw-r--r--spec/util_ip_spec.lua103
-rw-r--r--spec/util_iterators_spec.lua46
-rw-r--r--spec/util_jid_spec.lua146
-rw-r--r--spec/util_json_spec.lua70
-rw-r--r--spec/util_multitable_spec.lua60
-rw-r--r--spec/util_poll_spec.lua6
-rw-r--r--spec/util_promise_spec.lua497
-rw-r--r--spec/util_pubsub_spec.lua408
-rw-r--r--spec/util_queue_spec.lua103
-rw-r--r--spec/util_random_spec.lua19
-rw-r--r--spec/util_rfc6724_spec.lua97
-rw-r--r--spec/util_serialization_spec.lua79
-rw-r--r--spec/util_stanza_spec.lua373
-rw-r--r--spec/util_throttle_spec.lua150
-rw-r--r--spec/util_time_spec.lua31
-rw-r--r--spec/util_uuid_spec.lua25
-rw-r--r--spec/util_xml_spec.lua20
-rw-r--r--spec/util_xmppstream_spec.lua136
94 files changed, 8475 insertions, 0 deletions
diff --git a/spec/core_configmanager_spec.lua b/spec/core_configmanager_spec.lua
new file mode 100644
index 00000000..afb7d492
--- /dev/null
+++ b/spec/core_configmanager_spec.lua
@@ -0,0 +1,31 @@
+
+local configmanager = require "core.configmanager";
+
+describe("core.configmanager", function()
+ describe("#get()", function()
+ it("should work", function()
+ configmanager.set("example.com", "testkey", 123);
+ assert.are.equal(123, configmanager.get("example.com", "testkey"), "Retrieving a set key");
+
+ configmanager.set("*", "testkey1", 321);
+ assert.are.equal(321, configmanager.get("*", "testkey1"), "Retrieving a set global key");
+ assert.are.equal(321, configmanager.get("example.com", "testkey1"), "Retrieving a set key of undefined host, of which only a globally set one exists");
+
+ configmanager.set("example.com", ""); -- Creates example.com host in config
+ assert.are.equal(321, configmanager.get("example.com", "testkey1"), "Retrieving a set key, of which only a globally set one exists");
+
+ assert.are.equal(nil, configmanager.get(), "No parameters to get()");
+ assert.are.equal(nil, configmanager.get("undefined host"), "Getting for undefined host");
+ assert.are.equal(nil, configmanager.get("undefined host", "undefined key"), "Getting for undefined host & key");
+ end);
+ end);
+
+ describe("#set()", function()
+ it("should work", function()
+ assert.are.equal(false, configmanager.set("*"), "Set with no key");
+
+ assert.are.equal(true, configmanager.set("*", "set_test", "testkey"), "Setting a nil global value");
+ assert.are.equal(true, configmanager.set("*", "set_test", "testkey", 123), "Setting a global value");
+ end);
+ end);
+end);
diff --git a/spec/core_moduleapi_spec.lua b/spec/core_moduleapi_spec.lua
new file mode 100644
index 00000000..20431935
--- /dev/null
+++ b/spec/core_moduleapi_spec.lua
@@ -0,0 +1,76 @@
+
+package.loaded["core.configmanager"] = {};
+package.loaded["core.statsmanager"] = {};
+package.loaded["net.server"] = {};
+
+local set = require "util.set";
+
+_G.prosody = { hosts = {}, core_post_stanza = true };
+
+local api = require "core.moduleapi";
+
+local module = setmetatable({}, {__index = api});
+local opt = nil;
+function module:log() end
+function module:get_option(name)
+ if name == "opt" then
+ return opt;
+ else
+ return nil;
+ end
+end
+
+function test_option_value(value, returns)
+ opt = value;
+ assert(module:get_option_number("opt") == returns.number, "number doesn't match");
+ assert(module:get_option_string("opt") == returns.string, "string doesn't match");
+ assert(module:get_option_boolean("opt") == returns.boolean, "boolean doesn't match");
+
+ if type(returns.array) == "table" then
+ local target_array, returned_array = returns.array, module:get_option_array("opt");
+ assert(#target_array == #returned_array, "array length doesn't match");
+ for i=1,#target_array do
+ assert(target_array[i] == returned_array[i], "array item doesn't match");
+ end
+ else
+ assert(module:get_option_array("opt") == returns.array, "array is returned (not nil)");
+ end
+
+ if type(returns.set) == "table" then
+ local target_items, returned_items = set.new(returns.set), module:get_option_set("opt");
+ assert(target_items == returned_items, "set doesn't match");
+ else
+ assert(module:get_option_set("opt") == returns.set, "set is returned (not nil)");
+ end
+end
+
+describe("core.moduleapi", function()
+ describe("#get_option_*()", function()
+ it("should handle missing options", function()
+ test_option_value(nil, {});
+ end);
+
+ it("should return correctly handle boolean options", function()
+ test_option_value(true, { boolean = true, string = "true", array = {true}, set = {true} });
+ test_option_value(false, { boolean = false, string = "false", array = {false}, set = {false} });
+ test_option_value("true", { boolean = true, string = "true", array = {"true"}, set = {"true"} });
+ test_option_value("false", { boolean = false, string = "false", array = {"false"}, set = {"false"} });
+ test_option_value(1, { boolean = true, string = "1", array = {1}, set = {1}, number = 1 });
+ test_option_value(0, { boolean = false, string = "0", array = {0}, set = {0}, number = 0 });
+ end);
+
+ it("should return handle strings", function()
+ test_option_value("hello world", { string = "hello world", array = {"hello world"}, set = {"hello world"} });
+ end);
+
+ it("should return handle numbers", function()
+ test_option_value(1234, { string = "1234", number = 1234, array = {1234}, set = {1234} });
+ end);
+
+ it("should return handle arrays", function()
+ test_option_value({1, 2, 3}, { boolean = true, string = "1", number = 1, array = {1, 2, 3}, set = {1, 2, 3} });
+ test_option_value({1, 2, 3, 3, 4}, {boolean = true, string = "1", number = 1, array = {1, 2, 3, 3, 4}, set = {1, 2, 3, 4} });
+ test_option_value({0, 1, 2, 3}, { boolean = false, string = "0", number = 0, array = {0, 1, 2, 3}, set = {0, 1, 2, 3} });
+ end);
+ end)
+end)
diff --git a/spec/core_storagemanager_spec.lua b/spec/core_storagemanager_spec.lua
new file mode 100644
index 00000000..a0a8b5ef
--- /dev/null
+++ b/spec/core_storagemanager_spec.lua
@@ -0,0 +1,332 @@
+local unpack = table.unpack or unpack;
+local server = require "net.server_select";
+package.loaded["net.server"] = server;
+
+local st = require "util.stanza";
+
+local function mock_prosody()
+ _G.prosody = {
+ core_post_stanza = function () end;
+ events = require "util.events".new();
+ hosts = {};
+ paths = {
+ data = "./data";
+ };
+ };
+end
+
+local configs = {
+ memory = {
+ storage = "memory";
+ };
+ internal = {
+ storage = "internal";
+ };
+ sqlite = {
+ storage = "sql";
+ sql = { driver = "SQLite3", database = "prosody-tests.sqlite" };
+ };
+ mysql = {
+ storage = "sql";
+ sql = { driver = "MySQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" };
+ };
+ postgres = {
+ storage = "sql";
+ sql = { driver = "PostgreSQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" };
+ };
+};
+
+local test_host = "storage-unit-tests.invalid";
+
+describe("storagemanager", function ()
+ for backend, backend_config in pairs(configs) do
+ local tagged_name = "#"..backend;
+ if backend ~= backend_config.storage then
+ tagged_name = tagged_name.." #"..backend_config.storage;
+ end
+ insulate(tagged_name.." #storage backend", function ()
+ mock_prosody();
+
+ local config = require "core.configmanager";
+ local sm = require "core.storagemanager";
+ local hm = require "core.hostmanager";
+ local mm = require "core.modulemanager";
+
+ -- Simple check to ensure insulation is working correctly
+ assert.is_nil(config.get(test_host, "storage"));
+
+ for k, v in pairs(backend_config) do
+ config.set(test_host, k, v);
+ end
+ assert(hm.activate(test_host, {}));
+ sm.initialize_host(test_host);
+ assert(mm.load(test_host, "storage_"..backend_config.storage));
+
+ describe("key-value stores", function ()
+ -- These tests rely on being executed in order, disable any order
+ -- randomization for this block
+ randomize(false);
+
+ local store;
+ it("may be opened", function ()
+ store = assert(sm.open(test_host, "test"));
+ end);
+
+ local simple_data = { foo = "bar" };
+
+ it("may set data for a user", function ()
+ assert(store:set("user9999", simple_data));
+ end);
+
+ it("may get data for a user", function ()
+ assert.same(simple_data, assert(store:get("user9999")));
+ end);
+
+ it("may remove data for a user", function ()
+ assert(store:set("user9999", nil));
+ local ret, err = store:get("user9999");
+ assert.is_nil(ret);
+ assert.is_nil(err);
+ end);
+ end);
+
+ describe("archive stores", function ()
+ randomize(false);
+
+ local archive;
+ it("can be opened", function ()
+ archive = assert(sm.open(test_host, "test-archive", "archive"));
+ end);
+
+ local test_stanza = st.stanza("test", { xmlns = "urn:example:foo" })
+ :tag("foo"):up()
+ :tag("foo"):up();
+ local test_time = 1539204123;
+
+ local test_data = {
+ { nil, test_stanza, test_time, "contact@example.com" };
+ { nil, test_stanza, test_time+1, "contact2@example.com" };
+ { nil, test_stanza, test_time+2, "contact2@example.com" };
+ { nil, test_stanza, test_time-1, "contact2@example.com" };
+ };
+
+ it("can be added to", function ()
+ for _, data_item in ipairs(test_data) do
+ local ok = archive:append("user", unpack(data_item, 1, 4));
+ assert.truthy(ok);
+ end
+ end);
+
+ describe("can be queried", function ()
+ it("for all items", function ()
+ local data, err = archive:find("user", {});
+ assert.truthy(data);
+ local count = 0;
+ for id, item, when in data do
+ count = count + 1;
+ assert.truthy(id);
+ assert(st.is_stanza(item));
+ assert.equal("test", item.name);
+ assert.equal("urn:example:foo", item.attr.xmlns);
+ assert.equal(2, #item.tags);
+ assert.equal(test_data[count][3], when);
+ end
+ assert.equal(#test_data, count);
+ end);
+
+ it("by JID", function ()
+ local data, err = archive:find("user", {
+ with = "contact@example.com";
+ });
+ assert.truthy(data);
+ local count = 0;
+ for id, item, when in data do
+ count = count + 1;
+ assert.truthy(id);
+ assert(st.is_stanza(item));
+ assert.equal("test", item.name);
+ assert.equal("urn:example:foo", item.attr.xmlns);
+ assert.equal(2, #item.tags);
+ assert.equal(test_time, when);
+ end
+ assert.equal(1, count);
+ end);
+
+ it("by time (end)", function ()
+ local data, err = archive:find("user", {
+ ["end"] = test_time;
+ });
+ assert.truthy(data);
+ local count = 0;
+ for id, item, when in data do
+ count = count + 1;
+ assert.truthy(id);
+ assert(st.is_stanza(item));
+ assert.equal("test", item.name);
+ assert.equal("urn:example:foo", item.attr.xmlns);
+ assert.equal(2, #item.tags);
+ assert(test_time >= when);
+ end
+ assert.equal(2, count);
+ end);
+
+ it("by time (start)", function ()
+ local data, err = archive:find("user", {
+ ["start"] = test_time;
+ });
+ assert.truthy(data);
+ local count = 0;
+ for id, item, when in data do
+ count = count + 1;
+ assert.truthy(id);
+ assert(st.is_stanza(item));
+ assert.equal("test", item.name);
+ assert.equal("urn:example:foo", item.attr.xmlns);
+ assert.equal(2, #item.tags);
+ assert(test_time <= when);
+ end
+ assert.equal(#test_data -1, count);
+ end);
+
+ it("by time (start+end)", function ()
+ local data, err = archive:find("user", {
+ ["start"] = test_time;
+ ["end"] = test_time+1;
+ });
+ assert.truthy(data);
+ local count = 0;
+ for id, item, when in data do
+ count = count + 1;
+ assert.truthy(id);
+ assert(st.is_stanza(item));
+ assert.equal("test", item.name);
+ assert.equal("urn:example:foo", item.attr.xmlns);
+ assert.equal(2, #item.tags);
+ assert(when >= test_time, ("%d >= %d"):format(when, test_time));
+ assert(when <= test_time+1, ("%d <= %d"):format(when, test_time+1));
+ end
+ assert.equal(2, count);
+ end);
+ end);
+
+ it("can selectively delete items", function ()
+ local delete_id;
+ do
+ local data = assert(archive:find("user", {}));
+ local count = 0;
+ for id, item, when in data do --luacheck: ignore 213/item 213/when
+ count = count + 1;
+ if count == 2 then
+ delete_id = id;
+ end
+ assert.truthy(id);
+ end
+ assert.equal(#test_data, count);
+ end
+
+ assert(archive:delete("user", { key = delete_id }));
+
+ do
+ local data = assert(archive:find("user", {}));
+ local count = 0;
+ for id, item, when in data do --luacheck: ignore 213/item 213/when
+ count = count + 1;
+ assert.truthy(id);
+ assert.not_equal(delete_id, id);
+ end
+ assert.equal(#test_data-1, count);
+ end
+ end);
+
+ it("can be purged", function ()
+ local ok, err = archive:delete("user");
+ assert.truthy(ok);
+ local data, err = archive:find("user", {
+ with = "contact@example.com";
+ });
+ assert.truthy(data);
+ local count = 0;
+ for id, item, when in data do -- luacheck: ignore id item when
+ count = count + 1;
+ end
+ assert.equal(0, count);
+ end);
+
+ it("can truncate the oldest items", function ()
+ local username = "user-truncate";
+ for i = 1, 10 do
+ assert(archive:append(username, nil, test_stanza, i, "contact@example.com"));
+ end
+ assert(archive:delete(username, { truncate = 3 }));
+
+ do
+ local data = assert(archive:find(username, {}));
+ local count = 0;
+ for id, item, when in data do --luacheck: ignore 213/when
+ count = count + 1;
+ assert.truthy(id);
+ assert(st.is_stanza(item));
+ assert(when > 7, ("%d > 7"):format(when));
+ end
+ assert.equal(3, count);
+ end
+ end);
+
+ it("overwrites existing keys with new data", function ()
+ local prefix = ("a"):rep(50);
+ local username = "user-overwrite";
+ assert(archive:append(username, prefix.."-1", test_stanza, test_time, "contact@example.com"));
+ assert(archive:append(username, prefix.."-2", test_stanza, test_time, "contact@example.com"));
+
+ do
+ local data = assert(archive:find(username, {}));
+ local count = 0;
+ for id, item, when in data do --luacheck: ignore 213/when
+ count = count + 1;
+ assert.truthy(id);
+ assert.equals(("%s-%d"):format(prefix, count), id);
+ assert(st.is_stanza(item));
+ end
+ assert.equal(2, count);
+ end
+
+ local new_stanza = st.clone(test_stanza);
+ new_stanza.attr.foo = "bar";
+ assert(archive:append(username, prefix.."-2", new_stanza, test_time+1, "contact2@example.com"));
+
+ do
+ local data = assert(archive:find(username, {}));
+ local count = 0;
+ for id, item, when in data do
+ count = count + 1;
+ assert.truthy(id);
+ assert.equals(("%s-%d"):format(prefix, count), id);
+ assert(st.is_stanza(item));
+ if count == 2 then
+ assert.equals(test_time+1, when);
+ assert.equals("bar", item.attr.foo);
+ end
+ end
+ assert.equal(2, count);
+ end
+ end);
+
+ it("can contain multiple long unique keys #issue1073", function ()
+ local prefix = ("a"):rep(50);
+ assert(archive:append("user-issue1073", prefix.."-1", test_stanza, test_time, "contact@example.com"));
+ assert(archive:append("user-issue1073", prefix.."-2", test_stanza, test_time, "contact@example.com"));
+
+ local data = assert(archive:find("user-issue1073", {}));
+ local count = 0;
+ for id, item, when in data do --luacheck: ignore 213/when
+ count = count + 1;
+ assert.truthy(id);
+ assert(st.is_stanza(item));
+ end
+ assert.equal(2, count);
+ assert(archive:delete("user-issue1073"));
+ end);
+ end);
+ end);
+ end
+end);
diff --git a/spec/json/fail1.json b/spec/json/fail1.json
new file mode 100644
index 00000000..6216b865
--- /dev/null
+++ b/spec/json/fail1.json
@@ -0,0 +1 @@
+"A JSON payload should be an object or array, not a string." \ No newline at end of file
diff --git a/spec/json/fail10.json b/spec/json/fail10.json
new file mode 100644
index 00000000..5d8c0047
--- /dev/null
+++ b/spec/json/fail10.json
@@ -0,0 +1 @@
+{"Extra value after close": true} "misplaced quoted value" \ No newline at end of file
diff --git a/spec/json/fail11.json b/spec/json/fail11.json
new file mode 100644
index 00000000..76eb95b4
--- /dev/null
+++ b/spec/json/fail11.json
@@ -0,0 +1 @@
+{"Illegal expression": 1 + 2} \ No newline at end of file
diff --git a/spec/json/fail12.json b/spec/json/fail12.json
new file mode 100644
index 00000000..77580a45
--- /dev/null
+++ b/spec/json/fail12.json
@@ -0,0 +1 @@
+{"Illegal invocation": alert()} \ No newline at end of file
diff --git a/spec/json/fail13.json b/spec/json/fail13.json
new file mode 100644
index 00000000..379406b5
--- /dev/null
+++ b/spec/json/fail13.json
@@ -0,0 +1 @@
+{"Numbers cannot have leading zeroes": 013} \ No newline at end of file
diff --git a/spec/json/fail14.json b/spec/json/fail14.json
new file mode 100644
index 00000000..0ed366b3
--- /dev/null
+++ b/spec/json/fail14.json
@@ -0,0 +1 @@
+{"Numbers cannot be hex": 0x14} \ No newline at end of file
diff --git a/spec/json/fail15.json b/spec/json/fail15.json
new file mode 100644
index 00000000..fc8376b6
--- /dev/null
+++ b/spec/json/fail15.json
@@ -0,0 +1 @@
+["Illegal backslash escape: \x15"] \ No newline at end of file
diff --git a/spec/json/fail16.json b/spec/json/fail16.json
new file mode 100644
index 00000000..3fe21d4b
--- /dev/null
+++ b/spec/json/fail16.json
@@ -0,0 +1 @@
+[\naked] \ No newline at end of file
diff --git a/spec/json/fail17.json b/spec/json/fail17.json
new file mode 100644
index 00000000..62b9214a
--- /dev/null
+++ b/spec/json/fail17.json
@@ -0,0 +1 @@
+["Illegal backslash escape: \017"] \ No newline at end of file
diff --git a/spec/json/fail18.json b/spec/json/fail18.json
new file mode 100644
index 00000000..edac9271
--- /dev/null
+++ b/spec/json/fail18.json
@@ -0,0 +1 @@
+[[[[[[[[[[[[[[[[[[[["Too deep"]]]]]]]]]]]]]]]]]]]] \ No newline at end of file
diff --git a/spec/json/fail19.json b/spec/json/fail19.json
new file mode 100644
index 00000000..3b9c46fa
--- /dev/null
+++ b/spec/json/fail19.json
@@ -0,0 +1 @@
+{"Missing colon" null} \ No newline at end of file
diff --git a/spec/json/fail2.json b/spec/json/fail2.json
new file mode 100644
index 00000000..6b7c11e5
--- /dev/null
+++ b/spec/json/fail2.json
@@ -0,0 +1 @@
+["Unclosed array" \ No newline at end of file
diff --git a/spec/json/fail20.json b/spec/json/fail20.json
new file mode 100644
index 00000000..27c1af3e
--- /dev/null
+++ b/spec/json/fail20.json
@@ -0,0 +1 @@
+{"Double colon":: null} \ No newline at end of file
diff --git a/spec/json/fail21.json b/spec/json/fail21.json
new file mode 100644
index 00000000..62474573
--- /dev/null
+++ b/spec/json/fail21.json
@@ -0,0 +1 @@
+{"Comma instead of colon", null} \ No newline at end of file
diff --git a/spec/json/fail22.json b/spec/json/fail22.json
new file mode 100644
index 00000000..a7752581
--- /dev/null
+++ b/spec/json/fail22.json
@@ -0,0 +1 @@
+["Colon instead of comma": false] \ No newline at end of file
diff --git a/spec/json/fail23.json b/spec/json/fail23.json
new file mode 100644
index 00000000..494add1c
--- /dev/null
+++ b/spec/json/fail23.json
@@ -0,0 +1 @@
+["Bad value", truth] \ No newline at end of file
diff --git a/spec/json/fail24.json b/spec/json/fail24.json
new file mode 100644
index 00000000..caff239b
--- /dev/null
+++ b/spec/json/fail24.json
@@ -0,0 +1 @@
+['single quote'] \ No newline at end of file
diff --git a/spec/json/fail25.json b/spec/json/fail25.json
new file mode 100644
index 00000000..8b7ad23e
--- /dev/null
+++ b/spec/json/fail25.json
@@ -0,0 +1 @@
+[" tab character in string "] \ No newline at end of file
diff --git a/spec/json/fail26.json b/spec/json/fail26.json
new file mode 100644
index 00000000..845d26a6
--- /dev/null
+++ b/spec/json/fail26.json
@@ -0,0 +1 @@
+["tab\ character\ in\ string\ "] \ No newline at end of file
diff --git a/spec/json/fail27.json b/spec/json/fail27.json
new file mode 100644
index 00000000..6b01a2ca
--- /dev/null
+++ b/spec/json/fail27.json
@@ -0,0 +1,2 @@
+["line
+break"] \ No newline at end of file
diff --git a/spec/json/fail28.json b/spec/json/fail28.json
new file mode 100644
index 00000000..621a0101
--- /dev/null
+++ b/spec/json/fail28.json
@@ -0,0 +1,2 @@
+["line\
+break"] \ No newline at end of file
diff --git a/spec/json/fail29.json b/spec/json/fail29.json
new file mode 100644
index 00000000..47ec421b
--- /dev/null
+++ b/spec/json/fail29.json
@@ -0,0 +1 @@
+[0e] \ No newline at end of file
diff --git a/spec/json/fail3.json b/spec/json/fail3.json
new file mode 100644
index 00000000..168c81eb
--- /dev/null
+++ b/spec/json/fail3.json
@@ -0,0 +1 @@
+{unquoted_key: "keys must be quoted"} \ No newline at end of file
diff --git a/spec/json/fail30.json b/spec/json/fail30.json
new file mode 100644
index 00000000..8ab0bc4b
--- /dev/null
+++ b/spec/json/fail30.json
@@ -0,0 +1 @@
+[0e+] \ No newline at end of file
diff --git a/spec/json/fail31.json b/spec/json/fail31.json
new file mode 100644
index 00000000..1cce602b
--- /dev/null
+++ b/spec/json/fail31.json
@@ -0,0 +1 @@
+[0e+-1] \ No newline at end of file
diff --git a/spec/json/fail32.json b/spec/json/fail32.json
new file mode 100644
index 00000000..45cba739
--- /dev/null
+++ b/spec/json/fail32.json
@@ -0,0 +1 @@
+{"Comma instead if closing brace": true, \ No newline at end of file
diff --git a/spec/json/fail33.json b/spec/json/fail33.json
new file mode 100644
index 00000000..ca5eb19d
--- /dev/null
+++ b/spec/json/fail33.json
@@ -0,0 +1 @@
+["mismatch"} \ No newline at end of file
diff --git a/spec/json/fail4.json b/spec/json/fail4.json
new file mode 100644
index 00000000..9de168bf
--- /dev/null
+++ b/spec/json/fail4.json
@@ -0,0 +1 @@
+["extra comma",] \ No newline at end of file
diff --git a/spec/json/fail5.json b/spec/json/fail5.json
new file mode 100644
index 00000000..ddf3ce3d
--- /dev/null
+++ b/spec/json/fail5.json
@@ -0,0 +1 @@
+["double extra comma",,] \ No newline at end of file
diff --git a/spec/json/fail6.json b/spec/json/fail6.json
new file mode 100644
index 00000000..ed91580e
--- /dev/null
+++ b/spec/json/fail6.json
@@ -0,0 +1 @@
+[ , "<-- missing value"] \ No newline at end of file
diff --git a/spec/json/fail7.json b/spec/json/fail7.json
new file mode 100644
index 00000000..8a96af3e
--- /dev/null
+++ b/spec/json/fail7.json
@@ -0,0 +1 @@
+["Comma after the close"], \ No newline at end of file
diff --git a/spec/json/fail8.json b/spec/json/fail8.json
new file mode 100644
index 00000000..b28479c6
--- /dev/null
+++ b/spec/json/fail8.json
@@ -0,0 +1 @@
+["Extra close"]] \ No newline at end of file
diff --git a/spec/json/fail9.json b/spec/json/fail9.json
new file mode 100644
index 00000000..5815574f
--- /dev/null
+++ b/spec/json/fail9.json
@@ -0,0 +1 @@
+{"Extra comma": true,} \ No newline at end of file
diff --git a/spec/json/pass1.json b/spec/json/pass1.json
new file mode 100644
index 00000000..70e26854
--- /dev/null
+++ b/spec/json/pass1.json
@@ -0,0 +1,58 @@
+[
+ "JSON Test Pattern pass1",
+ {"object with 1 member":["array with 1 element"]},
+ {},
+ [],
+ -42,
+ true,
+ false,
+ null,
+ {
+ "integer": 1234567890,
+ "real": -9876.543210,
+ "e": 0.123456789e-12,
+ "E": 1.234567890E+34,
+ "": 23456789012E66,
+ "zero": 0,
+ "one": 1,
+ "space": " ",
+ "quote": "\"",
+ "backslash": "\\",
+ "controls": "\b\f\n\r\t",
+ "slash": "/ & \/",
+ "alpha": "abcdefghijklmnopqrstuvwyz",
+ "ALPHA": "ABCDEFGHIJKLMNOPQRSTUVWYZ",
+ "digit": "0123456789",
+ "0123456789": "digit",
+ "special": "`1~!@#$%^&*()_+-={':[,]}|;.</>?",
+ "hex": "\u0123\u4567\u89AB\uCDEF\uabcd\uef4A",
+ "true": true,
+ "false": false,
+ "null": null,
+ "array":[ ],
+ "object":{ },
+ "address": "50 St. James Street",
+ "url": "http://www.JSON.org/",
+ "comment": "// /* <!-- --",
+ "# -- --> */": " ",
+ " s p a c e d " :[1,2 , 3
+
+,
+
+4 , 5 , 6 ,7 ],"compact":[1,2,3,4,5,6,7],
+ "jsontext": "{\"object with 1 member\":[\"array with 1 element\"]}",
+ "quotes": "&#34; \u0022 %22 0x22 034 &#x22;",
+ "\/\\\"\uCAFE\uBABE\uAB98\uFCDE\ubcda\uef4A\b\f\n\r\t`1~!@#$%^&*()_+-=[]{}|;:',./<>?"
+: "A key can be any string"
+ },
+ 0.5 ,98.6
+,
+99.44
+,
+
+1066,
+1e1,
+0.1e1,
+1e-1,
+1e00,2e+00,2e-00
+,"rosebud"] \ No newline at end of file
diff --git a/spec/json/pass2.json b/spec/json/pass2.json
new file mode 100644
index 00000000..d3c63c7a
--- /dev/null
+++ b/spec/json/pass2.json
@@ -0,0 +1 @@
+[[[[[[[[[[[[[[[[[[["Not too deep"]]]]]]]]]]]]]]]]]]] \ No newline at end of file
diff --git a/spec/json/pass3.json b/spec/json/pass3.json
new file mode 100644
index 00000000..4528d51f
--- /dev/null
+++ b/spec/json/pass3.json
@@ -0,0 +1,6 @@
+{
+ "JSON Test Pattern pass3": {
+ "The outermost value": "must be an object or array.",
+ "In this test": "It is an object."
+ }
+}
diff --git a/spec/mod_bosh_spec.lua b/spec/mod_bosh_spec.lua
new file mode 100644
index 00000000..053e4b2a
--- /dev/null
+++ b/spec/mod_bosh_spec.lua
@@ -0,0 +1,674 @@
+
+-- Requires a host 'localhost' with SASL ANONYMOUS
+
+local bosh_url = "http://localhost:5280/http-bind"
+
+local logger = require "util.logger";
+
+local debug = false;
+
+local print = print;
+if debug then
+ logger.add_simple_sink(print, {
+ --"debug";
+ "info";
+ "warn";
+ "error";
+ });
+else
+ print = function () end
+end
+
+describe("#mod_bosh", function ()
+ local server = require "net.server_select";
+ package.loaded["net.server"] = server;
+ local async = require "util.async";
+ local timer = require "util.timer";
+ local http = require "net.http".new({ suppress_errors = false });
+
+ local function sleep(n)
+ local wait, done = async.waiter();
+ timer.add_task(n, function () done() end);
+ wait();
+ end
+
+ local st = require "util.stanza";
+ local xml = require "util.xml";
+
+ local function request(url, opt, cb, auto_wait)
+ local wait, done = async.waiter();
+ local ok, err;
+ http:request(url, opt, function (...)
+ ok, err = pcall(cb, ...);
+ if not ok then print("CAUGHT", err) end
+ done();
+ end);
+ local function err_wait(throw)
+ wait();
+ if throw ~= false and not ok then
+ error(err);
+ end
+ return ok, err;
+ end
+ if auto_wait == false then
+ return err_wait;
+ else
+ err_wait();
+ end
+ end
+
+ local function run_async(f)
+ local err;
+ local r = async.runner();
+ r:onerror(function (_, err_)
+ print("EER", err_)
+ err = err_;
+ server.setquitting("once");
+ end)
+ :onwaiting(function ()
+ --server.loop();
+ end)
+ :run(function ()
+ f()
+ server.setquitting("once");
+ end);
+ server.loop();
+ if err then
+ error(err);
+ end
+ if r.state ~= "ready" then
+ error("Runner in unexpected state: "..r.state);
+ end
+ end
+
+ it("test endpoint should be reachable", function ()
+ -- This is partly just to ensure the other tests have a chance to succeed
+ -- (i.e. the BOSH endpoint is up and functioning)
+ local function test()
+ request(bosh_url, nil, function (resp, code)
+ if code ~= 200 then
+ error("Unable to reach BOSH endpoint "..bosh_url);
+ end
+ assert.is_string(resp);
+ end);
+ end
+ run_async(test);
+ end);
+
+ it("should respond to past rids with past responses", function ()
+ local resp_1000_1, resp_1000_2 = "1", "2";
+
+ local function test_bosh()
+ local sid;
+
+ -- Set up BOSH session
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ to = "localhost";
+ from = "test@localhost";
+ content = "text/xml; charset=utf-8";
+ hold = "1";
+ rid = "998";
+ wait = "10";
+ ["xml:lang"] = "en";
+ ["xmpp:version"] = "1.0";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ })
+ :tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+ :tag("iq", { xmlns = "jabber:client", type = "set", id = "bind1" })
+ :tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" })
+ :tag("resource"):text("bosh-test1"):up()
+ :up()
+ :up()
+ );
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ if not response_body:find("<jid>", 1, true) then
+ print("ERR", resp:pretty_print());
+ error("Failed to set up BOSH session");
+ end
+ sid = assert(resp.attr.sid);
+ print("SID", sid);
+ end);
+
+ -- Receive some additional post-login stuff
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "999";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ })
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 999", resp:pretty_print());
+ end);
+
+ -- Send first long poll
+ print "SEND 1000#1"
+ local wait1000 = request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "1000";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }))
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ resp_1000_1 = resp;
+ print("RESP 1000#1", resp:pretty_print());
+ end, false);
+
+ -- Wait a couple of seconds
+ sleep(2)
+
+ -- Send an early request, causing rid 1000 to return early
+ print "SEND 1001"
+ local wait1001 = request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "1001";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }))
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 1001", resp:pretty_print());
+ end, false);
+ -- Ensure we've received the response for rid 1000
+ wait1000();
+
+ -- Sleep a couple of seconds
+ print "...pause..."
+ sleep(2);
+
+ -- Re-send rid 1000, we should get the same response
+ print "SEND 1000#2"
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "1000";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }))
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ resp_1000_2 = resp;
+ print("RESP 1000#2", resp:pretty_print());
+ end);
+
+ local wait_final = request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "1002";
+ type = "terminate";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }))
+ }, function ()
+ end, false);
+
+ print "WAIT 1001"
+ wait1001();
+ wait_final();
+ print "DONE ALL"
+ end
+ run_async(test_bosh);
+ assert.truthy(resp_1000_1);
+ assert.same(resp_1000_1, resp_1000_2);
+ end);
+
+ it("should handle out-of-order requests", function ()
+ local function test()
+ local sid;
+ -- Set up BOSH session
+ local wait, done = async.waiter();
+ http:request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ to = "localhost";
+ from = "test@localhost";
+ content = "text/xml; charset=utf-8";
+ hold = "1";
+ rid = "1";
+ wait = "10";
+ ["xml:lang"] = "en";
+ ["xmpp:version"] = "1.0";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }));
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ sid = assert(resp.attr.sid, "Failed to set up BOSH session");
+ print("SID", sid);
+ done();
+ end);
+ print "WAIT 1"
+ wait();
+ print "DONE 1"
+
+ local rid2_response_received = false;
+
+ -- Temporarily skip rid 2, to simulate missed request
+ local wait3, done3 = async.waiter();
+ http:request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "3";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+ :tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+ :up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 3", resp:pretty_print());
+ done3();
+ -- The server should not respond to this request until
+ -- it has responded to rid 2
+ assert.is_true(rid2_response_received);
+ end);
+
+ print "SLEEPING"
+ sleep(2);
+ print "SLEPT"
+
+ -- Send the "missed" rid 2
+ local wait2, done2 = async.waiter();
+ http:request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "2";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 2", resp:pretty_print());
+ rid2_response_received = true;
+ done2();
+ end);
+ print "WAIT 2"
+ wait2();
+ print "WAIT 3"
+ wait3();
+ print "QUIT"
+ end
+ run_async(test);
+ end);
+
+ it("should work", function ()
+ local function test()
+ local sid;
+ -- Set up BOSH session
+ local wait, done = async.waiter();
+ http:request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ to = "localhost";
+ from = "test@localhost";
+ content = "text/xml; charset=utf-8";
+ hold = "1";
+ rid = "1";
+ wait = "10";
+ ["xml:lang"] = "en";
+ ["xmpp:version"] = "1.0";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }));
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ sid = assert(resp.attr.sid, "Failed to set up BOSH session");
+ print("SID", sid);
+ done();
+ end);
+ print "WAIT 1"
+ wait();
+ print "DONE 1"
+
+ local rid2_response_received = false;
+
+ -- Send the "missed" rid 2
+ local wait2, done2 = async.waiter();
+ http:request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "2";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 2", resp:pretty_print());
+ rid2_response_received = true;
+ done2();
+ end);
+
+ local wait3, done3 = async.waiter();
+ http:request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "3";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+ :tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+ :up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 3", resp:pretty_print());
+ done3();
+ -- The server should not respond to this request until
+ -- it has responded to rid 2
+ assert.is_true(rid2_response_received);
+ end);
+
+ print "SLEEPING"
+ sleep(2);
+ print "SLEPT"
+
+ print "WAIT 2"
+ wait2();
+ print "WAIT 3"
+ wait3();
+ print "QUIT"
+ end
+ run_async(test);
+ end);
+
+ it("should handle aborted pending requests", function ()
+ local resp_1000_1, resp_1000_2 = "1", "2";
+
+ local function test_bosh()
+ local sid;
+
+ -- Set up BOSH session
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ to = "localhost";
+ from = "test@localhost";
+ content = "text/xml; charset=utf-8";
+ hold = "1";
+ rid = "998";
+ wait = "10";
+ ["xml:lang"] = "en";
+ ["xmpp:version"] = "1.0";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ })
+ :tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+ :tag("iq", { xmlns = "jabber:client", type = "set", id = "bind1" })
+ :tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" })
+ :tag("resource"):text("bosh-test1"):up()
+ :up()
+ :up()
+ );
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ if not response_body:find("<jid>", 1, true) then
+ print("ERR", resp:pretty_print());
+ error("Failed to set up BOSH session");
+ end
+ sid = assert(resp.attr.sid);
+ print("SID", sid);
+ end);
+
+ -- Receive some additional post-login stuff
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "999";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ })
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 999", resp:pretty_print());
+ end);
+
+ -- Send first long poll
+ print "SEND 1000#1"
+ local wait1000_1 = request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "1000";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }))
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ resp_1000_1 = resp;
+ assert.is_nil(resp.attr.type);
+ print("RESP 1000#1", resp:pretty_print());
+ end, false);
+
+ -- Wait a couple of seconds
+ sleep(2)
+
+ -- Re-send rid 1000, we should eventually get a normal response (with no stanzas)
+ print "SEND 1000#2"
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "1000";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }))
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ resp_1000_2 = resp;
+ assert.is_nil(resp.attr.type);
+ print("RESP 1000#2", resp:pretty_print());
+ end);
+
+ wait1000_1();
+ print "DONE ALL"
+ end
+ run_async(test_bosh);
+ assert.truthy(resp_1000_1);
+ assert.same(resp_1000_1, resp_1000_2);
+ end);
+
+ it("should fail on requests beyond rid window", function ()
+ local function test_bosh()
+ local sid;
+
+ -- Set up BOSH session
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ to = "localhost";
+ from = "test@localhost";
+ content = "text/xml; charset=utf-8";
+ hold = "1";
+ rid = "998";
+ wait = "10";
+ ["xml:lang"] = "en";
+ ["xmpp:version"] = "1.0";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ })
+ :tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+ :tag("iq", { xmlns = "jabber:client", type = "set", id = "bind1" })
+ :tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" })
+ :tag("resource"):text("bosh-test1"):up()
+ :up()
+ :up()
+ );
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ if not response_body:find("<jid>", 1, true) then
+ print("ERR", resp:pretty_print());
+ error("Failed to set up BOSH session");
+ end
+ sid = assert(resp.attr.sid);
+ print("SID", sid);
+ end);
+
+ -- Receive some additional post-login stuff
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "999";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ })
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 999", resp:pretty_print());
+ end);
+
+ -- Send poll with a rid that's too high (current + 2, where only current + 1 is allowed)
+ print "SEND 1002(!)"
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "1002";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }))
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ assert.equal("terminate", resp.attr.type);
+ print("RESP 1002(!)", resp:pretty_print());
+ end);
+
+ print "DONE ALL"
+ end
+ run_async(test_bosh);
+ end);
+
+ it("should always succeed for requests within the rid window", function ()
+ local function test()
+ local sid;
+ -- Set up BOSH session
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ to = "localhost";
+ from = "test@localhost";
+ content = "text/xml; charset=utf-8";
+ hold = "1";
+ rid = "1";
+ wait = "10";
+ ["xml:lang"] = "en";
+ ["xmpp:version"] = "1.0";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }));
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ sid = assert(resp.attr.sid, "Failed to set up BOSH session");
+ print("SID", sid);
+ end);
+ print "DONE 1"
+
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "2";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 2", resp:pretty_print());
+ end);
+
+ local resp3;
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "3";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+ :tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+ :up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 3#1", resp:pretty_print());
+ resp3 = resp;
+ end);
+
+
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "4";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("iq", { xmlns = "jabber:client", type = "get", id = "ping1" })
+ :tag("ping", { xmlns = "urn:xmpp:ping" }):up()
+ :up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 4", resp:pretty_print());
+ end);
+
+ request(bosh_url, {
+ body = tostring(st.stanza("body", {
+ sid = sid;
+ rid = "3";
+ content = "text/xml; charset=utf-8";
+ ["xml:lang"] = "en";
+ xmlns = "http://jabber.org/protocol/httpbind";
+ ["xmlns:xmpp"] = "urn:xmpp:xbosh";
+ }):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+ :tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+ :up()
+ )
+ }, function (response_body)
+ local resp = xml.parse(response_body);
+ print("RESP 3#2", resp:pretty_print());
+ assert.not_equal("terminate", resp.attr.type);
+ assert.same(resp3, resp);
+ end);
+
+
+ print "QUIT"
+ end
+ run_async(test);
+ end);
+end);
diff --git a/spec/muc_util_spec.lua b/spec/muc_util_spec.lua
new file mode 100644
index 00000000..cef68e80
--- /dev/null
+++ b/spec/muc_util_spec.lua
@@ -0,0 +1,35 @@
+local muc_util;
+
+local st = require "util.stanza";
+
+do
+ local old_pp = package.path;
+ package.path = "./?.lib.lua;"..package.path;
+ muc_util = require "plugins.muc.util";
+ package.path = old_pp;
+end
+
+describe("muc/util", function ()
+ describe("filter_muc_x()", function ()
+ it("correctly filters muc#user", function ()
+ local stanza = st.message({ to = "to", from = "from", id = "foo" })
+ :tag("x", { xmlns = "http://jabber.org/protocol/muc#user" })
+ :tag("invite", { to = "user@example.com" });
+
+ assert.equal(1, #stanza.tags);
+ assert.equal(stanza, muc_util.filter_muc_x(stanza));
+ assert.equal(0, #stanza.tags);
+ end);
+
+ it("correctly filters muc#user on a cloned stanza", function ()
+ local stanza = st.message({ to = "to", from = "from", id = "foo" })
+ :tag("x", { xmlns = "http://jabber.org/protocol/muc#user" })
+ :tag("invite", { to = "user@example.com" });
+
+ assert.equal(1, #stanza.tags);
+ local filtered = muc_util.filter_muc_x(st.clone(stanza));
+ assert.equal(1, #stanza.tags);
+ assert.equal(0, #filtered.tags);
+ end);
+ end);
+end);
diff --git a/spec/net_http_parser_spec.lua b/spec/net_http_parser_spec.lua
new file mode 100644
index 00000000..6bba087c
--- /dev/null
+++ b/spec/net_http_parser_spec.lua
@@ -0,0 +1,52 @@
+local httpstreams = { [[
+GET / HTTP/1.1
+Host: example.com
+
+]], [[
+HTTP/1.1 200 OK
+Content-Length: 0
+
+]], [[
+HTTP/1.1 200 OK
+Content-Length: 7
+
+Hello
+HTTP/1.1 200 OK
+Transfer-Encoding: chunked
+
+1
+H
+1
+e
+2
+ll
+1
+o
+0
+
+
+]]
+}
+
+
+local http_parser = require "net.http.parser";
+
+describe("net.http.parser", function()
+ describe("#new()", function()
+ it("should work", function()
+ for _, stream in ipairs(httpstreams) do
+ local success;
+ local function success_cb(packet)
+ success = true;
+ end
+ stream = stream:gsub("\n", "\r\n");
+ local parser = http_parser.new(success_cb, error, stream:sub(1,4) == "HTTP" and "client" or "server")
+ for chunk in stream:gmatch("..?.?") do
+ parser:feed(chunk);
+ end
+
+ assert.is_true(success);
+ end
+ end);
+ end);
+end);
diff --git a/spec/net_http_server_spec.lua b/spec/net_http_server_spec.lua
new file mode 100644
index 00000000..758b619d
--- /dev/null
+++ b/spec/net_http_server_spec.lua
@@ -0,0 +1,13 @@
+describe("net.http.server", function ()
+ package.loaded["net.server"] = {}
+ local server = require "net.http.server";
+ describe("events", function ()
+ it("should work with util.helpers", function ()
+ -- See #1044
+ server.add_handler("GET host/foo/*", function () end, 0);
+ server.add_handler("GET host/foo/bar", function () end, 0);
+ local helpers = require "util.helpers";
+ assert.is.string(helpers.show_events(server._events));
+ end);
+ end);
+end);
diff --git a/spec/net_websocket_frames_spec.lua b/spec/net_websocket_frames_spec.lua
new file mode 100644
index 00000000..d4df3a54
--- /dev/null
+++ b/spec/net_websocket_frames_spec.lua
@@ -0,0 +1,56 @@
+describe("net.websocket.frames", function ()
+ local nwf = require "net.websocket.frames";
+
+ local test_frames = {
+ simple_empty = {
+ ["opcode"] = 0;
+ ["length"] = 0;
+ ["data"] = "";
+ ["FIN"] = false;
+ ["MASK"] = false;
+ ["RSV1"] = false;
+ ["RSV2"] = false;
+ ["RSV3"] = false;
+ };
+ simple_data = {
+ ["opcode"] = 0;
+ ["length"] = 5;
+ ["data"] = "hello";
+ ["FIN"] = false;
+ ["MASK"] = false;
+ ["RSV1"] = false;
+ ["RSV2"] = false;
+ ["RSV3"] = false;
+ };
+ simple_fin = {
+ ["opcode"] = 0;
+ ["length"] = 0;
+ ["data"] = "";
+ ["FIN"] = true;
+ ["MASK"] = false;
+ ["RSV1"] = false;
+ ["RSV2"] = false;
+ ["RSV3"] = false;
+ };
+ }
+
+ describe("build", function ()
+ local build = nwf.build;
+ it("works", function ()
+ assert.equal("\0\0", build(test_frames.simple_empty));
+ assert.equal("\0\5hello", build(test_frames.simple_data));
+ assert.equal("\128\0", build(test_frames.simple_fin));
+ end);
+ end);
+
+ describe("parse", function ()
+ local parse = nwf.parse;
+ it("works", function ()
+ assert.same(test_frames.simple_empty, parse("\0\0"));
+ assert.same(test_frames.simple_data, parse("\0\5hello"));
+ assert.same(test_frames.simple_fin, parse("\128\0"));
+ end);
+ end);
+
+end);
+
diff --git a/spec/scansion/basic.scs b/spec/scansion/basic.scs
new file mode 100644
index 00000000..43c9831a
--- /dev/null
+++ b/spec/scansion/basic.scs
@@ -0,0 +1,18 @@
+# Basic login and initial presence
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+ <presence/>
+
+Romeo receives:
+ <presence/>
+
+Romeo disconnects
+
diff --git a/spec/scansion/basic_message.scs b/spec/scansion/basic_message.scs
new file mode 100644
index 00000000..fb21c465
--- /dev/null
+++ b/spec/scansion/basic_message.scs
@@ -0,0 +1,174 @@
+# Basic message routing and delivery
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: juliet@localhost
+ password: password
+
+[Client] Juliet's phone
+ jid: juliet@localhost
+ password: password
+ resource: mobile
+
+---------
+
+# Act 1, scene 1
+# The clients connect
+
+Romeo connects
+
+Juliet connects
+
+Juliet's phone connects
+
+# Romeo publishes his presence. Juliet has not, and so does not receive presence.
+
+Romeo sends:
+ <presence/>
+
+Romeo receives:
+ <presence from="${Romeo's full JID}" />
+
+# Romeo sends a message to Juliet's full JID
+
+Romeo sends:
+ <message to="${Juliet's full JID}" type="chat">
+ <body>Hello Juliet!</body>
+ </message>
+
+Juliet receives:
+ <message to="${Juliet's full JID}" from="${Romeo's full JID}" type="chat">
+ <body>Hello Juliet!</body>
+ </message>
+
+# Romeo sends a message to Juliet's phone
+
+Romeo sends:
+ <message to="${Juliet's phone's full JID}" type="chat">
+ <body>Hello Juliet, on your phone.</body>
+ </message>
+
+Juliet's phone receives:
+ <message to="${Juliet's phone's full JID}" from="${Romeo's full JID}" type="chat">
+ <body>Hello Juliet, on your phone.</body>
+ </message>
+
+# Scene 2
+# This requires the server to support offline messages (which is optional).
+
+# Romeo sends a message to Juliet's bare JID. This is not immediately delivered, as she
+# has not published presence on either of her resources.
+
+Romeo sends:
+ <message to="juliet@localhost" type="chat">
+ <body>Hello Juliet, are you there?</body>
+ </message>
+
+# Juliet sends presence on her phone, and should receive the message there
+
+Juliet's phone sends:
+ <presence/>
+
+Juliet's phone receives:
+ <presence/>
+
+Juliet's phone receives:
+ <message from="${Romeo's full JID}" type="chat">
+ <body>Hello Juliet, are you there?</body>
+ <delay xmlns='urn:xmpp:delay' from='localhost' stamp='{scansion:any}' />
+ </message>
+
+# Romeo sends another bare-JID message, it should be delivered
+# instantly to Juliet's phone
+
+Romeo sends:
+ <message to="juliet@localhost" type="chat">
+ <body>Oh, hi!</body>
+ </message>
+
+Juliet's phone receives:
+ <message from="${Romeo's full JID}" type="chat">
+ <body>Oh, hi!</body>
+ </message>
+
+# Juliet's laptop goes online, but with a negative priority
+
+Juliet sends:
+ <presence>
+ <priority>-1</priority>
+ </presence>
+
+Juliet receives:
+ <presence from="${Juliet's full JID}">
+ <priority>-1</priority>
+ </presence>
+
+Juliet's phone receives:
+ <presence from="${Juliet's full JID}">
+ <priority>-1</priority>
+ </presence>
+
+# Again, Romeo sends a message to her bare JID, but it should
+# only get delivered to her phone:
+
+Romeo sends:
+ <message to="juliet@localhost" type="chat">
+ <body>How are you?</body>
+ </message>
+
+Juliet's phone receives:
+ <message from="${Romeo's full JID}" type="chat">
+ <body>How are you?</body>
+ </message>
+
+# Romeo sends direct to Juliet's full JID, and she should receive it
+
+Romeo sends:
+ <message to="${Juliet's full JID}" type="chat">
+ <body>Are you hiding?</body>
+ </message>
+
+Juliet receives:
+ <message from="${Romeo's full JID}" type="chat">
+ <body>Are you hiding?</body>
+ </message>
+
+# Juliet publishes non-negative presence
+
+Juliet sends:
+ <presence/>
+
+Juliet receives:
+ <presence from="${Juliet's full JID}"/>
+
+Juliet's phone receives:
+ <presence from="${Juliet's full JID}"/>
+
+# And now Romeo's bare JID messages get delivered to both resources
+# (server behaviour may vary here)
+
+Romeo sends:
+ <message to="juliet@localhost" type="chat">
+ <body>There!</body>
+ </message>
+
+Juliet receives:
+ <message from="${Romeo's full JID}" type="chat">
+ <body>There!</body>
+ </message>
+
+Juliet's phone receives:
+ <message from="${Romeo's full JID}" type="chat">
+ <body>There!</body>
+ </message>
+
+# The End
+
+Romeo disconnects
+
+Juliet disconnects
+
+Juliet's phone disconnects
diff --git a/spec/scansion/basic_roster.scs b/spec/scansion/basic_roster.scs
new file mode 100644
index 00000000..2e292083
--- /dev/null
+++ b/spec/scansion/basic_roster.scs
@@ -0,0 +1,73 @@
+# Basic roster test
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: juliet@localhost
+ password: password
+
+---------
+
+Romeo connects
+
+Juliet connects
+
+Romeo sends:
+ <presence/>
+
+Romeo receives:
+ <presence from="${Romeo's full JID}" />
+
+Romeo sends:
+ <iq type="get" id="roster1">
+ <query xmlns='jabber:iq:roster'/>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id="roster1">
+ <query ver='{scansion:any}' xmlns="jabber:iq:roster"/>
+ </iq>
+
+# Add nurse to roster
+
+Romeo sends:
+ <iq type="set" id="roster2">
+ <query xmlns="jabber:iq:roster">
+ <item jid='nurse@localhost'/>
+ </query>
+ </iq>
+
+# Receive the roster add result
+
+Romeo receives:
+ <iq type="result" id="roster2"/>
+
+# Receive the roster push
+
+Romeo receives:
+ <iq type="set" id="{scansion:any}">
+ <query xmlns='jabber:iq:roster' ver='{scansion:any}'>
+ <item jid='nurse@localhost' subscription='none'/>
+ </query>
+ </iq>
+
+Romeo sends:
+ <iq type="result" id="fixme"/>
+
+# Fetch the roster, it should include nurse now
+
+Romeo sends:
+ <iq type="get" id="roster3">
+ <query xmlns='jabber:iq:roster'/>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id="roster3">
+ <query xmlns='jabber:iq:roster' ver="{scansion:any}">
+ <item subscription='none' jid='nurse@localhost'/>
+ </query>
+ </iq>
+
+Romeo disconnects
diff --git a/spec/scansion/issue1224.scs b/spec/scansion/issue1224.scs
new file mode 100644
index 00000000..b75cfbd1
--- /dev/null
+++ b/spec/scansion/issue1224.scs
@@ -0,0 +1,115 @@
+# MUC: Handle affiliation changes from buggy clients
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+Romeo sends:
+ <iq id='member1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Juliet's JID}" />
+ </query>
+ </iq>
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's JID}" affiliation='member' xmlns='http://jabber.org/protocol/muc#user'/>
+ </x>
+ </message>
+
+Romeo receives:
+ <iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+# Romeo makes Juliet a member of the room, however his client is buggy and only
+# specifies her nickname
+
+Romeo sends:
+ <iq id='member1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' nick='Juliet' />
+ </query>
+ </iq>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='member' role='participant' jid="${Juliet's full JID}">
+ <actor jid="${Romeo's full JID}" nick='Romeo'/>
+ </item>
+ </x>
+ </presence>
+
+Romeo receives:
+ <iq type='result' id='member1' from='room@conference.localhost' />
+
+Juliet receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='member' role='participant' jid="${Juliet's full JID}">
+ <actor nick='Romeo' />
+ </item>
+ <status xmlns='http://jabber.org/protocol/muc#user' code='110'/>
+ </x>
+ </presence>
diff --git a/spec/scansion/issue505.scs b/spec/scansion/issue505.scs
new file mode 100644
index 00000000..24fbeb72
--- /dev/null
+++ b/spec/scansion/issue505.scs
@@ -0,0 +1,79 @@
+# Issue 505: mod_muc doesn’t forward part statuses
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet sends:
+ <presence type='unavailable' to='room@conference.localhost'>
+ <status>Farewell</status>
+ </presence>
+
+Romeo receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet'>
+ <status>Farewell</status>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's full JID}" affiliation='none' role='none'/>
+ </x>
+ </presence>
diff --git a/spec/scansion/issue978-multi.scs b/spec/scansion/issue978-multi.scs
new file mode 100644
index 00000000..d8f99228
--- /dev/null
+++ b/spec/scansion/issue978-multi.scs
@@ -0,0 +1,111 @@
+# Issue 978: MUC does not carry error into occupant leave status (multiple clients)
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+[Client] Juliet's phone
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user' scansion:strict='false'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+# Juliet's phone connects, and joins the room
+Juliet's phone connects
+
+Juliet's phone sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet's phone receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet's phone receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet's phone receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user' scansion:strict='false'>
+ <item affiliation='none' jid="${Juliet's phone's full JID}" role='participant'/>
+ <item affiliation='none' jid="${Juliet's full JID}" role='participant'/>
+ </x>
+ </presence>
+
+# Juliet leaves with an error
+Juliet sends:
+ <presence type='error' to='room@conference.localhost'>
+ <error type='cancel'>
+ <service-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ <text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Test error</text>
+ </error>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's phone's full JID}" affiliation='none' role='participant'/>
+ </x>
+ </presence>
diff --git a/spec/scansion/issue978.scs b/spec/scansion/issue978.scs
new file mode 100644
index 00000000..59db8335
--- /dev/null
+++ b/spec/scansion/issue978.scs
@@ -0,0 +1,85 @@
+# Issue 978: MUC does not carry error into occupant leave status (single client)
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field var='muc#roomconfig_whois'>
+ <value>anyone</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet sends:
+ <presence type='error' to='room@conference.localhost'>
+ <error type='cancel'>
+ <service-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ <text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Test error</text>
+ </error>
+ </presence>
+
+Romeo receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet'>
+ <status>Kicked: service unavailable: Test error</status>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='333'/>
+ <item jid="${Juliet's full JID}" affiliation='none' role='none'/>
+ </x>
+ </presence>
diff --git a/spec/scansion/mam_prefs_prep.scs b/spec/scansion/mam_prefs_prep.scs
new file mode 100644
index 00000000..9589ec65
--- /dev/null
+++ b/spec/scansion/mam_prefs_prep.scs
@@ -0,0 +1,36 @@
+# mod_mam shold apply JIDprep in prefs
+
+[Client] Romeo
+ jid: romeo@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <iq id="lx2" type="set">
+ <prefs xmlns="urn:xmpp:mam:2" default="roster">
+ <always>
+ <jid>JULIET@MONTAGUE.LIT</jid>
+ </always>
+ <never>
+ <jid>MONTAGUE@MONTAGUE.LIT</jid>
+ </never>
+ </prefs>
+ </iq>
+
+Romeo receives:
+ <iq id="lx2" type="result">
+ <prefs xmlns="urn:xmpp:mam:2" default="roster">
+ <always>
+ <jid>juliet@montague.lit</jid>
+ </always>
+ <never>
+ <jid>montague@montague.lit</jid>
+ </never>
+ </prefs>
+ </iq>
+
+Romeo disconnects
+
diff --git a/spec/scansion/muc_affiliation_notify.scs b/spec/scansion/muc_affiliation_notify.scs
new file mode 100644
index 00000000..e7d21fcd
--- /dev/null
+++ b/spec/scansion/muc_affiliation_notify.scs
@@ -0,0 +1,137 @@
+# MUC: Notification of affiliation changes of non-occupants
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+[Client] Rosaline
+ jid: user3@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Promote Juliet to member
+Romeo sends:
+ <iq id='member1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Juliet's JID}" />
+ </query>
+ </iq>
+
+# Juliet is not in the room, so an affiliation change message is received
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's JID}" affiliation='member' xmlns='http://jabber.org/protocol/muc#user'/>
+ </x>
+ </message>
+
+# The affiliation change succeeded
+
+Romeo receives:
+ <iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+# To check the status of the room is as expected, Romeo requests the member list
+
+Romeo sends:
+ <iq id='member3' to='room@conference.localhost' type='get'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member'/>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq from='room@conference.localhost' type='result' id='member3'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Juliet's JID}" />
+ </query>
+ </iq>
+
+# Romeo grants membership to Rosaline, who is not in the room
+
+Romeo sends:
+ <iq id='member2' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Rosaline's JID}" />
+ </query>
+ </iq>
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Rosaline's JID}" affiliation='member' xmlns='http://jabber.org/protocol/muc#user'/>
+ </x>
+ </message>
+
+Romeo receives:
+ <iq type='result' id='member2' from='room@conference.localhost' />
+
+Romeo sends:
+ <message type="groupchat" to="room@conference.localhost">
+ <body>Finished!</body>
+ </message>
+
+Juliet receives:
+ <message type="groupchat" from="room@conference.localhost/Romeo">
+ <body>Finished!</body>
+ </message>
diff --git a/spec/scansion/muc_mediated_invite.scs b/spec/scansion/muc_mediated_invite.scs
new file mode 100644
index 00000000..340aefc7
--- /dev/null
+++ b/spec/scansion/muc_mediated_invite.scs
@@ -0,0 +1,76 @@
+# MUC: Mediated invites
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Juliet connects
+Juliet connects
+
+Juliet sends:
+ <presence/>
+
+Juliet receives:
+ <presence/>
+
+
+# Romeo invites Juliet to join the room
+
+Romeo sends:
+ <message to="room@conference.localhost" id="invite1">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <invite to="${Juliet's JID}" />
+ </x>
+ </message>
+
+Juliet receives:
+ <message from="room@conference.localhost" id="invite1">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <invite from="room@conference.localhost/Romeo">
+ <reason/>
+ </invite>
+ </x>
+ <body>room@conference.localhost/Romeo invited you to the room room@conference.localhost</body>
+ <x xmlns="jabber:x:conference" jid="room@conference.localhost"/>
+ </message>
diff --git a/spec/scansion/muc_members_only_change.scs b/spec/scansion/muc_members_only_change.scs
new file mode 100644
index 00000000..dc40b5a0
--- /dev/null
+++ b/spec/scansion/muc_members_only_change.scs
@@ -0,0 +1,114 @@
+# MUC: Members-only rooms kick members who lose affiliation
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form, set the room to members-only
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field var='muc#roomconfig_membersonly'>
+ <value>1</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Romeo adds Juliet to the member list
+Romeo sends:
+ <iq id='member1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Juliet's JID}" />
+ </query>
+ </iq>
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's JID}" affiliation='member' />
+ </x>
+ </message>
+
+Romeo receives:
+ <iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+
+# Romeo removes Juliet's membership status
+Romeo sends:
+ <iq id='member2' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='none' jid="${Juliet's JID}" />
+ </query>
+ </iq>
+
+# As a non-member, Juliet must now be removed from the room
+Romeo receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='321'/>
+ <item role='none' jid="${Juliet's full JID}" affiliation='none'>
+ <actor nick='Romeo' jid="${Romeo's full JID}"/>
+ </item>
+ </x>
+ </presence>
+
+Romeo receives:
+ <iq id='member2' type='result'/>
+
+Romeo disconnects
+
+Juliet disconnects
diff --git a/spec/scansion/muc_members_only_deregister.scs b/spec/scansion/muc_members_only_deregister.scs
new file mode 100644
index 00000000..dcc84146
--- /dev/null
+++ b/spec/scansion/muc_members_only_deregister.scs
@@ -0,0 +1,122 @@
+# MUC: Members-only rooms kick members who deregister
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form, set the room to members-only
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field var='muc#roomconfig_membersonly'>
+ <value>1</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Romeo adds Juliet to the member list
+Romeo sends:
+ <iq id='member1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Juliet's JID}" />
+ </query>
+ </iq>
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's JID}" affiliation='member' />
+ </x>
+ </message>
+
+Romeo receives:
+ <iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+
+# Tired of Romeo's company, Juliet unregisters from the room
+
+Juliet sends:
+ <iq type='set' to='room@conference.localhost' id='unreg1'>
+ <query xmlns='jabber:iq:register'>
+ <remove/>
+ </query>
+ </iq>
+
+Juliet receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='321'/>
+ <item affiliation='none' role='none' jid="${Juliet's full JID}" />
+ <status code='110'/>
+ </x>
+ </presence>
+
+Juliet receives:
+ <iq type='result' from='room@conference.localhost' id='unreg1'/>
+
+Romeo receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='321'/>
+ <item affiliation='none' role='none' jid="${Juliet's full JID}" />
+ </x>
+ </presence>
+
+
+Romeo disconnects
+
+Juliet disconnects
diff --git a/spec/scansion/muc_password.scs b/spec/scansion/muc_password.scs
new file mode 100644
index 00000000..82611183
--- /dev/null
+++ b/spec/scansion/muc_password.scs
@@ -0,0 +1,143 @@
+# MUC: Password-protected rooms
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field var='muc#roomconfig_roomsecret'>
+ <value>cauldronburn</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+# Juliet connects, and tries to join the room (password-protected)
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" type="error">
+ <error type="auth" code="401">
+ <not-authorized xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ </presence>
+
+# Retry with the correct password
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc">
+ <password>cauldronburn</password>
+ </x>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+# Ok, now Juliet leaves, and Romeo unsets the password
+
+Juliet sends:
+ <presence type="unavailable" to="room@conference.localhost"/>
+
+Romeo receives:
+ <presence type="unavailable" from="room@conference.localhost/Juliet"/>
+
+Juliet receives:
+ <presence type="unavailable" from="room@conference.localhost/Juliet"/>
+
+# Remove room password
+Romeo sends:
+ <iq id='config2' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field var='muc#roomconfig_roomsecret'>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+# Config change success
+Romeo receives:
+ <iq id="config2" from="room@conference.localhost" type="result">
+ </iq>
+
+# Notification of room configuration update
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='104'/>
+ </x>
+ </message>
+
+# Juliet tries to join (should succeed)
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+# Notification of Romeo's presence in the room
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+# Room topic
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
diff --git a/spec/scansion/muc_register.scs b/spec/scansion/muc_register.scs
new file mode 100644
index 00000000..e1eaf4e0
--- /dev/null
+++ b/spec/scansion/muc_register.scs
@@ -0,0 +1,528 @@
+# MUC: Room registration and reserved nicknames
+
+[Client] Romeo
+ jid: user@localhost
+ password: password
+
+[Client] Juliet
+ jid: user2@localhost
+ password: password
+
+[Client] Rosaline
+ jid: user3@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+ <iq id='config1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq id="config1" from="room@conference.localhost" type="result">
+ </iq>
+
+Romeo sends:
+ <iq id='member1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Juliet's JID}" />
+ </query>
+ </iq>
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's JID}" affiliation='member' />
+ </x>
+ </message>
+
+Romeo receives:
+ <iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+# Juliet retrieves the registration form
+
+Juliet sends:
+ <iq id='jw81b36f' to='room@conference.localhost' type='get'>
+ <query xmlns='jabber:iq:register'/>
+ </iq>
+
+Juliet receives:
+ <iq type='result' from='room@conference.localhost' id='jw81b36f'>
+ <query xmlns='jabber:iq:register'>
+ <x type='form' xmlns='jabber:x:data'>
+ <field type='hidden' var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#register</value>
+ </field>
+ <field type='text-single' label='Nickname' var='muc#register_roomnick'/>
+ </x>
+ </query>
+ </iq>
+
+Juliet sends:
+ <iq id='nv71va54' to='room@conference.localhost' type='set'>
+ <query xmlns='jabber:iq:register'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#register</value>
+ </field>
+ <field var='muc#register_roomnick'>
+ <value>Juliet</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Juliet receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='member' jid="${Juliet's full JID}" role='participant'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Juliet receives:
+ <iq type='result' from='room@conference.localhost' id='nv71va54'/>
+
+# Juliet discovers her reserved nick
+
+Juliet sends:
+ <iq id='getnick1' to='room@conference.localhost' type='get'>
+ <query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+ </iq>
+
+Juliet receives:
+ <iq type='result' from='room@conference.localhost' id='getnick1'>
+ <query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'>
+ <identity category='conference' name='Juliet' type='text'/>
+ </query>
+ </iq>
+
+# Juliet leaves the room:
+
+Juliet sends:
+ <presence type="unavailable" to="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's full JID}" affiliation='member' role='none'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's full JID}" affiliation='member' role='participant'/>
+ </x>
+ </presence>
+
+# Rosaline connect and tries to join the room as Juliet
+
+Rosaline connects
+
+Rosaline sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Rosaline receives:
+ <presence type='error' from='room@conference.localhost/Juliet'>
+ <error type='cancel'>
+ <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ <x xmlns='http://jabber.org/protocol/muc'/>
+ </presence>
+
+# In a heated moment, Juliet unregisters from the room
+
+Juliet sends:
+ <iq type='set' to='room@conference.localhost' id='unreg1'>
+ <query xmlns='jabber:iq:register'>
+ <remove/>
+ </query>
+ </iq>
+
+Juliet receives:
+ <iq type='result' from='room@conference.localhost' id='unreg1'/>
+
+# Romeo is notified of Juliet's sad decision
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user' scansion:strict='true'>
+ <item jid="${Juliet's JID}" />
+ </x>
+ </message>
+
+# Rosaline attempts once more to sneak into the room, disguised as Juliet
+
+Rosaline sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Rosaline receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='owner' role='moderator'/>
+ </x>
+ </presence>
+
+Rosaline receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='none' jid="${Rosaline's full JID}" role='participant'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='none' jid="${Rosaline's full JID}" role='participant'/>
+ </x>
+ </presence>
+
+# On discovering the ruse, Romeo restores Juliet's nick and status within the room
+
+Romeo sends:
+ <iq id='member1' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Juliet's JID}" nick='Juliet' />
+ </query>
+ </iq>
+
+# Rosaline is evicted from the room
+
+Romeo receives:
+ <presence from='room@conference.localhost/Juliet' type='unavailable'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='307'/>
+ <item affiliation='none' role='none' jid="${Rosaline's full JID}">
+ <reason>This nickname is reserved</reason>
+ </item>
+ </x>
+ </presence>
+
+# An out-of-room affiliation change is received for Juliet
+
+Romeo receives:
+ <message from='room@conference.localhost'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Juliet's JID}" affiliation='member' />
+ </x>
+ </message>
+
+Romeo receives:
+ <iq type='result' id='member1' from='room@conference.localhost' />
+
+Rosaline receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='307'/>
+ <item affiliation='none' jid="${Rosaline's full JID}" role='none'>
+ <reason>This nickname is reserved</reason>
+ </item>
+ <status code='110'/>
+ </x>
+ </presence>
+
+# Rosaline, frustrated, attempts to get back into the room...
+
+Rosaline sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+# ...but once again, is denied
+
+Rosaline receives:
+ <presence type='error' from='room@conference.localhost/Juliet'>
+ <error type='cancel'>
+ <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ <x xmlns='http://jabber.org/protocol/muc'/>
+ </presence>
+
+# Juliet, however, quietly joins the room with success
+
+Juliet sends:
+ <presence to="room@conference.localhost/Juliet">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Juliet receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Juliet" />
+
+# Romeo checks whether he has reserved his own nick yet
+
+Romeo sends:
+ <iq id='getnick1' to='room@conference.localhost' type='get'>
+ <query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+ </iq>
+
+# But no nick is returned, as he hasn't registered yet!
+
+Romeo receives:
+ <iq type='result' from='room@conference.localhost' id='getnick1'>
+ <query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item' scansion:strict='true' />
+ </iq>
+
+# Romeo updates his own registration
+
+Romeo sends:
+ <iq id='jw81b36f' to='room@conference.localhost' type='get'>
+ <query xmlns='jabber:iq:register'/>
+ </iq>
+
+Romeo receives:
+ <iq type='result' from='room@conference.localhost' id='jw81b36f'>
+ <query xmlns='jabber:iq:register'>
+ <x type='form' xmlns='jabber:x:data'>
+ <field type='hidden' var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#register</value>
+ </field>
+ <field type='text-single' label='Nickname' var='muc#register_roomnick'/>
+ </x>
+ </query>
+ </iq>
+
+Romeo sends:
+ <iq id='nv71va54' to='room@conference.localhost' type='set'>
+ <query xmlns='jabber:iq:register'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#register</value>
+ </field>
+ <field var='muc#register_roomnick'>
+ <value>Romeo</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='owner' jid="${Romeo's full JID}" role='moderator'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <iq type='result' from='room@conference.localhost' id='nv71va54'/>
+
+Juliet receives:
+ <presence from='room@conference.localhost/Romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item role='moderator' xmlns='http://jabber.org/protocol/muc#user' affiliation='owner'/>
+ </x>
+ </presence>
+
+# Romeo discovers his reserved nick
+
+Romeo sends:
+ <iq id='getnick1' to='room@conference.localhost' type='get'>
+ <query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+ </iq>
+
+Romeo receives:
+ <iq type='result' from='room@conference.localhost' id='getnick1'>
+ <query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'>
+ <identity category='conference' name='Romeo' type='text'/>
+ </query>
+ </iq>
+
+# To check the status of the room is as expected, Romeo requests the member list
+
+Romeo sends:
+ <iq id='member3' to='room@conference.localhost' type='get'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member'/>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq from='room@conference.localhost' type='result' id='member3'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item jid="${Juliet's JID}" affiliation='member' nick='Juliet'/>
+ </query>
+ </iq>
+
+Juliet sends:
+ <presence type="unavailable" to="room@conference.localhost/Juliet" />
+
+Juliet receives:
+ <presence from='room@conference.localhost/Juliet' type='unavailable' />
+
+Romeo receives:
+ <presence type='unavailable' from='room@conference.localhost/Juliet' />
+
+# Rosaline joins as herself
+
+Rosaline sends:
+ <presence to="room@conference.localhost/Rosaline">
+ <x xmlns="http://jabber.org/protocol/muc"/>
+ </presence>
+
+Rosaline receives:
+ <presence from="room@conference.localhost/Romeo" />
+
+Rosaline receives:
+ <presence from="room@conference.localhost/Rosaline" />
+
+Rosaline receives:
+ <message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Rosaline'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Rosaline's full JID}" affiliation='none' role='participant'/>
+ </x>
+ </presence>
+
+# Rosaline tries to register her own nickname, but unaffiliated
+# registration is disabled by default
+
+Rosaline sends:
+ <iq id='reg990' to='room@conference.localhost' type='get'>
+ <query xmlns='jabber:iq:register'/>
+ </iq>
+
+Rosaline receives:
+ <iq type='error' from='room@conference.localhost' id='reg990'>
+ <error type='auth'>
+ <registration-required xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ </iq>
+
+Rosaline sends:
+ <iq id='reg991' to='room@conference.localhost' type='set'>
+ <query xmlns='jabber:iq:register'>
+ <x xmlns='jabber:x:data' type='submit'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#register</value>
+ </field>
+ <field var='muc#register_roomnick'>
+ <value>Romeo</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Rosaline receives:
+ <iq id='reg991' type='error'>
+ <error type='auth'>
+ <registration-required xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ </iq>
+
+# Romeo reserves her nickname for her
+
+Romeo sends:
+ <iq id='member2' to='room@conference.localhost' type='set'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member' jid="${Rosaline's JID}" nick='Rosaline' />
+ </query>
+ </iq>
+
+Romeo receives:
+ <presence from='room@conference.localhost/Rosaline'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='member' role='participant' jid="${Rosaline's full JID}">
+ <actor jid="${Romeo's full JID}" nick='Romeo'/>
+ </item>
+ </x>
+ </presence>
+
+Romeo receives:
+ <iq type='result' id='member2' from='room@conference.localhost' />
+
+Rosaline receives:
+ <presence from='room@conference.localhost/Rosaline'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='member' role='participant' jid="${Rosaline's full JID}">
+ <actor nick='Romeo' />
+ </item>
+ <status xmlns='http://jabber.org/protocol/muc#user' code='110'/>
+ </x>
+ </presence>
+
+# Romeo sets their their own nickname via admin query (see #1273)
+Romeo sends:
+ <iq to="room@conference.localhost" id="reserve" type="set">
+ <query xmlns="http://jabber.org/protocol/muc#admin">
+ <item nick="Romeo" affiliation="owner" jid="${Romeo's JID}"/>
+ </query>
+ </iq>
+
+Romeo receives:
+ <presence from="room@conference.localhost/Romeo">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item xmlns="http://jabber.org/protocol/muc#user" role="moderator" jid="${Romeo's full JID}" affiliation="owner">
+ <actor xmlns="http://jabber.org/protocol/muc#user" nick="Romeo"/>
+ </item>
+ <status xmlns="http://jabber.org/protocol/muc#user" code="110"/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <iq from="room@conference.localhost" id="reserve" type="result"/>
+
diff --git a/spec/scansion/muc_whois_anyone_member.scs b/spec/scansion/muc_whois_anyone_member.scs
new file mode 100644
index 00000000..9a6f7e15
--- /dev/null
+++ b/spec/scansion/muc_whois_anyone_member.scs
@@ -0,0 +1,101 @@
+# MUC: Allow members to fetch the affiliation lists in open non-anonymous rooms
+
+[Client] Romeo
+ jid: romeo@localhost/MsliYo9C
+ password: password
+
+[Client] Juliet
+ jid: juliet@localhost/vJrUtY4Z
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence to='issue1230@conference.localhost/romeo'>
+ <x xmlns='http://jabber.org/protocol/muc'/>
+ </presence>
+
+Romeo receives:
+ <presence from='issue1230@conference.localhost/romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='201'/>
+ <item jid="${Romeo's JID}" role='moderator' affiliation='owner'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Romeo receives:
+ <message from='issue1230@conference.localhost' type='groupchat'>
+ <subject/>
+ </message>
+
+Romeo sends:
+ <iq id='lx3' type='set' to='issue1230@conference.localhost'>
+ <query xmlns='http://jabber.org/protocol/muc#owner'>
+ <x type='submit' xmlns='jabber:x:data'>
+ <field var='FORM_TYPE'>
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field var='muc#roomconfig_whois'>
+ <value>anyone</value>
+ </field>
+ </x>
+ </query>
+ </iq>
+
+Romeo receives:
+ <iq from='issue1230@conference.localhost' type='result' id='lx3'/>
+
+Romeo receives:
+ <message from='issue1230@conference.localhost' type='groupchat'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='172'/>
+ </x>
+ </message>
+
+Juliet connects
+
+Juliet sends:
+ <presence to='issue1230@conference.localhost/juliet'>
+ <x xmlns='http://jabber.org/protocol/muc'/>
+ </presence>
+
+Juliet receives:
+ <presence from='issue1230@conference.localhost/romeo'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item jid="${Romeo's JID}" role='moderator' affiliation='owner'/>
+ </x>
+ </presence>
+
+Juliet receives:
+ <presence from='issue1230@conference.localhost/juliet'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='100'/>
+ <item jid="${Juliet's JID}" role='participant' affiliation='none'/>
+ <status code='110'/>
+ </x>
+ </presence>
+
+Juliet receives:
+ <message from='issue1230@conference.localhost' type='groupchat'>
+ <subject/>
+ </message>
+
+Juliet sends:
+ <iq id='lx2' type='get' to='issue1230@conference.localhost'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'>
+ <item affiliation='member'/>
+ </query>
+ </iq>
+
+Juliet receives:
+ <iq from='issue1230@conference.localhost' type='result' id='lx2'>
+ <query xmlns='http://jabber.org/protocol/muc#admin'/>
+ </iq>
+
+Juliet disconnects
+
+Romeo disconnects
+
diff --git a/spec/scansion/pep_nickname.scs b/spec/scansion/pep_nickname.scs
new file mode 100644
index 00000000..f958ec75
--- /dev/null
+++ b/spec/scansion/pep_nickname.scs
@@ -0,0 +1,72 @@
+# Publishing a nickname in PEP and receiving a notification
+
+[Client] Romeo
+ jid: romeo@localhost/nJi7BeTR
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <iq id="4" type="set">
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <publish node="http://jabber.org/protocol/nick">
+ <item id="current">
+ <nickname xmlns="http://jabber.org/protocol/nick"/>
+ </item>
+ </publish>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq id="4" to="romeo@localhost/nJi7BeTR" type="result">
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <publish node="http://jabber.org/protocol/nick">
+ <item id="current"/>
+ </publish>
+ </pubsub>
+ </iq>
+
+Romeo sends:
+ <presence>
+ <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" node="http://code.matthewwild.co.uk/clix/" ver="jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
+ </presence>
+
+Romeo receives:
+ <iq id="disco" to="romeo@localhost/nJi7BeTR" from="romeo@localhost" type="get">
+ <query xmlns="http://jabber.org/protocol/disco#info" node="http://code.matthewwild.co.uk/clix/#jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
+ </iq>
+
+Romeo receives:
+ <presence from="romeo@localhost/nJi7BeTR">
+ <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" node="http://code.matthewwild.co.uk/clix/" ver="jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
+ </presence>
+
+Romeo sends:
+ <iq id="disco" type="result" to="romeo@localhost">
+ <query xmlns="http://jabber.org/protocol/disco#info" node="http://code.matthewwild.co.uk/clix/#jC32N+FhQoLrZ7nNQtZK3aqR0Fk=">
+ <identity type="console" name="clix" category="client"/>
+ <feature var="http://jabber.org/protocol/disco#items"/>
+ <feature var="http://jabber.org/protocol/disco#info"/>
+ <feature var="http://jabber.org/protocol/caps"/>
+ <feature var="http://jabber.org/protocol/nick+notify"/>
+ </query>
+ </iq>
+
+Romeo receives:
+ <message type="headline" from="romeo@localhost" to="romeo@localhost/nJi7BeTR">
+ <event xmlns="http://jabber.org/protocol/pubsub#event">
+ <items node="http://jabber.org/protocol/nick">
+ <item id="current">
+ <nickname xmlns="http://jabber.org/protocol/nick"/>
+ </item>
+ </items>
+ </event>
+ </message>
+
+Romeo sends:
+ <presence type="unavailable"/>
+
+Romeo disconnects
+
diff --git a/spec/scansion/pep_publish_subscribe.scs b/spec/scansion/pep_publish_subscribe.scs
new file mode 100644
index 00000000..e8080134
--- /dev/null
+++ b/spec/scansion/pep_publish_subscribe.scs
@@ -0,0 +1,210 @@
+# PEP publish, subscribe and publish-options
+
+[Client] Romeo
+ jid: pep-test-wjebo4kg@localhost
+ password: password
+
+[Client] Juliet
+ jid: pep-test-tqvqu_pv@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <presence>
+ <c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+ </presence>
+
+Romeo receives:
+ <iq type='get' id='disco' from="${Romeo's JID}">
+ <query node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY=' xmlns='http://jabber.org/protocol/disco#info'/>
+ </iq>
+
+Romeo receives:
+ <presence from="${Romeo's full JID}">
+ <c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+ </presence>
+
+Romeo sends:
+ <iq type='get' id='6'>
+ <query ver='' xmlns='jabber:iq:roster'/>
+ </iq>
+
+Romeo receives:
+ <iq type='result' id='6'>
+ <query ver='1' xmlns='jabber:iq:roster'/>
+ </iq>
+
+Juliet connects
+
+Juliet sends:
+ <presence>
+ <c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+ </presence>
+
+Juliet receives:
+ <iq type='get' id='disco' from="${Juliet's JID}">
+ <query node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY=' xmlns='http://jabber.org/protocol/disco#info'/>
+ </iq>
+
+Juliet receives:
+ <presence from="${Juliet's full JID}">
+ <c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+ </presence>
+
+Juliet sends:
+ <iq type='get' id='6'>
+ <query ver='' xmlns='jabber:iq:roster'/>
+ </iq>
+
+Juliet receives:
+ <iq type='result' id='6'>
+ <query ver='1' xmlns='jabber:iq:roster'/>
+ </iq>
+
+Romeo sends:
+ <iq type='result' id='disco' to='pep-test-wjebo4kg@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/></query></iq>
+
+Romeo sends:
+ <presence type='subscribe' to="${Juliet's JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+ <iq type='set' id='{scansion:any}'><query ver='1' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-tqvqu_pv@localhost' subscription='none'/></query></iq>
+
+Romeo receives:
+ <presence type='unavailable' to='pep-test-wjebo4kg@localhost' from='pep-test-tqvqu_pv@localhost'/>
+
+Juliet receives:
+ <presence type='subscribe' from='pep-test-wjebo4kg@localhost' to='pep-test-tqvqu_pv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet sends:
+ <iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/></query></iq>
+
+Juliet sends:
+ <presence type='subscribe' to="${Romeo's JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+ <iq type='set' id='{scansion:any}'><query ver='2' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-wjebo4kg@localhost' subscription='none'/></query></iq>
+
+Juliet receives:
+ <presence type='unavailable' to='pep-test-tqvqu_pv@localhost' from='pep-test-wjebo4kg@localhost'/>
+
+Romeo receives:
+ <presence type='subscribe' from='pep-test-tqvqu_pv@localhost' to='pep-test-wjebo4kg@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo sends:
+ <iq type='result' id='fixme'/>
+
+Romeo sends:
+ <presence type='subscribed' to='pep-test-tqvqu_pv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+ <iq type='set' id='{scansion:any}'><query ver='3' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-tqvqu_pv@localhost' subscription='from'/></query></iq>
+
+Juliet receives:
+ <presence type='subscribed' from='pep-test-wjebo4kg@localhost' to='pep-test-tqvqu_pv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+ <iq type='set' id='{scansion:any}'><query ver='3' xmlns='jabber:iq:roster'><item jid='pep-test-wjebo4kg@localhost' subscription='to'/></query></iq>
+
+Juliet receives:
+ <presence to='pep-test-tqvqu_pv@localhost' from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Juliet sends:
+ <presence type='subscribed' to='pep-test-wjebo4kg@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+ <iq type='set' id='{scansion:any}'><query ver='4' xmlns='jabber:iq:roster'><item jid='pep-test-wjebo4kg@localhost' subscription='both'/></query></iq>
+
+Juliet receives:
+ <presence to='pep-test-tqvqu_pv@localhost' from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Romeo receives:
+ <presence type='subscribed' from='pep-test-tqvqu_pv@localhost' to='pep-test-wjebo4kg@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+ <iq type='set' id='{scansion:any}'><query ver='4' xmlns='jabber:iq:roster'><item jid='pep-test-tqvqu_pv@localhost' subscription='both'/></query></iq>
+
+Romeo receives:
+ <presence to='pep-test-wjebo4kg@localhost' from="${Juliet's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Juliet sends:
+ <iq type='result' id='fixme'/>
+
+Romeo sends:
+ <iq type='result' id='fixme'/>
+
+Romeo sends:
+ <iq type='result' id='fixme'/>
+
+Romeo sends:
+ <presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+ <iq type='get' id='disco' from='pep-test-wjebo4kg@localhost'><query node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo receives:
+ <presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+ <iq type='get' id='disco' from='pep-test-tqvqu_pv@localhost'><query node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Juliet receives:
+ <presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo sends:
+ <presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+ <iq type='get' id='disco' from='pep-test-wjebo4kg@localhost'><query node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo receives:
+ <presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+ <iq type='get' id='disco' from='pep-test-tqvqu_pv@localhost'><query node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo sends:
+ <iq type='result' id='disco' to='pep-test-wjebo4kg@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg='/></iq>
+
+Romeo sends:
+ <iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg='/></iq>
+
+Romeo sends:
+ <iq type='result' id='disco' to='pep-test-wjebo4kg@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
+
+Juliet receives:
+ <presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet sends:
+ <iq type='result' id='fixme'/>
+
+Juliet sends:
+ <iq type='set' id='7'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></publish></pubsub></iq>
+
+Juliet receives:
+ <iq type='result' id='7' ><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'/></publish></pubsub></iq>
+
+Juliet sends:
+ <iq type='set' id='8'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/mood'><item><mood xmlns='http://jabber.org/protocol/mood'><happy/></mood></item></publish><publish-options><x type='submit' xmlns='jabber:x:data'><field type='hidden' var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field><field var='pubsub#persist_items'><value>true</value></field><field var='pubsub#access_model'><value>whitelist</value></field></x></publish-options></pubsub></iq>
+
+Juliet receives:
+ <iq type='result' id='8'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/mood'><item id='{scansion:any}'/></publish></pubsub></iq>
+
+Juliet sends:
+ <iq type='result' id='{scansion:any}'/>
+
+Romeo receives:
+ <message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+
+Romeo sends:
+ <iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
+
+Romeo receives:
+ <message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+
+Juliet disconnects
+
+Romeo disconnects
diff --git a/spec/scansion/prosody.cfg.lua b/spec/scansion/prosody.cfg.lua
new file mode 100644
index 00000000..f95ea31b
--- /dev/null
+++ b/spec/scansion/prosody.cfg.lua
@@ -0,0 +1,83 @@
+--luacheck: ignore
+
+admins = { "admin@localhost" }
+
+use_libevent = true
+
+modules_enabled = {
+ -- Generally required
+ "roster"; -- Allow users to have a roster. Recommended ;)
+ "saslauth"; -- Authentication for clients and servers. Recommended if you want to log in.
+ "tls"; -- Add support for secure TLS on c2s/s2s connections
+ "dialback"; -- s2s dialback support
+ "disco"; -- Service discovery
+
+ -- Not essential, but recommended
+ "carbons"; -- Keep multiple clients in sync
+ "pep"; -- Enables users to publish their mood, activity, playing music and more
+ "private"; -- Private XML storage (for room bookmarks, etc.)
+ "blocklist"; -- Allow users to block communications with other users
+ "vcard"; -- Allow users to set vCards
+
+ -- Nice to have
+ "version"; -- Replies to server version requests
+ "uptime"; -- Report how long server has been running
+ "time"; -- Let others know the time here on this server
+ "ping"; -- Replies to XMPP pings with pongs
+ "register"; -- Allow users to register on this server using a client and change passwords
+ "mam"; -- Store messages in an archive and allow users to access it
+
+ -- HTTP modules
+ --"bosh"; -- Enable BOSH clients, aka "Jabber over HTTP"
+ --"websocket"; -- XMPP over WebSockets
+ --"http_files"; -- Serve static files from a directory over HTTP
+
+ -- Other specific functionality
+ --"limits"; -- Enable bandwidth limiting for XMPP connections
+ --"groups"; -- Shared roster support
+ --"server_contact_info"; -- Publish contact information for this service
+ --"announce"; -- Send announcement to all online users
+ --"welcome"; -- Welcome users who register accounts
+ --"watchregistrations"; -- Alert admins of registrations
+ --"motd"; -- Send a message to users when they log in
+ --"legacyauth"; -- Legacy authentication. Only used by some old clients and bots.
+ --"proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use
+
+ -- Useful for testing
+ --"scansion_record"; -- Records things that happen in scansion test case format
+}
+
+certificate = "certs"
+
+allow_registration = false
+
+c2s_require_encryption = false
+allow_unencrypted_plain_auth = true
+
+authentication = "insecure"
+insecure_open_authentication = "Yes please, I know what I'm doing!"
+
+storage = "memory"
+
+mam_smart_enable = true
+
+-- For the "sql" backend, you can uncomment *one* of the below to configure:
+--sql = { driver = "SQLite3", database = "prosody.sqlite" } -- Default. 'database' is the filename.
+--sql = { driver = "MySQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" }
+--sql = { driver = "PostgreSQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" }
+
+
+-- Logging configuration
+-- For advanced logging see https://prosody.im/doc/logging
+log = "*console"
+
+daemonize = true
+pidfile = "prosody.pid"
+
+VirtualHost "localhost"
+
+Component "conference.localhost" "muc"
+ storage = "memory"
+
+Component "pubsub.localhost" "pubsub"
+ storage = "memory"
diff --git a/spec/scansion/pubsub_advanced.scs b/spec/scansion/pubsub_advanced.scs
new file mode 100644
index 00000000..c873486e
--- /dev/null
+++ b/spec/scansion/pubsub_advanced.scs
@@ -0,0 +1,167 @@
+# Pubsub: Node creation, publish, subscribe, affiliations and delete
+
+[Client] Balthasar
+ jid: admin@localhost
+ password: password
+
+[Client] Romeo
+ jid: romeo@localhost
+ password: password
+
+[Client] Juliet
+ jid: juliet@localhost
+ password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='create1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <create node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="error" id='create1'>
+ <error type="auth">
+ <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ </iq>
+
+Balthasar connects
+
+Balthasar sends:
+ <iq type='set' to='pubsub.localhost' id='create2'>
+ <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+ <create node='princely_musings'/>
+ </pubsub>
+ </iq>
+
+Balthasar receives:
+ <iq type="result" id='create2'/>
+
+Balthasar sends:
+ <iq type="set" to="pubsub.localhost" id='create3'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <create node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Balthasar receives:
+ <iq type="error" id='create3'>
+ <error type="cancel">
+ <conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ </error>
+ </iq>
+
+Juliet connects
+
+Juliet sends:
+ <iq type="set" to="pubsub.localhost" id='sub1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <subscribe node="princely_musings" jid="${Romeo's full JID}"/>
+ </pubsub>
+ </iq>
+
+Juliet receives:
+ <iq type="error" id='sub1'/>
+
+Juliet sends:
+ <iq type="set" to="pubsub.localhost" id='sub2'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <subscribe node="princely_musings" jid="${Juliet's full JID}"/>
+ </pubsub>
+ </iq>
+
+Juliet receives:
+ <iq type="result" id='sub2'>
+ <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+ <subscription jid="${Juliet's full JID}" node='princely_musings' subscription='subscribed'/>
+ </pubsub>
+ </iq>
+
+Balthasar sends:
+ <iq type="get" id='aff1' to='pubsub.localhost'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <affiliations node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Balthasar receives:
+ <iq type="result" id='aff1' from='pubsub.localhost'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <affiliations node="princely_musings">
+ <affiliation affiliation='owner' jid='admin@localhost' xmlns='http://jabber.org/protocol/pubsub#owner'/>
+ </affiliations>
+ </pubsub>
+ </iq>
+
+Balthasar sends:
+ <iq type="set" id='aff2' to='pubsub.localhost'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <affiliations node="princely_musings">
+ <affiliation affiliation='owner' jid='admin@localhost' xmlns='http://jabber.org/protocol/pubsub#owner'/>
+ <affiliation jid="${Romeo's JID}" affiliation="publisher"/>
+ </affiliations>
+ </pubsub>
+ </iq>
+
+Balthasar receives:
+ <iq type="result" id='aff2' from='pubsub.localhost'/>
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='pub1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <publish node="princely_musings">
+ <item id="current">
+ <entry xmlns="http://www.w3.org/2005/Atom">
+ <title>Soliloquy</title>
+ <summary>Lorem ipsum dolor sit amet</summary>
+ </entry>
+ </item>
+ </publish>
+ </pubsub>
+ </iq>
+
+Juliet receives:
+ <message type="headline" from="pubsub.localhost">
+ <event xmlns="http://jabber.org/protocol/pubsub#event">
+ <items node="princely_musings">
+ <item id="current">
+ <entry xmlns="http://www.w3.org/2005/Atom">
+ <title>Soliloquy</title>
+ <summary>Lorem ipsum dolor sit amet</summary>
+ </entry>
+ </item>
+ </items>
+ </event>
+ </message>
+
+Romeo receives:
+ <iq type="result" id='pub1'/>
+
+Juliet sends:
+ <iq type="set" to="pubsub.localhost" id='unsub1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <unsubscribe node="princely_musings" jid="${Juliet's full JID}"/>
+ </pubsub>
+ </iq>
+
+Juliet receives:
+ <iq type="result" id='unsub1'/>
+
+Balthasar sends:
+ <iq type="set" to="pubsub.localhost" id='del1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <delete node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Balthasar receives:
+ <iq type="result" from='pubsub.localhost' id='del1'/>
+
+Romeo disconnects
+
+// vim: syntax=xml:
diff --git a/spec/scansion/pubsub_basic.scs b/spec/scansion/pubsub_basic.scs
new file mode 100644
index 00000000..d983ff66
--- /dev/null
+++ b/spec/scansion/pubsub_basic.scs
@@ -0,0 +1,104 @@
+# Pubsub: Basic support
+
+[Client] Romeo
+ jid: admin@localhost
+ password: password
+
+// admin@localhost is assumed to have node creation privileges
+
+[Client] Juliet
+ jid: juliet@localhost
+ password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='create1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <create node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id='create1'/>
+
+Juliet connects
+
+-- Juliet sends:
+-- <iq type="set" to="pubsub.localhost">
+-- <pubsub xmlns="http://jabber.org/protocol/pubsub">
+-- <subscribe node="princely_musings" jid="${Romeo's full JID}"/>
+-- </pubsub>
+-- </iq>
+--
+-- Juliet receives:
+-- <iq type="error"/>
+
+Juliet sends:
+ <iq type="set" to="pubsub.localhost" id='sub1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <subscribe node="princely_musings" jid="${Juliet's full JID}"/>
+ </pubsub>
+ </iq>
+
+Juliet receives:
+ <iq type="result" id='sub1'/>
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='pub1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <publish node="princely_musings">
+ <item id="current">
+ <entry xmlns="http://www.w3.org/2005/Atom">
+ <title>Soliloquy</title>
+ <summary>Lorem ipsum dolor sit amet</summary>
+ </entry>
+ </item>
+ </publish>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id='pub1'/>
+
+Juliet receives:
+ <message type="headline" from="pubsub.localhost">
+ <event xmlns="http://jabber.org/protocol/pubsub#event">
+ <items node="princely_musings">
+ <item id="current">
+ <entry xmlns="http://www.w3.org/2005/Atom">
+ <title>Soliloquy</title>
+ <summary>Lorem ipsum dolor sit amet</summary>
+ </entry>
+ </item>
+ </items>
+ </event>
+ </message>
+
+Juliet sends:
+ <iq type="set" to="pubsub.localhost" id='unsub1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <unsubscribe node="princely_musings" jid="${Juliet's full JID}"/>
+ </pubsub>
+ </iq>
+
+Juliet receives:
+ <iq type="result" id='unsub1'/>
+
+Juliet disconnects
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='del1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <delete node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id='del1'/>
+
+Romeo disconnects
+
+// vim: syntax=xml:
diff --git a/spec/scansion/pubsub_config.scs b/spec/scansion/pubsub_config.scs
new file mode 100644
index 00000000..d979aca5
--- /dev/null
+++ b/spec/scansion/pubsub_config.scs
@@ -0,0 +1,205 @@
+# pubsub#title as name attribute in disco#items
+# Issue 1226
+
+[Client] Romeo
+ password: password
+ jid: jqpcrbq@localhost
+
+-----
+
+Romeo connects
+
+Romeo sends:
+ <iq id="67eb1f47-1e69-4cb3-91e2-4d5943e72d4c" type="set">
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <publish node="http://jabber.org/protocol/tune">
+ <item id="current">
+ <tune xmlns="http://jabber.org/protocol/tune"/>
+ </item>
+ </publish>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq id="67eb1f47-1e69-4cb3-91e2-4d5943e72d4c" type="result">
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <publish node="http://jabber.org/protocol/tune">
+ <item id="current"/>
+ </publish>
+ </pubsub>
+ </iq>
+
+Romeo sends:
+ <iq id="52d74a36-afb0-4028-87ed-b25b988b049e" type="get">
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <configure node="http://jabber.org/protocol/tune"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq id="52d74a36-afb0-4028-87ed-b25b988b049e" type="result">
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <configure node="http://jabber.org/protocol/tune">
+ <x xmlns="jabber:x:data" type="form">
+ <field var="FORM_TYPE" type="hidden">
+ <value>http://jabber.org/protocol/pubsub#node_config</value>
+ </field>
+ <field var="pubsub#title" label="Title" type="text-single"/>
+ <field var="pubsub#description" label="Description" type="text-single"/>
+ <field var="pubsub#type" label="The type of node data, usually specified by the namespace of the payload (if any)" type="text-single"/>
+ <field var="pubsub#max_items" label="Max # of items to persist" type="text-single">
+ <validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="xs:integer"/>
+ <value>1</value>
+ </field>
+ <field var="pubsub#persist_items" label="Persist items to storage" type="boolean">
+ <value>1</value>
+ </field>
+ <field var="pubsub#access_model" label="Specify the subscriber model" type="list-single">
+ <option label="authorize">
+ <value>authorize</value>
+ </option>
+ <option label="open">
+ <value>open</value>
+ </option>
+ <option label="presence">
+ <value>presence</value>
+ </option>
+ <option label="roster">
+ <value>roster</value>
+ </option>
+ <option label="whitelist">
+ <value>whitelist</value>
+ </option>
+ <value>presence</value>
+ </field>
+ <field var="pubsub#publish_model" label="Specify the publisher model" type="list-single">
+ <option label="publishers">
+ <value>publishers</value>
+ </option>
+ <option label="subscribers">
+ <value>subscribers</value>
+ </option>
+ <option label="open">
+ <value>open</value>
+ </option>
+ <value>publishers</value>
+ </field>
+ <field var="pubsub#deliver_notifications" label="Whether to deliver event notifications" type="boolean">
+ <value>1</value>
+ </field>
+ <field var="pubsub#deliver_payloads" label="Whether to deliver payloads with event notifications" type="boolean">
+ <value>1</value>
+ </field>
+ <field var="pubsub#notification_type" label="Specify the delivery style for notifications" type="list-single">
+ <option label="Messages of type normal">
+ <value>normal</value>
+ </option>
+ <option label="Messages of type headline">
+ <value>headline</value>
+ </option>
+ <value>headline</value>
+ </field>
+ <field var="pubsub#notify_delete" label="Whether to notify subscribers when the node is deleted" type="boolean">
+ <value>1</value>
+ </field>
+ <field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
+ <value>1</value>
+ </field>
+ </x>
+ </configure>
+ </pubsub>
+ </iq>
+
+Romeo sends:
+ <iq id="a73aac09-74be-4ee2-97e5-571bbdbcd956" type="set">
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <configure node="http://jabber.org/protocol/tune">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="FORM_TYPE" type="hidden">
+ <value>http://jabber.org/protocol/pubsub#node_config</value>
+ </field>
+ <field var="pubsub#title" type="text-single" label="Title">
+ <value>Nice tunes</value>
+ </field>
+ <field var="pubsub#description" type="text-single" label="Description"/>
+ <field var="pubsub#type" type="text-single" label="The type of node data, usually specified by the namespace of the payload (if any)"/>
+ <field var="pubsub#max_items" type="text-single" label="Max # of items to persist">
+ <validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="xs:integer"/>
+ <value>1</value>
+ </field>
+ <field var="pubsub#persist_items" type="boolean" label="Persist items to storage">
+ <value>1</value>
+ </field>
+ <field var="pubsub#access_model" type="list-single" label="Specify the subscriber model">
+ <option label="authorize">
+ <value>authorize</value>
+ </option>
+ <option label="open">
+ <value>open</value>
+ </option>
+ <option label="presence">
+ <value>presence</value>
+ </option>
+ <option label="roster">
+ <value>roster</value>
+ </option>
+ <option label="whitelist">
+ <value>whitelist</value>
+ </option>
+ <value>presence</value>
+ </field>
+ <field var="pubsub#publish_model" type="list-single" label="Specify the publisher model">
+ <option label="publishers">
+ <value>publishers</value>
+ </option>
+ <option label="subscribers">
+ <value>subscribers</value>
+ </option>
+ <option label="open">
+ <value>open</value>
+ </option>
+ <value>publishers</value>
+ </field>
+ <field var="pubsub#deliver_notifications" type="boolean" label="Whether to deliver event notifications">
+ <value>1</value>
+ </field>
+ <field var="pubsub#deliver_payloads" type="boolean" label="Whether to deliver payloads with event notifications">
+ <value>1</value>
+ </field>
+ <field var="pubsub#notification_type" type="list-single" label="Specify the delivery style for notifications">
+ <option label="Messages of type normal">
+ <value>normal</value>
+ </option>
+ <option label="Messages of type headline">
+ <value>headline</value>
+ </option>
+ <value>headline</value>
+ </field>
+ <field var="pubsub#notify_delete" type="boolean" label="Whether to notify subscribers when the node is deleted">
+ <value>1</value>
+ </field>
+ <field var="pubsub#notify_retract" type="boolean" label="Whether to notify subscribers when items are removed from the node">
+ <value>1</value>
+ </field>
+ </x>
+ </configure>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq id="a73aac09-74be-4ee2-97e5-571bbdbcd956" type="result"/>
+
+Romeo sends:
+ <iq id="ab0e92d2-c06b-4987-9d45-f9f9e7721709" type="get">
+ <query xmlns="http://jabber.org/protocol/disco#items"/>
+ </iq>
+
+Romeo receives:
+ <iq id="ab0e92d2-c06b-4987-9d45-f9f9e7721709" type="result">
+ <query xmlns="http://jabber.org/protocol/disco#items">
+ <item name="Nice tunes" node="http://jabber.org/protocol/tune" jid="${Romeo's JID}"/>
+ </query>
+ </iq>
+
+Romeo disconnects
+
diff --git a/spec/scansion/pubsub_createdelete.scs b/spec/scansion/pubsub_createdelete.scs
new file mode 100644
index 00000000..a44695e7
--- /dev/null
+++ b/spec/scansion/pubsub_createdelete.scs
@@ -0,0 +1,63 @@
+# Pubsub: Create and delete
+
+[Client] Romeo
+ jid: admin@localhost
+ password: password
+
+// admin@localhost is assumed to have node creation privileges
+
+---------
+
+Romeo connects
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='create1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <create node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id='create1'/>
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='create2'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ <create node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="error" id='create2'>
+ <error type="cancel">
+ <conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ </error>
+ </iq>
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='delete1'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <delete node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id='delete1'/>
+
+Romeo sends:
+ <iq type="set" to="pubsub.localhost" id='delete2'>
+ <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+ <delete node="princely_musings"/>
+ </pubsub>
+ </iq>
+
+Romeo receives:
+ <iq type="error" id='delete2'>
+ <error type="cancel">
+ <item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ </error>
+ </iq>
+
+Romeo disconnects
+
+// vim: syntax=xml:
diff --git a/spec/scansion/vcard_temp.scs b/spec/scansion/vcard_temp.scs
new file mode 100644
index 00000000..38c6f755
--- /dev/null
+++ b/spec/scansion/vcard_temp.scs
@@ -0,0 +1,80 @@
+# XEP-0054 vCard-temp writable and readable by anyone
+# mod_scansion_record on host 'localhost' recording started 2018-10-20T15:00:12Z
+
+[Client] Romeo
+ jid: romeo@localhost
+ password: password
+
+[Client] Juliet
+ jid: juliet@localhost
+ password: password
+
+-----
+
+Romeo connects
+
+# Romeo sets his vCard
+# FN and N are required by the schema and mod_vcard_legacy will always inject them
+Romeo sends:
+ <iq id="lx3" type="set">
+ <vCard xmlns="vcard-temp">
+ <FN>Romeo Montague</FN>
+ <N>
+ <FAMILY>Montague</FAMILY>
+ <GIVEN>Romeo</GIVEN>
+ <MIDDLE/>
+ <PREFIX/>
+ <SUFFIX/>
+ </N>
+ </vCard>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id="lx3" to="${Romeo's full JID}"/>
+
+Romeo sends:
+ <iq id="lx4" type="get">
+ <vCard xmlns="vcard-temp"/>
+ </iq>
+
+Romeo receives:
+ <iq type="result" id="lx4" to="${Romeo's full JID}">
+ <vCard xmlns="vcard-temp">
+ <FN>Romeo Montague</FN>
+ <N>
+ <FAMILY>Montague</FAMILY>
+ <GIVEN>Romeo</GIVEN>
+ <MIDDLE/>
+ <PREFIX/>
+ <SUFFIX/>
+ </N>
+ </vCard>
+ </iq>
+
+Romeo disconnects
+
+Juliet connects
+
+Juliet sends:
+ <iq type="get" id="lx3" to="romeo@localhost">
+ <vCard xmlns="vcard-temp"/>
+ </iq>
+
+# Juliet can see Romeo's vCard since it's public
+Juliet receives:
+ <iq type="result" from="romeo@localhost" id="lx3" to="${Juliet's full JID}">
+ <vCard xmlns="vcard-temp">
+ <FN>Romeo Montague</FN>
+ <N>
+ <FAMILY>Montague</FAMILY>
+ <GIVEN>Romeo</GIVEN>
+ <MIDDLE/>
+ <PREFIX/>
+ <SUFFIX/>
+ </N>
+ </vCard>
+ </iq>
+
+Juliet disconnects
+
+# recording ended on 2018-10-20T15:02:14Z
diff --git a/spec/utf8_sequences.txt b/spec/utf8_sequences.txt
new file mode 100644
index 00000000..1b967b2e
--- /dev/null
+++ b/spec/utf8_sequences.txt
@@ -0,0 +1,52 @@
+Should pass: 41 42 43 # Simple ASCII - abc
+Should pass: 41 42 c3 87 # "ABÇ"
+Should pass: 41 42 e1 b8 88 # "ABḈ"
+Should pass: 41 42 f0 9d 9c 8d # "AB𝜍"
+Should pass: F4 8F BF BF # Last valid sequence (U+10FFFF)
+Should fail: F4 90 80 80 # First invalid sequence (U+110000)
+Should fail: 80 81 82 83 # Invalid sequence (invalid start byte)
+Should fail: C2 C3 # Invalid sequence (invalid continuation byte)
+Should fail: C0 43 # Overlong sequence
+Should fail: F5 80 80 80 # U+140000 (out of range)
+Should fail: ED A0 80 # U+D800 (forbidden by RFC 3629)
+Should fail: ED BF BF # U+DFFF (forbidden by RFC 3629)
+Should pass: ED 9F BF # U+D7FF (U+D800 minus 1: allowed)
+Should pass: EE 80 80 # U+E000 (U+D7FF plus 1: allowed)
+Should fail: C0 # Invalid start byte
+Should fail: C1 # Invalid start byte
+Should fail: C2 # Incomplete sequence
+Should fail: F8 88 80 80 80 # 6-byte sequence
+Should pass: 7F # Last valid 1-byte sequence (U+00007F)
+Should pass: DF BF # Last valid 2-byte sequence (U+0007FF)
+Should pass: EF BF BF # Last valid 3-byte sequence (U+00FFFF)
+Should pass: 00 # First valid 1-byte sequence (U+000000)
+Should pass: C2 80 # First valid 2-byte sequence (U+000080)
+Should pass: E0 A0 80 # First valid 3-byte sequence (U+000800)
+Should pass: F0 90 80 80 # First valid 4-byte sequence (U+000800)
+Should fail: F8 88 80 80 80 # First 5-byte sequence - invalid per RFC 3629
+Should fail: FC 84 80 80 80 80 # First 6-byte sequence - invalid per RFC 3629
+Should pass: EF BF BD # U+00FFFD (replacement character)
+Should fail: 80 # First continuation byte
+Should fail: BF # Last continuation byte
+Should fail: 80 BF # 2 continuation bytes
+Should fail: 80 BF 80 # 3 continuation bytes
+Should fail: 80 BF 80 BF # 4 continuation bytes
+Should fail: 80 BF 80 BF 80 # 5 continuation bytes
+Should fail: 80 BF 80 BF 80 BF # 6 continuation bytes
+Should fail: 80 BF 80 BF 80 BF 80 # 7 continuation bytes
+Should fail: FE # Impossible byte
+Should fail: FF # Impossible byte
+Should fail: FE FE FF FF # Impossible bytes
+Should fail: C0 AF # Overlong "/"
+Should fail: E0 80 AF # Overlong "/"
+Should fail: F0 80 80 AF # Overlong "/"
+Should fail: F8 80 80 80 AF # Overlong "/"
+Should fail: FC 80 80 80 80 AF # Overlong "/"
+Should fail: C0 80 AF # Overlong "/" (invalid)
+Should fail: C1 BF # Overlong
+Should fail: E0 9F BF # Overlong
+Should fail: F0 8F BF BF # Overlong
+Should fail: F8 87 BF BF BF # Overlong
+Should fail: FC 83 BF BF BF BF # Overlong
+Should pass: EF BF BE # U+FFFE (invalid unicode, valid UTF-8)
+Should pass: EF BF BF # U+FFFF (invalid unicode, valid UTF-8)
diff --git a/spec/util_async_spec.lua b/spec/util_async_spec.lua
new file mode 100644
index 00000000..d2de8c94
--- /dev/null
+++ b/spec/util_async_spec.lua
@@ -0,0 +1,616 @@
+local async = require "util.async";
+
+describe("util.async", function()
+ local debug = false;
+ local print = print;
+ if debug then
+ require "util.logger".add_simple_sink(print);
+ else
+ print = function () end
+ end
+
+ local function mock_watchers(event_log)
+ local function generic_logging_watcher(name)
+ return function (...)
+ table.insert(event_log, { name = name, n = select("#", ...)-1, select(2, ...) });
+ end;
+ end;
+ return setmetatable(mock{
+ ready = generic_logging_watcher("ready");
+ waiting = generic_logging_watcher("waiting");
+ error = generic_logging_watcher("error");
+ }, {
+ __index = function (_, event)
+ -- Unexpected watcher called
+ assert(false, "unexpected watcher called: "..event);
+ end;
+ })
+ end
+
+ local function new(func)
+ local event_log = {};
+ local spy_func = spy.new(func);
+ return async.runner(spy_func, mock_watchers(event_log)), spy_func, event_log;
+ end
+ describe("#runner", function()
+ it("should work", function()
+ local r = new(function (item) assert(type(item) == "number") end);
+ r:run(1);
+ r:run(2);
+ end);
+
+ it("should be ready after creation", function ()
+ local r = new(function () end);
+ assert.equal(r.state, "ready");
+ end);
+
+ it("should do nothing if the queue is empty", function ()
+ local did_run;
+ local r = new(function () did_run = true end);
+ r:run();
+ assert.equal(r.state, "ready");
+ assert.is_nil(did_run);
+ r:run("hello");
+ assert.is_true(did_run);
+ end);
+
+ it("should support queuing work items without running", function ()
+ local did_run;
+ local r = new(function () did_run = true end);
+ r:enqueue("hello");
+ assert.equal(r.state, "ready");
+ assert.is_nil(did_run);
+ r:run();
+ assert.is_true(did_run);
+ end);
+
+ it("should support queuing multiple work items", function ()
+ local last_item;
+ local r, s = new(function (item) last_item = item; end);
+ r:enqueue("hello");
+ r:enqueue("there");
+ r:enqueue("world");
+ assert.equal(r.state, "ready");
+ r:run();
+ assert.equal(r.state, "ready");
+ assert.spy(s).was.called(3);
+ assert.equal(last_item, "world");
+ end);
+
+ it("should support all simple data types", function ()
+ local last_item;
+ local r, s = new(function (item) last_item = item; end);
+ local values = { {}, 123, "hello", true, false };
+ for i = 1, #values do
+ r:enqueue(values[i]);
+ end
+ assert.equal(r.state, "ready");
+ r:run();
+ assert.equal(r.state, "ready");
+ assert.spy(s).was.called(#values);
+ for i = 1, #values do
+ assert.spy(s).was.called_with(values[i]);
+ end
+ assert.equal(last_item, values[#values]);
+ end);
+
+ it("should work with no parameters", function ()
+ local item = "fail";
+ local r = async.runner();
+ local f = spy.new(function () item = "success"; end);
+ r:run(f);
+ assert.spy(f).was.called();
+ assert.equal(item, "success");
+ end);
+
+ it("supports a default error handler", function ()
+ local item = "fail";
+ local r = async.runner();
+ local f = spy.new(function () error("test error"); end);
+ assert.error_matches(function ()
+ r:run(f);
+ end, "test error");
+ assert.spy(f).was.called();
+ assert.equal(item, "fail");
+ end);
+
+ describe("#errors", function ()
+ describe("should notify", function ()
+ local last_processed_item, last_error;
+ local r;
+ r = async.runner(function (item)
+ if item == "error" then
+ error({ e = "test error" });
+ end
+ last_processed_item = item;
+ end, mock{
+ ready = function () end;
+ waiting = function () end;
+ error = function (runner, err)
+ assert.equal(r, runner);
+ last_error = err;
+ end;
+ });
+
+ -- Simple item, no error
+ r:run("hello");
+ assert.equal(r.state, "ready");
+ assert.equal(last_processed_item, "hello");
+ assert.spy(r.watchers.ready).was_not.called();
+ assert.spy(r.watchers.error).was_not.called();
+
+ -- Trigger an error inside the runner
+ assert.equal(last_error, nil);
+ r:run("error");
+ test("the correct watcher functions", function ()
+ -- Only the error watcher should have been called
+ assert.spy(r.watchers.ready).was_not.called();
+ assert.spy(r.watchers.waiting).was_not.called();
+ assert.spy(r.watchers.error).was.called(1);
+ end);
+ test("with the correct error", function ()
+ -- The error watcher state should be correct, to
+ -- demonstrate the error was passed correctly
+ assert.is_table(last_error);
+ assert.equal(last_error.e, "test error");
+ last_error = nil;
+ end);
+ assert.equal(r.state, "ready");
+ assert.equal(last_processed_item, "hello");
+ end);
+
+ do
+ local last_processed_item, last_error;
+ local r;
+ local wait, done;
+ r = async.runner(function (item)
+ if item == "error" then
+ error({ e = "test error" });
+ elseif item == "wait" then
+ wait, done = async.waiter();
+ wait();
+ error({ e = "post wait error" });
+ end
+ last_processed_item = item;
+ end, mock({
+ ready = function () end;
+ waiting = function () end;
+ error = function (runner, err)
+ assert.equal(r, runner);
+ last_error = err;
+ end;
+ }));
+
+ randomize(false); --luacheck: ignore 113/randomize
+
+ it("should not be fatal to the runner", function ()
+ r:run("world");
+ assert.equal(r.state, "ready");
+ assert.spy(r.watchers.ready).was_not.called();
+ assert.equal(last_processed_item, "world");
+ end);
+ it("should work despite a #waiter", function ()
+ -- This test covers an important case where a runner
+ -- throws an error while being executed outside of the
+ -- main loop. This happens when it was blocked ('waiting'),
+ -- and then released (via a call to done()).
+ last_error = nil;
+ r:run("wait");
+ assert.equal(r.state, "waiting");
+ assert.spy(r.watchers.waiting).was.called(1);
+ done();
+ -- At this point an error happens (state goes error->ready)
+ assert.equal(r.state, "ready");
+ assert.spy(r.watchers.error).was.called(1);
+ assert.spy(r.watchers.ready).was.called(1);
+ assert.is_table(last_error);
+ assert.equal(last_error.e, "post wait error");
+ last_error = nil;
+ r:run("hello again");
+ assert.spy(r.watchers.ready).was.called(1);
+ assert.spy(r.watchers.waiting).was.called(1);
+ assert.spy(r.watchers.error).was.called(1);
+ assert.equal(r.state, "ready");
+ assert.equal(last_processed_item, "hello again");
+ end);
+ end
+
+ it("should continue to process work items", function ()
+ local last_item;
+ local runner, runner_func = new(function (item)
+ if item == "error" then
+ error("test error");
+ end
+ last_item = item;
+ end);
+ runner:enqueue("one");
+ runner:enqueue("error");
+ runner:enqueue("two");
+ runner:run();
+ assert.equal(runner.state, "ready");
+ assert.spy(runner_func).was.called(3);
+ assert.spy(runner.watchers.error).was.called(1);
+ assert.spy(runner.watchers.ready).was.called(0);
+ assert.spy(runner.watchers.waiting).was.called(0);
+ assert.equal(last_item, "two");
+ end);
+
+ it("should continue to process work items during resume", function ()
+ local wait, done, last_item;
+ local runner, runner_func = new(function (item)
+ if item == "wait-error" then
+ wait, done = async.waiter();
+ wait();
+ error("test error");
+ end
+ last_item = item;
+ end);
+ runner:enqueue("one");
+ runner:enqueue("wait-error");
+ runner:enqueue("two");
+ runner:run();
+ done();
+ assert.equal(runner.state, "ready");
+ assert.spy(runner_func).was.called(3);
+ assert.spy(runner.watchers.error).was.called(1);
+ assert.spy(runner.watchers.waiting).was.called(1);
+ assert.spy(runner.watchers.ready).was.called(1);
+ assert.equal(last_item, "two");
+ end);
+ end);
+ end);
+ describe("#waiter", function()
+ it("should error outside of async context", function ()
+ assert.has_error(function ()
+ async.waiter();
+ end);
+ end);
+ it("should work", function ()
+ local wait, done;
+
+ local r = new(function (item)
+ assert(type(item) == "number")
+ if item == 3 then
+ wait, done = async.waiter();
+ wait();
+ end
+ end);
+
+ r:run(1);
+ assert(r.state == "ready");
+ r:run(2);
+ assert(r.state == "ready");
+ r:run(3);
+ assert(r.state == "waiting");
+ done();
+ assert(r.state == "ready");
+ --for k, v in ipairs(l) do print(k,v) end
+ end);
+
+ it("should work", function ()
+ --------------------
+ local wait, done;
+ local last_item = 0;
+ local r = new(function (item)
+ assert(type(item) == "number")
+ assert(item == last_item + 1);
+ last_item = item;
+ if item == 3 then
+ wait, done = async.waiter();
+ wait();
+ end
+ end);
+
+ r:run(1);
+ assert(r.state == "ready");
+ r:run(2);
+ assert(r.state == "ready");
+ r:run(3);
+ assert(r.state == "waiting");
+ r:run(4);
+ assert(r.state == "waiting");
+ done();
+ assert(r.state == "ready");
+ --for k, v in ipairs(l) do print(k,v) end
+ end);
+ it("should work", function ()
+ --------------------
+ local wait, done;
+ local last_item = 0;
+ local r = new(function (item)
+ assert(type(item) == "number")
+ assert((item == last_item + 1) or item == 3);
+ last_item = item;
+ if item == 3 then
+ wait, done = async.waiter();
+ wait();
+ end
+ end);
+
+ r:run(1);
+ assert(r.state == "ready");
+ r:run(2);
+ assert(r.state == "ready");
+
+ r:run(3);
+ assert(r.state == "waiting");
+ r:run(3);
+ assert(r.state == "waiting");
+ r:run(3);
+ assert(r.state == "waiting");
+ r:run(4);
+ assert(r.state == "waiting");
+
+ for i = 1, 3 do
+ done();
+ if i < 3 then
+ assert(r.state == "waiting");
+ end
+ end
+
+ assert(r.state == "ready");
+ --for k, v in ipairs(l) do print(k,v) end
+ end);
+ it("should work", function ()
+ --------------------
+ local wait, done;
+ local last_item = 0;
+ local r = new(function (item)
+ assert(type(item) == "number")
+ assert((item == last_item + 1) or item == 3);
+ last_item = item;
+ if item == 3 then
+ wait, done = async.waiter();
+ wait();
+ end
+ end);
+
+ r:run(1);
+ assert(r.state == "ready");
+ r:run(2);
+ assert(r.state == "ready");
+
+ r:run(3);
+ assert(r.state == "waiting");
+ r:run(3);
+ assert(r.state == "waiting");
+
+ for i = 1, 2 do
+ done();
+ if i < 2 then
+ assert(r.state == "waiting");
+ end
+ end
+
+ assert(r.state == "ready");
+ r:run(4);
+ assert(r.state == "ready");
+
+ assert(r.state == "ready");
+ --for k, v in ipairs(l) do print(k,v) end
+ end);
+ it("should work with multiple runners in parallel", function ()
+ -- Now with multiple runners
+ --------------------
+ local wait1, done1;
+ local last_item1 = 0;
+ local r1 = new(function (item)
+ assert(type(item) == "number")
+ assert((item == last_item1 + 1) or item == 3);
+ last_item1 = item;
+ if item == 3 then
+ wait1, done1 = async.waiter();
+ wait1();
+ end
+ end, "r1");
+
+ local wait2, done2;
+ local last_item2 = 0;
+ local r2 = new(function (item)
+ assert(type(item) == "number")
+ assert((item == last_item2 + 1) or item == 3);
+ last_item2 = item;
+ if item == 3 then
+ wait2, done2 = async.waiter();
+ wait2();
+ end
+ end, "r2");
+
+ r1:run(1);
+ assert(r1.state == "ready");
+ r1:run(2);
+ assert(r1.state == "ready");
+
+ r1:run(3);
+ assert(r1.state == "waiting");
+ r1:run(3);
+ assert(r1.state == "waiting");
+
+ r2:run(1);
+ assert(r1.state == "waiting");
+ assert(r2.state == "ready");
+
+ r2:run(2);
+ assert(r1.state == "waiting");
+ assert(r2.state == "ready");
+
+ r2:run(3);
+ assert(r1.state == "waiting");
+ assert(r2.state == "waiting");
+ done2();
+
+ r2:run(3);
+ assert(r1.state == "waiting");
+ assert(r2.state == "waiting");
+ done2();
+
+ r2:run(4);
+ assert(r1.state == "waiting");
+ assert(r2.state == "ready");
+
+ for i = 1, 2 do
+ done1();
+ if i < 2 then
+ assert(r1.state == "waiting");
+ end
+ end
+
+ assert(r1.state == "ready");
+ r1:run(4);
+ assert(r1.state == "ready");
+
+ assert(r1.state == "ready");
+ --for k, v in ipairs(l1) do print(k,v) end
+ end);
+ it("should work work with multiple runners in parallel", function ()
+ --------------------
+ local wait1, done1;
+ local last_item1 = 0;
+ local r1 = new(function (item)
+ print("r1 processing ", item);
+ assert(type(item) == "number")
+ assert((item == last_item1 + 1) or item == 3);
+ last_item1 = item;
+ if item == 3 then
+ wait1, done1 = async.waiter();
+ wait1();
+ end
+ end, "r1");
+
+ local wait2, done2;
+ local last_item2 = 0;
+ local r2 = new(function (item)
+ print("r2 processing ", item);
+ assert.is_number(item);
+ assert((item == last_item2 + 1) or item == 3);
+ last_item2 = item;
+ if item == 3 then
+ wait2, done2 = async.waiter();
+ wait2();
+ end
+ end, "r2");
+
+ r1:run(1);
+ assert.equal(r1.state, "ready");
+ r1:run(2);
+ assert.equal(r1.state, "ready");
+
+ r1:run(5);
+ assert.equal(r1.state, "ready");
+
+ r1:run(3);
+ assert.equal(r1.state, "waiting");
+ r1:run(5); -- Will error, when we get to it
+ assert.equal(r1.state, "waiting");
+ done1();
+ assert.equal(r1.state, "ready");
+ r1:run(3);
+ assert.equal(r1.state, "waiting");
+
+ r2:run(1);
+ assert.equal(r1.state, "waiting");
+ assert.equal(r2.state, "ready");
+
+ r2:run(2);
+ assert.equal(r1.state, "waiting");
+ assert.equal(r2.state, "ready");
+
+ r2:run(3);
+ assert.equal(r1.state, "waiting");
+ assert.equal(r2.state, "waiting");
+
+ done2();
+ assert.equal(r1.state, "waiting");
+ assert.equal(r2.state, "ready");
+
+ r2:run(3);
+ assert.equal(r1.state, "waiting");
+ assert.equal(r2.state, "waiting");
+
+ done2();
+ assert.equal(r1.state, "waiting");
+ assert.equal(r2.state, "ready");
+
+ r2:run(4);
+ assert.equal(r1.state, "waiting");
+ assert.equal(r2.state, "ready");
+
+ done1();
+
+ assert.equal(r1.state, "ready");
+ r1:run(4);
+ assert.equal(r1.state, "ready");
+
+ assert.equal(r1.state, "ready");
+ end);
+
+ it("should support multiple done() calls", function ()
+ local processed_item;
+ local wait, done;
+ local r, rf = new(function (item)
+ wait, done = async.waiter(4);
+ wait();
+ processed_item = item;
+ end);
+ r:run("test");
+ for _ = 1, 3 do
+ done();
+ assert.equal(r.state, "waiting");
+ assert.is_nil(processed_item);
+ end
+ done();
+ assert.equal(r.state, "ready");
+ assert.equal(processed_item, "test");
+ assert.spy(r.watchers.error).was_not.called();
+ end);
+
+ it("should not allow done() to be called more than specified", function ()
+ local processed_item;
+ local wait, done;
+ local r, rf = new(function (item)
+ wait, done = async.waiter(4);
+ wait();
+ processed_item = item;
+ end);
+ r:run("test");
+ for _ = 1, 4 do
+ done();
+ end
+ assert.has_error(done);
+ assert.equal(r.state, "ready");
+ assert.equal(processed_item, "test");
+ assert.spy(r.watchers.error).was_not.called();
+ end);
+
+ it("should allow done() to be called before wait()", function ()
+ local processed_item;
+ local r, rf = new(function (item)
+ local wait, done = async.waiter();
+ done();
+ wait();
+ processed_item = item;
+ end);
+ r:run("test");
+ assert.equal(processed_item, "test");
+ assert.equal(r.state, "ready");
+ -- Since the observable state did not change,
+ -- the watchers should not have been called
+ assert.spy(r.watchers.waiting).was_not.called();
+ assert.spy(r.watchers.ready).was_not.called();
+ end);
+ end);
+
+ describe("#ready()", function ()
+ it("should return false outside an async context", function ()
+ assert.falsy(async.ready());
+ end);
+ it("should return true inside an async context", function ()
+ local r = new(function ()
+ assert.truthy(async.ready());
+ end);
+ r:run(true);
+ assert.spy(r.func).was.called();
+ assert.spy(r.watchers.error).was_not.called();
+ end);
+ end);
+end);
diff --git a/spec/util_cache_spec.lua b/spec/util_cache_spec.lua
new file mode 100644
index 00000000..15e86ee9
--- /dev/null
+++ b/spec/util_cache_spec.lua
@@ -0,0 +1,316 @@
+
+local cache = require "util.cache";
+
+describe("util.cache", function()
+ describe("#new()", function()
+ it("should work", function()
+
+ local c = cache.new(5);
+
+ local function expect_kv(key, value, actual_key, actual_value)
+ assert.are.equal(key, actual_key, "key incorrect");
+ assert.are.equal(value, actual_value, "value incorrect");
+ end
+
+ expect_kv(nil, nil, c:head());
+ expect_kv(nil, nil, c:tail());
+
+ assert.are.equal(c:count(), 0);
+
+ c:set("one", 1)
+ assert.are.equal(c:count(), 1);
+ expect_kv("one", 1, c:head());
+ expect_kv("one", 1, c:tail());
+
+ c:set("two", 2)
+ expect_kv("two", 2, c:head());
+ expect_kv("one", 1, c:tail());
+
+ c:set("three", 3)
+ expect_kv("three", 3, c:head());
+ expect_kv("one", 1, c:tail());
+
+ c:set("four", 4)
+ c:set("five", 5);
+ assert.are.equal(c:count(), 5);
+ expect_kv("five", 5, c:head());
+ expect_kv("one", 1, c:tail());
+
+ c:set("foo", nil);
+ assert.are.equal(c:count(), 5);
+ expect_kv("five", 5, c:head());
+ expect_kv("one", 1, c:tail());
+
+ assert.are.equal(c:get("one"), 1);
+ expect_kv("five", 5, c:head());
+ expect_kv("one", 1, c:tail());
+
+ assert.are.equal(c:get("two"), 2);
+ assert.are.equal(c:get("three"), 3);
+ assert.are.equal(c:get("four"), 4);
+ assert.are.equal(c:get("five"), 5);
+
+ assert.are.equal(c:get("foo"), nil);
+ assert.are.equal(c:get("bar"), nil);
+
+ c:set("six", 6);
+ assert.are.equal(c:count(), 5);
+ expect_kv("six", 6, c:head());
+ expect_kv("two", 2, c:tail());
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), 2);
+ assert.are.equal(c:get("three"), 3);
+ assert.are.equal(c:get("four"), 4);
+ assert.are.equal(c:get("five"), 5);
+ assert.are.equal(c:get("six"), 6);
+
+ c:set("three", nil);
+ assert.are.equal(c:count(), 4);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), 2);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), 4);
+ assert.are.equal(c:get("five"), 5);
+ assert.are.equal(c:get("six"), 6);
+
+ c:set("seven", 7);
+ assert.are.equal(c:count(), 5);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), 2);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), 4);
+ assert.are.equal(c:get("five"), 5);
+ assert.are.equal(c:get("six"), 6);
+ assert.are.equal(c:get("seven"), 7);
+
+ c:set("eight", 8);
+ assert.are.equal(c:count(), 5);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), nil);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), 4);
+ assert.are.equal(c:get("five"), 5);
+ assert.are.equal(c:get("six"), 6);
+ assert.are.equal(c:get("seven"), 7);
+ assert.are.equal(c:get("eight"), 8);
+
+ c:set("four", 4);
+ assert.are.equal(c:count(), 5);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), nil);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), 4);
+ assert.are.equal(c:get("five"), 5);
+ assert.are.equal(c:get("six"), 6);
+ assert.are.equal(c:get("seven"), 7);
+ assert.are.equal(c:get("eight"), 8);
+
+ c:set("nine", 9);
+ assert.are.equal(c:count(), 5);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), nil);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), 4);
+ assert.are.equal(c:get("five"), nil);
+ assert.are.equal(c:get("six"), 6);
+ assert.are.equal(c:get("seven"), 7);
+ assert.are.equal(c:get("eight"), 8);
+ assert.are.equal(c:get("nine"), 9);
+
+ do
+ local keys = { "nine", "four", "eight", "seven", "six" };
+ local values = { 9, 4, 8, 7, 6 };
+ local i = 0;
+ for k, v in c:items() do
+ i = i + 1;
+ assert.are.equal(k, keys[i]);
+ assert.are.equal(v, values[i]);
+ end
+ assert.are.equal(i, 5);
+
+ c:set("four", "2+2");
+ assert.are.equal(c:count(), 5);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), nil);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), "2+2");
+ assert.are.equal(c:get("five"), nil);
+ assert.are.equal(c:get("six"), 6);
+ assert.are.equal(c:get("seven"), 7);
+ assert.are.equal(c:get("eight"), 8);
+ assert.are.equal(c:get("nine"), 9);
+ end
+
+ do
+ local keys = { "four", "nine", "eight", "seven", "six" };
+ local values = { "2+2", 9, 8, 7, 6 };
+ local i = 0;
+ for k, v in c:items() do
+ i = i + 1;
+ assert.are.equal(k, keys[i]);
+ assert.are.equal(v, values[i]);
+ end
+ assert.are.equal(i, 5);
+
+ c:set("foo", nil);
+ assert.are.equal(c:count(), 5);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), nil);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), "2+2");
+ assert.are.equal(c:get("five"), nil);
+ assert.are.equal(c:get("six"), 6);
+ assert.are.equal(c:get("seven"), 7);
+ assert.are.equal(c:get("eight"), 8);
+ assert.are.equal(c:get("nine"), 9);
+ end
+
+ do
+ local keys = { "four", "nine", "eight", "seven", "six" };
+ local values = { "2+2", 9, 8, 7, 6 };
+ local i = 0;
+ for k, v in c:items() do
+ i = i + 1;
+ assert.are.equal(k, keys[i]);
+ assert.are.equal(v, values[i]);
+ end
+ assert.are.equal(i, 5);
+
+ c:set("four", nil);
+
+ assert.are.equal(c:get("one"), nil);
+ assert.are.equal(c:get("two"), nil);
+ assert.are.equal(c:get("three"), nil);
+ assert.are.equal(c:get("four"), nil);
+ assert.are.equal(c:get("five"), nil);
+ assert.are.equal(c:get("six"), 6);
+ assert.are.equal(c:get("seven"), 7);
+ assert.are.equal(c:get("eight"), 8);
+ assert.are.equal(c:get("nine"), 9);
+ end
+
+ do
+ local keys = { "nine", "eight", "seven", "six" };
+ local values = { 9, 8, 7, 6 };
+ local i = 0;
+ for k, v in c:items() do
+ i = i + 1;
+ assert.are.equal(k, keys[i]);
+ assert.are.equal(v, values[i]);
+ end
+ assert.are.equal(i, 4);
+ end
+
+ do
+ local evicted_key, evicted_value;
+ local c2 = cache.new(3, function (_key, _value)
+ evicted_key, evicted_value = _key, _value;
+ end);
+ local function set(k, v, should_evict_key, should_evict_value)
+ evicted_key, evicted_value = nil, nil;
+ c2:set(k, v);
+ assert.are.equal(evicted_key, should_evict_key);
+ assert.are.equal(evicted_value, should_evict_value);
+ end
+ set("a", 1)
+ set("a", 1)
+ set("a", 1)
+ set("a", 1)
+ set("a", 1)
+
+ set("b", 2)
+ set("c", 3)
+ set("b", 2)
+ set("d", 4, "a", 1)
+ set("e", 5, "c", 3)
+ end
+
+ do
+ local evicted_key, evicted_value;
+ local c3 = cache.new(1, function (_key, _value)
+ evicted_key, evicted_value = _key, _value;
+ if _key == "a" then
+ -- Sanity check for what we're evicting
+ assert.are.equal(_key, "a");
+ assert.are.equal(_value, 1);
+ -- We're going to block eviction of this key/value, so set to nil...
+ evicted_key, evicted_value = nil, nil;
+ -- Returning false to block eviction
+ return false
+ end
+ end);
+ local function set(k, v, should_evict_key, should_evict_value)
+ evicted_key, evicted_value = nil, nil;
+ local ret = c3:set(k, v);
+ assert.are.equal(evicted_key, should_evict_key);
+ assert.are.equal(evicted_value, should_evict_value);
+ return ret;
+ end
+ set("a", 1)
+ set("a", 1)
+ set("a", 1)
+ set("a", 1)
+ set("a", 1)
+
+ -- Our on_evict prevents "a" from being evicted, causing this to fail...
+ assert.are.equal(set("b", 2), false, "Failed to prevent eviction, or signal result");
+
+ expect_kv("a", 1, c3:head());
+ expect_kv("a", 1, c3:tail());
+
+ -- Check the final state is what we expect
+ assert.are.equal(c3:get("a"), 1);
+ assert.are.equal(c3:get("b"), nil);
+ assert.are.equal(c3:count(), 1);
+ end
+
+
+ local c4 = cache.new(3, false);
+
+ assert.are.equal(c4:set("a", 1), true);
+ assert.are.equal(c4:set("a", 1), true);
+ assert.are.equal(c4:set("a", 1), true);
+ assert.are.equal(c4:set("a", 1), true);
+ assert.are.equal(c4:set("b", 2), true);
+ assert.are.equal(c4:set("c", 3), true);
+ assert.are.equal(c4:set("d", 4), false);
+ assert.are.equal(c4:set("d", 4), false);
+ assert.are.equal(c4:set("d", 4), false);
+
+ expect_kv("c", 3, c4:head());
+ expect_kv("a", 1, c4:tail());
+
+ local c5 = cache.new(3, function (k, v) --luacheck: ignore 212/v
+ if k == "a" then
+ return nil;
+ elseif k == "b" then
+ return true;
+ end
+ return false;
+ end);
+
+ assert.are.equal(c5:set("a", 1), true);
+ assert.are.equal(c5:set("a", 1), true);
+ assert.are.equal(c5:set("a", 1), true);
+ assert.are.equal(c5:set("a", 1), true);
+ assert.are.equal(c5:set("b", 2), true);
+ assert.are.equal(c5:set("c", 3), true);
+ assert.are.equal(c5:set("d", 4), true); -- "a" evicted (cb returned nil)
+ assert.are.equal(c5:set("d", 4), true); -- nop
+ assert.are.equal(c5:set("d", 4), true); -- nop
+ assert.are.equal(c5:set("e", 5), true); -- "b" evicted (cb returned true)
+ assert.are.equal(c5:set("f", 6), false); -- "c" won't evict (cb returned false)
+
+ expect_kv("e", 5, c5:head());
+ expect_kv("c", 3, c5:tail());
+ end);
+ end);
+end);
diff --git a/spec/util_dataforms_spec.lua b/spec/util_dataforms_spec.lua
new file mode 100644
index 00000000..89759035
--- /dev/null
+++ b/spec/util_dataforms_spec.lua
@@ -0,0 +1,427 @@
+local dataforms = require "util.dataforms";
+local st = require "util.stanza";
+local jid = require "util.jid";
+local iter = require "util.iterators";
+
+describe("util.dataforms", function ()
+ local some_form, xform;
+ setup(function ()
+ some_form = dataforms.new({
+ title = "form-title",
+ instructions = "form-instructions",
+ {
+ type = "hidden",
+ name = "FORM_TYPE",
+ value = "xmpp:prosody.im/spec/util.dataforms#1",
+ };
+ {
+ type = "fixed";
+ value = "Fixed field";
+ },
+ {
+ type = "boolean",
+ label = "boolean-label",
+ name = "boolean-field",
+ value = true,
+ },
+ {
+ type = "fixed",
+ label = "fixed-label",
+ name = "fixed-field",
+ value = "fixed-value",
+ },
+ {
+ type = "hidden",
+ label = "hidden-label",
+ name = "hidden-field",
+ value = "hidden-value",
+ },
+ {
+ type = "jid-multi",
+ label = "jid-multi-label",
+ name = "jid-multi-field",
+ value = {
+ "jid@multi/value#1",
+ "jid@multi/value#2",
+ },
+ },
+ {
+ type = "jid-single",
+ label = "jid-single-label",
+ name = "jid-single-field",
+ value = "jid@single/value",
+ },
+ {
+ type = "list-multi",
+ label = "list-multi-label",
+ name = "list-multi-field",
+ value = {
+ "list-multi-option-value#1",
+ "list-multi-option-value#3",
+ },
+ options = {
+ {
+ label = "list-multi-option-label#1",
+ value = "list-multi-option-value#1",
+ default = true,
+ },
+ {
+ label = "list-multi-option-label#2",
+ value = "list-multi-option-value#2",
+ default = false,
+ },
+ {
+ label = "list-multi-option-label#3",
+ value = "list-multi-option-value#3",
+ default = true,
+ },
+ }
+ },
+ {
+ type = "list-single",
+ label = "list-single-label",
+ name = "list-single-field",
+ value = "list-single-value",
+ options = {
+ "list-single-value",
+ "list-single-value#2",
+ "list-single-value#3",
+ }
+ },
+ {
+ type = "text-multi",
+ label = "text-multi-label",
+ name = "text-multi-field",
+ value = "text\nmulti\nvalue",
+ },
+ {
+ type = "text-private",
+ label = "text-private-label",
+ name = "text-private-field",
+ value = "text-private-value",
+ },
+ {
+ type = "text-single",
+ label = "text-single-label",
+ name = "text-single-field",
+ value = "text-single-value",
+ },
+ });
+ xform = some_form:form();
+ end);
+
+ it("works", function ()
+ assert.truthy(xform);
+ assert.truthy(st.is_stanza(xform));
+ assert.equal("x", xform.name);
+ assert.equal("jabber:x:data", xform.attr.xmlns);
+ assert.equal("FORM_TYPE", xform:find("field@var"));
+ assert.equal("xmpp:prosody.im/spec/util.dataforms#1", xform:find("field/value#"));
+ local allowed_direct_children = {
+ title = true,
+ instructions = true,
+ field = true,
+ }
+ for tag in xform:childtags() do
+ assert.truthy(allowed_direct_children[tag.name], "unknown direct child");
+ end
+ end);
+
+ it("produced boolean field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "boolean-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("boolean-field", f.attr.var);
+ assert.equal("boolean", f.attr.type);
+ assert.equal("boolean-label", f.attr.label);
+ assert.equal(1, iter.count(f:childtags("value")));
+ local val = f:get_child_text("value");
+ assert.truthy(val == "true" or val == "1");
+ end);
+
+ it("produced fixed field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "fixed-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("fixed-field", f.attr.var);
+ assert.equal("fixed", f.attr.type);
+ assert.equal("fixed-label", f.attr.label);
+ assert.equal(1, iter.count(f:childtags("value")));
+ assert.equal("fixed-value", f:get_child_text("value"));
+ end);
+
+ it("produced hidden field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "hidden-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("hidden-field", f.attr.var);
+ assert.equal("hidden", f.attr.type);
+ assert.equal("hidden-label", f.attr.label);
+ assert.equal(1, iter.count(f:childtags("value")));
+ assert.equal("hidden-value", f:get_child_text("value"));
+ end);
+
+ it("produced jid-multi field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "jid-multi-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("jid-multi-field", f.attr.var);
+ assert.equal("jid-multi", f.attr.type);
+ assert.equal("jid-multi-label", f.attr.label);
+ assert.equal(2, iter.count(f:childtags("value")));
+
+ local i = 0;
+ for value in f:childtags("value") do
+ i = i + 1;
+ assert.equal(("jid@multi/value#%d"):format(i), value:get_text());
+ end
+ end);
+
+ it("produced jid-single field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "jid-single-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("jid-single-field", f.attr.var);
+ assert.equal("jid-single", f.attr.type);
+ assert.equal("jid-single-label", f.attr.label);
+ assert.equal(1, iter.count(f:childtags("value")));
+ assert.equal("jid@single/value", f:get_child_text("value"));
+ assert.truthy(jid.prep(f:get_child_text("value")));
+ end);
+
+ it("produced list-multi field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "list-multi-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("list-multi-field", f.attr.var);
+ assert.equal("list-multi", f.attr.type);
+ assert.equal("list-multi-label", f.attr.label);
+ assert.equal(2, iter.count(f:childtags("value")));
+ assert.equal("list-multi-option-value#1", f:get_child_text("value"));
+ assert.equal(3, iter.count(f:childtags("option")));
+ end);
+
+ it("produced list-single field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "list-single-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("list-single-field", f.attr.var);
+ assert.equal("list-single", f.attr.type);
+ assert.equal("list-single-label", f.attr.label);
+ assert.equal(1, iter.count(f:childtags("value")));
+ assert.equal("list-single-value", f:get_child_text("value"));
+ assert.equal(3, iter.count(f:childtags("option")));
+ end);
+
+ it("produced text-multi field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "text-multi-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("text-multi-field", f.attr.var);
+ assert.equal("text-multi", f.attr.type);
+ assert.equal("text-multi-label", f.attr.label);
+ assert.equal(3, iter.count(f:childtags("value")));
+ end);
+
+ it("produced text-private field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "text-private-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("text-private-field", f.attr.var);
+ assert.equal("text-private", f.attr.type);
+ assert.equal("text-private-label", f.attr.label);
+ assert.equal(1, iter.count(f:childtags("value")));
+ assert.equal("text-private-value", f:get_child_text("value"));
+ end);
+
+ it("produced text-single field correctly", function ()
+ local f;
+ for field in xform:childtags("field") do
+ if field.attr.var == "text-single-field" then
+ f = field;
+ break;
+ end
+ end
+
+ assert.truthy(st.is_stanza(f));
+ assert.equal("text-single-field", f.attr.var);
+ assert.equal("text-single", f.attr.type);
+ assert.equal("text-single-label", f.attr.label);
+ assert.equal(1, iter.count(f:childtags("value")));
+ assert.equal("text-single-value", f:get_child_text("value"));
+ end);
+
+ describe("get_type()", function ()
+ it("identifes dataforms", function ()
+ assert.equal(nil, dataforms.get_type(nil));
+ assert.equal(nil, dataforms.get_type(""));
+ assert.equal(nil, dataforms.get_type({}));
+ assert.equal(nil, dataforms.get_type(st.stanza("no-a-form")));
+ assert.equal("xmpp:prosody.im/spec/util.dataforms#1", dataforms.get_type(xform));
+ end);
+ end);
+
+ describe(":data", function ()
+ it("works", function ()
+ assert.truthy(some_form:data(xform));
+ end);
+ end);
+
+ describe("issue1177", function ()
+ local form_with_stuff;
+ setup(function ()
+ form_with_stuff = dataforms.new({
+ {
+ type = "list-single";
+ name = "abtest";
+ label = "A or B?";
+ options = {
+ { label = "A", value = "a", default = true },
+ { label = "B", value = "b" },
+ };
+ },
+ });
+ end);
+
+ it("includes options when value is included", function ()
+ local f = form_with_stuff:form({ abtest = "a" });
+ assert.truthy(f:find("field/option"));
+ end);
+
+ it("includes options when value is excluded", function ()
+ local f = form_with_stuff:form({});
+ assert.truthy(f:find("field/option"));
+ end);
+ end);
+
+ describe("using current values in place of missing fields", function ()
+ it("gets back the previous values when given an empty form", function ()
+ local current = {
+ ["list-multi-field"] = {
+ "list-multi-option-value#2";
+ };
+ ["list-single-field"] = "list-single-value#2";
+ ["hidden-field"] = "hidden-value";
+ ["boolean-field"] = false;
+ ["text-multi-field"] = "words\ngo\nhere";
+ ["jid-single-field"] = "alice@example.com";
+ ["text-private-field"] = "hunter2";
+ ["text-single-field"] = "text-single-value";
+ ["jid-multi-field"] = {
+ "bob@example.net";
+ };
+ };
+ local expect = {
+ -- FORM_TYPE = "xmpp:prosody.im/spec/util.dataforms#1"; -- does this need to be included?
+ ["list-multi-field"] = {
+ "list-multi-option-value#2";
+ };
+ ["list-single-field"] = "list-single-value#2";
+ ["hidden-field"] = "hidden-value";
+ ["boolean-field"] = false;
+ ["text-multi-field"] = "words\ngo\nhere";
+ ["jid-single-field"] = "alice@example.com";
+ ["text-private-field"] = "hunter2";
+ ["text-single-field"] = "text-single-value";
+ ["jid-multi-field"] = {
+ "bob@example.net";
+ };
+ };
+ local data, err = some_form:data(st.stanza("x", {xmlns="jabber:x:data"}), current);
+ assert.is.table(data, err);
+ assert.same(expect, data, "got back the same data");
+ end);
+ end);
+
+ describe("field 'var' property", function ()
+ it("works as expected", function ()
+ local f = dataforms.new {
+ {
+ var = "someprefix#the-field",
+ name = "the_field",
+ type = "text-single",
+ }
+ };
+ local x = f:form({the_field = "hello"});
+ assert.equal("someprefix#the-field", x:find"field@var");
+ assert.equal("hello", x:find"field/value#");
+ end);
+ end);
+
+ describe("validation", function ()
+ local f = dataforms.new {
+ {
+ name = "number",
+ type = "text-single",
+ datatype = "xs:integer",
+ },
+ };
+
+ it("works", function ()
+ local d = f:data(f:form({number = 1}));
+ assert.equal(1, d.number);
+ end);
+
+ it("works", function ()
+ local d,e = f:data(f:form({number = "nan"}));
+ assert.not_equal(1, d.number);
+ assert.table(e);
+ assert.string(e.number);
+ end);
+ end);
+end);
+
diff --git a/spec/util_datetime_spec.lua b/spec/util_datetime_spec.lua
new file mode 100644
index 00000000..497ab7d3
--- /dev/null
+++ b/spec/util_datetime_spec.lua
@@ -0,0 +1,76 @@
+local util_datetime = require "util.datetime";
+
+describe("util.datetime", function ()
+ it("should have been loaded", function ()
+ assert.is_table(util_datetime);
+ end);
+ describe("#date", function ()
+ local date = util_datetime.date;
+ it("should exist", function ()
+ assert.is_function(date);
+ end);
+ it("should return a string", function ()
+ assert.is_string(date());
+ end);
+ it("should look like a date", function ()
+ assert.truthy(string.find(date(), "^%d%d%d%d%-%d%d%-%d%d$"));
+ end);
+ it("should work", function ()
+ assert.equals(date(1136239445), "2006-01-02");
+ end);
+ end);
+ describe("#time", function ()
+ local time = util_datetime.time;
+ it("should exist", function ()
+ assert.is_function(time);
+ end);
+ it("should return a string", function ()
+ assert.is_string(time());
+ end);
+ it("should look like a timestamp", function ()
+ -- Note: Sub-second precision and timezones are ignored
+ assert.truthy(string.find(time(), "^%d%d:%d%d:%d%d"));
+ end);
+ it("should work", function ()
+ assert.equals(time(1136239445), "22:04:05");
+ end);
+ end);
+ describe("#datetime", function ()
+ local datetime = util_datetime.datetime;
+ it("should exist", function ()
+ assert.is_function(datetime);
+ end);
+ it("should return a string", function ()
+ assert.is_string(datetime());
+ end);
+ it("should look like a timestamp", function ()
+ -- Note: Sub-second precision and timezones are ignored
+ assert.truthy(string.find(datetime(), "^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d"));
+ end);
+ it("should work", function ()
+ assert.equals(datetime(1136239445), "2006-01-02T22:04:05Z");
+ end);
+ end);
+ describe("#legacy", function ()
+ local legacy = util_datetime.legacy;
+ it("should exist", function ()
+ assert.is_function(legacy);
+ end);
+ end);
+ describe("#parse", function ()
+ local parse = util_datetime.parse;
+ it("should exist", function ()
+ assert.is_function(parse);
+ end);
+ it("should work", function ()
+ -- Timestamp used by Go
+ assert.equals(parse("2017-11-19T17:58:13Z"), 1511114293);
+ assert.equals(parse("2017-11-19T18:58:50+0100"), 1511114330);
+ assert.equals(parse("2006-01-02T15:04:05-0700"), 1136239445);
+ end);
+ it("should handle timezones", function ()
+ -- https://xmpp.org/extensions/xep-0082.html#example-2 and 3
+ assert.equals(parse("1969-07-21T02:56:15Z"), parse("1969-07-20T21:56:15-05:00"));
+ end);
+ end);
+end);
diff --git a/spec/util_encodings_spec.lua b/spec/util_encodings_spec.lua
new file mode 100644
index 00000000..0f4fc2b7
--- /dev/null
+++ b/spec/util_encodings_spec.lua
@@ -0,0 +1,41 @@
+
+local encodings = require "util.encodings";
+local utf8 = assert(encodings.utf8, "no encodings.utf8 module");
+
+describe("util.encodings", function ()
+ describe("#encode()", function()
+ it("should work", function ()
+ assert.is.equal(encodings.base64.encode(""), "");
+ assert.is.equal(encodings.base64.encode('coucou'), "Y291Y291");
+ assert.is.equal(encodings.base64.encode("\0\0\0"), "AAAA");
+ assert.is.equal(encodings.base64.encode("\255\255\255"), "////");
+ end);
+ end);
+ describe("#decode()", function()
+ it("should work", function ()
+ assert.is.equal(encodings.base64.decode(""), "");
+ assert.is.equal(encodings.base64.decode("="), "");
+ assert.is.equal(encodings.base64.decode('Y291Y291'), "coucou");
+ assert.is.equal(encodings.base64.decode("AAAA"), "\0\0\0");
+ assert.is.equal(encodings.base64.decode("////"), "\255\255\255");
+ end);
+ end);
+end);
+describe("util.encodings.utf8", function()
+ describe("#valid()", function()
+ it("should work", function()
+
+ for line in io.lines("spec/utf8_sequences.txt") do
+ local data = line:match(":%s*([^#]+)"):gsub("%s+", ""):gsub("..", function (c) return string.char(tonumber(c, 16)); end)
+ local expect = line:match("(%S+):");
+
+ assert(expect == "pass" or expect == "fail", "unknown expectation: "..line:match("^[^:]+"));
+
+ local valid = utf8.valid(data);
+ assert.is.equal(valid, utf8.valid(data.." "));
+ assert.is.equal(valid, expect == "pass", line);
+ end
+
+ end);
+ end);
+end);
diff --git a/spec/util_events_spec.lua b/spec/util_events_spec.lua
new file mode 100644
index 00000000..fee60f8f
--- /dev/null
+++ b/spec/util_events_spec.lua
@@ -0,0 +1,212 @@
+local events = require "util.events";
+
+describe("util.events", function ()
+ it("should export a new() function", function ()
+ assert.is_function(events.new);
+ end);
+ describe("new()", function ()
+ it("should return return a new events object", function ()
+ local e = events.new();
+ assert.is_function(e.add_handler);
+ assert.is_function(e.remove_handler);
+ end);
+ end);
+
+ local e, h;
+
+
+ describe("API", function ()
+ before_each(function ()
+ e = events.new();
+ h = spy.new(function () end);
+ end);
+
+ it("should call handlers when an event is fired", function ()
+ e.add_handler("myevent", h);
+ e.fire_event("myevent");
+ assert.spy(h).was_called();
+ end);
+
+ it("should not call handlers when a different event is fired", function ()
+ e.add_handler("myevent", h);
+ e.fire_event("notmyevent");
+ assert.spy(h).was_not_called();
+ end);
+
+ it("should pass the data argument to handlers", function ()
+ e.add_handler("myevent", h);
+ e.fire_event("myevent", "mydata");
+ assert.spy(h).was_called_with("mydata");
+ end);
+
+ it("should support non-string events", function ()
+ local myevent = {};
+ e.add_handler(myevent, h);
+ e.fire_event(myevent, "mydata");
+ assert.spy(h).was_called_with("mydata");
+ end);
+
+ it("should call handlers in priority order", function ()
+ local data = {};
+ e.add_handler("myevent", function () table.insert(data, "h1"); end, 5);
+ e.add_handler("myevent", function () table.insert(data, "h2"); end, 3);
+ e.add_handler("myevent", function () table.insert(data, "h3"); end);
+ e.fire_event("myevent", "mydata");
+ assert.same(data, { "h1", "h2", "h3" });
+ end);
+
+ it("should support non-integer priority values", function ()
+ local data = {};
+ e.add_handler("myevent", function () table.insert(data, "h1"); end, 1);
+ e.add_handler("myevent", function () table.insert(data, "h2"); end, 0.5);
+ e.add_handler("myevent", function () table.insert(data, "h3"); end, 0.25);
+ e.fire_event("myevent", "mydata");
+ assert.same(data, { "h1", "h2", "h3" });
+ end);
+
+ it("should support negative priority values", function ()
+ local data = {};
+ e.add_handler("myevent", function () table.insert(data, "h1"); end, 1);
+ e.add_handler("myevent", function () table.insert(data, "h2"); end, 0);
+ e.add_handler("myevent", function () table.insert(data, "h3"); end, -1);
+ e.fire_event("myevent", "mydata");
+ assert.same(data, { "h1", "h2", "h3" });
+ end);
+
+ it("should support removing handlers", function ()
+ e.add_handler("myevent", h);
+ e.fire_event("myevent");
+ e.remove_handler("myevent", h);
+ e.fire_event("myevent");
+ assert.spy(h).was_called(1);
+ end);
+
+ it("should support adding multiple handlers at the same time", function ()
+ local ht = {
+ myevent1 = spy.new(function () end);
+ myevent2 = spy.new(function () end);
+ myevent3 = spy.new(function () end);
+ };
+ e.add_handlers(ht);
+ e.fire_event("myevent1");
+ e.fire_event("myevent2");
+ assert.spy(ht.myevent1).was_called();
+ assert.spy(ht.myevent2).was_called();
+ assert.spy(ht.myevent3).was_not_called();
+ end);
+
+ it("should support removing multiple handlers at the same time", function ()
+ local ht = {
+ myevent1 = spy.new(function () end);
+ myevent2 = spy.new(function () end);
+ myevent3 = spy.new(function () end);
+ };
+ e.add_handlers(ht);
+ e.remove_handlers(ht);
+ e.fire_event("myevent1");
+ e.fire_event("myevent2");
+ assert.spy(ht.myevent1).was_not_called();
+ assert.spy(ht.myevent2).was_not_called();
+ assert.spy(ht.myevent3).was_not_called();
+ end);
+
+ pending("should support adding handlers within an event handler")
+ pending("should support removing handlers within an event handler")
+
+ it("should support getting the current handlers for an event", function ()
+ e.add_handler("myevent", h);
+ local handlers = e.get_handlers("myevent");
+ assert.equal(h, handlers[1]);
+ end);
+
+ describe("wrappers", function ()
+ local w
+ before_each(function ()
+ w = spy.new(function (handlers, event_name, event_data)
+ assert.is_function(handlers);
+ assert.equal("myevent", event_name)
+ assert.equal("abc", event_data);
+ return handlers(event_name, event_data);
+ end);
+ end);
+
+ it("should get called", function ()
+ e.add_wrapper("myevent", w);
+ e.add_handler("myevent", h);
+ e.fire_event("myevent", "abc");
+ assert.spy(w).was_called(1);
+ assert.spy(h).was_called(1);
+ end);
+
+ it("should be removable", function ()
+ e.add_wrapper("myevent", w);
+ e.add_handler("myevent", h);
+ e.fire_event("myevent", "abc");
+ e.remove_wrapper("myevent", w);
+ e.fire_event("myevent", "abc");
+ assert.spy(w).was_called(1);
+ assert.spy(h).was_called(2);
+ end);
+
+ it("should allow multiple wrappers", function ()
+ local w2 = spy.new(function (handlers, event_name, event_data)
+ return handlers(event_name, event_data);
+ end);
+ e.add_wrapper("myevent", w);
+ e.add_handler("myevent", h);
+ e.add_wrapper("myevent", w2);
+ e.fire_event("myevent", "abc");
+ e.remove_wrapper("myevent", w);
+ e.fire_event("myevent", "abc");
+ assert.spy(w).was_called(1);
+ assert.spy(w2).was_called(2);
+ assert.spy(h).was_called(2);
+ end);
+
+ it("should support a mix of global and event wrappers", function ()
+ local w2 = spy.new(function (handlers, event_name, event_data)
+ return handlers(event_name, event_data);
+ end);
+ e.add_wrapper(false, w);
+ e.add_handler("myevent", h);
+ e.add_wrapper("myevent", w2);
+ e.fire_event("myevent", "abc");
+ e.remove_wrapper(false, w);
+ e.fire_event("myevent", "abc");
+ assert.spy(w).was_called(1);
+ assert.spy(w2).was_called(2);
+ assert.spy(h).was_called(2);
+ end);
+ end);
+
+ describe("global wrappers", function ()
+ local w
+ before_each(function ()
+ w = spy.new(function (handlers, event_name, event_data)
+ assert.is_function(handlers);
+ assert.equal("myevent", event_name)
+ assert.equal("abc", event_data);
+ return handlers(event_name, event_data);
+ end);
+ end);
+
+ it("should get called", function ()
+ e.add_wrapper(false, w);
+ e.add_handler("myevent", h);
+ e.fire_event("myevent", "abc");
+ assert.spy(w).was_called(1);
+ assert.spy(h).was_called(1);
+ end);
+
+ it("should be removable", function ()
+ e.add_wrapper(false, w);
+ e.add_handler("myevent", h);
+ e.fire_event("myevent", "abc");
+ e.remove_wrapper(false, w);
+ e.fire_event("myevent", "abc");
+ assert.spy(w).was_called(1);
+ assert.spy(h).was_called(2);
+ end);
+ end);
+ end);
+end);
diff --git a/spec/util_format_spec.lua b/spec/util_format_spec.lua
new file mode 100644
index 00000000..7e6a0c6e
--- /dev/null
+++ b/spec/util_format_spec.lua
@@ -0,0 +1,14 @@
+local format = require "util.format".format;
+
+describe("util.format", function()
+ describe("#format()", function()
+ it("should work", function()
+ assert.equal("hello", format("%s", "hello"));
+ assert.equal("<nil>", format("%s"));
+ assert.equal(" [<nil>]", format("", nil));
+ assert.equal("true", format("%s", true));
+ assert.equal("[true]", format("%d", true));
+ assert.equal("% [true]", format("%%", true));
+ end);
+ end);
+end);
diff --git a/spec/util_http_spec.lua b/spec/util_http_spec.lua
new file mode 100644
index 00000000..0f51a86c
--- /dev/null
+++ b/spec/util_http_spec.lua
@@ -0,0 +1,87 @@
+
+local http = require "util.http";
+
+describe("util.http", function()
+ describe("#urlencode()", function()
+ it("should not change normal characters", function()
+ assert.are.equal(http.urlencode("helloworld123"), "helloworld123");
+ end);
+
+ it("should escape spaces", function()
+ assert.are.equal(http.urlencode("hello world"), "hello%20world");
+ end);
+
+ it("should escape important URL characters", function()
+ assert.are.equal(http.urlencode("This & that = something"), "This%20%26%20that%20%3d%20something");
+ end);
+ end);
+
+ describe("#urldecode()", function()
+ it("should not change normal characters", function()
+ assert.are.equal("helloworld123", http.urldecode("helloworld123"), "Normal characters not escaped");
+ end);
+
+ it("should decode spaces", function()
+ assert.are.equal("hello world", http.urldecode("hello%20world"), "Spaces escaped");
+ end);
+
+ it("should decode important URL characters", function()
+ assert.are.equal("This & that = something", http.urldecode("This%20%26%20that%20%3d%20something"), "Important URL chars escaped");
+ end);
+ end);
+
+ describe("#formencode()", function()
+ it("should encode basic data", function()
+ assert.are.equal(http.formencode({ { name = "one", value = "1"}, { name = "two", value = "2" } }), "one=1&two=2", "Form encoded");
+ end);
+
+ it("should encode special characters with escaping", function()
+ assert.are.equal(http.formencode({ { name = "one two", value = "1"}, { name = "two one&", value = "2" } }), "one+two=1&two+one%26=2", "Form encoded");
+ end);
+ end);
+
+ describe("#formdecode()", function()
+ it("should decode basic data", function()
+ local t = http.formdecode("one=1&two=2");
+ assert.are.same(t, {
+ { name = "one", value = "1" };
+ { name = "two", value = "2" };
+ one = "1";
+ two = "2";
+ });
+ end);
+
+ it("should decode special characters", function()
+ local t = http.formdecode("one+two=1&two+one%26=2");
+ assert.are.same(t, {
+ { name = "one two", value = "1" };
+ { name = "two one&", value = "2" };
+ ["one two"] = "1";
+ ["two one&"] = "2";
+ });
+ end);
+ end);
+
+ describe("normalize_path", function ()
+ it("root path is always '/'", function ()
+ assert.equal("/", http.normalize_path("/"));
+ assert.equal("/", http.normalize_path(""));
+ assert.equal("/", http.normalize_path("/", true));
+ assert.equal("/", http.normalize_path("", true));
+ end);
+
+ it("works", function ()
+ assert.equal("/foo", http.normalize_path("foo"));
+ assert.equal("/foo", http.normalize_path("/foo"));
+ assert.equal("/foo", http.normalize_path("foo/"));
+ assert.equal("/foo", http.normalize_path("/foo/"));
+ end);
+
+ it("is_dir works", function ()
+ assert.equal("/foo/", http.normalize_path("foo", true));
+ assert.equal("/foo/", http.normalize_path("/foo", true));
+ assert.equal("/foo/", http.normalize_path("foo/", true));
+ assert.equal("/foo/", http.normalize_path("/foo/", true));
+ end);
+ end);
+end);
diff --git a/spec/util_ip_spec.lua b/spec/util_ip_spec.lua
new file mode 100644
index 00000000..be5e4cff
--- /dev/null
+++ b/spec/util_ip_spec.lua
@@ -0,0 +1,103 @@
+
+local ip = require "util.ip";
+
+local new_ip = ip.new_ip;
+local match = ip.match;
+local parse_cidr = ip.parse_cidr;
+local commonPrefixLength = ip.commonPrefixLength;
+
+describe("util.ip", function()
+ describe("#match()", function()
+ it("should work", function()
+ local _ = new_ip;
+ local ip = _"10.20.30.40";
+ assert.are.equal(match(ip, _"10.0.0.0", 8), true);
+ assert.are.equal(match(ip, _"10.0.0.0", 16), false);
+ assert.are.equal(match(ip, _"10.0.0.0", 24), false);
+ assert.are.equal(match(ip, _"10.0.0.0", 32), false);
+
+ assert.are.equal(match(ip, _"10.20.0.0", 8), true);
+ assert.are.equal(match(ip, _"10.20.0.0", 16), true);
+ assert.are.equal(match(ip, _"10.20.0.0", 24), false);
+ assert.are.equal(match(ip, _"10.20.0.0", 32), false);
+
+ assert.are.equal(match(ip, _"0.0.0.0", 32), false);
+ assert.are.equal(match(ip, _"0.0.0.0", 0), true);
+ assert.are.equal(match(ip, _"0.0.0.0"), false);
+
+ assert.are.equal(match(ip, _"10.0.0.0", 255), false, "excessive number of bits");
+ assert.are.equal(match(ip, _"10.0.0.0", -8), true, "negative number of bits");
+ assert.are.equal(match(ip, _"10.0.0.0", -32), true, "negative number of bits");
+ assert.are.equal(match(ip, _"10.0.0.0", 0), true, "zero bits");
+ assert.are.equal(match(ip, _"10.0.0.0"), false, "no specified number of bits (differing ip)");
+ assert.are.equal(match(ip, _"10.20.30.40"), true, "no specified number of bits (same ip)");
+
+ assert.are.equal(match(_"127.0.0.1", _"127.0.0.1"), true, "simple ip");
+
+ assert.are.equal(match(_"8.8.8.8", _"8.8.0.0", 16), true);
+ assert.are.equal(match(_"8.8.4.4", _"8.8.0.0", 16), true);
+ end);
+ end);
+
+ describe("#parse_cidr()", function()
+ it("should work", function()
+ assert.are.equal(new_ip"0.0.0.0", new_ip"0.0.0.0")
+
+ local function assert_cidr(cidr, ip, bits)
+ local parsed_ip, parsed_bits = parse_cidr(cidr);
+ assert.are.equal(new_ip(ip), parsed_ip, cidr.." parsed ip is "..ip);
+ assert.are.equal(bits, parsed_bits, cidr.." parsed bits is "..tostring(bits));
+ end
+ assert_cidr("0.0.0.0", "0.0.0.0", nil);
+ assert_cidr("127.0.0.1", "127.0.0.1", nil);
+ assert_cidr("127.0.0.1/0", "127.0.0.1", 0);
+ assert_cidr("127.0.0.1/8", "127.0.0.1", 8);
+ assert_cidr("127.0.0.1/32", "127.0.0.1", 32);
+ assert_cidr("127.0.0.1/256", "127.0.0.1", 256);
+ assert_cidr("::/48", "::", 48);
+ end);
+ end);
+
+ describe("#new_ip()", function()
+ it("should work", function()
+ local v4, v6 = "IPv4", "IPv6";
+ local function assert_proto(s, proto)
+ local ip = new_ip(s);
+ if proto then
+ assert.are.equal(ip and ip.proto, proto, "protocol is correct for "..("%q"):format(s));
+ else
+ assert.are.equal(ip, nil, "address is invalid");
+ end
+ end
+ assert_proto("127.0.0.1", v4);
+ assert_proto("::1", v6);
+ assert_proto("", nil);
+ assert_proto("abc", nil);
+ assert_proto(" ", nil);
+ end);
+ end);
+
+ describe("#commonPrefixLength()", function()
+ it("should work", function()
+ local function assert_cpl6(a, b, len, v4)
+ local ipa, ipb = new_ip(a), new_ip(b);
+ if v4 then len = len+96; end
+ assert.are.equal(commonPrefixLength(ipa, ipb), len, "common prefix length of "..a.." and "..b.." is "..len);
+ assert.are.equal(commonPrefixLength(ipb, ipa), len, "common prefix length of "..b.." and "..a.." is "..len);
+ end
+ local function assert_cpl4(a, b, len)
+ return assert_cpl6(a, b, len, "IPv4");
+ end
+ assert_cpl4("0.0.0.0", "0.0.0.0", 32);
+ assert_cpl4("255.255.255.255", "0.0.0.0", 0);
+ assert_cpl4("255.255.255.255", "255.255.0.0", 16);
+ assert_cpl4("255.255.255.255", "255.255.255.255", 32);
+ assert_cpl4("255.255.255.255", "255.255.255.255", 32);
+
+ assert_cpl6("::1", "::1", 128);
+ assert_cpl6("abcd::1", "abcd::1", 128);
+ assert_cpl6("abcd::abcd", "abcd::", 112);
+ assert_cpl6("abcd::abcd", "abcd::abcd:abcd", 96);
+ end);
+ end);
+end);
diff --git a/spec/util_iterators_spec.lua b/spec/util_iterators_spec.lua
new file mode 100644
index 00000000..4cf6f19d
--- /dev/null
+++ b/spec/util_iterators_spec.lua
@@ -0,0 +1,46 @@
+local iter = require "util.iterators";
+
+describe("util.iterators", function ()
+ describe("join", function ()
+ it("should produce a joined iterator", function ()
+ local expect = { "a", "b", "c", 1, 2, 3 };
+ local output = {};
+ for x in iter.join(iter.values({"a", "b", "c"})):append(iter.values({1, 2, 3})) do
+ table.insert(output, x);
+ end
+ assert.same(output, expect);
+ end);
+ end);
+
+ describe("sorted_pairs", function ()
+ it("should produce sorted pairs", function ()
+ local orig = { b = 1, c = 2, a = "foo", d = false };
+ local n, last_key = 0, nil;
+ for k, v in iter.sorted_pairs(orig) do
+ n = n + 1;
+ if last_key then
+ assert(k > last_key, "Expected "..k.." > "..last_key)
+ end
+ assert.equal(orig[k], v);
+ last_key = k;
+ end
+ assert.equal("d", last_key);
+ assert.equal(4, n);
+ end);
+
+ it("should allow a custom sort function", function ()
+ local orig = { b = 1, c = 2, a = "foo", d = false };
+ local n, last_key = 0, nil;
+ for k, v in iter.sorted_pairs(orig, function (a, b) return a > b end) do
+ n = n + 1;
+ if last_key then
+ assert(k < last_key, "Expected "..k.." > "..last_key)
+ end
+ assert.equal(orig[k], v);
+ last_key = k;
+ end
+ assert.equal("a", last_key);
+ assert.equal(4, n);
+ end);
+ end);
+end);
diff --git a/spec/util_jid_spec.lua b/spec/util_jid_spec.lua
new file mode 100644
index 00000000..c075212f
--- /dev/null
+++ b/spec/util_jid_spec.lua
@@ -0,0 +1,146 @@
+
+local jid = require "util.jid";
+
+describe("util.jid", function()
+ describe("#join()", function()
+ it("should work", function()
+ assert.are.equal(jid.join("a", "b", "c"), "a@b/c", "builds full JID");
+ assert.are.equal(jid.join("a", "b", nil), "a@b", "builds bare JID");
+ assert.are.equal(jid.join(nil, "b", "c"), "b/c", "builds full host JID");
+ assert.are.equal(jid.join(nil, "b", nil), "b", "builds bare host JID");
+ assert.are.equal(jid.join(nil, nil, nil), nil, "invalid JID is nil");
+ assert.are.equal(jid.join("a", nil, nil), nil, "invalid JID is nil");
+ assert.are.equal(jid.join(nil, nil, "c"), nil, "invalid JID is nil");
+ assert.are.equal(jid.join("a", nil, "c"), nil, "invalid JID is nil");
+ end);
+ end);
+ describe("#split()", function()
+ it("should work", function()
+ local function test(input_jid, expected_node, expected_server, expected_resource)
+ local rnode, rserver, rresource = jid.split(input_jid);
+ assert.are.equal(expected_node, rnode, "split("..tostring(input_jid)..") failed");
+ assert.are.equal(expected_server, rserver, "split("..tostring(input_jid)..") failed");
+ assert.are.equal(expected_resource, rresource, "split("..tostring(input_jid)..") failed");
+ end
+
+ -- Valid JIDs
+ test("node@server", "node", "server", nil );
+ test("node@server/resource", "node", "server", "resource" );
+ test("server", nil, "server", nil );
+ test("server/resource", nil, "server", "resource" );
+ test("server/resource@foo", nil, "server", "resource@foo" );
+ test("server/resource@foo/bar", nil, "server", "resource@foo/bar");
+
+ -- Always invalid JIDs
+ test(nil, nil, nil, nil);
+ test("node@/server", nil, nil, nil);
+ test("@server", nil, nil, nil);
+ test("@server/resource", nil, nil, nil);
+ test("@/resource", nil, nil, nil);
+ end);
+ end);
+
+
+ describe("#bare()", function()
+ it("should work", function()
+ assert.are.equal(jid.bare("user@host"), "user@host", "bare JID remains bare");
+ assert.are.equal(jid.bare("host"), "host", "Host JID remains host");
+ assert.are.equal(jid.bare("host/resource"), "host", "Host JID with resource becomes host");
+ assert.are.equal(jid.bare("user@host/resource"), "user@host", "user@host JID with resource becomes user@host");
+ assert.are.equal(jid.bare("user@/resource"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("@/resource"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("@/"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("/"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare(""), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("@"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("user@"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("user@@"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("user@@host"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("user@@host/resource"), nil, "invalid JID is nil");
+ assert.are.equal(jid.bare("user@host/"), nil, "invalid JID is nil");
+ end);
+ end);
+
+ describe("#compare()", function()
+ it("should work", function()
+ assert.are.equal(jid.compare("host", "host"), true, "host should match");
+ assert.are.equal(jid.compare("host", "other-host"), false, "host should not match");
+ assert.are.equal(jid.compare("other-user@host/resource", "host"), true, "host should match");
+ assert.are.equal(jid.compare("other-user@host", "user@host"), false, "user should not match");
+ assert.are.equal(jid.compare("user@host", "host"), true, "host should match");
+ assert.are.equal(jid.compare("user@host/resource", "host"), true, "host should match");
+ assert.are.equal(jid.compare("user@host/resource", "user@host"), true, "user and host should match");
+ assert.are.equal(jid.compare("user@other-host", "host"), false, "host should not match");
+ assert.are.equal(jid.compare("user@other-host", "user@host"), false, "host should not match");
+ end);
+ end);
+
+ it("should work with nodes", function()
+ local function test(_jid, expected_node)
+ assert.are.equal(jid.node(_jid), expected_node, "Unexpected node for "..tostring(_jid));
+ end
+
+ test("example.com", nil);
+ test("foo.example.com", nil);
+ test("foo.example.com/resource", nil);
+ test("foo.example.com/some resource", nil);
+ test("foo.example.com/some@resource", nil);
+
+ test("foo@foo.example.com/some@resource", "foo");
+ test("foo@example/some@resource", "foo");
+
+ test("foo@example/@resource", "foo");
+ test("foo@example@resource", nil);
+ test("foo@example", "foo");
+ test("foo", nil);
+
+ test(nil, nil);
+ end);
+
+ it("should work with hosts", function()
+ local function test(_jid, expected_host)
+ assert.are.equal(jid.host(_jid), expected_host, "Unexpected host for "..tostring(_jid));
+ end
+
+ test("example.com", "example.com");
+ test("foo.example.com", "foo.example.com");
+ test("foo.example.com/resource", "foo.example.com");
+ test("foo.example.com/some resource", "foo.example.com");
+ test("foo.example.com/some@resource", "foo.example.com");
+
+ test("foo@foo.example.com/some@resource", "foo.example.com");
+ test("foo@example/some@resource", "example");
+
+ test("foo@example/@resource", "example");
+ test("foo@example@resource", nil);
+ test("foo@example", "example");
+ test("foo", "foo");
+
+ test(nil, nil);
+ end);
+
+ it("should work with resources", function()
+ local function test(_jid, expected_resource)
+ assert.are.equal(jid.resource(_jid), expected_resource, "Unexpected resource for "..tostring(_jid));
+ end
+
+ test("example.com", nil);
+ test("foo.example.com", nil);
+ test("foo.example.com/resource", "resource");
+ test("foo.example.com/some resource", "some resource");
+ test("foo.example.com/some@resource", "some@resource");
+
+ test("foo@foo.example.com/some@resource", "some@resource");
+ test("foo@example/some@resource", "some@resource");
+
+ test("foo@example/@resource", "@resource");
+ test("foo@example@resource", nil);
+ test("foo@example", nil);
+ test("foo", nil);
+ test("/foo", nil);
+ test("@x/foo", nil);
+ test("@/foo", nil);
+
+ test(nil, nil);
+ end);
+end);
diff --git a/spec/util_json_spec.lua b/spec/util_json_spec.lua
new file mode 100644
index 00000000..43360540
--- /dev/null
+++ b/spec/util_json_spec.lua
@@ -0,0 +1,70 @@
+
+local json = require "util.json";
+
+describe("util.json", function()
+ describe("#encode()", function()
+ it("should work", function()
+ local function test(f, j, e)
+ if e then
+ assert.are.equal(f(j), e);
+ end
+ assert.are.equal(f(j), f(json.decode(f(j))));
+ end
+ test(json.encode, json.null, "null")
+ test(json.encode, {}, "{}")
+ test(json.encode, {a=1});
+ test(json.encode, {a={1,2,3}});
+ test(json.encode, {1}, "[1]");
+ end);
+ end);
+
+ describe("#decode()", function()
+ it("should work", function()
+ local empty_array = json.decode("[]");
+ assert.are.equal(type(empty_array), "table");
+ assert.are.equal(#empty_array, 0);
+ assert.are.equal(next(empty_array), nil);
+ end);
+ end);
+
+ describe("testcases", function()
+
+ local valid_data = {};
+ local invalid_data = {};
+
+ local skip = "fail1.json fail9.json fail18.json fail15.json fail13.json fail25.json fail26.json fail27.json fail28.json fail17.json pass1.json";
+
+ setup(function()
+ local lfs = require "lfs";
+ local path = "spec/json";
+ for name in lfs.dir(path) do
+ if name:match("%.json$") then
+ local f = assert(io.open(path.."/"..name));
+ local content = assert(f:read("*a"));
+ assert(f:close());
+ if skip:find(name) then --luacheck: ignore 542
+ -- Skip
+ elseif name:match("^pass") then
+ valid_data[name] = content;
+ elseif name:match("^fail") then
+ invalid_data[name] = content;
+ end
+ end
+ end
+ end)
+
+ it("should pass valid testcases", function()
+ for name, content in pairs(valid_data) do
+ local parsed, err = json.decode(content);
+ assert(parsed, name..": "..tostring(err));
+ end
+ end);
+
+ it("should fail invalid testcases", function()
+ for name, content in pairs(invalid_data) do
+ local parsed, err = json.decode(content);
+ assert(not parsed, name..": "..tostring(err));
+ end
+ end);
+ end)
+end);
diff --git a/spec/util_multitable_spec.lua b/spec/util_multitable_spec.lua
new file mode 100644
index 00000000..40759f7a
--- /dev/null
+++ b/spec/util_multitable_spec.lua
@@ -0,0 +1,60 @@
+
+local multitable = require "util.multitable";
+
+describe("util.multitable", function()
+ describe("#new()", function()
+ it("should create a multitable", function()
+ local mt = multitable.new();
+ assert.is_table(mt, "Multitable is a table");
+ assert.is_function(mt.add, "Multitable has method add");
+ assert.is_function(mt.get, "Multitable has method get");
+ assert.is_function(mt.remove, "Multitable has method remove");
+ end);
+ end);
+
+ describe("#get()", function()
+ it("should allow getting correctly", function()
+ local function has_items(list, ...)
+ local should_have = {};
+ if select('#', ...) > 0 then
+ assert.is_table(list, "has_items: list is table", 3);
+ else
+ assert.is.falsy(list and #list > 0, "No items, and no list");
+ return true, "has-all";
+ end
+ for n=1,select('#', ...) do should_have[select(n, ...)] = true; end
+ for _, item in ipairs(list) do
+ if not should_have[item] then return false, "too-many"; end
+ should_have[item] = nil;
+ end
+ if next(should_have) then
+ return false, "not-enough";
+ end
+ return true, "has-all";
+ end
+ local function assert_has_all(message, list, ...)
+ return assert.are.equal(select(2, has_items(list, ...)), "has-all", message or "List has all expected items, and no more", 2);
+ end
+
+ local mt = multitable.new();
+
+ local trigger1, trigger2, trigger3 = {}, {}, {};
+ local item1, item2, item3 = {}, {}, {};
+
+ assert_has_all("Has no items with trigger1", mt:get(trigger1));
+
+
+ mt:add(1, 2, 3, item1);
+
+ assert_has_all("Has item1 for 1, 2, 3", mt:get(1, 2, 3), item1);
+ end);
+ end);
+
+ -- Doesn't support nil
+ --[[ mt:add(nil, item1);
+ mt:add(nil, item2);
+ mt:add(nil, item3);
+
+ assert_has_all("Has all items with (nil)", mt:get(nil), item1, item2, item3);
+ ]]
+end);
diff --git a/spec/util_poll_spec.lua b/spec/util_poll_spec.lua
new file mode 100644
index 00000000..a763be90
--- /dev/null
+++ b/spec/util_poll_spec.lua
@@ -0,0 +1,6 @@
+describe("util.poll", function ()
+ it("loads", function ()
+ require "util.poll"
+ end);
+end);
+
diff --git a/spec/util_promise_spec.lua b/spec/util_promise_spec.lua
new file mode 100644
index 00000000..65d252f6
--- /dev/null
+++ b/spec/util_promise_spec.lua
@@ -0,0 +1,497 @@
+local promise = require "util.promise";
+
+describe("util.promise", function ()
+ --luacheck: ignore 212/resolve 212/reject
+ describe("new()", function ()
+ it("returns a promise object", function ()
+ assert(promise.new());
+ end);
+ end);
+ it("notifies immediately for fulfilled promises", function ()
+ local p = promise.new(function (resolve)
+ resolve("foo");
+ end);
+ local cb = spy.new(function (v)
+ assert.equal("foo", v);
+ end);
+ p:next(cb);
+ assert.spy(cb).was_called(1);
+ end);
+ it("notifies on fulfilment of pending promises", function ()
+ local r;
+ local p = promise.new(function (resolve)
+ r = resolve;
+ end);
+ local cb = spy.new(function (v)
+ assert.equal("foo", v);
+ end);
+ p:next(cb);
+ assert.spy(cb).was_called(0);
+ r("foo");
+ assert.spy(cb).was_called(1);
+ end);
+ it("allows chaining :next() calls", function ()
+ local r;
+ local result;
+ local p = promise.new(function (resolve)
+ r = resolve;
+ end);
+ local cb1 = spy.new(function (v)
+ assert.equal("foo", v);
+ return "bar";
+ end);
+ local cb2 = spy.new(function (v)
+ assert.equal("bar", v);
+ result = v;
+ end);
+ p:next(cb1):next(cb2);
+ assert.spy(cb1).was_called(0);
+ assert.spy(cb2).was_called(0);
+ r("foo");
+ assert.spy(cb1).was_called(1);
+ assert.spy(cb2).was_called(1);
+ assert.equal("bar", result);
+ end);
+ it("supports multiple :next() calls on the same promise", function ()
+ local r;
+ local result;
+ local p = promise.new(function (resolve)
+ r = resolve;
+ end);
+ local cb1 = spy.new(function (v)
+ assert.equal("foo", v);
+ result = v;
+ end);
+ local cb2 = spy.new(function (v)
+ assert.equal("foo", v);
+ result = v;
+ end);
+ p:next(cb1);
+ p:next(cb2);
+ assert.spy(cb1).was_called(0);
+ assert.spy(cb2).was_called(0);
+ r("foo");
+ assert.spy(cb1).was_called(1);
+ assert.spy(cb2).was_called(1);
+ assert.equal("foo", result);
+ end);
+ it("automatically rejects on error", function ()
+ local r;
+ local p = promise.new(function (resolve)
+ r = resolve;
+ error("oh no");
+ end);
+ local cb = spy.new(function () end);
+ local err_cb = spy.new(function (v)
+ assert.equal("oh no", v);
+ end);
+ p:next(cb, err_cb);
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(1);
+ r("foo");
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(1);
+ end);
+ it("supports reject()", function ()
+ local r, result;
+ local p = promise.new(function (resolve, reject)
+ r = reject;
+ end);
+ local cb = spy.new(function () end);
+ local err_cb = spy.new(function (v)
+ result = v;
+ assert.equal("oh doh", v);
+ end);
+ p:next(cb, err_cb);
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(0);
+ r("oh doh");
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(1);
+ assert.equal("oh doh", result);
+ end);
+ it("supports chaining of rejected promises", function ()
+ local r, result;
+ local p = promise.new(function (resolve, reject)
+ r = reject;
+ end);
+ local cb = spy.new(function () end);
+ local err_cb = spy.new(function (v)
+ result = v;
+ assert.equal("oh doh", v);
+ return "ok"
+ end);
+ local cb2 = spy.new(function (v)
+ result = v;
+ end);
+ local err_cb2 = spy.new(function () end);
+ p:next(cb, err_cb):next(cb2, err_cb2)
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(0);
+ assert.spy(cb2).was_called(0);
+ assert.spy(err_cb2).was_called(0);
+ r("oh doh");
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(1);
+ assert.spy(cb2).was_called(1);
+ assert.spy(err_cb2).was_called(0);
+ assert.equal("ok", result);
+ end);
+
+ it("propagates errors down the chain, even when some handlers are not provided", function ()
+ local r, result;
+ local test_error = {};
+ local p = promise.new(function (resolve, reject)
+ r = reject;
+ end);
+ local cb = spy.new(function () end);
+ local err_cb = spy.new(function (e) result = e end);
+ local p2 = p:next(function () error(test_error) end);
+ local p3 = p2:next(cb)
+ p3:catch(err_cb);
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(0);
+ r("oh doh");
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(1);
+ assert.spy(err_cb).was_called_with("oh doh");
+ assert.equal("oh doh", result);
+ end);
+
+ it("propagates values down the chain, even when some handlers are not provided", function ()
+ local r;
+ local p = promise.new(function (resolve, reject)
+ r = resolve;
+ end);
+ local cb = spy.new(function () end);
+ local err_cb = spy.new(function () end);
+ local p2 = p:next(function (v) return v; end);
+ local p3 = p2:catch(err_cb)
+ p3:next(cb);
+ assert.spy(cb).was_called(0);
+ assert.spy(err_cb).was_called(0);
+ r(1337);
+ assert.spy(cb).was_called(1);
+ assert.spy(cb).was_called_with(1337);
+ assert.spy(err_cb).was_called(0);
+ end);
+
+ it("fulfilled promises do not call error handlers and do propagate value", function ()
+ local p = promise.resolve("foo");
+ local cb = spy.new(function () end);
+ local p2 = p:catch(cb);
+ assert.spy(cb).was_called(0);
+
+ local cb2 = spy.new(function () end);
+ p2:catch(cb2);
+ assert.spy(cb2).was_called(0);
+ end);
+
+ it("rejected promises do not call fulfilled handlers and do propagate reason", function ()
+ local p = promise.reject("foo");
+ local cb = spy.new(function () end);
+ local p2 = p:next(cb);
+ assert.spy(cb).was_called(0);
+
+ local cb2 = spy.new(function () end);
+ local cb2_err = spy.new(function () end);
+ p2:next(cb2, cb2_err);
+ assert.spy(cb2).was_called(0);
+ assert.spy(cb2_err).was_called(1);
+ assert.spy(cb2_err).was_called_with("foo");
+ end);
+
+ describe("allows callbacks to return", function ()
+ it("pending promises", function ()
+ local r;
+ local p = promise.resolve()
+ local cb = spy.new(function ()
+ return promise.new(function (resolve)
+ r = resolve;
+ end);
+ end);
+ local cb2 = spy.new(function () end);
+ p:next(cb):next(cb2);
+ assert.spy(cb).was_called(1);
+ assert.spy(cb2).was_called(0);
+ r("hello");
+ assert.spy(cb).was_called(1);
+ assert.spy(cb2).was_called(1);
+ assert.spy(cb2).was_called_with("hello");
+ end);
+
+ it("resolved promises", function ()
+ local p = promise.resolve()
+ local cb = spy.new(function ()
+ return promise.resolve("hello");
+ end);
+ local cb2 = spy.new(function () end);
+ p:next(cb):next(cb2);
+ assert.spy(cb).was_called(1);
+ assert.spy(cb2).was_called(1);
+ assert.spy(cb2).was_called_with("hello");
+ end);
+
+ it("rejected promises", function ()
+ local p = promise.resolve()
+ local cb = spy.new(function ()
+ return promise.reject("hello");
+ end);
+ local cb2 = spy.new(function ()
+ return promise.reject("goodbye");
+ end);
+ local cb3 = spy.new(function () end);
+ p:next(cb):catch(cb2):catch(cb3);
+ assert.spy(cb).was_called(1);
+ assert.spy(cb2).was_called(1);
+ assert.spy(cb2).was_called_with("hello");
+ assert.spy(cb3).was_called(1);
+ assert.spy(cb3).was_called_with("goodbye");
+ end);
+ end);
+
+ describe("race()", function ()
+ it("works with fulfilled promises", function ()
+ local p1, p2 = promise.resolve("yep"), promise.resolve("nope");
+ local p = promise.race({ p1, p2 });
+ local result;
+ p:next(function (v)
+ result = v;
+ end);
+ assert.equal("yep", result);
+ end);
+ it("works with pending promises", function ()
+ local r1, r2;
+ local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+ local p = promise.race({ p1, p2 });
+
+ local result;
+ local cb = spy.new(function (v)
+ result = v;
+ end);
+ p:next(cb);
+ assert.spy(cb).was_called(0);
+ r2("yep");
+ r1("nope");
+ assert.spy(cb).was_called(1);
+ assert.equal("yep", result);
+ end);
+ end);
+ describe("all()", function ()
+ it("works with fulfilled promises", function ()
+ local p1, p2 = promise.resolve("yep"), promise.resolve("nope");
+ local p = promise.all({ p1, p2 });
+ local result;
+ p:next(function (v)
+ result = v;
+ end);
+ assert.same({ "yep", "nope" }, result);
+ end);
+ it("works with pending promises", function ()
+ local r1, r2;
+ local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+ local p = promise.all({ p1, p2 });
+
+ local result;
+ local cb = spy.new(function (v)
+ result = v;
+ end);
+ p:next(cb);
+ assert.spy(cb).was_called(0);
+ r2("yep");
+ assert.spy(cb).was_called(0);
+ r1("nope");
+ assert.spy(cb).was_called(1);
+ assert.same({ "nope", "yep" }, result);
+ end);
+ it("rejects if any promise rejects", function ()
+ local r1, r2;
+ local p1 = promise.new(function (resolve, reject) r1 = reject end);
+ local p2 = promise.new(function (resolve, reject) r2 = reject end);
+ local p = promise.all({ p1, p2 });
+
+ local result;
+ local cb = spy.new(function (v)
+ result = v;
+ end);
+ local cb_err = spy.new(function (v)
+ result = v;
+ end);
+ p:next(cb, cb_err);
+ assert.spy(cb).was_called(0);
+ assert.spy(cb_err).was_called(0);
+ r2("fail");
+ assert.spy(cb).was_called(0);
+ assert.spy(cb_err).was_called(1);
+ r1("nope");
+ assert.spy(cb).was_called(0);
+ assert.spy(cb_err).was_called(1);
+ assert.equal("fail", result);
+ end);
+ end);
+ describe("catch()", function ()
+ it("works", function ()
+ local result;
+ local p = promise.new(function (resolve)
+ error({ foo = true });
+ end);
+ local cb1 = spy.new(function (v)
+ result = v;
+ end);
+ assert.spy(cb1).was_called(0);
+ p:catch(cb1);
+ assert.spy(cb1).was_called(1);
+ assert.same({ foo = true }, result);
+ end);
+ end);
+ it("promises may be resolved by other promises", function ()
+ local r1, r2;
+ local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+
+ local result;
+ local cb = spy.new(function (v)
+ result = v;
+ end);
+ p1:next(cb);
+ assert.spy(cb).was_called(0);
+
+ r1(p2);
+ assert.spy(cb).was_called(0);
+ r2("yep");
+ assert.spy(cb).was_called(1);
+ assert.equal("yep", result);
+ end);
+ describe("reject()", function ()
+ it("returns a rejected promise", function ()
+ local p = promise.reject("foo");
+ local cb = spy.new(function () end);
+ p:catch(cb);
+ assert.spy(cb).was_called(1);
+ assert.spy(cb).was_called_with("foo");
+ end);
+ it("returns a rejected promise and does not call on_fulfilled", function ()
+ local p = promise.reject("foo");
+ local cb = spy.new(function () end);
+ p:next(cb);
+ assert.spy(cb).was_called(0);
+ end);
+ end);
+ describe("finally()", function ()
+ local p, p2, resolve, reject, on_finally;
+ before_each(function ()
+ p = promise.new(function (_resolve, _reject)
+ resolve, reject = _resolve, _reject;
+ end);
+ on_finally = spy.new(function () end);
+ p2 = p:finally(on_finally);
+ end);
+ it("runs when a promise is resolved", function ()
+ assert.spy(on_finally).was_called(0);
+ resolve("foo");
+ assert.spy(on_finally).was_called(1);
+ assert.spy(on_finally).was_not_called_with("foo");
+ end);
+ it("runs when a promise is rejected", function ()
+ assert.spy(on_finally).was_called(0);
+ reject("foo");
+ assert.spy(on_finally).was_called(1);
+ assert.spy(on_finally).was_not_called_with("foo");
+ end);
+ it("returns a promise that fulfills with the original value", function ()
+ local cb2 = spy.new(function () end);
+ p2:next(cb2);
+ assert.spy(on_finally).was_called(0);
+ assert.spy(cb2).was_called(0);
+ resolve("foo");
+ assert.spy(on_finally).was_called(1);
+ assert.spy(cb2).was_called(1);
+ assert.spy(on_finally).was_not_called_with("foo");
+ assert.spy(cb2).was_called_with("foo");
+ end);
+ it("returns a promise that rejects with the original error", function ()
+ local on_finally_err = spy.new(function () end);
+ local on_finally_ok = spy.new(function () end);
+ p2:catch(on_finally_err);
+ p2:next(on_finally_ok);
+ assert.spy(on_finally).was_called(0);
+ assert.spy(on_finally_err).was_called(0);
+ reject("foo");
+ assert.spy(on_finally).was_called(1);
+ -- Since the original promise was rejected, the finally promise should also be
+ assert.spy(on_finally_ok).was_called(0);
+ assert.spy(on_finally_err).was_called(1);
+ assert.spy(on_finally).was_not_called_with("foo");
+ assert.spy(on_finally_err).was_called_with("foo");
+ end);
+ it("returns a promise that rejects with an uncaught error inside on_finally", function ()
+ p = promise.new(function (_resolve, _reject)
+ resolve, reject = _resolve, _reject;
+ end);
+ local test_error = {};
+ on_finally = spy.new(function () error(test_error) end);
+ p2 = p:finally(on_finally);
+
+ local on_finally_err = spy.new(function () end);
+ p2:catch(on_finally_err);
+ assert.spy(on_finally).was_called(0);
+ assert.spy(on_finally_err).was_called(0);
+ reject("foo");
+ assert.spy(on_finally).was_called(1);
+ assert.spy(on_finally_err).was_called(1);
+ assert.spy(on_finally).was_not_called_with("foo");
+ assert.spy(on_finally).was_not_called_with(test_error);
+ assert.spy(on_finally_err).was_called_with(test_error);
+ end);
+ end);
+ describe("try()", function ()
+ it("works with functions that return a promise", function ()
+ local resolve;
+ local p = promise.try(function ()
+ return promise.new(function (_resolve)
+ resolve = _resolve;
+ end);
+ end);
+ assert.is_function(resolve);
+ local on_resolved = spy.new(function () end);
+ p:next(on_resolved);
+ assert.spy(on_resolved).was_not_called();
+ resolve("foo");
+ assert.spy(on_resolved).was_called_with("foo");
+ end);
+
+ it("works with functions that return a value", function ()
+ local p = promise.try(function ()
+ return "foo";
+ end);
+ local on_resolved = spy.new(function () end);
+ p:next(on_resolved);
+ assert.spy(on_resolved).was_called_with("foo");
+ end);
+
+ it("works with functions that return a promise that rejects", function ()
+ local reject;
+ local p = promise.try(function ()
+ return promise.new(function (_, _reject)
+ reject = _reject;
+ end);
+ end);
+ assert.is_function(reject);
+ local on_rejected = spy.new(function () end);
+ p:catch(on_rejected);
+ assert.spy(on_rejected).was_not_called();
+ reject("foo");
+ assert.spy(on_rejected).was_called_with("foo");
+ end);
+
+ it("works with functions that throw errors", function ()
+ local test_error = {};
+ local p = promise.try(function ()
+ error(test_error);
+ end);
+ local on_rejected = spy.new(function () end);
+ p:catch(on_rejected);
+ assert.spy(on_rejected).was_called(1);
+ assert.spy(on_rejected).was_called_with(test_error);
+ end);
+ end);
+end);
diff --git a/spec/util_pubsub_spec.lua b/spec/util_pubsub_spec.lua
new file mode 100644
index 00000000..6386100b
--- /dev/null
+++ b/spec/util_pubsub_spec.lua
@@ -0,0 +1,408 @@
+local pubsub;
+setup(function ()
+ pubsub = require "util.pubsub";
+end);
+
+--[[TODO:
+ Retract
+ Purge
+ auto-create/auto-subscribe
+ Item store/node store
+ resize on max_items change
+ service creation config provides alternative node_defaults
+ get subscriptions
+]]
+
+describe("util.pubsub", function ()
+ describe("simple node creation and deletion", function ()
+ randomize(false); -- These tests are ordered
+
+ -- Roughly a port of scansion/scripts/pubsub_createdelete.scs
+ local service = pubsub.new();
+
+ describe("#create", function ()
+ randomize(false); -- These tests are ordered
+ it("creates a new node", function ()
+ assert.truthy(service:create("princely_musings", true));
+ end);
+
+ it("fails to create the same node again", function ()
+ assert.falsy(service:create("princely_musings", true));
+ end);
+ end);
+
+ describe("#delete", function ()
+ randomize(false); -- These tests are ordered
+ it("deletes the node", function ()
+ assert.truthy(service:delete("princely_musings", true));
+ end);
+
+ it("can't delete an already deleted node", function ()
+ assert.falsy(service:delete("princely_musings", true));
+ end);
+ end);
+ end);
+
+ describe("simple publishing", function ()
+ randomize(false); -- These tests are ordered
+
+ local notified;
+ local broadcaster = spy.new(function (notif_type, node_name, subscribers, item) -- luacheck: ignore 212
+ notified = subscribers;
+ end);
+ local service = pubsub.new({
+ broadcaster = broadcaster;
+ });
+
+ it("creates a node", function ()
+ assert.truthy(service:create("node", true));
+ end);
+
+ it("lets someone subscribe", function ()
+ assert.truthy(service:add_subscription("node", true, "someone"));
+ end);
+
+ it("publishes an item", function ()
+ assert.truthy(service:publish("node", true, "1", "item 1"));
+ assert.truthy(notified["someone"]);
+ end);
+
+ it("called the broadcaster", function ()
+ assert.spy(broadcaster).was_called();
+ end);
+
+ it("should return one item", function ()
+ local ok, ret = service:get_items("node", true);
+ assert.truthy(ok);
+ assert.same({ "1", ["1"] = "item 1" }, ret);
+ end);
+
+ it("lets someone unsubscribe", function ()
+ assert.truthy(service:remove_subscription("node", true, "someone"));
+ end);
+
+ it("does not send notifications after subscription is removed", function ()
+ assert.truthy(service:publish("node", true, "1", "item 1"));
+ assert.is_nil(notified["someone"]);
+ end);
+ end);
+
+ describe("publish with config", function ()
+ randomize(false); -- These tests are ordered
+
+ local broadcaster = spy.new(function (notif_type, node_name, subscribers, item) -- luacheck: ignore 212
+ end);
+ local service = pubsub.new({
+ broadcaster = broadcaster;
+ autocreate_on_publish = true;
+ });
+
+ it("automatically creates node with requested config", function ()
+ assert(service:publish("node", true, "1", "item 1", { myoption = true }));
+
+ local ok, config = assert(service:get_node_config("node", true));
+ assert.equals(true, config.myoption);
+ end);
+
+ it("fails to publish to a node with differing config", function ()
+ local ok, err = service:publish("node", true, "1", "item 2", { myoption = false });
+ assert.falsy(ok);
+ assert.equals("precondition-not-met", err);
+ end);
+
+ it("allows to publish to a node with differing config when only defaults are suggested", function ()
+ assert(service:publish("node", true, "1", "item 2", { _defaults_only = true, myoption = false }));
+ end);
+ end);
+
+ describe("#issue1082", function ()
+ randomize(false); -- These tests are ordered
+
+ local service = pubsub.new();
+
+ it("creates a node with max_items = 1", function ()
+ assert.truthy(service:create("node", true, { max_items = 1 }));
+ end);
+
+ it("changes max_items to 2", function ()
+ assert.truthy(service:set_node_config("node", true, { max_items = 2 }));
+ end);
+
+ it("publishes one item", function ()
+ assert.truthy(service:publish("node", true, "1", "item 1"));
+ end);
+
+ it("should return one item", function ()
+ local ok, ret = service:get_items("node", true);
+ assert.truthy(ok);
+ assert.same({ "1", ["1"] = "item 1" }, ret);
+ end);
+
+ it("publishes another item", function ()
+ assert.truthy(service:publish("node", true, "2", "item 2"));
+ end);
+
+ it("should return two items", function ()
+ local ok, ret = service:get_items("node", true);
+ assert.truthy(ok);
+ assert.same({
+ "2",
+ "1",
+ ["1"] = "item 1",
+ ["2"] = "item 2",
+ }, ret);
+ end);
+
+ it("publishes yet another item", function ()
+ assert.truthy(service:publish("node", true, "3", "item 3"));
+ end);
+
+ it("should still return only two items", function ()
+ local ok, ret = service:get_items("node", true);
+ assert.truthy(ok);
+ assert.same({
+ "3",
+ "2",
+ ["2"] = "item 2",
+ ["3"] = "item 3",
+ }, ret);
+ end);
+
+ end);
+
+ describe("node config", function ()
+ local service;
+ before_each(function ()
+ service = pubsub.new();
+ service:create("test", true);
+ end);
+ it("access is forbidden for unaffiliated entities", function ()
+ local ok, err = service:get_node_config("test", "stranger");
+ assert.is_falsy(ok);
+ assert.equals("forbidden", err);
+ end);
+ it("returns an error for nodes that do not exist", function ()
+ local ok, err = service:get_node_config("nonexistent", true);
+ assert.is_falsy(ok);
+ assert.equals("item-not-found", err);
+ end);
+ end);
+
+ describe("access model", function ()
+ describe("open", function ()
+ local service;
+ before_each(function ()
+ service = pubsub.new();
+ -- Do not supply any config, 'open' should be default
+ service:create("test", true);
+ end);
+ it("should be the default", function ()
+ local ok, config = service:get_node_config("test", true);
+ assert.equal("open", config.access_model);
+ end);
+ it("should allow anyone to subscribe", function ()
+ local ok = service:add_subscription("test", "stranger", "stranger");
+ assert.is_true(ok);
+ end);
+ it("should still reject outcast-affiliated entities", function ()
+ assert(service:set_affiliation("test", true, "enemy", "outcast"));
+ local ok, err = service:add_subscription("test", "enemy", "enemy");
+ assert.is_falsy(ok);
+ assert.equal("forbidden", err);
+ end);
+ end);
+ describe("whitelist", function ()
+ local service;
+ before_each(function ()
+ service = assert(pubsub.new());
+ assert.is_true(service:create("test", true, { access_model = "whitelist" }));
+ end);
+ it("should be present in the configuration", function ()
+ local ok, config = service:get_node_config("test", true);
+ assert.equal("whitelist", config.access_model);
+ end);
+ it("should not allow anyone to subscribe", function ()
+ local ok, err = service:add_subscription("test", "stranger", "stranger");
+ assert.is_false(ok);
+ assert.equals("forbidden", err);
+ end);
+ end);
+ describe("change", function ()
+ local service;
+ before_each(function ()
+ service = pubsub.new();
+ service:create("test", true, { access_model = "open" });
+ end);
+ it("affects existing subscriptions", function ()
+ do
+ local ok = service:add_subscription("test", "stranger", "stranger");
+ assert.is_true(ok);
+ end
+ do
+ local ok, sub = service:get_subscription("test", "stranger", "stranger");
+ assert.is_true(ok);
+ assert.is_true(sub);
+ end
+ assert(service:set_node_config("test", true, { access_model = "whitelist" }));
+ do
+ local ok, sub = service:get_subscription("test", "stranger", "stranger");
+ assert.is_true(ok);
+ assert.is_nil(sub);
+ end
+ end);
+ end);
+ end);
+
+ describe("publish model", function ()
+ describe("publishers", function ()
+ local service;
+ before_each(function ()
+ service = pubsub.new();
+ -- Do not supply any config, 'publishers' should be default
+ service:create("test", true);
+ end);
+ it("should be the default", function ()
+ local ok, config = service:get_node_config("test", true);
+ assert.equal("publishers", config.publish_model);
+ end);
+ it("should not allow anyone to publish", function ()
+ assert.is_true(service:add_subscription("test", "stranger", "stranger"));
+ local ok, err = service:publish("test", "stranger", "item1", "foo");
+ assert.is_falsy(ok);
+ assert.equals("forbidden", err);
+ end);
+ it("should allow publishers to publish", function ()
+ assert(service:set_affiliation("test", true, "mypublisher", "publisher"));
+ local ok, err = service:publish("test", "mypublisher", "item1", "foo");
+ assert.is_true(ok);
+ end);
+ it("should allow owners to publish", function ()
+ assert(service:set_affiliation("test", true, "myowner", "owner"));
+ local ok = service:publish("test", "myowner", "item1", "foo");
+ assert.is_true(ok);
+ end);
+ end);
+ describe("open", function ()
+ local service;
+ before_each(function ()
+ service = pubsub.new();
+ service:create("test", true, { publish_model = "open" });
+ end);
+ it("should allow anyone to publish", function ()
+ local ok = service:publish("test", "stranger", "item1", "foo");
+ assert.is_true(ok);
+ end);
+ end);
+ describe("subscribers", function ()
+ local service;
+ before_each(function ()
+ service = pubsub.new();
+ service:create("test", true, { publish_model = "subscribers" });
+ end);
+ it("should not allow non-subscribers to publish", function ()
+ local ok, err = service:publish("test", "stranger", "item1", "foo");
+ assert.is_falsy(ok);
+ assert.equals("forbidden", err);
+ end);
+ it("should allow subscribers to publish without an affiliation", function ()
+ assert.is_true(service:add_subscription("test", "stranger", "stranger"));
+ local ok = service:publish("test", "stranger", "item1", "foo");
+ assert.is_true(ok);
+ end);
+ it("should allow publishers to publish without a subscription", function ()
+ assert(service:set_affiliation("test", true, "mypublisher", "publisher"));
+ local ok, err = service:publish("test", "mypublisher", "item1", "foo");
+ assert.is_true(ok);
+ end);
+ it("should allow owners to publish without a subscription", function ()
+ assert(service:set_affiliation("test", true, "myowner", "owner"));
+ local ok = service:publish("test", "myowner", "item1", "foo");
+ assert.is_true(ok);
+ end);
+ end);
+ end);
+
+ describe("item API", function ()
+ local service;
+ before_each(function ()
+ service = pubsub.new();
+ service:create("test", true, { publish_model = "subscribers" });
+ end);
+ describe("get_last_item()", function ()
+ it("succeeds with nil on empty nodes", function ()
+ local ok, id, item = service:get_last_item("test", true);
+ assert.is_true(ok);
+ assert.is_nil(id);
+ assert.is_nil(item);
+ end);
+ it("succeeds and returns the last item", function ()
+ service:publish("test", true, "one", "hello world");
+ service:publish("test", true, "two", "hello again");
+ service:publish("test", true, "three", "hey");
+ service:publish("test", true, "one", "bye");
+ local ok, id, item = service:get_last_item("test", true);
+ assert.is_true(ok);
+ assert.equal("one", id);
+ assert.equal("bye", item);
+ end);
+ end);
+ describe("get_items()", function ()
+ it("fails on non-existent nodes", function ()
+ local ok, err = service:get_items("no-node", true);
+ assert.is_falsy(ok);
+ assert.equal("item-not-found", err);
+ end);
+ it("returns no items on an empty node", function ()
+ local ok, items = service:get_items("test", true);
+ assert.is_true(ok);
+ assert.equal(0, #items);
+ assert.is_nil(next(items));
+ end);
+ it("returns no items on an empty node", function ()
+ local ok, items = service:get_items("test", true);
+ assert.is_true(ok);
+ assert.equal(0, #items);
+ assert.is_nil((next(items)));
+ end);
+ it("returns all published items", function ()
+ service:publish("test", true, "one", "hello world");
+ service:publish("test", true, "two", "hello again");
+ service:publish("test", true, "three", "hey");
+ service:publish("test", true, "one", "bye");
+ local ok, items = service:get_items("test", true);
+ assert.is_true(ok);
+ assert.same({ "one", "three", "two", two = "hello again", three = "hey", one = "bye" }, items);
+ end);
+ end);
+ end);
+
+ describe("restoring data from nodestore", function ()
+ local nodestore = {
+ data = {
+ test = {
+ name = "test";
+ config = {};
+ affiliations = {};
+ subscribers = {
+ ["someone"] = true;
+ };
+ }
+ }
+ };
+ function nodestore:users()
+ return pairs(self.data)
+ end
+ function nodestore:get(key)
+ return self.data[key];
+ end
+ local service = pubsub.new({
+ nodestore = nodestore;
+ });
+ it("subscriptions", function ()
+ local ok, ret = service:get_subscriptions(nil, true, nil)
+ assert.is_true(ok);
+ assert.same({ { node = "test", jid = "someone", subscription = true, } }, ret);
+ end);
+ end);
+
+end);
diff --git a/spec/util_queue_spec.lua b/spec/util_queue_spec.lua
new file mode 100644
index 00000000..7cd3d695
--- /dev/null
+++ b/spec/util_queue_spec.lua
@@ -0,0 +1,103 @@
+
+local queue = require "util.queue";
+
+describe("util.queue", function()
+ describe("#new()", function()
+ it("should work", function()
+
+ do
+ local q = queue.new(10);
+
+ assert.are.equal(q.size, 10);
+ assert.are.equal(q:count(), 0);
+
+ assert.is_true(q:push("one"));
+ assert.is_true(q:push("two"));
+ assert.is_true(q:push("three"));
+
+ for i = 4, 10 do
+ assert.is_true(q:push("hello"));
+ assert.are.equal(q:count(), i, "count is not "..i.."("..q:count()..")");
+ end
+ assert.are.equal(q:push("hello"), nil, "queue overfull!");
+ assert.are.equal(q:push("hello"), nil, "queue overfull!");
+ assert.are.equal(q:pop(), "one", "queue item incorrect");
+ assert.are.equal(q:pop(), "two", "queue item incorrect");
+ assert.is_true(q:push("hello"));
+ assert.is_true(q:push("hello"));
+ assert.are.equal(q:pop(), "three", "queue item incorrect");
+ assert.is_true(q:push("hello"));
+ assert.are.equal(q:push("hello"), nil, "queue overfull!");
+ assert.are.equal(q:push("hello"), nil, "queue overfull!");
+
+ assert.are.equal(q:count(), 10, "queue count incorrect");
+
+ for _ = 1, 10 do
+ assert.are.equal(q:pop(), "hello", "queue item incorrect");
+ end
+
+ assert.are.equal(q:count(), 0, "queue count incorrect");
+ assert.are.equal(q:pop(), nil, "empty queue pops non-nil result");
+ assert.are.equal(q:count(), 0, "popping empty queue affects count");
+
+ assert.are.equal(q:peek(), nil, "empty queue peeks non-nil result");
+ assert.are.equal(q:count(), 0, "peeking empty queue affects count");
+
+ assert.is_true(q:push(1));
+ for i = 1, 1001 do
+ assert.are.equal(q:pop(), i);
+ assert.are.equal(q:count(), 0);
+ assert.is_true(q:push(i+1));
+ assert.are.equal(q:count(), 1);
+ end
+ assert.are.equal(q:pop(), 1002);
+ assert.is_true(q:push(1));
+ for i = 1, 1000 do
+ assert.are.equal(q:pop(), i);
+ assert.is_true(q:push(i+1));
+ end
+ assert.are.equal(q:pop(), 1001);
+ assert.are.equal(q:count(), 0);
+ end
+
+ do
+ -- Test queues that purge old items when pushing to a full queue
+ local q = queue.new(10, true);
+
+ for i = 1, 10 do
+ q:push(i);
+ end
+
+ assert.are.equal(q:count(), 10);
+
+ assert.is_true(q:push(11));
+ assert.are.equal(q:count(), 10);
+ assert.are.equal(q:pop(), 2); -- First item should have been purged
+ assert.are.equal(q:peek(), 3);
+
+ for i = 12, 32 do
+ assert.is_true(q:push(i));
+ end
+
+ assert.are.equal(q:count(), 10);
+ assert.are.equal(q:pop(), 23);
+ end
+
+ do
+ -- Test iterator
+ local q = queue.new(10, true);
+
+ for i = 1, 10 do
+ q:push(i);
+ end
+
+ local i = 0;
+ for item in q:items() do
+ i = i + 1;
+ assert.are.equal(item, i, "unexpected item returned by iterator")
+ end
+ end
+
+ end);
+ end);
+end);
diff --git a/spec/util_random_spec.lua b/spec/util_random_spec.lua
new file mode 100644
index 00000000..c080a2c9
--- /dev/null
+++ b/spec/util_random_spec.lua
@@ -0,0 +1,19 @@
+
+local random = require "util.random";
+
+describe("util.random", function()
+ describe("#bytes()", function()
+ it("should return a string", function()
+ assert.is_string(random.bytes(16));
+ end);
+
+ it("should return the requested number of bytes", function()
+ -- Makes no attempt at testing how random the bytes are,
+ -- just that it returns the number of bytes requested
+
+ for i = 1, 20 do
+ assert.are.equal(2^i, #random.bytes(2^i));
+ end
+ end);
+ end);
+end);
diff --git a/spec/util_rfc6724_spec.lua b/spec/util_rfc6724_spec.lua
new file mode 100644
index 00000000..30e935b6
--- /dev/null
+++ b/spec/util_rfc6724_spec.lua
@@ -0,0 +1,97 @@
+
+local rfc6724 = require "util.rfc6724";
+local new_ip = require"util.ip".new_ip;
+
+describe("util.rfc6724", function()
+ describe("#source()", function()
+ it("should work", function()
+ assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+ {new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
+ "2001:db8:3::1",
+ "prefer appropriate scope");
+ assert.are.equal(rfc6724.source(new_ip("ff05::1", "IPv6"),
+ {new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
+ "2001:db8:3::1",
+ "prefer appropriate scope");
+ assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+ {new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:2::1", "IPv6")}).addr,
+ "2001:db8:1::1",
+ "prefer same address"); -- "2001:db8:1::1" should be marked "deprecated" here, we don't handle that right now
+ assert.are.equal(rfc6724.source(new_ip("fe80::1", "IPv6"),
+ {new_ip("fe80::2", "IPv6"), new_ip("2001:db8:1::1", "IPv6")}).addr,
+ "fe80::2",
+ "prefer appropriate scope"); -- "fe80::2" should be marked "deprecated" here, we don't handle that right now
+ assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
+ "2001:db8:1::2",
+ "longest matching prefix");
+ --[[ "2001:db8:1::2" should be a care-of address and "2001:db8:3::2" a home address, we can't handle this and would fail
+ assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
+ "2001:db8:3::2",
+ "prefer home address");
+ ]]
+ assert.are.equal(rfc6724.source(new_ip("2002:c633:6401::1", "IPv6"),
+ {new_ip("2002:c633:6401::d5e3:7953:13eb:22e8", "IPv6"), new_ip("2001:db8:1::2", "IPv6")}).addr,
+ "2002:c633:6401::d5e3:7953:13eb:22e8",
+ "prefer matching label"); -- "2002:c633:6401::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
+ assert.are.equal(rfc6724.source(new_ip("2001:db8:1::d5e3:0:0:1", "IPv6"),
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:1::d5e3:7953:13eb:22e8", "IPv6")}).addr,
+ "2001:db8:1::d5e3:7953:13eb:22e8",
+ "prefer temporary address") -- "2001:db8:1::2" should be marked "public" and "2001:db8:1::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
+ end);
+ end);
+ describe("#destination()", function()
+ it("should work", function()
+ local order;
+ order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("169.254.13.78", "IPv4")})
+ assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer matching scope");
+ assert.are.equal(order[2].addr, "198.51.100.121", "prefer matching scope");
+
+ order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
+ {new_ip("fe80::1", "IPv6"), new_ip("198.51.100.117", "IPv4")})
+ assert.are.equal(order[1].addr, "198.51.100.121", "prefer matching scope");
+ assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer matching scope");
+
+ order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("10.1.2.3", "IPv4")},
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("10.1.2.4", "IPv4")})
+ assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
+ assert.are.equal(order[2].addr, "10.1.2.3", "prefer higher precedence");
+
+ order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+ assert.are.equal(order[1].addr, "fe80::1", "prefer smaller scope");
+ assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer smaller scope");
+
+ --[[ "2001:db8:1::2" and "fe80::2" should be marked "care-of address", while "2001:db8:3::1" should be marked "home address", we can't currently handle this and would fail the test
+ order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::2", "IPv6")})
+ assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer home address");
+ assert.are.equal(order[2].addr, "fe80::1", "prefer home address");
+ ]]
+
+ --[[ "fe80::2" should be marked "deprecated", we can't currently handle this and would fail the test
+ order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+ assert.are.equal(order[1].addr, "2001:db8:1::1", "avoid deprecated addresses");
+ assert.are.equal(order[2].addr, "fe80::1", "avoid deprecated addresses");
+ ]]
+
+ order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:3ffe::1", "IPv6")},
+ {new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3f44::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+ assert.are.equal(order[1].addr, "2001:db8:1::1", "longest matching prefix");
+ assert.are.equal(order[2].addr, "2001:db8:3ffe::1", "longest matching prefix");
+
+ order = rfc6724.destination({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
+ {new_ip("2002:c633:6401::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+ assert.are.equal(order[1].addr, "2002:c633:6401::1", "prefer matching label");
+ assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer matching label");
+
+ order = rfc6724.destination({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
+ {new_ip("2002:c633:6401::2", "IPv6"), new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+ assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
+ assert.are.equal(order[2].addr, "2002:c633:6401::1", "prefer higher precedence");
+ end);
+ end);
+end);
diff --git a/spec/util_serialization_spec.lua b/spec/util_serialization_spec.lua
new file mode 100644
index 00000000..2af5d803
--- /dev/null
+++ b/spec/util_serialization_spec.lua
@@ -0,0 +1,79 @@
+local serialization = require "util.serialization";
+
+describe("util.serialization", function ()
+ describe("serialize", function ()
+ it("makes a string", function ()
+ assert.is_string(serialization.serialize({}));
+ assert.is_string(serialization.serialize(nil));
+ assert.is_string(serialization.serialize(1));
+ assert.is_string(serialization.serialize(true));
+ end);
+
+ it("rejects function by default", function ()
+ assert.has_error(function ()
+ serialization.serialize(function () end)
+ end);
+ end);
+
+ it("makes a string in debug mode", function ()
+ assert.is_string(serialization.serialize(function () end, "debug"));
+ end);
+
+ it("rejects cycles", function ()
+ assert.has_error(function ()
+ local t = {}
+ t[t] = { t };
+ serialization.serialize(t)
+ end);
+ -- also with multirefs allowed
+ assert.has_error(function ()
+ local t = {}
+ t[t] = { t };
+ serialization.serialize(t, { multirefs = true })
+ end);
+ end);
+
+ it("rejects multiple references to same table", function ()
+ assert.has_error(function ()
+ local t1 = {};
+ local t2 = { t1, t1 };
+ serialization.serialize(t2, { multirefs = false });
+ end);
+ end);
+
+ it("optionally allows multiple references to same table", function ()
+ assert.has_error(function ()
+ local t1 = {};
+ local t2 = { t1, t1 };
+ serialization.serialize(t2, { multirefs = true });
+ end);
+ end);
+
+ it("roundtrips", function ()
+ local function test(data)
+ local serialized = serialization.serialize(data);
+ assert.is_string(serialized);
+ local deserialized, err = serialization.deserialize(serialized);
+ assert.same(data, deserialized, err);
+ end
+
+ test({});
+ test({hello="world"});
+ test("foobar")
+ test("\0\1\2\3");
+ test("nödåtgärd");
+ test({1,2,3,4});
+ test({foo={[100]={{"bar"},{baz=1}}}});
+ test({["goto"] = {["function"]={["do"]="keywords"}}});
+ end);
+
+ it("can serialize with metatables", function ()
+ local s = serialization.new({ freeze = true });
+ local t = setmetatable({ a = "hi" }, { __freeze = function (t) return { t.a } end });
+ local rt = serialization.deserialize(s(t));
+ assert.same({"hi"}, rt);
+ end);
+
+ end);
+end);
+
diff --git a/spec/util_stanza_spec.lua b/spec/util_stanza_spec.lua
new file mode 100644
index 00000000..6fbae41a
--- /dev/null
+++ b/spec/util_stanza_spec.lua
@@ -0,0 +1,373 @@
+
+local st = require "util.stanza";
+
+describe("util.stanza", function()
+ describe("#preserialize()", function()
+ it("should work", function()
+ local stanza = st.stanza("message", { type = "chat" }):text_tag("body", "Hello");
+ local stanza2 = st.preserialize(stanza);
+ assert.is_table(stanza2, "Preserialized stanza is a table");
+ assert.is_nil(getmetatable(stanza2), "Preserialized stanza has no metatable");
+ assert.is_string(stanza2.name, "Preserialized stanza has a name field");
+ assert.equal(stanza.name, stanza2.name, "Preserialized stanza has same name as the input stanza");
+ assert.same(stanza.attr, stanza2.attr, "Preserialized stanza same attr table as input stanza");
+ assert.is_nil(stanza2.tags, "Preserialized stanza has no tag list");
+ assert.is_nil(stanza2.last_add, "Preserialized stanza has no last_add marker");
+ assert.is_table(stanza2[1], "Preserialized child element preserved");
+ assert.equal("body", stanza2[1].name, "Preserialized child element name preserved");
+ end);
+ end);
+
+ describe("#deserialize()", function()
+ it("should work", function()
+ local stanza = { name = "message", attr = { type = "chat" }, { name = "body", attr = { }, "Hello" } };
+ local stanza2 = st.deserialize(st.preserialize(stanza));
+
+ assert.is_table(stanza2, "Deserialized stanza is a table");
+ assert.equal(st.stanza_mt, getmetatable(stanza2), "Deserialized stanza has stanza metatable");
+ assert.is_string(stanza2.name, "Deserialized stanza has a name field");
+ assert.equal(stanza.name, stanza2.name, "Deserialized stanza has same name as the input table");
+ assert.same(stanza.attr, stanza2.attr, "Deserialized stanza same attr table as input table");
+ assert.is_table(stanza2.tags, "Deserialized stanza has tag list");
+ assert.is_table(stanza2[1], "Deserialized child element preserved");
+ assert.equal("body", stanza2[1].name, "Deserialized child element name preserved");
+ end);
+ end);
+
+ describe("#stanza()", function()
+ it("should work", function()
+ local s = st.stanza("foo", { xmlns = "myxmlns", a = "attr-a" });
+ assert.are.equal(s.name, "foo");
+ assert.are.equal(s.attr.xmlns, "myxmlns");
+ assert.are.equal(s.attr.a, "attr-a");
+
+ local s1 = st.stanza("s1");
+ assert.are.equal(s1.name, "s1");
+ assert.are.equal(s1.attr.xmlns, nil);
+ assert.are.equal(#s1, 0);
+ assert.are.equal(#s1.tags, 0);
+
+ s1:tag("child1");
+ assert.are.equal(#s1.tags, 1);
+ assert.are.equal(s1.tags[1].name, "child1");
+
+ s1:tag("grandchild1"):up();
+ assert.are.equal(#s1.tags, 1);
+ assert.are.equal(s1.tags[1].name, "child1");
+ assert.are.equal(#s1.tags[1], 1);
+ assert.are.equal(s1.tags[1][1].name, "grandchild1");
+
+ s1:up():tag("child2");
+ assert.are.equal(#s1.tags, 2, tostring(s1));
+ assert.are.equal(s1.tags[1].name, "child1");
+ assert.are.equal(s1.tags[2].name, "child2");
+ assert.are.equal(#s1.tags[1], 1);
+ assert.are.equal(s1.tags[1][1].name, "grandchild1");
+
+ s1:up():text("Hello world");
+ assert.are.equal(#s1.tags, 2);
+ assert.are.equal(#s1, 3);
+ assert.are.equal(s1.tags[1].name, "child1");
+ assert.are.equal(s1.tags[2].name, "child2");
+ assert.are.equal(#s1.tags[1], 1);
+ assert.are.equal(s1.tags[1][1].name, "grandchild1");
+ end);
+ it("should work with unicode values", function ()
+ local s = st.stanza("Объект", { xmlns = "myxmlns", ["Объект"] = "&" });
+ assert.are.equal(s.name, "Объект");
+ assert.are.equal(s.attr.xmlns, "myxmlns");
+ assert.are.equal(s.attr["Объект"], "&");
+ end);
+ it("should allow :text() with nil and empty strings", function ()
+ local s_control = st.stanza("foo");
+ assert.same(st.stanza("foo"):text(), s_control);
+ assert.same(st.stanza("foo"):text(nil), s_control);
+ assert.same(st.stanza("foo"):text(""), s_control);
+ end);
+ end);
+
+ describe("#message()", function()
+ it("should work", function()
+ local m = st.message();
+ assert.are.equal(m.name, "message");
+ end);
+ end);
+
+ describe("#iq()", function()
+ it("should create an iq stanza", function()
+ local i = st.iq({ id = "foo" });
+ assert.are.equal("iq", i.name);
+ assert.are.equal("foo", i.attr.id);
+ end);
+
+ it("should reject stanzas with no id", function ()
+ assert.has.error_match(function ()
+ st.iq();
+ end, "id attribute");
+
+ assert.has.error_match(function ()
+ st.iq({ foo = "bar" });
+ end, "id attribute");
+ end);
+ end);
+
+ describe("#presence()", function ()
+ it("should work", function()
+ local p = st.presence();
+ assert.are.equal(p.name, "presence");
+ end);
+ end);
+
+ describe("#reply()", function()
+ it("should work for <s>", function()
+ -- Test stanza
+ local s = st.stanza("s", { to = "touser", from = "fromuser", id = "123" })
+ :tag("child1");
+ -- Make reply stanza
+ local r = st.reply(s);
+ assert.are.equal(r.name, s.name);
+ assert.are.equal(r.id, s.id);
+ assert.are.equal(r.attr.to, s.attr.from);
+ assert.are.equal(r.attr.from, s.attr.to);
+ assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
+ end);
+
+ it("should work for <iq get>", function()
+ -- Test stanza
+ local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
+ :tag("child1");
+ -- Make reply stanza
+ local r = st.reply(s);
+ assert.are.equal(r.name, s.name);
+ assert.are.equal(r.id, s.id);
+ assert.are.equal(r.attr.to, s.attr.from);
+ assert.are.equal(r.attr.from, s.attr.to);
+ assert.are.equal(r.attr.type, "result");
+ assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
+ end);
+
+ it("should work for <iq set>", function()
+ -- Test stanza
+ local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "set" })
+ :tag("child1");
+ -- Make reply stanza
+ local r = st.reply(s);
+ assert.are.equal(r.name, s.name);
+ assert.are.equal(r.id, s.id);
+ assert.are.equal(r.attr.to, s.attr.from);
+ assert.are.equal(r.attr.from, s.attr.to);
+ assert.are.equal(r.attr.type, "result");
+ assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
+ end);
+ end);
+
+ describe("#error_reply()", function()
+ it("should work for <s>", function()
+ -- Test stanza
+ local s = st.stanza("s", { to = "touser", from = "fromuser", id = "123" })
+ :tag("child1");
+ -- Make reply stanza
+ local r = st.error_reply(s, "cancel", "service-unavailable");
+ assert.are.equal(r.name, s.name);
+ assert.are.equal(r.id, s.id);
+ assert.are.equal(r.attr.to, s.attr.from);
+ assert.are.equal(r.attr.from, s.attr.to);
+ assert.are.equal(#r.tags, 1);
+ assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
+ end);
+
+ it("should work for <iq get>", function()
+ -- Test stanza
+ local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
+ :tag("child1");
+ -- Make reply stanza
+ local r = st.error_reply(s, "cancel", "service-unavailable");
+ assert.are.equal(r.name, s.name);
+ assert.are.equal(r.id, s.id);
+ assert.are.equal(r.attr.to, s.attr.from);
+ assert.are.equal(r.attr.from, s.attr.to);
+ assert.are.equal(r.attr.type, "error");
+ assert.are.equal(#r.tags, 1);
+ assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
+ end);
+ end);
+
+ describe("should reject #invalid", function ()
+ local invalid_names = {
+ ["empty string"] = "", ["characters"] = "<>";
+ }
+ local invalid_data = {
+ ["number"] = 1234, ["table"] = {};
+ ["utf8"] = string.char(0xF4, 0x90, 0x80, 0x80);
+ ["nil"] = "nil"; ["boolean"] = true;
+ };
+
+ for value_type, value in pairs(invalid_names) do
+ it(value_type.." in tag names", function ()
+ assert.error_matches(function ()
+ st.stanza(value);
+ end, value_type);
+ end);
+ it(value_type.." in attribute names", function ()
+ assert.error_matches(function ()
+ st.stanza("valid", { [value] = "valid" });
+ end, value_type);
+ end);
+ end
+ for value_type, value in pairs(invalid_data) do
+ if value == "nil" then value = nil; end
+ it(value_type.." in tag names", function ()
+ assert.error_matches(function ()
+ st.stanza(value);
+ end, value_type);
+ end);
+ it(value_type.." in attribute names", function ()
+ assert.error_matches(function ()
+ st.stanza("valid", { [value] = "valid" });
+ end, value_type);
+ end);
+ if value ~= nil then
+ it(value_type.." in attribute values", function ()
+ assert.error_matches(function ()
+ st.stanza("valid", { valid = value });
+ end, value_type);
+ end);
+ it(value_type.." in text node", function ()
+ assert.error_matches(function ()
+ st.stanza("valid"):text(value);
+ end, value_type);
+ end);
+ end
+ end
+ end);
+
+ describe("#is_stanza", function ()
+ -- is_stanza(any) -> boolean
+ it("identifies stanzas as stanzas", function ()
+ assert.truthy(st.is_stanza(st.stanza("x")));
+ end);
+ it("identifies strings as not stanzas", function ()
+ assert.falsy(st.is_stanza(""));
+ end);
+ it("identifies numbers as not stanzas", function ()
+ assert.falsy(st.is_stanza(1));
+ end);
+ it("identifies tables as not stanzas", function ()
+ assert.falsy(st.is_stanza({}));
+ end);
+ end);
+
+ describe("#remove_children", function ()
+ it("should work", function ()
+ local s = st.stanza("x", {xmlns="test"})
+ :tag("y", {xmlns="test"}):up()
+ :tag("z", {xmlns="test2"}):up()
+ :tag("x", {xmlns="test2"}):up()
+
+ s:remove_children("x");
+ assert.falsy(s:get_child("x"))
+ assert.truthy(s:get_child("z","test2"));
+ assert.truthy(s:get_child("x","test2"));
+
+ s:remove_children(nil, "test2");
+ assert.truthy(s:get_child("y"))
+ assert.falsy(s:get_child(nil,"test2"));
+
+ s:remove_children();
+ assert.falsy(s.tags[1]);
+ end);
+ end);
+
+ describe("#maptags", function ()
+ it("should work", function ()
+ local s = st.stanza("test")
+ :tag("one"):up()
+ :tag("two"):up()
+ :tag("one"):up()
+ :tag("three"):up();
+
+ local function one_filter(tag)
+ if tag.name == "one" then
+ return nil;
+ end
+ return tag;
+ end
+ assert.equal(4, #s.tags);
+ s:maptags(one_filter);
+ assert.equal(2, #s.tags);
+ end);
+
+ it("should work with multiple consecutive text nodes", function ()
+ local s = st.deserialize({
+ "\n";
+ {
+ "away";
+ name = "show";
+ attr = {};
+ };
+ "\n";
+ {
+ "I am away";
+ name = "status";
+ attr = {};
+ };
+ "\n";
+ {
+ "0";
+ name = "priority";
+ attr = {};
+ };
+ "\n";
+ {
+ name = "c";
+ attr = {
+ xmlns = "http://jabber.org/protocol/caps";
+ node = "http://psi-im.org";
+ hash = "sha-1";
+ };
+ };
+ "\n";
+ "\n";
+ name = "presence";
+ attr = {
+ to = "user@example.com/jflsjfld";
+ from = "room@chat.example.org/nick";
+ };
+ });
+
+ assert.equal(4, #s.tags);
+
+ s:maptags(function (tag) return tag; end);
+ assert.equal(4, #s.tags);
+
+ s:maptags(function (tag)
+ if tag.name == "c" then
+ return nil;
+ end
+ return tag;
+ end);
+ assert.equal(3, #s.tags);
+ end);
+ it("errors on invalid data - #981", function ()
+ local s = st.message({}, "Hello");
+ s.tags[1] = st.clone(s.tags[1]);
+ assert.has_error_match(function ()
+ s:maptags(function () end);
+ end, "Invalid stanza");
+ end);
+ end);
+
+ describe("#clone", function ()
+ it("works", function ()
+ local s = st.message({type="chat"}, "Hello"):reset();
+ local c = st.clone(s);
+ assert.same(s, c);
+ end);
+
+ it("works", function ()
+ assert.has_error(function ()
+ st.clone("this is not a stanza");
+ end);
+ end);
+ end);
+end);
diff --git a/spec/util_throttle_spec.lua b/spec/util_throttle_spec.lua
new file mode 100644
index 00000000..75daf1b9
--- /dev/null
+++ b/spec/util_throttle_spec.lua
@@ -0,0 +1,150 @@
+
+
+-- Mock util.time
+local now = 0; -- wibbly-wobbly... timey-wimey... stuff
+local function later(n)
+ now = now + n; -- time passes at a different rate
+end
+package.loaded["util.time"] = {
+ now = function() return now; end
+}
+
+
+local throttle = require "util.throttle";
+
+describe("util.throttle", function()
+ describe("#create()", function()
+ it("should be created with correct values", function()
+ now = 5;
+ local a = throttle.create(3, 10);
+ assert.same(a, { balance = 3, max = 3, rate = 0.3, t = 5 });
+
+ local a = throttle.create(3, 5);
+ assert.same(a, { balance = 3, max = 3, rate = 0.6, t = 5 });
+
+ local a = throttle.create(1, 1);
+ assert.same(a, { balance = 1, max = 1, rate = 1, t = 5 });
+
+ local a = throttle.create(10, 10);
+ assert.same(a, { balance = 10, max = 10, rate = 1, t = 5 });
+
+ local a = throttle.create(10, 1);
+ assert.same(a, { balance = 10, max = 10, rate = 10, t = 5 });
+ end);
+ end);
+
+ describe("#update()", function()
+ it("does nothing when no time has passed, even if balance is not full", function()
+ now = 5;
+ local a = throttle.create(10, 10);
+ for i=1,5 do
+ a:update();
+ assert.same(a, { balance = 10, max = 10, rate = 1, t = 5 });
+ end
+ a.balance = 0;
+ for i=1,5 do
+ a:update();
+ assert.same(a, { balance = 0, max = 10, rate = 1, t = 5 });
+ end
+ end);
+ it("updates only time when time passes but balance is full", function()
+ now = 5;
+ local a = throttle.create(10, 10);
+ for i=1,5 do
+ later(5);
+ a:update();
+ assert.same(a, { balance = 10, max = 10, rate = 1, t = 5 + i*5 });
+ end
+ end);
+ it("updates balance when balance has room to grow as time passes", function()
+ now = 5;
+ local a = throttle.create(10, 10);
+ a.balance = 0;
+ assert.same(a, { balance = 0, max = 10, rate = 1, t = 5 });
+
+ later(1);
+ a:update();
+ assert.same(a, { balance = 1, max = 10, rate = 1, t = 6 });
+
+ later(3);
+ a:update();
+ assert.same(a, { balance = 4, max = 10, rate = 1, t = 9 });
+
+ later(10);
+ a:update();
+ assert.same(a, { balance = 10, max = 10, rate = 1, t = 19 });
+ end);
+ it("handles 10 x 0.1s updates the same as 1 x 1s update ", function()
+ now = 5;
+ local a = throttle.create(1, 1);
+
+ a.balance = 0;
+ later(1);
+ a:update();
+ assert.same(a, { balance = 1, max = 1, rate = 1, t = now });
+
+ a.balance = 0;
+ for i=1,10 do
+ later(0.1);
+ a:update();
+ end
+ assert(math.abs(a.balance - 1) < 0.0001); -- incremental updates cause rouding errors
+ end);
+ end);
+
+ -- describe("po")
+
+ describe("#poll()", function()
+ it("should only allow successful polls until cost is hit", function()
+ now = 5;
+
+ local a = throttle.create(3, 10);
+ assert.same(a, { balance = 3, max = 3, rate = 0.3, t = 5 });
+
+ assert.is_true(a:poll(1)); -- 3 -> 2
+ assert.same(a, { balance = 2, max = 3, rate = 0.3, t = 5 });
+
+ assert.is_true(a:poll(2)); -- 2 -> 1
+ assert.same(a, { balance = 0, max = 3, rate = 0.3, t = 5 });
+
+ assert.is_false(a:poll(1)); -- MEEP, out of credits!
+ assert.is_false(a:poll(1)); -- MEEP, out of credits!
+ assert.same(a, { balance = 0, max = 3, rate = 0.3, t = 5 });
+ end);
+
+ it("should not allow polls more than the cost", function()
+ now = 0;
+
+ local a = throttle.create(10, 10);
+ assert.same(a, { balance = 10, max = 10, rate = 1, t = 0 });
+
+ assert.is_false(a:poll(11));
+ assert.same(a, { balance = 10, max = 10, rate = 1, t = 0 });
+
+ assert.is_true(a:poll(6));
+ assert.same(a, { balance = 4, max = 10, rate = 1, t = 0 });
+
+ assert.is_false(a:poll(5));
+ assert.same(a, { balance = 4, max = 10, rate = 1, t = 0 });
+
+ -- fractional
+ assert.is_true(a:poll(3.5));
+ assert.same(a, { balance = 0.5, max = 10, rate = 1, t = 0 });
+
+ assert.is_true(a:poll(0.25));
+ assert.same(a, { balance = 0.25, max = 10, rate = 1, t = 0 });
+
+ assert.is_false(a:poll(0.3));
+ assert.same(a, { balance = 0.25, max = 10, rate = 1, t = 0 });
+
+ assert.is_true(a:poll(0.25));
+ assert.same(a, { balance = 0, max = 10, rate = 1, t = 0 });
+
+ assert.is_false(a:poll(0.1));
+ assert.same(a, { balance = 0, max = 10, rate = 1, t = 0 });
+
+ assert.is_true(a:poll(0));
+ assert.same(a, { balance = 0, max = 10, rate = 1, t = 0 });
+ end);
+ end);
+end);
diff --git a/spec/util_time_spec.lua b/spec/util_time_spec.lua
new file mode 100644
index 00000000..54a99b82
--- /dev/null
+++ b/spec/util_time_spec.lua
@@ -0,0 +1,31 @@
+describe("util.time", function ()
+ local time;
+ setup(function ()
+ time = require "util.time";
+ end);
+ describe("now()", function ()
+ it("exists", function ()
+ assert.is_function(time.now);
+ end);
+ it("returns a number", function ()
+ assert.is_number(time.now());
+ end);
+ end);
+ describe("monotonic()", function ()
+ it("exists", function ()
+ assert.is_function(time.monotonic);
+ end);
+ it("returns a number", function ()
+ assert.is_number(time.monotonic());
+ end);
+ it("time goes in one direction", function ()
+ local a = time.monotonic();
+ local b = time.monotonic();
+ assert.truthy(a <= b);
+ end);
+ end);
+end);
+
+
+
+
diff --git a/spec/util_uuid_spec.lua b/spec/util_uuid_spec.lua
new file mode 100644
index 00000000..95ae0a20
--- /dev/null
+++ b/spec/util_uuid_spec.lua
@@ -0,0 +1,25 @@
+-- This tests the format, not the randomness
+
+local uuid = require "util.uuid";
+
+describe("util.uuid", function()
+ describe("#generate()", function()
+ it("should work follow the UUID pattern", function()
+ -- https://tools.ietf.org/html/rfc4122#section-4.4
+
+ local pattern = "^" .. table.concat({
+ string.rep("%x", 8),
+ string.rep("%x", 4),
+ "4" .. -- version
+ string.rep("%x", 3),
+ "[89ab]" .. -- reserved bits of 1 and 0
+ string.rep("%x", 3),
+ string.rep("%x", 12),
+ }, "%-") .. "$";
+
+ for _ = 1, 100 do
+ assert.is_string(uuid.generate():match(pattern));
+ end
+ end);
+ end);
+end);
diff --git a/spec/util_xml_spec.lua b/spec/util_xml_spec.lua
new file mode 100644
index 00000000..11820894
--- /dev/null
+++ b/spec/util_xml_spec.lua
@@ -0,0 +1,20 @@
+
+local xml = require "util.xml";
+
+describe("util.xml", function()
+ describe("#parse()", function()
+ it("should work", function()
+ local x =
+[[<x xmlns:a="b">
+ <y xmlns:a="c"> <!-- this overwrites 'a' -->
+ <a:z/>
+ </y>
+ <a:z/> <!-- prefix 'a' is nil here, but should be 'b' -->
+</x>
+]]
+ local stanza = xml.parse(x);
+ assert.are.equal(stanza.tags[2].attr.xmlns, "b");
+ assert.are.equal(stanza.tags[2].namespaces["a"], "b");
+ end);
+ end);
+end);
diff --git a/spec/util_xmppstream_spec.lua b/spec/util_xmppstream_spec.lua
new file mode 100644
index 00000000..38b3cbd2
--- /dev/null
+++ b/spec/util_xmppstream_spec.lua
@@ -0,0 +1,136 @@
+
+local xmppstream = require "util.xmppstream";
+
+describe("util.xmppstream", function()
+ local function test(xml, expect_success, ex)
+ local stanzas = {};
+ local session = { notopen = true };
+ local callbacks = {
+ stream_ns = "streamns";
+ stream_tag = "stream";
+ default_ns = "stanzans";
+ streamopened = function (_session)
+ assert.are.equal(session, _session);
+ assert.are.equal(session.notopen, true);
+ _session.notopen = nil;
+ return true;
+ end;
+ handlestanza = function (_session, stanza)
+ assert.are.equal(session, _session);
+ assert.are.equal(_session.notopen, nil);
+ table.insert(stanzas, stanza);
+ end;
+ streamclosed = function (_session)
+ assert.are.equal(session, _session);
+ assert.are.equal(_session.notopen, nil);
+ _session.notopen = nil;
+ end;
+ }
+ if type(ex) == "table" then
+ for k, v in pairs(ex) do
+ if k ~= "_size_limit" then
+ callbacks[k] = v;
+ end
+ end
+ end
+ local stream = xmppstream.new(session, callbacks, ex and ex._size_limit or nil);
+ local ok, err = pcall(function ()
+ assert(stream:feed(xml));
+ end);
+
+ if ok and type(expect_success) == "function" then
+ expect_success(stanzas);
+ end
+ assert.are.equal(not not ok, not not expect_success, "Expected "..(expect_success and ("success ("..tostring(err)..")") or "failure"));
+ end
+
+ local function test_stanza(stanza, expect_success, ex)
+ return test([[<stream:stream xmlns:stream="streamns" xmlns="stanzans">]]..stanza, expect_success, ex);
+ end
+
+ describe("#new()", function()
+ it("should work", function()
+ test([[<stream:stream xmlns:stream="streamns"/>]], true);
+ test([[<stream xmlns="streamns"/>]], true);
+
+ -- Incorrect stream tag name should be rejected
+ test([[<stream1 xmlns="streamns"/>]], false);
+ -- Incorrect stream namespace should be rejected
+ test([[<stream xmlns="streamns1"/>]], false);
+ -- Invalid XML should be rejected
+ test("<>", false);
+
+ test_stanza("<message/>", function (stanzas)
+ assert.are.equal(#stanzas, 1);
+ assert.are.equal(stanzas[1].name, "message");
+ end);
+ test_stanza("< message>>>>/>\n", false);
+
+ test_stanza([[<x xmlns:a="b">
+ <y xmlns:a="c">
+ <a:z/>
+ </y>
+ <a:z/>
+ </x>]], function (stanzas)
+ assert.are.equal(#stanzas, 1);
+ local s = stanzas[1];
+ assert.are.equal(s.name, "x");
+ assert.are.equal(#s.tags, 2);
+
+ assert.are.equal(s.tags[1].name, "y");
+ assert.are.equal(s.tags[1].attr.xmlns, nil);
+
+ assert.are.equal(s.tags[1].tags[1].name, "z");
+ assert.are.equal(s.tags[1].tags[1].attr.xmlns, "c");
+
+ assert.are.equal(s.tags[2].name, "z");
+ assert.are.equal(s.tags[2].attr.xmlns, "b");
+
+ assert.are.equal(s.namespaces, nil);
+ end);
+ end);
+ end);
+
+ it("should allow an XML declaration", function ()
+ test([[<?xml version="1.0" encoding="UTF-8"?><stream xmlns="streamns"/>]], true);
+ test([[<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><stream xmlns="streamns"/>]], true);
+ test([[<?xml version="1.0" encoding="utf-8" ?><stream xmlns="streamns"/>]], true);
+ end);
+
+ it("should not accept XML versions other than 1.0", function ()
+ test([[<?xml version="1.1" encoding="utf-8" ?><stream xmlns="streamns"/>]], false);
+ end);
+
+ it("should not allow a misplaced XML declaration", function ()
+ test([[<stream xmlns="streamns"><?xml version="1.0" encoding="UTF-8"?></stream>]], false);
+ end);
+
+ describe("should forbid restricted XML:", function ()
+ it("comments", function ()
+ test_stanza("<!-- hello world -->", false);
+ end);
+ it("DOCTYPE", function ()
+ test([[<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE stream SYSTEM "mydtd.dtd">]], false);
+ end);
+ it("incorrect encoding specification", function ()
+ -- This is actually caught by the underlying XML parser
+ test([[<?xml version="1.0" encoding="UTF-16"?><stream xmlns="streamns"/>]], false);
+ end);
+ it("non-UTF8 encodings: ISO-8859-1", function ()
+ test([[<?xml version="1.0" encoding="ISO-8859-1"?><stream xmlns="streamns"/>]], false);
+ end);
+ it("non-UTF8 encodings: UTF-16", function ()
+ -- <?xml version="1.0" encoding="UTF-16"?><stream xmlns="streamns"/>
+ -- encoded into UTF-16
+ local hx = ([[fffe3c003f0078006d006c002000760065007200730069006f006e003d00
+ 220031002e0030002200200065006e0063006f00640069006e0067003d00
+ 22005500540046002d003100360022003f003e003c007300740072006500
+ 61006d00200078006d006c006e0073003d00220073007400720065006100
+ 6d006e00730022002f003e00]]):gsub("%x%x", function (c) return string.char(tonumber(c, 16)); end);
+ test(hx, false);
+ end);
+ it("processing instructions", function ()
+ test([[<stream xmlns="streamns"><?xml-stylesheet type="text/xsl" href="style.xsl"?></stream>]], false);
+ end);
+ end);
+end);