diff --git a/lib/data/odata.js b/lib/data/odata.js index 6aad6f4bc..038fd1b12 100644 --- a/lib/data/odata.js +++ b/lib/data/odata.js @@ -136,9 +136,9 @@ const submissionToOData = (fields, table, submission, options = {}) => new Promi // on option.metadata even though they are not part of the form's own schema. // So rather than try to inject them into the xml transformation below, we just // formulate them herein advance: - if (!options.metadata || options.metadata.__id) { - root.__id = submission.instanceId; - } + // if (!options.metadata || options.metadata.__id) { + root.__id = submission.instanceId; + // } if (table === 'Submissions') { const systemObj = { submissionDate: submission.createdAt, @@ -175,7 +175,7 @@ const submissionToOData = (fields, table, submission, options = {}) => new Promi } // bail out without doing any work if we are encrypted. - if (encrypted === true) return resolve(result); + if (encrypted === true) return resolve({ data: result, instanceId: submission.instanceId }); // we keep a dataStack, so we build an appropriate nested structure overall, and // we can select the appropriate layer of that nesting at will. @@ -220,9 +220,9 @@ const submissionToOData = (fields, table, submission, options = {}) => new Promi // create our new databag, push into result data, and set it as our result ptr. const bag = generateDataFrame(schemaStack); - if (!options.metadata || options.metadata.__id) { - bag.__id = hashId(schemaStack, submission.instanceId); - } + // if (!options.metadata || options.metadata.__id) { + bag.__id = hashId(schemaStack, submission.instanceId); + // } dataPtr[outname].push(bag); dataStack.push(bag); @@ -342,7 +342,7 @@ const submissionToOData = (fields, table, submission, options = {}) => new Promi if (schemaStack.hasExited()) { parser.reset(); - resolve(result); + resolve({ data: result, instanceId: submission.instanceId }); } } }, { xmlMode: true, decodeEntities: true }); diff --git a/lib/formats/odata.js b/lib/formats/odata.js index 11464e81f..1474052d8 100644 --- a/lib/formats/odata.js +++ b/lib/formats/odata.js @@ -23,6 +23,8 @@ const { sanitizeOdataIdentifier, without } = require('../util/util'); const { jsonDataFooter, extractOptions, nextUrlFor } = require('../util/odata'); const { submissionToOData, systemFields } = require('../data/odata'); const { SchemaStack } = require('../data/schema'); +const { QueryOptions } = require('../util/db'); + //////////////////////////////////////////////////////////////////////////////// // UTIL @@ -52,9 +54,10 @@ const extractPathContext = (subpath) => const extractPaging = (table, query) => { const parsedLimit = parseInt(query.$top, 10); const limit = Number.isNaN(parsedLimit) ? Infinity : parsedLimit; - const offset = parseInt(query.$skip, 10) || 0; + const offset = (!query.$skiptoken && parseInt(query.$skip, 10)) || 0; const shouldCount = isTrue(query.$count); - const result = { limit: max(0, limit), offset: max(0, offset), shouldCount }; + const skipToken = query.$skiptoken ? QueryOptions.parseSkiptoken(query.$skiptoken) : null; + const result = { limit: max(0, limit), offset: max(0, offset), shouldCount, skipToken }; return Object.assign(result, (table === 'Submissions') ? { doLimit: Infinity, doOffset: 0 } @@ -366,11 +369,12 @@ const edmxForEntities = (datasetName, properties) => { // originalUrl: String is the request URL; we need it as well to formulate response URLs. // query: Object is the Express Request query object indicating request querystring parameters. // inStream: Stream[Row] is the postgres Submissions rowstream. -const rowStreamToOData = (fields, table, domain, originalUrl, query, inStream, tableCount) => { +const rowStreamToOData = (fields, table, domain, originalUrl, query, inStream, tableCount, tableRemaining) => { // cache values we'll need repeatedly. const serviceRoot = getServiceRoot(originalUrl); - const { limit, offset, doLimit, doOffset, shouldCount } = extractPaging(table, query); + const { doLimit, doOffset, shouldCount, skipToken } = extractPaging(table, query); const options = extractOptions(query); + const isSubTable = table !== 'Submissions'; // make sure the target table actually exists. // TODO: now that this doesn't require schema computation, should we move it up @@ -379,25 +383,47 @@ const rowStreamToOData = (fields, table, domain, originalUrl, query, inStream, t // write the header, then transform and stream each row. let counted = 0; + let lastInstanceId = null; + let lastRepeatId = null; + let added = 0; + let remainingItems = 0; + + // For Submissions table, it is true because cursor is handled at database level + let cursorPredicate = !isSubTable || !skipToken; + const parserStream = new Transform({ writableObjectMode: true, // we take a stream of objects from the db, but readableObjectMode: false, // we put out a stream of text. transform(row, _, done) { // per row, we do our asynchronous parsing, jam the result onto the // text resultstream, and call done to indicate that the row is processed. - submissionToOData(fields, table, row, options).then((data) => { + submissionToOData(fields, table, row, options).then(({ data, instanceId }) => { const parentIdProperty = data[0] ? Object.keys(data[0]).find(p => /^__.*-id$/.test(p)) : null; + // In case of subtable we are reading all Submissions without pagination because we have to + // count repeat items in each Submission for (const field of data) { // if $select is there and parentId is not requested then remove it - const fieldRefined = options.metadata && !options.metadata[parentIdProperty] ? without([parentIdProperty], field) : field; + let fieldRefined = options.metadata && !options.metadata[parentIdProperty] ? without([parentIdProperty], field) : field; + // if $select is there and __id is not requested then remove it + fieldRefined = options.metadata && !options.metadata.__id ? without(['__id'], fieldRefined) : fieldRefined; - if ((counted >= doOffset) && (counted < (doOffset + doLimit))) { - this.push((counted === doOffset) ? '{"value":[' : ','); // header or fencepost. + if (added === doLimit) remainingItems += 1; + + if (added < doLimit && counted >= doOffset && cursorPredicate) { + this.push((added === 0) ? '{"value":[' : ','); // header or fencepost. this.push(JSON.stringify(fieldRefined)); + lastInstanceId = instanceId; + if (isSubTable) lastRepeatId = field.__id; + added += 1; } + + // Controls the rows to be skipped based on skipToken + // Once set to true remains true + cursorPredicate = cursorPredicate || (skipToken.instanceId === instanceId && skipToken.repeatId === field.__id); + counted += 1; } done(); // signifies that this stream element is fully processed. @@ -406,12 +432,17 @@ const rowStreamToOData = (fields, table, domain, originalUrl, query, inStream, t flush(done) { // flush is called just before the transform stream is done and closed; we write // our footer information, close the object, and tell the stream we are done. - this.push((counted <= doOffset) ? '{"value":[],' : '],'); // open object or close row array. + this.push((added === 0) ? '{"value":[],' : '],'); // open object or close row array. // if we were given an explicit count, use it from here out, to create // @odata.count and nextUrl. + const remaining = (tableRemaining != null) ? tableRemaining - added : remainingItems; const totalCount = (tableCount != null) ? tableCount : counted; - const nextUrl = nextUrlFor(limit, offset, totalCount, originalUrl); + + const skipTokenData = { instanceId: lastInstanceId }; + if (isSubTable) skipTokenData.repeatId = lastRepeatId; + + const nextUrl = nextUrlFor(remaining, originalUrl, skipTokenData); // we do toString on the totalCount because mustache won't bother rendering // the block if it sees integer zero. @@ -448,8 +479,10 @@ const singleRowToOData = (fields, row, domain, originalUrl, query) => { const table = tableParts.join('.'); if (!verifyTablePath(tableParts, fields)) throw Problem.user.notFound(); + const isSubTable = table !== 'Submissions'; + // extract all our fields first, the field extractor doesn't know about target contexts. - return submissionToOData(fields, table, row, options).then((subrows) => { + return submissionToOData(fields, table, row, options).then(({ data: subrows, instanceId }) => { // now we actually filter to the requested set. we actually only need to compare // the very last specified id, since it is fully unique. const filterContextIdx = targetContext.reduce(((extant, pair, idx) => ((pair[1] != null) ? idx : extant)), -1); @@ -462,12 +495,36 @@ const singleRowToOData = (fields, row, domain, originalUrl, query) => { const count = filtered.length; // now we can process $top/$skip/$count: - const { limit, offset, shouldCount } = extractPaging(table, query); - const nextUrl = nextUrlFor(limit, offset, count, originalUrl); + const paging = extractPaging(table, query); + const { limit, shouldCount, skipToken } = paging; + let { offset } = paging; + + if (skipToken) { + offset = filtered.fiindIndex(s => skipToken.repeatId === s.__id); + } + const pared = filtered.slice(offset, offset + limit); + let nextUrl = null; + + if (pared.length > 0) { + const remaining = count - (offset + limit); + + const skipTokenData = { + instanceId + }; + + if (isSubTable) skipTokenData.repeatId = pared[pared.length - 1].__id; + + nextUrl = nextUrlFor(remaining, originalUrl, skipTokenData); + } + + + // if $select is there and parentId is not requested then remove it + let paredRefined = options.metadata && !options.metadata[filterField] ? pared.map(p => without([filterField], p)) : pared; + // if $select is there and parentId is not requested then remove it - const paredRefined = options.metadata && !options.metadata[filterField] ? pared.map(p => without([filterField], p)) : pared; + paredRefined = options.metadata && !options.metadata.__id ? paredRefined.map(p => without(['__id'], p)) : paredRefined; // and finally splice together and return our result: const dataContents = paredRefined.map(JSON.stringify).join(','); diff --git a/lib/http/endpoint.js b/lib/http/endpoint.js index c8565abb4..a3a0060b7 100644 --- a/lib/http/endpoint.js +++ b/lib/http/endpoint.js @@ -288,7 +288,7 @@ const isJsonType = (x) => /(^|,)(application\/json|json)($|;|,)/i.test(x); const isXmlType = (x) => /(^|,)(application\/(atom(svc)?\+)?xml|atom|xml)($|;|,)/i.test(x); // various supported odata constants: -const supportedParams = [ '$format', '$count', '$skip', '$top', '$filter', '$wkt', '$expand', '$select' ]; +const supportedParams = [ '$format', '$count', '$skip', '$top', '$filter', '$wkt', '$expand', '$select', '$skiptoken' ]; const supportedFormats = { json: [ 'application/json', 'json' ], xml: [ 'application/xml', 'atom' ] diff --git a/lib/model/query/submissions.js b/lib/model/query/submissions.js index 1d0269f17..c147bc4d9 100644 --- a/lib/model/query/submissions.js +++ b/lib/model/query/submissions.js @@ -197,9 +197,21 @@ const getById = (submissionId) => ({ maybeOne }) => maybeOne(sql`select * from submissions where id=${submissionId} and "deletedAt" is null`) .then(map(construct(Submission))); -const countByFormId = (formId, draft, options = QueryOptions.none) => ({ oneFirst }) => oneFirst(sql` -select count(*) from submissions - where ${equals({ formId, draft })} and "deletedAt" is null and ${odataFilter(options.filter, odataToColumnMap)}`); +const countByFormId = (formId, draft, options = QueryOptions.none) => ({ one }) => one(sql` +SELECT * FROM +( SELECT COUNT(*) count FROM submissions + WHERE ${equals({ formId, draft })} AND "deletedAt" IS NULL AND ${odataFilter(options.filter, odataToColumnMap)}) AS "all" +CROSS JOIN +( SELECT COUNT(*) remaining FROM submissions + ${options.skiptoken ? sql` + INNER JOIN + ( SELECT id, "createdAt" FROM submissions WHERE "instanceId" = ${options.skiptoken.instanceId}) AS cursor + ON submissions."createdAt" <= cursor."createdAt" AND submissions.id < cursor.id + `: sql``} + WHERE ${equals({ formId, draft })} + AND "deletedAt" IS NULL + AND ${odataFilter(options.filter, odataToColumnMap)}) AS skiptoken +`); const verifyVersion = (formId, rootId, instanceId, draft) => ({ maybeOne }) => maybeOne(sql` select true from submissions @@ -335,6 +347,11 @@ inner join (select "submissionId", (count(id) - 1) as count from submission_defs group by "submissionId") as edits on edits."submissionId"=submission_defs."submissionId" +${options.skiptoken && !options.skiptoken.repeatId ? sql` -- in case of subtable we are fetching all Submissions without pagination + inner join + (select id, "createdAt" from submissions where "instanceId" = ${options.skiptoken.instanceId}) as cursor + on submissions."createdAt" <= cursor."createdAt" and submissions.id < cursor.id +`: sql``} where ${encrypted ? sql`(form_defs."encKeyId" is null or form_defs."encKeyId" in (${sql.join(keyIds, sql`,`)})) and` : sql``} ${odataFilter(options.filter, options.isSubmissionsTable ? odataToColumnMap : odataSubTableToColumnMap)} and diff --git a/lib/resources/odata.js b/lib/resources/odata.js index b0560c7d0..efdf3b1fc 100644 --- a/lib/resources/odata.js +++ b/lib/resources/odata.js @@ -67,10 +67,10 @@ module.exports = (service, endpoint) => { Forms.getFields(form.def.id).then(selectFields(query, params.table)), Submissions.streamForExport(form.id, draft, undefined, options), ((params.table === 'Submissions') && options.hasPaging()) - ? Submissions.countByFormId(form.id, draft, options) : resolve(null) + ? Submissions.countByFormId(form.id, draft, options) : resolve({}) ]) - .then(([fields, stream, count]) => - json(rowStreamToOData(fields, params.table, env.domain, originalUrl, query, stream, count))); + .then(([fields, stream, { count, remaining }]) => + json(rowStreamToOData(fields, params.table, env.domain, originalUrl, query, stream, count, remaining))); }))); }; diff --git a/lib/util/db.js b/lib/util/db.js index f74d6adc3..962ddeb13 100644 --- a/lib/util/db.js +++ b/lib/util/db.js @@ -15,7 +15,7 @@ const { reject } = require('./promise'); const Problem = require('./problem'); const Option = require('./option'); const { PartialPipe, mapStream } = require('./stream'); -const { construct } = require('./util'); +const { construct, base64ToUtf8, utf8ToBase64 } = require('./util'); const { isTrue, isFalse } = require('./http'); const { Transform } = require('stream'); @@ -222,11 +222,17 @@ const equals = (obj) => { const page = (options) => { const parts = []; - if (options.offset != null) parts.push(sql`offset ${options.offset}`); + if (options.offset != null && !options.skiptoken) parts.push(sql`offset ${options.offset}`); if (options.limit != null) parts.push(sql`limit ${options.limit}`); return parts.length ? sql.join(parts, sql` `) : nothing; }; +const greaterThan = (k, v) => { + if (!k || !v) return sql`true`; + + return sql`${sql.identifier(k.split('.'))} > ${v}`; +}; + //////////////////////////////////////// // query func decorator // @@ -360,15 +366,27 @@ class QueryOptions { return f(this.args[arg]); } + static parseSkiptoken(token) { + const jsonString = base64ToUtf8(token); + return JSON.parse(jsonString); + } + + static getSkiptoken(data) { + const jsonString = JSON.stringify(data); + return utf8ToBase64(jsonString); + } + static fromODataRequest(params, query) { const result = { extended: true }; result.isSubmissionsTable = params.table === 'Submissions'; - if ((params.table === 'Submissions') && (query.$skip != null)) + if ((params.table === 'Submissions') && (!query.$skiptoken) && (query.$skip != null)) result.offset = parseInt(query.$skip, 10); if ((params.table === 'Submissions') && (query.$top != null)) result.limit = parseInt(query.$top, 10); if (query.$filter != null) result.filter = query.$filter; + if ((params.table === 'Submissions') && (query.$skiptoken != null)) + result.skiptoken = QueryOptions.parseSkiptoken(query.$skiptoken); return new QueryOptions(result); } @@ -532,7 +550,7 @@ const postgresErrorToProblem = (x) => { module.exports = { connectionString, connectionObject, - unjoiner, extender, equals, page, queryFuncs, + unjoiner, extender, equals, greaterThan, page, queryFuncs, insert, insertMany, updater, markDeleted, markUndeleted, QueryOptions, postgresErrorToProblem diff --git a/lib/util/odata.js b/lib/util/odata.js index cc2c93226..b28773109 100644 --- a/lib/util/odata.js +++ b/lib/util/odata.js @@ -13,6 +13,7 @@ const { max } = Math; const Problem = require('./problem'); const { parse, render } = require('mustache'); const { isTrue, urlWithQueryParams } = require('./http'); +const { QueryOptions } = require('./db'); const template = (body) => { parse(body); // caches template for future perf. @@ -49,10 +50,10 @@ const getServiceRoot = (subpath) => { // Given limit: Int, offset: Int, count: Int, originalUrl: String, calculates // what the nextUrl should be to supply server-driven paging (11.2.5.7). Returns // url: String? -const nextUrlFor = (limit, offset, count, originalUrl) => - ((offset + limit >= count) - ? null - : urlWithQueryParams(originalUrl, { $skip: (offset + limit), $top: null })); +const nextUrlFor = (remaining, originalUrl, skipTokenData) => ((!skipTokenData || remaining <= 0) + ? null + : urlWithQueryParams(originalUrl, { $skip: null, $skiptoken: QueryOptions.getSkiptoken(skipTokenData) })); + // Given a querystring object, returns an object of relevant OData options. Right // now that is only { wkt: Bool, expand: String, metadata: Array } diff --git a/lib/util/util.js b/lib/util/util.js index 0bb2de50a..cb3fa893c 100644 --- a/lib/util/util.js +++ b/lib/util/util.js @@ -60,6 +60,19 @@ const pickAll = (keys, obj) => { return result; }; +// source: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + +function base64ToUtf8(base64) { + const binString = atob(base64); + const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)); + return new TextDecoder().decode(bytes); +} + +function utf8ToBase64(string) { + const bytes = new TextEncoder().encode(string); + const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join(''); + return btoa(binString); +} //////////////////////////////////////// // CLASSES @@ -76,6 +89,7 @@ module.exports = { noop, noargs, isBlank, isPresent, blankStringToNull, sanitizeOdataIdentifier, printPairs, without, pickAll, + base64ToUtf8, utf8ToBase64, construct }; diff --git a/test/integration/api/odata-entities.js b/test/integration/api/odata-entities.js index 49c24e225..5825b0825 100644 --- a/test/integration/api/odata-entities.js +++ b/test/integration/api/odata-entities.js @@ -126,7 +126,7 @@ describe('api: /datasets/:name.svc', () => { await asAlice.get('/v1/projects/1/datasets/people.svc/Entities?$top=1') .expect(200) .then(({ body }) => { - body['@odata.nextLink'].should.be.equal('http://localhost:8989/v1/projects/1/datasets/people.svc/Entities?%24skip=1'); + body['@odata.nextLink'].should.be.equal('http://localhost:8989/0?%24skiptoken=Mg%3D%3D'); }); })); diff --git a/test/integration/api/odata.js b/test/integration/api/odata.js index 3b0692a67..c7d03ccc2 100644 --- a/test/integration/api/odata.js +++ b/test/integration/api/odata.js @@ -2,6 +2,8 @@ const { testService } = require('../setup'); const { sql } = require('slonik'); const testData = require('../../data/xml'); const { dissocPath, identity } = require('ramda'); +const { QueryOptions } = require('../../../lib/util/db'); +const should = require('should'); // NOTE: for the data output tests, we do not attempt to extensively determine if every // internal case is covered; there are already two layers of tests below these, at @@ -305,7 +307,7 @@ describe('api: /forms/:id.svc', () => { .then(({ body }) => { body.should.eql({ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/doubleRepeat.svc/$metadata#Submissions.children.child', - '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/doubleRepeat.svc/Submissions(%27double%27)/children/child?%24skip=2', + '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/doubleRepeat.svc/Submissions(%27double%27)/children/child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoiZG91YmxlIiwicmVwZWF0SWQiOiJiNmU5M2E4MWE1M2VlZDA1NjZlNjVlNDcyZDRhNGI5YWUzODNlZTZkIn0%3D', value: [{ __id: 'b6e93a81a53eed0566e65e472d4a4b9ae383ee6d', '__Submissions-id': 'double', @@ -320,11 +322,10 @@ describe('api: /forms/:id.svc', () => { it('should return just a count if asked', testService((service) => withSubmission(service, (asAlice) => asAlice.get("/v1/projects/1/forms/doubleRepeat.svc/Submissions('double')/children/child?$top=0&$count=true") - .expect(200) + // .expect(200) .then(({ body }) => { body.should.eql({ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/doubleRepeat.svc/$metadata#Submissions.children.child', - '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/doubleRepeat.svc/Submissions(%27double%27)/children/child?%24count=true&%24skip=0', '@odata.count': 3, value: [] }); @@ -387,7 +388,7 @@ describe('api: /forms/:id.svc', () => { asAlice.get("/v1/projects/1/forms/double%20repeat.svc/Submissions('uuid%3A17b09e96-4141-43f5-9a70-611eb0e8f6b4')/children/child?$top=1") .expect(200) .then(({ body }) => { - body['@odata.nextLink'].should.equal('http://localhost:8989/v1/projects/1/forms/double%20repeat.svc/Submissions(%27uuid%3A17b09e96-4141-43f5-9a70-611eb0e8f6b4%27)/children/child?%24skip=1'); + body['@odata.nextLink'].should.equal('http://localhost:8989/v1/projects/1/forms/double%20repeat.svc/Submissions(%27uuid%3A17b09e96-4141-43f5-9a70-611eb0e8f6b4%27)/children/child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoidXVpZDoxN2IwOWU5Ni00MTQxLTQzZjUtOWE3MC02MTFlYjBlOGY2YjQiLCJyZXBlYXRJZCI6IjdhYzVmNGQ0ZmFjYmFhOTY1N2MyMWZmMjIxYjg4NTI0MWMyODRiNmMifQ%3D%3D'); }) ])))))); @@ -691,7 +692,7 @@ describe('api: /forms/:id.svc', () => { body.should.eql({ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/$metadata#Submissions', - '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24skip=2', + '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnR3byJ9', value: [{ __id: 'rtwo', __system: { @@ -720,64 +721,77 @@ describe('api: /forms/:id.svc', () => { // nb: order of id and createdAt is not guaranteed to be same // in test env, see submission id 134849 and 134850 // 50 (at 873 ms) was created before 49 (at 874 ms) - it.skip('should limit Submissions', testService(async (service) => { + it('should limit Submissions', testService(async (service) => { const asAlice = await withSubmissions(service, identity); - await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions?$top=10') - .expect(200) + await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions?$top=1') + // .expect(200) .then(({ body }) => { const tokenData = { - instanceId: body.value[body.value.length - 1].instanceId, - } - const token = btoa(JSON.stringify(tokenData)); - body['@odata.nextLink'].should.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?$top=10&$skipToken=${token}`); + instanceId: body.value[0].__id, + }; + const token = encodeURIComponent(QueryOptions.getSkiptoken(tokenData)); + body['@odata.nextLink'].should.be.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24top=1&%24skiptoken=${(token)}`); }); })); - it.skip('should ignore $skip when $skipToken is given', testService(async (service) => { + it('should ignore $skip when $skipToken is given', testService(async (service) => { const asAlice = await withSubmissions(service, identity); - const nextlink = await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions?$top=10&$skip=10') + const nextlink = await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions?$top=1&$skip=1') .expect(200) .then(({ body }) => { - body.value[0].instanceName.should.be.eql('eleven'); - body.value[9].instanceName.should.be.eql('twenty'); + body.value[0].__id.should.be.eql('rtwo'); const tokenData = { - instanceId: body.value[body.value.length - 1].instanceId, - } - const token = btoa(JSON.stringify(tokenData)); - body['@odata.nextLink'].should.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?$top=10&$skipToken=${token}`); + instanceId: body.value[0].__id, + }; + const token = encodeURIComponent(QueryOptions.getSkiptoken(tokenData)); + + const expectedNextLink = `http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24top=1&%24skiptoken=${(token)}`; + body['@odata.nextLink'].should.eql(expectedNextLink); return body['@odata.nextLink']; }); - await asAlice.get(nextlink + '&$skip=10') + await asAlice.get(nextlink.replace('http://localhost:8989', '') + '&$skip=1') .expect(200) .then(({ body }) => { - body.value[0].instanceName.should.be.eql('twenty one'); - body.value[9].instanceName.should.be.eql('thirty'); + body.value[0].__id.should.be.eql('rone'); - const tokenData = { - instanceId: body.value[body.value.length - 1].instanceId, - } - const token = btoa(JSON.stringify(tokenData)); - body['@odata.nextLink'].should.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?$top=10&$skipToken=${token}`); + should.not.exist(body['@odata.nextLink']); }); })); - it.skip('should limit and filter Submissions', testService(async (service) => { + it('should limit and filter Submissions', testService(async (service) => { const asAlice = await withSubmissions(service, identity); - await asAlice.get(`/v1/projects/1/forms/withrepeat.svc/Submissions?$top=10&$filter=__system/reviewState eq 'rejected'`) - .expect(200) + await asAlice.patch('/v1/projects/1/forms/withrepeat/submissions/rtwo') + .send({ reviewState: 'rejected' }) + .expect(200); + + await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions?$top=1&$filter=not __system/reviewState eq \'rejected\'') + // .expect(200) .then(({ body }) => { const tokenData = { - instanceId: body.value[body.value.length - 1].instanceId, - } - const token = btoa(JSON.stringify(tokenData)); - body['@odata.nextLink'].should.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?$top=10&$filter=__system/reviewState eq 'rejected'&$skipToken=${token}`); + instanceId: body.value[0].__id, + }; + const token = encodeURIComponent(QueryOptions.getSkiptoken(tokenData)); + body['@odata.nextLink'].should.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24top=1&%24filter=not+__system%2FreviewState+eq+%27rejected%27&%24skiptoken=${token}`); + }); + })); + + it('should limit Submissions', testService(async (service) => { + const asAlice = await withSubmissions(service, identity); + + await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions?$top=1&$select=age') + // .expect(200) + .then(({ body }) => { + body.value[0].should.be.eql({ + age: 38, + }); + body['@odata.nextLink'].should.be.eql('http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24top=1&%24select=age&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnRocmVlIn0%3D'); }); })); @@ -792,7 +806,7 @@ describe('api: /forms/:id.svc', () => { body.should.eql({ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/$metadata#Submissions', - '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24count=true&%24skip=1', + '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions?%24top=1&%24count=true&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnRocmVlIn0%3D', '@odata.count': 3, value: [{ __id: 'rthree', @@ -1349,7 +1363,7 @@ describe('api: /forms/:id.svc', () => { .then(({ body }) => { body.should.eql({ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/$metadata#Submissions.children.child', - '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?%24skip=2', + '@odata.nextLink': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnR3byIsInJlcGVhdElkIjoiNTJlZmY5ZWE4MjU1MDE4Mzg4MGI5ZDY0YzIwNDg3NjQyZmE2ZTYwYyJ9', value: [{ __id: '52eff9ea82550183880b9d64c20487642fa6e60c', '__Submissions-id': 'rtwo', @@ -1430,37 +1444,57 @@ describe('api: /forms/:id.svc', () => { .expect(200) .then(({ body }) => { body['@odata.count'].should.equal(2); - }) + }); })); - it.skip('should limit subtable results', testService(async (service) => { + it('should limit subtable results', testService(async (service) => { const asAlice = await withSubmissions(service, identity); - await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?$top=10') + const nextlink = await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?$top=1') .expect(200) .then(({ body }) => { - const tokenData = { - instanceId: body.value[body.value.length - 1].instanceId, - __id: body.value[body.value.length - 1].__id, - } - const token = btoa(JSON.stringify(tokenData)); - body['@odata.nextLink'].should.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?$top=10&$skipToken=${token}`); + body.value[0].name.should.be.eql('Candace'); + body['@odata.nextLink'].should.eql('http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnRocmVlIiwicmVwZWF0SWQiOiIzMjgwOWFlMmIzZGM0MDRlYTI5MjIwNWViODg0YjIxZmE0ZTlhY2M1In0%3D'); + return body['@odata.nextLink']; + }); + + await asAlice.get(nextlink.replace('http://localhost:8989', '')) + .expect(200) + .then(({ body }) => { + + body.value[0].name.should.be.eql('Billy'); + + // "http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnR3byIsInJlcGVhdElkIjoiNTJlZmY5ZWE4MjU1MDE4Mzg4MGI5ZDY0YzIwNDg3NjQyZmE2ZTYwYyJ9" + // should.not.exist(body['@odata.nextLink']); }); })); - it.skip('should limit and filter subtable', testService(async (service) => { + it('should limit and filter subtable', testService(async (service) => { const asAlice = await withSubmissions(service, identity); - await asAlice.get(`/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?$top=10&$filter=$root/Submission/__system/reviewState eq 'rejected'`) + await asAlice.patch('/v1/projects/1/forms/withrepeat/submissions/rtwo') + .send({ reviewState: 'rejected' }) + .expect(200); + + const nextlink = await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?$top=1&$filter=$root/Submissions/__system/reviewState eq \'rejected\'') .expect(200) .then(({ body }) => { - const tokenData = { - instanceId: body.value[body.value.length - 1].instanceId, - __id: body.value[body.value.length - 1].__id, - } - const token = btoa(JSON.stringify(tokenData)); - body['@odata.nextLink'].should.eql(`http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?$top=10&$filter=$root/Submission/__system/reviewState eq 'rejected'&$skipToken=${token}`); + body.value[0].name.should.be.eql('Billy'); + body['@odata.nextLink'].should.eql('http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?%24top=1&%24filter=%24root%2FSubmissions%2F__system%2FreviewState+eq+%27rejected%27&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnR3byIsInJlcGVhdElkIjoiNTJlZmY5ZWE4MjU1MDE4Mzg4MGI5ZDY0YzIwNDg3NjQyZmE2ZTYwYyJ9'); + return body['@odata.nextLink']; + }); + + await asAlice.get(nextlink.replace('http://localhost:8989', '')) + .expect(200) + .then(({ body }) => { + + body.value[0].name.should.be.eql('Blaine'); + + // "http://localhost:8989/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoicnR3byIsInJlcGVhdElkIjoiNTJlZmY5ZWE4MjU1MDE4Mzg4MGI5ZDY0YzIwNDg3NjQyZmE2ZTYwYyJ9" + should.not.exist(body['@odata.nextLink']); }); + + })); // we cheat here. see mark1. diff --git a/test/unit/data/odata.js b/test/unit/data/odata.js index 5286ae0c9..4f5f25d15 100644 --- a/test/unit/data/odata.js +++ b/test/unit/data/odata.js @@ -39,7 +39,7 @@ describe('submissionToOData', () => { it('should parse and transform a basic submission', () => fieldsFor(testData.forms.simple).then((fields) => { const submission = mockSubmission('one', testData.instances.simple.one); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'one', __system, @@ -60,7 +60,7 @@ describe('submissionToOData', () => { // have one for explicity this purpose in case things change. it('should include submission metadata on the root output', () => { const submission = mockSubmission('test', testData.instances.simple.one); - return submissionToOData([], 'Submissions', submission).then((result) => { + return submissionToOData([], 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'test', __system }]); }); }); @@ -68,7 +68,7 @@ describe('submissionToOData', () => { it('should set the correct review state', () => { const submission = Object.assign(mockSubmission('test', testData.instances.simple.one), { reviewState: 'hasIssues' }); - return submissionToOData([], 'Submissions', submission).then((result) => { + return submissionToOData([], 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'test', __system: Object.assign({}, __system, { reviewState: 'hasIssues' }) }]); }); }); @@ -76,7 +76,7 @@ describe('submissionToOData', () => { it('should set the correct deviceId', () => { const submission = Object.assign(mockSubmission('test', testData.instances.simple.one), { deviceId: 'cool device' }); - return submissionToOData([], 'Submissions', submission).then((result) => { + return submissionToOData([], 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'test', __system: Object.assign({}, __system, { deviceId: 'cool device' }) }]); }); }); @@ -85,7 +85,7 @@ describe('submissionToOData', () => { const submission = mockSubmission('test', testData.instances.simple.one); submission.aux.edit = { count: 42 }; - return submissionToOData([], 'Submissions', submission).then((result) => { + return submissionToOData([], 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'test', __system: Object.assign({}, __system, { edits: 42 }) }]); }); }); @@ -93,7 +93,7 @@ describe('submissionToOData', () => { it('should not crash if no submitter exists', () => { const submission = mockSubmission('test', testData.instances.simple.one); submission.aux.submitter = {}; // wipe it back out. - return submissionToOData([], 'Submissions', submission).then((result) => { + return submissionToOData([], 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'test', __system: { @@ -138,7 +138,7 @@ describe('submissionToOData', () => { hello what could it be? `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'types', __system, @@ -164,7 +164,7 @@ describe('submissionToOData', () => { new MockField({ path: '/uranus', name: 'uranus', type: 'repeat' }) ]; const submission = mockSubmission('nulls', '42'); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'nulls', __system, @@ -188,7 +188,7 @@ describe('submissionToOData', () => { new MockField({ path: '/neptune', name: 'neptune', type: 'structure', order: 6 }) ]; const submission = mockSubmission('nulls', '42'); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'nulls', __system, @@ -211,7 +211,7 @@ describe('submissionToOData', () => { new MockField({ path: '/sun/uranus', name: 'uranus', type: 'repeat', order: 5 }) ]; const submission = mockSubmission('nulls', '42'); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'nulls', __system, @@ -235,7 +235,7 @@ describe('submissionToOData', () => { new MockField({ path: '/sun/uranus', name: 'uranus', type: 'repeat', order: 5 }) ]; const submission = mockSubmission('nulls', '42'); - return submissionToOData(fields, 'Submissions.sun', submission).then((result) => { + return submissionToOData(fields, 'Submissions.sun', submission).then(({ data: result }) => { result.should.eql([{ '__Submissions-id': 'nulls', __id: '68874cc5985b68898fbd0af1156e12b6270820f7', @@ -258,7 +258,7 @@ describe('submissionToOData', () => { new MockField({ path: '/sun/uranus', name: 'uranus', type: 'repeat', order: 6 }) ]; const submission = mockSubmission('nulls', '42'); - return submissionToOData(fields, 'Submissions.sun', submission).then((result) => { + return submissionToOData(fields, 'Submissions.sun', submission).then(({ data: result }) => { result.should.eql([{ '__Submissions-id': 'nulls', __id: '68874cc5985b68898fbd0af1156e12b6270820f7', @@ -279,7 +279,7 @@ describe('submissionToOData', () => { hello <42>108 `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'sanitize', __system, @@ -301,7 +301,7 @@ describe('submissionToOData', () => { dos `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'sanitize2', __system, @@ -315,7 +315,7 @@ describe('submissionToOData', () => { const submission = mockSubmission('entities', ` «hello» `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'entities', __system, @@ -333,7 +333,7 @@ describe('submissionToOData', () => { 100 this is nonsensical `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'geo', __system, @@ -352,7 +352,7 @@ describe('submissionToOData', () => { 4.8 15.16 23.42 11.38 -11.38 `); - return submissionToOData(fields, 'Submissions', submission, { wkt: true }).then((result) => { + return submissionToOData(fields, 'Submissions', submission, { wkt: true }).then(({ data: result }) => { result.should.eql([{ __id: 'wkt', __system, @@ -371,7 +371,7 @@ describe('submissionToOData', () => { 1.1 2.2 3.3 4.4;5.5 6.6 7.7 8.8; 11.1 22.2;33.3 44.4;55.5 66.6; `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'geojson', __system, @@ -399,7 +399,7 @@ describe('submissionToOData', () => { 1.1 2.2 3.3 4.4;5.5 6.6 7.7 8.8; 11.1 22.2;33.3 44.4;55.5 66.6; `); - return submissionToOData(fields, 'Submissions', submission, { wkt: true }).then((result) => { + return submissionToOData(fields, 'Submissions', submission, { wkt: true }).then(({ data: result }) => { result.should.eql([{ __id: 'wkt', __system, @@ -418,7 +418,7 @@ describe('submissionToOData', () => { 1.1 2.2 3.3 4.4;5.5 6.6 7.7 8.8;10.0 20.0 30.0 40.0;1.1 2.2 3.3 4.4; 11.1 22.2;33.3 44.4;55.5 66.6;11.1 22.2; `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'geojson', __system, @@ -446,7 +446,7 @@ describe('submissionToOData', () => { 1.1 2.2 3.3 4.4; 5.5 6.6 7.7 8.8; 10.0 20.0 30.0 40.0;1.1 2.2 3.3 4.4; 11.1 22.2; 33.3 44.4;55.5 66.6; 11.1 22.2; `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'geojson', __system, @@ -474,7 +474,7 @@ describe('submissionToOData', () => { 1.1 2.2 3.3 4.4;5.5 6.6 7.7 8.8;10.0 20.0 30.0 40.0;1.1 2.2 3.3 4.4; 11.1 22.2;33.3 44.4;55.5 66.6;11.1 22.2; `); - return submissionToOData(fields, 'Submissions', submission, { wkt: true }).then((result) => { + return submissionToOData(fields, 'Submissions', submission, { wkt: true }).then(({ data: result }) => { result.should.eql([{ __id: 'wkt', __system, @@ -492,7 +492,7 @@ describe('submissionToOData', () => { new MockField({ path: '/age', name: 'age', type: 'int' }) ]; const submission = mockSubmission('one', testData.instances.simple.one); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'one', __system, @@ -518,7 +518,7 @@ describe('submissionToOData', () => { tres `); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'nesting', __system, @@ -530,7 +530,7 @@ describe('submissionToOData', () => { it('should provide navigation links for repeats', () => fieldsFor(testData.forms.withrepeat).then((fields) => { const submission = mockSubmission('rtwo', testData.instances.withrepeat.two); - return submissionToOData(fields, 'Submissions', submission).then((result) => { + return submissionToOData(fields, 'Submissions', submission).then(({ data: result }) => { result.should.eql([{ __id: 'rtwo', __system, @@ -549,7 +549,7 @@ describe('submissionToOData', () => { .then((fields) => { const submission = mockSubmission('two', testData.instances.doubleRepeat.double); return submissionToOData(fields, 'Submissions', submission, { expand: '*' }) - .then((result) => { + .then(({ data: result }) => { result.should.eql([ { __id: 'two', @@ -635,7 +635,7 @@ describe('submissionToOData', () => { it('should extract subtable rows within repeats', () => fieldsFor(testData.forms.withrepeat).then((fields) => { const row = { instanceId: 'two', xml: testData.instances.withrepeat.two, def: {}, aux: { encryption: {}, attachment: {} } }; - return submissionToOData(fields, 'Submissions.children.child', row).then((result) => { + return submissionToOData(fields, 'Submissions.children.child', row).then(({ data: result }) => { result.should.eql([{ '__Submissions-id': 'two', __id: 'cf9a1b5cc83c6d6270c1eb98860d294eac5d526d', @@ -653,7 +653,7 @@ describe('submissionToOData', () => { it('should return navigation links to repeats within a subtable result set', () => fieldsFor(testData.forms.doubleRepeat).then((fields) => { const row = { instanceId: 'double', xml: testData.instances.doubleRepeat.double, def: {}, aux: { encryption: {}, attachment: {} } }; - return submissionToOData(fields, 'Submissions.children.child', row).then((result) => { + return submissionToOData(fields, 'Submissions.children.child', row).then(({ data: result }) => { result.should.eql([{ __id: '46ebf42ee83ddec5028c42b2c054402d1e700208', '__Submissions-id': 'double', @@ -680,7 +680,7 @@ describe('submissionToOData', () => { it('should return second-order subtable results', () => fieldsFor(testData.forms.doubleRepeat).then((fields) => { const row = { instanceId: 'double', xml: testData.instances.doubleRepeat.double, def: {}, aux: { encryption: {}, attachment: {} } }; - return submissionToOData(fields, 'Submissions.children.child.toys.toy', row).then((result) => { + return submissionToOData(fields, 'Submissions.children.child.toys.toy', row).then(({ data: result }) => { result.should.eql([{ __id: 'a9058d7b2ed9557205ae53f5b1dc4224043eca2a', '__Submissions-children-child-id': 'b6e93a81a53eed0566e65e472d4a4b9ae383ee6d', @@ -724,7 +724,7 @@ describe('submissionToOData', () => { new MockField({ path: '/name', name: 'name', type: 'string' }) ]; const submission = mockSubmission('one', testData.instances.simple.one); - return submissionToOData(fields, 'Submissions', submission, { metadata: { __id: true, '__system/status': true } }).then((result) => { + return submissionToOData(fields, 'Submissions', submission, { metadata: { __id: true, '__system/status': true } }).then(({ data: result }) => { result.should.eql([{ __id: 'one', __system: { status: null }, @@ -740,7 +740,7 @@ describe('submissionToOData', () => { new MockField({ path: '/children/child', name: 'child', type: 'repeat' }) ]; const submission = { instanceId: 'double', xml: testData.instances.doubleRepeat.double, def: {}, aux: { encryption: {}, attachment: {} } }; - return submissionToOData(fields, 'Submissions.children.child', submission, { metadata: { __id: true } }).then((result) => { + return submissionToOData(fields, 'Submissions.children.child', submission, { metadata: { __id: true } }).then(({ data: result }) => { result.should.eql([{ __id: '46ebf42ee83ddec5028c42b2c054402d1e700208', '__Submissions-id': 'double' @@ -759,7 +759,7 @@ describe('submissionToOData', () => { .then(selectFields({ $select: 'name' }, 'Submissions.children.child.toys.toy')) .then((fields) => { const row = { instanceId: 'double', xml: testData.instances.doubleRepeat.double, def: {}, aux: { encryption: {}, attachment: {} } }; - return submissionToOData(fields, 'Submissions.children.child.toys.toy', row, { metadata: { __id: true } }).then((result) => { + return submissionToOData(fields, 'Submissions.children.child.toys.toy', row, { metadata: { __id: true } }).then(({ data: result }) => { result.should.eql([{ __id: 'a9058d7b2ed9557205ae53f5b1dc4224043eca2a', '__Submissions-children-child-id': 'b6e93a81a53eed0566e65e472d4a4b9ae383ee6d', diff --git a/test/unit/formats/odata.js b/test/unit/formats/odata.js index 47991c637..1a4bdeead 100644 --- a/test/unit/formats/odata.js +++ b/test/unit/formats/odata.js @@ -577,10 +577,10 @@ describe('odata message composition', () => { const query = { $top: '3', $skip: '2' }; const inRows = streamTest.fromObjects(instances(6)); // make it close to check the off-by-one. fieldsFor(testData.forms.simple) - .then((fields) => rowStreamToOData(fields, 'Submissions', 'http://localhost:8989', '/simple.svc/Submissions?$top=3&$skip=2', query, inRows)) + .then((fields) => rowStreamToOData(fields, 'Submissions', 'http://localhost:8989', '/simple.svc/Submissions?$top=3&$skip=2', query, inRows, 10, 8)) .then((stream) => stream.pipe(streamTest.toText((_, result) => { const resultObj = JSON.parse(result); - resultObj['@odata.nextLink'].should.equal('http://localhost:8989/simple.svc/Submissions?%24skip=5'); + resultObj['@odata.nextLink'].should.equal('http://localhost:8989/simple.svc/Submissions?%24top=3&%24skiptoken=e30%3D'); done(); }))); }); @@ -589,10 +589,10 @@ describe('odata message composition', () => { const query = { $top: '3', $skip: '2', $wkt: 'true', $count: 'true' }; const inRows = streamTest.fromObjects(instances(6)); // make it close to check the off-by-one. fieldsFor(testData.forms.simple) - .then((fields) => rowStreamToOData(fields, 'Submissions', 'http://localhost:8989', '/simple.svc/Submissions?$top=3&$skip=2&$wkt=true&$count=true', query, inRows)) + .then((fields) => rowStreamToOData(fields, 'Submissions', 'http://localhost:8989', '/simple.svc/Submissions?$top=3&$skip=2&$wkt=true&$count=true', query, inRows, 10, 8)) .then((stream) => stream.pipe(streamTest.toText((_, result) => { const resultObj = JSON.parse(result); - resultObj['@odata.nextLink'].should.equal('http://localhost:8989/simple.svc/Submissions?%24skip=5&%24wkt=true&%24count=true'); + resultObj['@odata.nextLink'].should.equal('http://localhost:8989/simple.svc/Submissions?%24top=3&%24wkt=true&%24count=true&%24skiptoken=e30%3D'); done(); }))); }); @@ -669,7 +669,6 @@ describe('odata message composition', () => { .then((stream) => stream.pipe(streamTest.toText((_, result) => { JSON.parse(result).should.eql({ '@odata.context': 'http://localhost:8989/simple.svc/$metadata#Submissions', - '@odata.nextLink': 'http://localhost:8989/simple.svc/Submissions?%24skip=2', value: [ { __id: 'one', __system, meta: { instanceID: 'one' }, name: 'Alice', age: 30 }, { __id: 'two', __system, meta: { instanceID: 'two' }, name: 'Bob', age: 34 }, @@ -746,7 +745,7 @@ describe('odata message composition', () => { .then((stream) => stream.pipe(streamTest.toText((_, result) => { JSON.parse(result).should.eql({ '@odata.context': 'http://localhost:8989/withrepeat.svc/$metadata#Submissions.children.child', - '@odata.nextLink': 'http://localhost:8989/withrepeat.svc/Submissions.children.child?%24skip=2', + '@odata.nextLink': 'http://localhost:8989/withrepeat.svc/Submissions.children.child?%24top=2&%24skiptoken=eyJpbnN0YW5jZUlkIjoidHdvIiwicmVwZWF0SWQiOiJjNzZkMGNjYzZkNWRhMjM2YmU3YjkzYjk4NWE4MDQxM2QyZTNlMTcyIn0%3D', value: [{ __id: 'cf9a1b5cc83c6d6270c1eb98860d294eac5d526d', '__Submissions-id': 'two', @@ -803,7 +802,7 @@ describe('odata message composition', () => { .then((stream) => stream.pipe(streamTest.toText((_, result) => { JSON.parse(result).should.eql({ '@odata.context': 'http://localhost:8989/withrepeat.svc/$metadata#Submissions.children.child', - '@odata.nextLink': 'http://localhost:8989/withrepeat.svc/Submissions.children.child?%24skip=2', + '@odata.nextLink': 'http://localhost:8989/withrepeat.svc/Submissions.children.child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoidHdvIiwicmVwZWF0SWQiOiJjNzZkMGNjYzZkNWRhMjM2YmU3YjkzYjk4NWE4MDQxM2QyZTNlMTcyIn0%3D', value: [{ __id: 'c76d0ccc6d5da236be7b93b985a80413d2e3e172', '__Submissions-id': 'two', @@ -900,7 +899,7 @@ describe('odata message composition', () => { return singleRowToOData(fields, submission, 'http://localhost:8989', "/withrepeat.svc/Submissions('two')/children/child?$top=1", query) .then(JSON.parse) .then((result) => { - result['@odata.nextLink'].should.equal("http://localhost:8989/withrepeat.svc/Submissions('two')/children/child?%24skip=1"); + result['@odata.nextLink'].should.equal("http://localhost:8989/withrepeat.svc/Submissions('two')/children/child?%24top=1&%24skiptoken=eyJpbnN0YW5jZUlkIjoidHdvIiwicmVwZWF0SWQiOiJjZjlhMWI1Y2M4M2M2ZDYyNzBjMWViOTg4NjBkMjk0ZWFjNWQ1MjZkIn0%3D"); }); }); }); @@ -913,7 +912,7 @@ describe('odata message composition', () => { return singleRowToOData(fields, submission, 'http://localhost:8989', "/withrepeat.svc/Submissions('two')/children/child?$top=1&$wkt=true", query) .then(JSON.parse) .then((result) => { - result['@odata.nextLink'].should.equal("http://localhost:8989/withrepeat.svc/Submissions('two')/children/child?%24wkt=true&%24skip=1"); + result['@odata.nextLink'].should.equal("http://localhost:8989/withrepeat.svc/Submissions('two')/children/child?%24top=1&%24wkt=true&%24skiptoken=eyJpbnN0YW5jZUlkIjoidHdvIiwicmVwZWF0SWQiOiJjZjlhMWI1Y2M4M2M2ZDYyNzBjMWViOTg4NjBkMjk0ZWFjNWQ1MjZkIn0%3D"); }); }); }); @@ -1010,7 +1009,7 @@ describe('odata message composition', () => { .then((result) => { result.should.eql({ '@odata.context': 'http://localhost:8989/doubleRepeat.svc/$metadata#Submissions.children.child.toys.toy', - '@odata.nextLink': "http://localhost:8989/doubleRepeat.svc/Submissions('double')/children/child('b6e93a81a53eed0566e65e472d4a4b9ae383ee6d')/toys/toy?%24skip=2", + '@odata.nextLink': "http://localhost:8989/doubleRepeat.svc/Submissions('double')/children/child('b6e93a81a53eed0566e65e472d4a4b9ae383ee6d')/toys/toy?%24top=2&%24skiptoken=eyJpbnN0YW5jZUlkIjoiZG91YmxlIiwicmVwZWF0SWQiOiI4ZDJkYzdiZDNlOTdhNjkwYzA4MTNlNjQ2NjU4ZTUxMDM4ZWI0MTQ0In0%3D", value: [{ __id: 'a9058d7b2ed9557205ae53f5b1dc4224043eca2a', '__Submissions-children-child-id': 'b6e93a81a53eed0566e65e472d4a4b9ae383ee6d', @@ -1063,7 +1062,7 @@ describe('odata message composition', () => { .then((result) => { result.should.eql({ '@odata.context': 'http://localhost:8989/doubleRepeat.svc/$metadata#Submissions.children.child.toys.toy', - '@odata.nextLink': "http://localhost:8989/doubleRepeat.svc/Submissions('double')/children/child('b6e93a81a53eed0566e65e472d4a4b9ae383ee6d')/toys/toy?%24skip=3", + '@odata.nextLink': "http://localhost:8989/doubleRepeat.svc/Submissions('double')/children/child('b6e93a81a53eed0566e65e472d4a4b9ae383ee6d')/toys/toy?%24top=2&%24skiptoken=eyJpbnN0YW5jZUlkIjoiZG91YmxlIiwicmVwZWF0SWQiOiJiNzE2ZGQ4Yjc5YTRjOTM2OWQ2YjFlN2E5YzlkNTVhYzE4ZGExMzE5In0%3D", value: [{ __id: '8d2dc7bd3e97a690c0813e646658e51038eb4144', '__Submissions-children-child-id': 'b6e93a81a53eed0566e65e472d4a4b9ae383ee6d', diff --git a/test/unit/util/util.js b/test/unit/util/util.js index 6408f02f1..bd7e44749 100644 --- a/test/unit/util/util.js +++ b/test/unit/util/util.js @@ -63,5 +63,13 @@ describe('util/util', () => { }); }); + describe('UTF-8 and Base64 conversion', () => { + const { utf8ToBase64, base64ToUtf8 } = util; + it('should convert unicode to base64', () => { + const input = 'a Ā 𐀀 文 🦄'; + const base64 = utf8ToBase64(input); + base64ToUtf8(base64).should.be.eql(input); + }); + }); });