From d85e07d6fe6a0435fa1b911443d88c36a1b03901 Mon Sep 17 00:00:00 2001 From: ionous Date: Sat, 31 Jan 2026 11:47:50 -0800 Subject: [PATCH 1/8] tweaks to existing tests ex. use testData.expectError where possible --- app/test/manage_test.js | 10 ++-------- app/test/ride_count_test.js | 13 +++++++++---- app/test/search_test.js | 8 ++------ 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/app/test/manage_test.js b/app/test/manage_test.js index 6d1b8865..dfeb72ce 100644 --- a/app/test/manage_test.js +++ b/app/test/manage_test.js @@ -94,10 +94,7 @@ describe("managing events", () => { return request(app) .post(manage_api) .send(post) - .expect(400) - .then(res => { - assert.ok(res.body.error.fields[key]); - }); + .then(res => testData.expectError(res, key)); }) } return seq; @@ -126,10 +123,7 @@ describe("managing events", () => { return request(app) .post(manage_api) .send(post) - .expect(400) - .then(res => { - assert.ok(res.body.error.fields[key]); - }); + .then(res => testData.expectError(res, key)); }) } return seq; diff --git a/app/test/ride_count_test.js b/app/test/ride_count_test.js index 41b66df4..bbc1dc12 100644 --- a/app/test/ride_count_test.js +++ b/app/test/ride_count_test.js @@ -1,5 +1,6 @@ const app = require("../appEndpoints"); const testdb = require("./testdb"); +const testData = require("./testData"); // const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); @@ -14,6 +15,12 @@ describe("ride count testing", () => { after(() => { return testdb.destroy(); }); + // FIX! it would be good to support an unspecified range + it("handles an unspecified range", () => { + return request(app) + .get('/api/ride_count.php') + .expect(testData.expectError); + }); it("handles an all encompassing range", () => { return request(app) .get('/api/ride_count.php') @@ -42,14 +49,12 @@ describe("ride count testing", () => { it("errors on a missing time", () => { return request(app) .get('/api/ride_count.php') - .expect(400) - .expect('Content-Type', /json/); + .expect(testData.expectError); }); it("errors on an invalid time", () => { return request(app) .get('/api/ride_count.php') .query({s: "yesterday", e: "tomorrow"}) - .expect(400) - .expect('Content-Type', /json/); + .expect(testData.expectError); }); }); diff --git a/app/test/search_test.js b/app/test/search_test.js index c78fcdfc..ce3a8cb4 100644 --- a/app/test/search_test.js +++ b/app/test/search_test.js @@ -1,5 +1,6 @@ const app = require("../appEndpoints"); const testdb = require("./testdb"); +const testData = require("./testData"); const { EventSearch } = require("../models/calConst"); // const { describe, it, before, after } = require("node:test"); @@ -19,12 +20,7 @@ describe("searching for events", () => { it("errors on an empty search term", () => { return request(app) .get('/api/search.php') - // .query({q: "events"}) - .expect(400) - .expect('Content-Type', /json/) - .then(res => { - assert.ok(res.body?.error, "expects an error string"); - }); + .expect(testData.expectError); }); it("handles a search", () => { return request(app) From 38db9e7dd68421f7124b0e3f4af6a33ae5cf42ab Mon Sep 17 00:00:00 2001 From: ionous Date: Sat, 31 Jan 2026 12:10:01 -0800 Subject: [PATCH 2/8] comments --- app/test/search_test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/test/search_test.js b/app/test/search_test.js index ce3a8cb4..5dde4986 100644 --- a/app/test/search_test.js +++ b/app/test/search_test.js @@ -62,6 +62,7 @@ describe("searching for events", () => { .expect(200) .expect('Content-Type', /json/) .then(res => { + // pagination: still 14 events available; but we've asked for two at a time. assert.equal(res.body?.pagination.fullcount, 14); assert.equal(res.body?.pagination.offset, 0); assert.equal(res.body?.pagination.limit, 2); @@ -83,6 +84,7 @@ describe("searching for events", () => { .expect(200) .expect('Content-Type', /json/) .then(res => { + // pagination: still 14 events available; but we've asked for two at a time. assert.equal(res.body?.pagination?.fullcount, 14); assert.equal(res.body?.pagination?.offset, 2); assert.equal(res.body?.pagination?.limit, 2); From 2e1eec45ce0db6b3bd4d3826f770b542de7e8771 Mon Sep 17 00:00:00 2001 From: ionous Date: Sat, 31 Jan 2026 12:10:19 -0800 Subject: [PATCH 3/8] remove unused import statements --- app/appEndpoints.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/appEndpoints.js b/app/appEndpoints.js index 2b215726..1bd87e4b 100644 --- a/app/appEndpoints.js +++ b/app/appEndpoints.js @@ -2,8 +2,6 @@ const express = require('express'); const config = require("./config"); const errors = require("./util/errors"); const nunjucks = require("./nunjucks"); -const { initMail } = require("./emailer"); -const knex = require("./db"); // initialize on startup const app = express(); // shift.conf for nginx sets the x-forward header @@ -70,5 +68,5 @@ app.use(function(err, req, res, next) { console.error(err.stack); }); -// for testing +// for starting a server or running tests module.exports = app; From a274f6752e666bfba3860b882cf4c977605ee471 Mon Sep 17 00:00:00 2001 From: ionous Date: Thu, 19 Feb 2026 19:11:15 -0800 Subject: [PATCH 4/8] fix test data generation so its not subject to race conditions previously, since the test data was generated as it wrote to the database, different timings of the database writes could generate different data. this generates all the data first, and then writes that data. i also separated the data logging -- which cleans up the code slightly -- the command line make fake data can log without having to worry about the tests generating logs ( and mucking up the test output ) --- app/test/fakeData.js | 60 +++++++------ app/test/testdb.js | 7 +- app/test/testing.md | 184 ++++++++++++++++++++-------------------- tools/makeFakeEvents.js | 10 ++- 4 files changed, 137 insertions(+), 124 deletions(-) diff --git a/app/test/fakeData.js b/app/test/fakeData.js index e1988a81..92c7daa7 100644 --- a/app/test/fakeData.js +++ b/app/test/fakeData.js @@ -9,23 +9,32 @@ const password = "supersecret"; // number of days within which to create fake events const fakeRange = 7; -// promise an array of events +// promise an array of {event: {}, days: [{}]} // - firstDay is a dt day // - numEvents: a number of events to create // - seed: an optional random seed -function makeFakeData(firstDay, lastDay, numEvents) { - const fakeData = generateFakeData(firstDay, lastDay, numEvents); +function insertFakeData(fakeData) { + return db.query.transaction(tx => _insertData(tx, fakeData)); +} + +// +function logFakeData(data) { + data.forEach(({event, days}) => { + const url = config.site.url("addevent", `edit-${event.id}-${event.password}`); + const start = dt.friendlyDate(days[0].eventdate); + console.log(`created ${url}\n "${event.title}" with ${days.length} days starting on ${start}`); + }); +} + +function _insertData(tx, fakeData) { const promisedEvents = fakeData.map(data => { - return db.query('calevent') + return tx('calevent') .insert(data.event).then(row => { - const id = row[0]; // the magic to get the event id. - // when passing a seed (ex. for tests); silence the output. - if (!config.isTesting) { - logData(id, data); - } + const id = row[0]; // the magic to get the event id. + data.event.id = id; // update the data so we can return it. const promisedDays = data.days.map(at => { at.id = id; // assign the id before adding to the db - return db.query('caldaily').insert(at); + return tx('caldaily').insert(at); }); return Promise.all(promisedDays); }); @@ -34,29 +43,29 @@ function makeFakeData(firstDay, lastDay, numEvents) { return Promise.all(promisedEvents); } -// -function logData(id, data) { - const url = config.site.url("addevent", `edit-${id}-${password}`); - const start = dt.friendlyDate(data.days[0].eventdate); - console.log(`created "${data.event.title}" with ${data.days.length} days starting on ${start}\n ${url}`); -} - // build the data before inserting into the db // this avoids race conditions influencing the data generated. -function generateFakeData(firstDay, lastDay, numEvents) { +// nextEventId can be a number, in which case it creates events starting with that id. +function generateFakeData(firstDay, lastDay, numEvents, seed, nextEventId = undefined) { + faker.seed(seed); const out = []; + let nextDayId = nextEventId ? 1 : undefined; for (let i = 0; i< numEvents; i++) { // always have one event on the day specified; // on subsequent events, pick from a range of days. - const start = !i ? firstDay: + const start = !i ? firstDay : dt.convert(faker.date.between({ from: firstDay.toDate(), to: lastDay.toDate(), })); const title = faker.music.songName(); - const event = makeCalEvent(title); + const event = makeCalEvent(title, nextEventId); const numDays = randomDayCount(); - const days = makeCalDailies(start, numDays); + const days = makeCalDailies(start, numDays, nextDayId); + if (nextEventId !== undefined) { + nextEventId += 1; + nextDayId += numDays; + } out.push({ event, days, @@ -66,7 +75,7 @@ function generateFakeData(firstDay, lastDay, numEvents) { } // export! -module.exports = { makeFakeData }; +module.exports = { insertFakeData, generateFakeData, logFakeData }; function randomDayCount() { // some dumb weighted random @@ -88,7 +97,7 @@ function nextDay(days, refDate) { }); } -function makeCalDailies(start, numDays) { +function makeCalDailies(start, numDays, nextPkid = undefined) { const out = []; const active = faker.datatype.boolean(0.8); const flash = faker.datatype.boolean(!active? 0.8: 0.3); @@ -103,6 +112,7 @@ function makeCalDailies(start, numDays) { eventdate : db.toDate(start), eventstatus : active? EventStatus.Active : EventStatus.Cancelled, newsflash : msg, + pkid : (nextPkid !== undefined) ? (nextPkid++) : undefined, }); } return out; @@ -113,7 +123,7 @@ function capitalize(str, yes= true) { return (yes? first.toUpperCase() : first.toLowerCase() ) + str.slice(1); } -function makeCalEvent(title) { +function makeCalEvent(title, predefinedId = undefined) { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); const organizer = faker.person.fullName({firstName, lastName}); @@ -165,7 +175,7 @@ function makeCalEvent(title) { // created, // modified, changes, - // id: eventId, + id: predefinedId, name: organizer, email, hideemail, diff --git a/app/test/testdb.js b/app/test/testdb.js index c9b318c2..d1f85679 100644 --- a/app/test/testdb.js +++ b/app/test/testdb.js @@ -4,7 +4,7 @@ const dt = require("../util/dateTime"); const { faker } = require('@faker-js/faker'); const testData = require("./testData"); const db = require("../db"); -const { makeFakeData } = require("./fakeData"); +const { generateFakeData, insertFakeData } = require("./fakeData"); module.exports = { // generates a hand rolled set of data @@ -23,8 +23,9 @@ module.exports = { const firstDay = dt.fromYMDString("2002-08-01"); const lastDay = dt.fromYMDString("2002-08-31"); const numEvents = 46; - faker.seed(23204); // keeps the generated data stable. - await makeFakeData(firstDay, lastDay, numEvents); + const seed = 23204; // keeps the generated data stable. + const fakeData = generateFakeData(firstDay, lastDay, numEvents, seed, seed); + return insertFakeData(fakeData); }, destroy() { // leaves the tables in place; lets create drop them when needed. diff --git a/app/test/testing.md b/app/test/testing.md index 4d3313bb..93b0831a 100644 --- a/app/test/testing.md +++ b/app/test/testing.md @@ -17,95 +17,95 @@ By default tests use sqlite, you can test against mysql as well: `npm test -db= fakeData.js generates the following events: -* "The Tracks of My Tears" with 1 days starting on 2002-08-01 - http://localhost:3080/addevent/edit-1-supersecret -* "Knock On Wood" with 1 days starting on 2002-08-20 - http://localhost:3080/addevent/edit-2-supersecret -* "Tonight's the Night (Gonna Be Alright)" with 2 days starting on 2002-08-02 - http://localhost:3080/addevent/edit-3-supersecret -* "One" with 2 days starting on 2002-08-02 - http://localhost:3080/addevent/edit-4-supersecret -* "Whip It" with 4 days starting on 2002-08-16 - http://localhost:3080/addevent/edit-5-supersecret -* "Losing My Religion" with 5 days starting on 2002-08-22 - http://localhost:3080/addevent/edit-6-supersecret -* "I'm a Believer" with 1 days starting on 2002-08-20 - http://localhost:3080/addevent/edit-7-supersecret -* "Hips don't lie" with 1 days starting on 2002-08-28 - http://localhost:3080/addevent/edit-8-supersecret -* "Living For the City" with 1 days starting on 2002-08-01 - http://localhost:3080/addevent/edit-9-supersecret -* "Shake Down" with 1 days starting on 2002-08-08 - http://localhost:3080/addevent/edit-10-supersecret -* "Wicked Game" with 3 days starting on 2002-08-19 - http://localhost:3080/addevent/edit-11-supersecret -* "Jive Talkin'" with 2 days starting on 2002-08-28 - http://localhost:3080/addevent/edit-12-supersecret -* "Wheel of Fortune" with 3 days starting on 2002-08-17 - http://localhost:3080/addevent/edit-13-supersecret -* "Travellin' Band" with 1 days starting on 2002-08-09 - http://localhost:3080/addevent/edit-14-supersecret -* "Bye" with 1 days starting on 2002-08-07 - http://localhost:3080/addevent/edit-15-supersecret -* "The Girl From Ipanema" with 2 days starting on 2002-08-26 - http://localhost:3080/addevent/edit-16-supersecret -* "If (They Made Me a King)" with 2 days starting on 2002-08-15 - http://localhost:3080/addevent/edit-17-supersecret -* "This Used to Be My Playground" with 2 days starting on 2002-08-16 - http://localhost:3080/addevent/edit-18-supersecret -* "Crying" with 5 days starting on 2002-08-27 - http://localhost:3080/addevent/edit-19-supersecret -* "Na Na Hey Hey (Kiss Him Goodbye)" with 2 days starting on 2002-08-13 - http://localhost:3080/addevent/edit-20-supersecret -* "Upside Down" with 1 days starting on 2002-08-28 - http://localhost:3080/addevent/edit-21-supersecret -* "Love Me Do" with 4 days starting on 2002-08-20 - http://localhost:3080/addevent/edit-22-supersecret -* "Breathe" with 5 days starting on 2002-08-09 - http://localhost:3080/addevent/edit-23-supersecret -* "Brandy (You're A Fine Girl)" with 2 days starting on 2002-08-23 - http://localhost:3080/addevent/edit-24-supersecret -* "Swanee" with 2 days starting on 2002-08-16 - http://localhost:3080/addevent/edit-25-supersecret -* "Earth Angel" with 1 days starting on 2002-08-16 - http://localhost:3080/addevent/edit-26-supersecret -* "Let's Get it On" with 1 days starting on 2002-08-25 - http://localhost:3080/addevent/edit-27-supersecret -* "Arthur's Theme (Best That You Can Do)" with 1 days starting on 2002-08-08 - http://localhost:3080/addevent/edit-28-supersecret -* "Sunday" with 1 days starting on 2002-08-25 - http://localhost:3080/addevent/edit-29-supersecret -* "Nothing's Gonna Stop Us Now" with 2 days starting on 2002-08-25 - http://localhost:3080/addevent/edit-30-supersecret -* "Change the World" with 4 days starting on 2002-08-01 - http://localhost:3080/addevent/edit-31-supersecret -* "Tammy" with 2 days starting on 2002-08-10 - http://localhost:3080/addevent/edit-32-supersecret -* "Come Together" with 2 days starting on 2002-08-10 - http://localhost:3080/addevent/edit-33-supersecret -* "Take On Me" with 2 days starting on 2002-08-03 - http://localhost:3080/addevent/edit-34-supersecret -* "Fantasy" with 1 days starting on 2002-08-10 - http://localhost:3080/addevent/edit-35-supersecret -* "Centerfold" with 5 days starting on 2002-08-02 - http://localhost:3080/addevent/edit-36-supersecret -* "I Gotta Feeling" with 2 days starting on 2002-08-18 - http://localhost:3080/addevent/edit-37-supersecret -* "I Can't Get Started" with 2 days starting on 2002-08-19 - http://localhost:3080/addevent/edit-38-supersecret -* "Only The Lonely (Know The Way I Feel)" with 2 days starting on 2002-08-27 - http://localhost:3080/addevent/edit-39-supersecret -* "Escape (The Pina Colada Song)" with 1 days starting on 2002-08-07 - http://localhost:3080/addevent/edit-40-supersecret -* "(Ghost) Riders in the Sky" with 2 days starting on 2002-08-04 - http://localhost:3080/addevent/edit-41-supersecret -* "When a Man Loves a Woman" with 3 days starting on 2002-08-24 - http://localhost:3080/addevent/edit-42-supersecret -* "Dreamlover" with 1 days starting on 2002-08-27 - http://localhost:3080/addevent/edit-43-supersecret -* "Brown Eyed Girl" with 3 days starting on 2002-08-20 - http://localhost:3080/addevent/edit-44-supersecret -* "(They Long to Be) Close to You" with 1 days starting on 2002-08-17 - http://localhost:3080/addevent/edit-45-supersecret -* "Rock With You" with 1 days starting on 2002-08-24 - http://localhost:3080/addevent/edit-46-supersecret \ No newline at end of file +* http://localhost:3080/addevent/edit-23204-supersecret + "The Tracks of My Tears" with 1 days starting on Thu, Aug 1st +* http://localhost:3080/addevent/edit-23205-supersecret + "Knock On Wood" with 1 days starting on Tue, Aug 20th +* http://localhost:3080/addevent/edit-23206-supersecret + "Tonight's the Night (Gonna Be Alright)" with 2 days starting on Fri, Aug 2nd +* http://localhost:3080/addevent/edit-23207-supersecret + "One" with 2 days starting on Fri, Aug 2nd +* http://localhost:3080/addevent/edit-23208-supersecret + "Whip It" with 4 days starting on Fri, Aug 16th +* http://localhost:3080/addevent/edit-23209-supersecret + "Losing My Religion" with 5 days starting on Thu, Aug 22nd +* http://localhost:3080/addevent/edit-23210-supersecret + "I'm a Believer" with 1 days starting on Tue, Aug 20th +* http://localhost:3080/addevent/edit-23211-supersecret + "Hips don't lie" with 1 days starting on Wed, Aug 28th +* http://localhost:3080/addevent/edit-23212-supersecret + "Living For the City" with 1 days starting on Thu, Aug 1st +* http://localhost:3080/addevent/edit-23213-supersecret + "Shake Down" with 1 days starting on Thu, Aug 8th +* http://localhost:3080/addevent/edit-23214-supersecret + "Wicked Game" with 3 days starting on Mon, Aug 19th +* http://localhost:3080/addevent/edit-23215-supersecret + "Jive Talkin'" with 2 days starting on Wed, Aug 28th +* http://localhost:3080/addevent/edit-23216-supersecret + "Wheel of Fortune" with 3 days starting on Sat, Aug 17th +* http://localhost:3080/addevent/edit-23217-supersecret + "Travellin' Band" with 1 days starting on Fri, Aug 9th +* http://localhost:3080/addevent/edit-23218-supersecret + "Bye" with 1 days starting on Wed, Aug 7th +* http://localhost:3080/addevent/edit-23219-supersecret + "The Girl From Ipanema" with 2 days starting on Mon, Aug 26th +* http://localhost:3080/addevent/edit-23220-supersecret + "If (They Made Me a King)" with 2 days starting on Thu, Aug 15th +* http://localhost:3080/addevent/edit-23221-supersecret + "This Used to Be My Playground" with 2 days starting on Fri, Aug 16th +* http://localhost:3080/addevent/edit-23222-supersecret + "Crying" with 5 days starting on Tue, Aug 27th +* http://localhost:3080/addevent/edit-23223-supersecret + "Na Na Hey Hey (Kiss Him Goodbye)" with 2 days starting on Tue, Aug 13th +* http://localhost:3080/addevent/edit-23224-supersecret + "Upside Down" with 1 days starting on Wed, Aug 28th +* http://localhost:3080/addevent/edit-23225-supersecret + "Love Me Do" with 4 days starting on Tue, Aug 20th +* http://localhost:3080/addevent/edit-23226-supersecret + "Breathe" with 5 days starting on Fri, Aug 9th +* http://localhost:3080/addevent/edit-23227-supersecret + "Brandy (You're A Fine Girl)" with 2 days starting on Fri, Aug 23rd +* http://localhost:3080/addevent/edit-23228-supersecret + "Swanee" with 2 days starting on Fri, Aug 16th +* http://localhost:3080/addevent/edit-23229-supersecret + "Earth Angel" with 1 days starting on Fri, Aug 16th +* http://localhost:3080/addevent/edit-23230-supersecret + "Let's Get it On" with 1 days starting on Sun, Aug 25th +* http://localhost:3080/addevent/edit-23231-supersecret + "Arthur's Theme (Best That You Can Do)" with 1 days starting on Thu, Aug 8th +* http://localhost:3080/addevent/edit-23232-supersecret + "Sunday" with 1 days starting on Sun, Aug 25th +* http://localhost:3080/addevent/edit-23233-supersecret + "Nothing's Gonna Stop Us Now" with 2 days starting on Sun, Aug 25th +* http://localhost:3080/addevent/edit-23234-supersecret + "Change the World" with 4 days starting on Thu, Aug 1st +* http://localhost:3080/addevent/edit-23235-supersecret + "Tammy" with 2 days starting on Sat, Aug 10th +* http://localhost:3080/addevent/edit-23236-supersecret + "Come Together" with 2 days starting on Sat, Aug 10th +* http://localhost:3080/addevent/edit-23237-supersecret + "Take On Me" with 2 days starting on Sat, Aug 3rd +* http://localhost:3080/addevent/edit-23238-supersecret + "Fantasy" with 1 days starting on Sat, Aug 10th +* http://localhost:3080/addevent/edit-23239-supersecret + "Centerfold" with 5 days starting on Fri, Aug 2nd +* http://localhost:3080/addevent/edit-23240-supersecret + "I Gotta Feeling" with 2 days starting on Sun, Aug 18th +* http://localhost:3080/addevent/edit-23241-supersecret + "I Can't Get Started" with 2 days starting on Mon, Aug 19th +* http://localhost:3080/addevent/edit-23242-supersecret + "Only The Lonely (Know The Way I Feel)" with 2 days starting on Tue, Aug 27th +* http://localhost:3080/addevent/edit-23243-supersecret + "Escape (The Pina Colada Song)" with 1 days starting on Wed, Aug 7th +* http://localhost:3080/addevent/edit-23244-supersecret + "(Ghost) Riders in the Sky" with 2 days starting on Sun, Aug 4th +* http://localhost:3080/addevent/edit-23245-supersecret + "When a Man Loves a Woman" with 3 days starting on Sat, Aug 24th +* http://localhost:3080/addevent/edit-23246-supersecret + "Dreamlover" with 1 days starting on Tue, Aug 27th +* http://localhost:3080/addevent/edit-23247-supersecret + "Brown Eyed Girl" with 3 days starting on Tue, Aug 20th +* http://localhost:3080/addevent/edit-23248-supersecret + "(They Long to Be) Close to You" with 1 days starting on Sat, Aug 17th +* http://localhost:3080/addevent/edit-23249-supersecret + "Rock With You" with 1 days starting on Sat, Aug 24th diff --git a/tools/makeFakeEvents.js b/tools/makeFakeEvents.js index 99fd0d91..e9bed89a 100644 --- a/tools/makeFakeEvents.js +++ b/tools/makeFakeEvents.js @@ -2,9 +2,9 @@ * create one or more fake events. * ex. npm run -w tools make-fake-events */ -const knex = require("shift-docs/db"); +const db = require("shift-docs/db"); const dt = require("shift-docs/util/dateTime"); -const { makeFakeData } = require("shift-docs/test/fakeData"); +const { generateFakeData, insertFakeData, logFakeData } = require("shift-docs/test/fakeData"); // todo: improve commandline parsing // this uses npm's command vars ( probably a bad idea ) @@ -31,10 +31,12 @@ async function makeFakeEvents() { const firstDay = dt.getNow().add(args.start, 'days'); const lastDay = firstDay.add(args.range, 'days'); const numEvents = args.make; - return knex.initialize().then(_ => { - return makeFakeData(firstDay, lastDay, numEvents); + const fakeData = generateFakeData(firstDay, lastDay, numEvents); + return db.initialize().then(_ => { + return insertFakeData(fakeData); }) .then(_ => { + logFakeData(fakeData); console.log("done"); // can't use top-level "await" with commonjs modules // ( ie. await makeFakeEvents() ) From 3beaed3ad502ebf7d509a8419d34901661bc0f0e Mon Sep 17 00:00:00 2001 From: ionous Date: Thu, 19 Feb 2026 19:34:23 -0800 Subject: [PATCH 5/8] update comments --- app/test/fakeData.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/test/fakeData.js b/app/test/fakeData.js index 92c7daa7..0f8b648d 100644 --- a/app/test/fakeData.js +++ b/app/test/fakeData.js @@ -9,23 +9,12 @@ const password = "supersecret"; // number of days within which to create fake events const fakeRange = 7; -// promise an array of {event: {}, days: [{}]} -// - firstDay is a dt day -// - numEvents: a number of events to create -// - seed: an optional random seed +// writes an array of the fake data format into the db. +// separating generation from insertion prevents race conditions from influencing the generated data. function insertFakeData(fakeData) { return db.query.transaction(tx => _insertData(tx, fakeData)); } -// -function logFakeData(data) { - data.forEach(({event, days}) => { - const url = config.site.url("addevent", `edit-${event.id}-${event.password}`); - const start = dt.friendlyDate(days[0].eventdate); - console.log(`created ${url}\n "${event.title}" with ${days.length} days starting on ${start}`); - }); -} - function _insertData(tx, fakeData) { const promisedEvents = fakeData.map(data => { return tx('calevent') @@ -43,8 +32,19 @@ function _insertData(tx, fakeData) { return Promise.all(promisedEvents); } -// build the data before inserting into the db -// this avoids race conditions influencing the data generated. +// prints an array of fake data format to console. +function logFakeData(data) { + data.forEach(({event, days}) => { + const url = config.site.url("addevent", `edit-${event.id}-${event.password}`); + const start = dt.friendlyDate(days[0].eventdate); + console.log(`created ${url}\n "${event.title}" with ${days.length} days starting on ${start}`); + }); +} + +// promise an array of {event: {}, days: [{}]} +// - firstDay is a dt day +// - numEvents: a number of events to create +// - seed: an optional random seed // nextEventId can be a number, in which case it creates events starting with that id. function generateFakeData(firstDay, lastDay, numEvents, seed, nextEventId = undefined) { faker.seed(seed); From 38d8aa4dfd4a4ea6e626235025641c8635e5a01c Mon Sep 17 00:00:00 2001 From: ionous Date: Thu, 19 Mar 2026 17:55:20 -0700 Subject: [PATCH 6/8] use a custom test runner to handle command line params --- .vscode/launch.json | 6 ++-- app/config.js | 69 +++++++++++++++++++++++++++++--------- app/db.js | 5 ++- app/package.json | 2 +- app/test/db_setup.js | 42 ++++++++++++----------- app/test/testRunner.js | 74 +++++++++++++++++++++++++++++++++++++++++ app/test/testdb.js | 4 +-- app/test/testing.md | 9 ++--- app/util/cmdLine.js | 64 +++++++++++++++++++++++++++++++++++ package.json | 2 +- tools/makeFakeEvents.js | 2 +- 11 files changed, 230 insertions(+), 49 deletions(-) create mode 100644 app/test/testRunner.js create mode 100644 app/util/cmdLine.js diff --git a/.vscode/launch.json b/.vscode/launch.json index 57440f73..e51e057b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Launch via NPM", + "name": "Node Server", "request": "launch", "runtimeArgs": [ "run-script", @@ -18,7 +18,7 @@ "type": "node" }, { - "name": "Test", + "name": "Full Test", "request": "launch", "runtimeArgs": [ "run-script", @@ -29,6 +29,6 @@ "/**" ], "type": "node" - }, + } ] } \ No newline at end of file diff --git a/app/config.js b/app/config.js index 3934b2e8..af4fb504 100644 --- a/app/config.js +++ b/app/config.js @@ -1,10 +1,34 @@ +// global configuration for the backend +// holds all environment, commandline options, paths, etc. +// +// const config = require('./config'); +// const path = require('path'); const fs = require("fs"); +const { CommandLine } = require('./util/cmdLine.js'); +// helper to read environment variables function env_default(field, def) { return process.env[field] ?? def; } +// helper to turn lines of quoted text into a single string +function lines(...lines) { + return lines.join("\n"); +} + +const cmdLine = new CommandLine({ + db: lines( + `an optional string. can be 'sqlite' or 'mysql'.`, + `the behavior depends on context. for tests, defaults to sqlite. for prod and dev defaults to mysql.`, + `- 'mysql': requires docker. for prod and dev pulls its full configuration from the environment; tests have a hardcoded configuration.`, + `- 'sqlite': can followed by the path to its file. For example: '-db=sqlite:../bin/events.db'. That example path is its default.`, + ), + db_debug: lines( + `an optional flag. adds extra debugging info for queries.`, + ), +}); + // node server listen for its http requests // fix? there is no such environment variable right now. const listen = env_default('NODE_PORT', 3080); @@ -15,23 +39,26 @@ const siteHost = siteUrl(listen); // location of app.js ( same as config.js ) const appPath = path.resolve(__dirname); -// for max file size +// for max image size const bytesPerMeg = 1024*1024; const staticFiles = env_default('SHIFT_STATIC_FILES'); -const isTesting = !!(process.env.npm_lifecycle_event || "").match(/test$/); -// read the command line parameter for db configuration -const dbType = env_default('npm_config_db'); -const dbDebug = !!env_default('npm_config_db_debug'); +// running node --test with a glob ( ex. node --test **/*_test.js ) +// injects all of those files onto the command line as separate arguments. +const isTesting = process.env.npm_lifecycle_event && process.env.npm_lifecycle_event.startsWith('test'); +// ^ FIX: check for --test if running node directly? + +const dbConfig = getDatabaseConfig(cmdLine.options.db, cmdLine.bool('db_debug'), isTesting); +// The config object exported const config = { appPath, api: { header: 'Api-Version', version: "3.59.10", }, - db: getDatabaseConfig(dbType, isTesting), + db: dbConfig, // maybe bad, but some code likes to know: isTesting, // a nodemailer friendly config, or false if smtp is not configured. @@ -166,7 +193,7 @@ function getSmtpSettings() { if (emailCfg) { try { const raw= fs.readFileSync(emailCfg, "utf8"); - return JSON.parse(raw); + return JSON.getBool(raw); } catch (err) { // its okay if there is no such file... if (err.code !== 'ENOENT') { @@ -195,17 +222,27 @@ function getSmtpSettings() { } } -// our semi-agnostic database configuration -function getDatabaseConfig(dbType, isTesting) { - // dbType comes from the command-line - // if nothing was specfied, use the MYSQL_DATABASE environment variable - const env = env_default('MYSQL_DATABASE') - if (!dbType && env) { - dbType = env.startsWith("sqlite") ? env : null; - } +// nothing specified in the env fails +// sqlite specified in the env uses.... sqlite. +// anything other than sqlite in the env is assumed to be a mysql spec. +function readDbEnv(env = 'MYSQL_DATABASE') { + const dbType = env_default(env); if (!dbType) { - dbType = isTesting ? 'sqlite' : 'mysql' + throw new Error(`expected db type on command line or '${env}' env variable`); } + return dbType.startsWith("sqlite") ? dbType : 'mysql'; +} + +// returns our semi-agnostic database configuration. +// read the command line and environment for the desired db. +// +// dbType can be 'sqlite' or 'mysql' or none. +// 'mysql' pulls its full configuration from the environment. +// 'sqlite' can optionally followed by the path to its file. +// testing defaults to sqlite; dev and prod fallback to the environment. +// +function getDatabaseConfig(dbType, dbDebug, isTesting) { + dbType = dbType || (isTesting ? 'sqlite' : readDbEnv()); const [name, parts] = dbType.split(':'); const config = { mysql: !isTesting ? getMysqlDefault : getMysqlTesting, diff --git a/app/db.js b/app/db.js index 991cbb89..b0d4912e 100644 --- a/app/db.js +++ b/app/db.js @@ -7,6 +7,7 @@ const dt = require("./util/dateTime"); const dbConfig = unpackConfig(config.db); const useSqlite = config.db.type === 'sqlite'; const dropOnCreate = config.db.connect?.name === 'shift_test'; +let prevSetup; const db = { config: config.db, @@ -19,10 +20,11 @@ const db = { // waits to open a connection. async initialize(name) { if (db.query) { - throw new Error("db already initialized"); + throw new Error(`db being initialized by ${name} when already initialized by ${this.initialized}.`); } const connection = knex(dbConfig); db.query = connection; + db.initialized = name; await connection; }, @@ -33,6 +35,7 @@ const db = { throw new Error("db already destroyed"); } db.query = false; + db.initialized = false; return connection.destroy(); }, diff --git a/app/package.json b/app/package.json index 2bad03e3..8f70c937 100644 --- a/app/package.json +++ b/app/package.json @@ -20,7 +20,7 @@ }, "scripts": { "start": "node app.js", - "test": "node --test --trace-warnings --test-global-setup=test/db_setup.js --test-concurrency=1 **/*_test.js --" + "test": "node ./test/testRunner.js" }, "devDependencies": { "shelljs": "^0.10.0", diff --git a/app/test/db_setup.js b/app/test/db_setup.js index bbeff445..97089953 100644 --- a/app/test/db_setup.js +++ b/app/test/db_setup.js @@ -1,25 +1,21 @@ +// Configures mysql while testing. // // > npm test -db=mysql -db_debug=true // -const shell = require( 'shelljs'); // cross-platform shell interaction -const { setTimeout } = require('node:timers/promises'); -const db = require('../db'); +const shell = require('shelljs'); // talks to docker in a cross-platform way. +const { setTimeout } = require('node:timers/promises'); // mysql takes time to start. +const db = require('../db'); -// re: Unknown cli config "--db". This will stop working in the next major version of npm. -// the alternative is ugly; tbd but would rather wait till it breaks. -// const custom = process.argv.indexOf("--"); -// if (custom >= 0) { -// console.log(process.argv.slice(custom+1)); -// } +const dockerImage = `mysql:8.4.7`; // fix? pull from env somewhere. function shutdownMysql() { - console.log(`shutting down up docker mysql...`); + console.log(`shutting down up docker...`); const code = shell.exec("docker stop test_shift", {silent: true}).code; - console.log(`docker shutdown ${code}.`); + console.log(`docker shutdown ${code === 0 ? 'successfully' : code}.`); } async function startupMysql(connect) { - console.log(`setting up docker mysql...`); + console.log(`setting up docker image ${dockerImage}...`); // ex. if a test failed and the earlier run didn't exit well. const alreadyExists = shell.exec("docker start test_shift", {silent: true}).code === 0; if (alreadyExists) { @@ -27,21 +23,21 @@ async function startupMysql(connect) { console.log("docker test_shift already running. reusing the container."); } - // setup docker mysql + // setup docker mysql for testing // https://dev.mysql.com/doc/refman/8.4/en/docker-mysql-more-topics.html // https://docs.docker.com/reference/cli/docker/container/run/ // https://hub.docker.com/_/mysql/ - if (!alreadyExists && shell.exec(lines( + if (!alreadyExists && shell.exec(join( `docker run`, `--name test_shift`, // container name `--detach`, // keep running the container in the background `--rm` , // cleanup the container after exit - `-p ${connect.port}:3306`, // expose mysql's internal 3306 port as our custom port + `-p ${connect.port}:3306`, // expose mysql's internal 3306 port as our custom port `-e MYSQL_RANDOM_ROOT_PASSWORD=true`, // alt: MYSQL_ROOT_PASSWORD `-e MYSQL_DATABASE=${connect.name}`, `-e MYSQL_USER=${connect.user}`, `-e MYSQL_PASSWORD=${connect.pass}`, - `mysql:8.4.7`, // fix? pull from env somewhere. + dockerImage, `--disable-log-bin=true`, `--character-set-server=utf8mb4`, `--collation-server=utf8mb4_unicode_ci`)).code !== 0) { @@ -51,7 +47,7 @@ async function startupMysql(connect) { } async function initConnection() { - // configure the connection + // configure a connection await db.initialize("test setup"); // wait for an empty query to succeed. @@ -70,25 +66,31 @@ async function initConnection() { console.log(`waiting ${i} seconds....`); await setTimeout(i * 1000); } + + await db.destroy(); } -// helpers +// helper to read environment variables function env_default(field, def) { return process.env[field] ?? def; } -function lines(...lines) { + +// helper to turn lines of quoted text into a single string +function join(...lines) { return lines.join(" "); } +// when testing, called once before any tests start async function globalSetup() { + console.log("global setup using: ", JSON.stringify(db.config, null, " ")); if (db.config.type === 'mysql') { await startupMysql(db.config.connect); } await initConnection(); } +// when testing, called once after all tests have completed async function globalTeardown() { - await db.destroy(); if (db.config.type === 'mysql') { shutdownMysql(); } diff --git a/app/test/testRunner.js b/app/test/testRunner.js new file mode 100644 index 00000000..24a92e21 --- /dev/null +++ b/app/test/testRunner.js @@ -0,0 +1,74 @@ +// +// A custom wrapper for running multiple tests. +// +// Rationale: node's test runner ignores multiple files when using its only and pattern filters. +// https://github.com/nodejs/node/issues/51384 +// +const fs = require('fs'); // search for test files +const path = require('path'); // building paths to run those files +const shell = require('shelljs'); // runs node from node; what could be simpler :sob:. +const { CommandLine } = require('../util/cmdLine.js'); +const { globalSetup, globalTeardown } = require('./db_setup.js'); + +const cmdLine = new CommandLine({ + only: `flag to invoke --test-only`, + pattern: `regex for --test-name-pattern`, +}); + +// arguments to pass to node for each test +const testArgs = [ + `--trace-warnings`, + `--test-concurrency=1`, + // normally we'd let node handle the global setup ( ex. starting docker ) + // however, when running multiple tests, the setup should only happen once. + //`--test-global-setup=test/db_setup.js` +]; + +// the custom test runner +async function runTests() { + const startingDir = process.cwd(); // ex. /Users/ionous/dev/shift/real-shift/app + const originalCmdLine = process.argv.slice(2); // skip 0 (bin/node) and 1 (testRunner.js) + await globalSetup(); + _runTests(startingDir, testArgs, originalCmdLine, cmdLine.options); + await globalTeardown(); +} +runTests(); + +// helpers: +function _runTests(dir, args, orig, { only, pattern }) { + if (only && pattern) { + throw new Error(`The test runner expects at most one option. Both only and pattern were specified.`); + } + const files = findTestFiles(dir); + if (!only && !pattern) { + // the regular test command can handle processing multiple files + test([`--test`].concat(args, files, '--', orig)); + + } else if (only) { + // find will stop running tests when something returns a non-zero error code. + files.find(f => test([`--test-only`].concat(args, f,'--', orig)) !== 0 ); + + } else if (pattern) { + // find will stop running tests when something returns a non-zero error code. + files.find(f => test(args.concat(`--test-name-pattern="${pattern}"`, f)) !== 0 ); + } +} + +// return an array of filenames to test +// (returned paths are relative to dir) +function findTestFiles(dir) { + const entries = fs.readdirSync(dir, { + withFileTypes: true, + recursive: true, + }); + return entries.filter(f => f.name.endsWith("_test.js")) + .map(f => path.relative(dir, path.resolve(f.parentPath, f.name))); +} + +function test(parts) { + const cmdLine = `node ${parts.join(' ')}`; + shell.echo(cmdLine); + // note: shell.cmd is safer, but i can't get it to work with quoted text options. :shrug: + // ex. --test-name-pattern="date time" looks correct when echo'd but doesn't pass the pattern to node. + return shell.exec(cmdLine).code; +} \ No newline at end of file diff --git a/app/test/testdb.js b/app/test/testdb.js index d1f85679..89899197 100644 --- a/app/test/testdb.js +++ b/app/test/testdb.js @@ -9,7 +9,7 @@ const { generateFakeData, insertFakeData } = require("./fakeData"); module.exports = { // generates a hand rolled set of data setupTestData: async (name) => { - await db.initialize(); + await db.initialize('setupTestData'); await tables.dropTables(); await tables.createTables(); faker.seed(23204); // uses lorem generator @@ -17,7 +17,7 @@ module.exports = { }, // uses faker to generate a good amount of fake data setupFakeData: async (name) => { - await db.initialize(); + await db.initialize('setupFakeData'); await tables.dropTables(); await tables.createTables(); const firstDay = dt.fromYMDString("2002-08-01"); diff --git a/app/test/testing.md b/app/test/testing.md index 93b0831a..9f52484c 100644 --- a/app/test/testing.md +++ b/app/test/testing.md @@ -2,16 +2,17 @@ To test the backend, at the root of repo run: `npm test`. - All tests use the [Node Test runner](https://nodejs.org/docs/latest/api/test.html#test-runner), with [supertest](https://github.com/forwardemail/supertest) for making (local) http requests. ## Isolating tests: -Tests can be identified by name: ex. `npm test -- --test-name-pattern="ical feed"` +Tests can be singled out by name: ex. `npm test -- -pattern="ical"` + +Or, in the test code, they can be temporarily marked with 'only'. For example: `describe.only("ical feed")`, and then isolated with: `npm test -- -only` -Or, temporarily can be marked with 'only' in the code. For example: `describe.only()`, and then selected with: `npm test -- --test-only``` +# Mysql tests: -By default tests use sqlite, you can test against mysql as well: `npm test -db=mysql`. It launches a standalone docker container for the tests. Additionally, `npm test -db_debug` will log queries to the db. +By default tests use sqlite, you can test against mysql as well: `npm test -- -db=mysql`. It launches a standalone docker container for the tests. Additionally, `npm test -- -db_debug` will log queries to the db. # Test Data diff --git a/app/util/cmdLine.js b/app/util/cmdLine.js new file mode 100644 index 00000000..c833ef64 --- /dev/null +++ b/app/util/cmdLine.js @@ -0,0 +1,64 @@ + +// a simple helper to parse key=value parameters passed via the command line. +// adds the user specified values to a '.options' member +// ex. `-hello=world` becomes `.options.hello === 'world` +class CommandLine { + // pass valid command line options as a map of name to documentation. + // will throw if the user has specified an option that isn't part of the known set + constructor(known) { + const pairs = process.argv + .filter(arg => arg.startsWith('-') && !arg.startsWith('--')) + .map(arg => { + const match = arg.slice(1).match(/^(\w+)=(.+)|(\w+)$/); + // an argument '-a=b' gets split into key, value + // an argument '-c' gets assigned to flag + const [str, key, value, flag] = (match || [arg]); + // return them both as pairs to generate the options + return (key !== undefined) ? [key, value] : + (flag !== undefined) ? [flag, 'true'] : + [str]; + }); + // note: allows unknown options + // ( ex. so test runner and tests can have overlapping command lines ) + this.known = known; + this.options = Object.fromEntries(pairs); + } + // read a true/false style option. + // or undefined if no such option was specified. + bool(key) { + if (!(key in this.known)) { + console.error(`unknown key ${key}.`); + this.listKnown(this.known, console.error); + throw new Error("unexpected boolean key"); + } + const value = this.options[key]; + if (value) { + const map = { + '0': false, 'false': false, + '1': true, 'true': true, + }; + const res = map[value]; + if (res === undefined) { + console.error(`CommandLine had '${value}' when a boolean value for '${key}' was expected`); + throw new Error("unexpected boolean value"); + } + return res; + } + } +} + +function listKnown(known, out) { + out(`CommandLine options:`); + Object.keys(known).forEach(key => { + const value = known[key]; + out(`* ${key} = ${value}`); + }); +} + +function quote(list) { + return list.map(quote => `'${quote}'`).join(", ") +} + +module.exports = { + CommandLine +}; diff --git a/package.json b/package.json index 2e33008e..9df17ce8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "postdeploy": "cp site/public/404/index.html site/public/404.html", "postinstall": "hugo-installer --version 0.144.2", - "test": "npm -w app test --", + "test": "npm run -w app test --", "preview": "concurrently --kill-others-on-fail \"npm:preview-*\"", "preview-hugo": "npm run dev-hugo", diff --git a/tools/makeFakeEvents.js b/tools/makeFakeEvents.js index e9bed89a..f8d2e5dc 100644 --- a/tools/makeFakeEvents.js +++ b/tools/makeFakeEvents.js @@ -32,7 +32,7 @@ async function makeFakeEvents() { const lastDay = firstDay.add(args.range, 'days'); const numEvents = args.make; const fakeData = generateFakeData(firstDay, lastDay, numEvents); - return db.initialize().then(_ => { + return db.initialize('makeFakeEvents').then(_ => { return insertFakeData(fakeData); }) .then(_ => { From 5790f67fb53c16071eb4583545a726db2f91f3f7 Mon Sep 17 00:00:00 2001 From: ionous Date: Thu, 19 Mar 2026 19:02:57 -0700 Subject: [PATCH 7/8] fix json call probably from a global find and replace from the command line parameters lookup --- app/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config.js b/app/config.js index e54b2516..384e3d02 100644 --- a/app/config.js +++ b/app/config.js @@ -193,7 +193,7 @@ function getSmtpSettings() { if (emailCfg) { try { const raw= fs.readFileSync(emailCfg, "utf8"); - return JSON.getBool(raw); + return JSON.parse(raw); } catch (err) { // its okay if there is no such file... if (err.code !== 'ENOENT') { From 663f8cc2805f0da4bb08a11a72665afdaf8fd6c3 Mon Sep 17 00:00:00 2001 From: ionous Date: Sat, 18 Apr 2026 12:37:26 -0700 Subject: [PATCH 8/8] misc cleanup - don't use query.id directly; pass it through "readInt" - reuse safeParse function - change some 'let' statements to 'const' - minor comment / formatting improvements --- app/endpoints/crawl.js | 2 +- app/endpoints/delete_event.js | 21 +- app/endpoints/events.js | 21 +- app/endpoints/ical.js | 3 +- app/endpoints/manage_event.js | 19 +- app/models/calEventValidator.js | 15 + app/test/ical_test.js | 5 +- app/test/manage_test.js | 19 +- app/test/testdb.js | 17 +- app/test/testing.html | 1191 ------------------------------- app/uploader.js | 4 +- app/util/dateTime.js | 24 +- app/util/errors.js | 1 - 13 files changed, 80 insertions(+), 1262 deletions(-) delete mode 100644 app/test/testing.html diff --git a/app/endpoints/crawl.js b/app/endpoints/crawl.js index 58346788..09220566 100644 --- a/app/endpoints/crawl.js +++ b/app/endpoints/crawl.js @@ -15,7 +15,7 @@ const { CalDaily } = require("../models/calDaily"); const { to12HourString, from24HourString, friendlyDate } = require("../util/dateTime"); exports.get = function(req, res, next) { - let id = req.query.id; + const id = req.query.id; const p = { title: config.crawl.title, description : config.crawl.description, diff --git a/app/endpoints/delete_event.js b/app/endpoints/delete_event.js index f8bad740..b5de3655 100644 --- a/app/endpoints/delete_event.js +++ b/app/endpoints/delete_event.js @@ -19,18 +19,16 @@ const express = require('express'); const textError = require("../util/errors"); const { CalEvent } = require("../models/calEvent"); const { uploader } = require("../uploader"); +const { safeParse } = require("../models/calEventValidator"); // the front end sends a multi-part form post // so... we need to handle that. exports.post = [ uploader.makeHandler(), handleRequest ]; function handleRequest(req, res, next) { - let data = req.body; // fix? client uploads form data containing json... // probably to match manage_event where its currently required. - if (data && data.json) { - data = safeParse(data.json); - } + const data = safeParse(req.body); if (!data) { return res.textError('JSON could not be decoded'); } @@ -39,7 +37,8 @@ function handleRequest(req, res, next) { } // get the event: delete seems to send an int, where manage is a string. // normalize it to a string for consistency ( tbd: is that good, or even needed? ) - return CalEvent.getByID(''+data.id).then((evt) => { + const seriesId = '' + data.id; // normalize int into a string + return CalEvent.getByID(seriesId).then((evt) => { // verify the event exists. if (!evt) { return res.textError('Event not found'); @@ -52,7 +51,7 @@ function handleRequest(req, res, next) { // if the event was never published, we can delete it completely; // otherwise, soft delete it. - let q = !evt.isPublished() ? evt.eraseEvent() : evt.softDelete(); + const q = !evt.isPublished() ? evt.eraseEvent() : evt.softDelete(); q.then((_) => { // note: the frontend currently doesn't use this json; // instead it looks for request success ( http 200 ) @@ -66,13 +65,3 @@ function handleRequest(req, res, next) { }); }).catch(next); } - -// read json into a javascript object. -// returns undefined for any error. -function safeParse(json) { - try { - return JSON.parse(json); - } catch (err) { - console.error(err); - } -} diff --git a/app/endpoints/events.js b/app/endpoints/events.js index 4f1fd933..c9716dde 100644 --- a/app/endpoints/events.js +++ b/app/endpoints/events.js @@ -29,26 +29,25 @@ const { fromYMDString, to24HourString, toYMDString } = require("../util/dateTime const { CalEvent } = require("../models/calEvent"); const { CalDaily } = require("../models/calDaily"); const { EventsRange } = require("../models/calConst"); +const validator = require('validator'); // the events endpoint: exports.get = function(req, res, next) { - let id = req.query.id; + const dayId = readInt(req.query.id); // pkid let start = req.query.startdate; let end = req.query.enddate; const includeAllEvents = (req.query.all === "true") || (req.query.all === "1"); - if (id && start && end) { + if (dayId && start && end) { res.textError("expected only an id or date range"); // fix, i think its supposed be sending a json error. - } else if (id) { - // return the summary of a particular daily event: - return CalDaily.getByDailyID(id).then((daily) => { + } else if (dayId) { + // return the summary of a particular event on a particular day: + return CalDaily.getByDailyID(dayId).then((daily) => { if (!daily) { - res.textError("no such time"); + res.textError("no such day"); } else { return getSummaries([daily]).then((events) => { res.set(config.api.header, config.api.version); - res.json({ - events - }); + res.json({events}); }); } }).catch(next); @@ -82,6 +81,10 @@ exports.get = function(req, res, next) { } } +function readInt(i, opt) { + return (i !== undefined) && validator.isInt(i, opt) && validator.toInt(i); +} + // promise an array containing json-friendly summaries of all the passed CalDaily(s) // see also: buildEntries() function getSummaries(dailies) { diff --git a/app/endpoints/ical.js b/app/endpoints/ical.js index 82c18353..8aa47dea 100644 --- a/app/endpoints/ical.js +++ b/app/endpoints/ical.js @@ -227,6 +227,7 @@ function buildCalEntry(evt, at) { // php handles this just fine. startAt = dt.combineDateAndTime(at.eventdate, dt.from12HourString("12:00 PM")); } + const endAt = evt.addDuration(startAt); const url = at.getShareable(); let title = evt.title; @@ -249,7 +250,7 @@ function buildCalEntry(evt, at) { description: [ news, evt.descr, evt.timedetails, - evt.locend? "Ends at "+ evt.locend: null, + evt.locend ? ("Ends at "+ evt.locend) : null, url ], location: [ diff --git a/app/endpoints/manage_event.js b/app/endpoints/manage_event.js index 1842b363..9ab1d49e 100644 --- a/app/endpoints/manage_event.js +++ b/app/endpoints/manage_event.js @@ -23,7 +23,7 @@ const dt = require("../util/dateTime"); const config = require("../config"); const emailer = require("../emailer"); const nunjucks = require("../nunjucks"); -const { validateEvent } = require("../models/calEventValidator"); +const { safeParse, validateEvent } = require("../models/calEventValidator"); // read multipart (and curl) posts. exports.post = [ uploader.makeHandler(), handleRequest ]; @@ -37,13 +37,10 @@ exports.post = [ uploader.makeHandler(), handleRequest ]; * it should only reject due to things like database or programmer errors. */ function handleRequest(req, res, next) { - let input = req.body; // fix? the client uploads form data containing json // ( rather than raw json ) because multi-part forms require that. // a more rest-like api might use a separate put at some event/image url. - if (input && input.json) { - input = safeParse(input.json); - } + const input = safeParse(req.body); if (!input) { return res.textError("invalid request"); } @@ -66,7 +63,7 @@ function handleRequest(req, res, next) { // save the uploaded file (if any) const saveImage = !req.file ? Promise.resolve() : // the image gets written to disk as "id.ext" - uploader.write( req.file, evt.id ).then(f => { + uploader.write(req.file, evt.id.toString()).then(f => { // the image name gets stored in the db as "id-sequence.ext" // the sequence number needs to be different for each new image. // ( shift.conf strips off the sequence when the file is requested; see cache_busting.md ) @@ -161,13 +158,3 @@ function sendConfirmationEmail(evt) { // attachments }, evt.name, evt.email, evt.title, url); } - -// read json into a javascript object. -// returns undefined for any error. -function safeParse(json) { - try { - return JSON.parse(json); - } catch (err) { - console.error(err); - } -} diff --git a/app/models/calEventValidator.js b/app/models/calEventValidator.js index b14816dc..299961cf 100644 --- a/app/models/calEventValidator.js +++ b/app/models/calEventValidator.js @@ -85,6 +85,7 @@ function makeValidator(input, errors) { if (validator.isEmpty(str)) { errors.addError('time'); } else { + // input is AM/PM style let t = dt.from12HourString(str); if (!t.isValid()) { t = dt.from24HourString(str); @@ -252,8 +253,22 @@ function validateEvent(input) { }; } +// read a possible json request into a javascript object. +// returns undefined for any error. +function safeParse(data) { + if (data && data.json) { + try { + data = JSON.parse(data.json); + } catch (err) { + console.error(err); + } + } + return data; +} + module.exports = { validateEvent, + safeParse, // exported for testing: makeValidator, ErrorCollector diff --git a/app/test/ical_test.js b/app/test/ical_test.js index 9fc1c723..86072af5 100644 --- a/app/test/ical_test.js +++ b/app/test/ical_test.js @@ -122,7 +122,8 @@ describe("ical feed", () => { }); }); it("can handle a canceled event", () => { - return CalEvent.getByID(2).then(evt => { + const seriesId = 2; + return CalEvent.getByID(seriesId).then(evt => { // todo: create a separate test where these values are nil and zero. // that had caused a bad feed at one point; its fixed but still good to test. // evt.eventtime = null; @@ -134,7 +135,7 @@ describe("ical feed", () => { return request(app) .get('/api/ical.php') .query({ - id: 2, // an event id + id: seriesId, // an event id }) .expect(200) .expect('content-type', CalendarType) diff --git a/app/test/manage_test.js b/app/test/manage_test.js index dfeb72ce..59258d77 100644 --- a/app/test/manage_test.js +++ b/app/test/manage_test.js @@ -246,8 +246,9 @@ describe("managing events", () => { }); } it("attaches an image", () => { - const imageSource = path.join( config.image.dir, "bike.jpg" ); + const imageSource = path.join(config.image.dir, "bike.jpg"); const imageTarget = getImageTarget(3, imageSource); + // post the image once return postImage(3, imageSource, imageTarget) .then(res => { assert.equal(res.status, 200); @@ -268,14 +269,18 @@ describe("managing events", () => { return postImage(3, imageSource, imageTarget) .then(res => { testData.expectError(res, 'image'); + + // check for the image on disk: return fsp.stat(imageTarget) .then(_ => { - assert.fail(`didn't expect ${imageTarget} to exists`); + // fail if it existed + assert.fail(`didn't expect ${imageTarget} to exist`); }) .catch(_ => { - assert(true); + // make sure we get here + assert.ok(true); }); - }); + }); }); it("fails bad format", () => { const imageSource = path.join( config.image.dir, "bike-bad.tiff" ); @@ -284,10 +289,12 @@ describe("managing events", () => { testData.expectError(res, 'image'); return fsp.stat(imageTarget) .then(_ => { - assert.fail(`didn't expect ${imageTarget} to exists`); + // fail if it existed + assert.fail(`didn't expect ${imageTarget} to exist`); }) .catch(_ => { - assert(true); + // make sure we get here + assert.ok(true); }); }); }); diff --git a/app/test/testdb.js b/app/test/testdb.js index 89899197..0a76149a 100644 --- a/app/test/testdb.js +++ b/app/test/testdb.js @@ -34,12 +34,17 @@ module.exports = { } async function createTestData() { - await db.query('calevent').insert(fakeCalEvent(1)); - await db.query('calevent').insert(fakeCalEvent(2)); - await db.query('calevent').insert(fakeCalEvent(3)); - // - await db.query('caldaily').insert(fakeCalDaily(1, 2)); - await db.query('caldaily').insert(fakeCalDaily(2, 2)); + // create 3 separate series with ids 1, 2, 3 + for (const id of [1, 2, 3]) { + const tableValues = fakeCalEvent(id); + await db.query('calevent').insert(tableValues); + } + // generates two status days for series 2. + const eventId = 2; + for (const order of [1, 2]) { + const item = fakeCalDaily(order, eventId); + await db.query('caldaily').insert(item); + } }; function fakeCalDaily(order, eventId) { diff --git a/app/test/testing.html b/app/test/testing.html deleted file mode 100644 index 9e7c15ef..00000000 --- a/app/test/testing.html +++ /dev/null @@ -1,1191 +0,0 @@ -testing

Testing

-

To test the backend, at the root of repo run:

-
npm test
-
- -

All tests use the Node Test runner, with supertest for making (local) http requests.

-

Command line options:

-

-db

-

Isolating tests:

-

Tests can be identified by name:

-
npm test -- --test-name-pattern="ical feed"
-
- -

Or, temporarily can be marked with ‘only’ in the code. For example: describe.only(), and then selected with:

-
npm test -- --test-only
-
- -

Test Data

-

fakeData.js generates the following events:

-
\ No newline at end of file diff --git a/app/uploader.js b/app/uploader.js index f50ba630..b6e58553 100644 --- a/app/uploader.js +++ b/app/uploader.js @@ -11,8 +11,8 @@ exports.uploader = { // The uploaded file contains a 'mimetype', and either: // a 'buffer' of binary data, or a 'path' (to a temp file in system tmp.) // Promises an object with the name and an extension: `{name, ext}`. - write( file, name ) { - if (!name) { + write(file, name) { + if (!name || (typeof(name) !== 'string')) { return Promise.reject(Error("cant store an image without a valid name")); } if (!file) { diff --git a/app/util/dateTime.js b/app/util/dateTime.js index 4018f364..2115330f 100644 --- a/app/util/dateTime.js +++ b/app/util/dateTime.js @@ -1,6 +1,6 @@ // DateTime formatting helpers // -// note: the mysql driver converts timestamp, date, and datetime values into javascript Date. +// note: the mysql driver reads timestamp, date, and datetime values into javascript Date. // https://github.com/mysqljs/mysql#type-casting const dayjs = require("dayjs"); @@ -8,11 +8,14 @@ const customParseFormat = require("dayjs/plugin/customParseFormat"); const utc = require('dayjs/plugin/utc'); const timezone = require('dayjs/plugin/timezone'); const localZone = 'America/Los_Angeles'; + dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(customParseFormat); dayjs.tz.setDefault(localZone); +// these all return dayjs objects +// unless otherwise specified. module.exports = { friendlyDate, // out: "Mon, Aug 8th" icalFormat, // out: "20230413T041000Z" @@ -20,15 +23,15 @@ module.exports = { toYMDString, // out: "YYYY-MM-DD" to12HourString, // out: "9:10 PM" to24HourString, // out: "21:10:00" - toTimestamp, // out: mysql format + toTimestamp, // out: mysql timestamp format - fromYMDString, // in : "YYYY-MM-DD" - from24HourString, // in : "9:10 PM" - from12HourString, // in : "21:10:00" + fromYMDString, // in: "YYYY-MM-DD" + from24HourString, // in: "9:10 PM" + from12HourString, // in: "21:10:00" - combineDateAndTime, - getNow, - convert, + combineDateAndTime, // in: (date, time) + getNow, // + convert, // in: javascript date }; // wraps "now" so it can be stubbed out by tests @@ -121,11 +124,10 @@ function combineDateAndTime(d, t) { return out.add(t.hour(), 'h').add(t.minute(), 'm'); } - // https://stackoverflow.com/questions/13627308/add-st-nd-rd-and-th-ordinal-suffix-to-a-number function daySuffix(i) { - let j = i % 10; - let k = i % 100; + const j = i % 10; + const k = i % 100; if (j == 1 && k != 11) { return "st"; } else if (j == 2 && k != 12) { diff --git a/app/util/errors.js b/app/util/errors.js index 597c2eb6..179431cc 100644 --- a/app/util/errors.js +++ b/app/util/errors.js @@ -23,7 +23,6 @@ function textError(res, message="unknown error", status_code=400) { }}); } - /** * Triggers a 400 response, and formats the passed key,value array into json messages for the client. * {