Osan 4 oppimistavoitteet

  • Node.js / Express
    • Router
    • sovelluksen jakaminen osiin
  • Node.js -sovellusten testaus
    • jest/supertest
  • JS
    • async/await
  • Mongoose
    • Monimutkaisemmat skeemat
    • Viittaukset kokoelmien välillä
    • populointi
  • Web
    • Token-autentikaatio
    • JWT

Sovelluksen rakenteen parantelu

Jatketaan osassa 3 tehdyn muistiinpanosovelluksen backendin kehittämistä.

Muutetaan sovelluksen rakennetta siten, että projektin juuressa oleva index.js ainoastaan konfiguroi sovelluksen tietokannan ja käytettävät middlewaret. Routejen määrittely siirretään omaan tiedostoonsa, eli siitä tehdään moduuli.

Routejen tapahtumankäsittelijöitä kutsutaan usein kontrollereiksi. Luodaankin hakemisto controllers ja sinne tiedosto notes.js johon tulemme siirtämään kaikki muistiinpanoihin liittyvien reittien määrittelyt.

Tiedoston sisältö on seuraava:

const notesRouter = require('express').Router()
const Note = require('../models/note')

const formatNote = (note) => {
  return {
    id: note._id,
    content: note.content,
    date: note.date,
    important: note.important
  }
}

notesRouter.get('/', (request, response) => {
  Note
    .find({})
    .then(notes => {
      response.json(notes.map(formatNote))
    })
})

notesRouter.get('/:id', (request, response) => {
  Note
    .findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(formatNote(note))
      } else {
        response.status(404).end()
      }
    })
    .catch(error => {
      response.status(400).send({ error: 'malformatted id' })
    })
})

notesRouter.delete('/:id', (request, response) => {
  Note
    .findByIdAndRemove(request.params.id)
    .then(result => {
      console.log(result)
      response.status(204).end()
    })
    .catch(error => {
      response.status(400).send({ error: 'malformatted id' })
    })
})

notesRouter.post('/', (request, response) => {
  const body = request.body

  if (body.content === undefined) {
    response.status(400).json({ error: 'content missing' })
  }

  const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    date: new Date()
  })

  note
    .save()
    .then(note => {
      return formatNote(note)
    })
    .then(formattedNote => {
      response.json(formattedNote)
    })

})

notesRouter.put('/:id', (request, response) => {
  const body = request.body

  const note = {
    content: body.content,
    important: body.important
  }

  Note
    .findByIdAndUpdate(request.params.id, note, { new: true })
    .then(updatedNote => {
      response.json(formatNote(updatedNote))
    })
    .catch(error => {
      console.log(error)
      response.status(400).send({ error: 'malformatted id' })
    })
})

module.exports = notesRouter

Kyseessä on käytännössä melkein suora copypaste tiedostosta index.js.

Muutoksia on muutama. Tiedoston alussa luodaan router-olio:

const notesRouter = require('express').Router()

//...

module.exports = notesRouter

Tiedosto eksporttaa moduulin käyttäjille määritellyn routerin.

Kaikki määriteltävät routet liitetään router-olioon, samaan tapaan kuin aiemmassa versiossa routet liitettiin sovellusta edustavaan olioon.

Huomioinarvoinen seikka routejen määrittelyssä on se, että polut ovat typistyneet, aiemmin määrittelimme esim.

app.delete('/api/notes/:id', (request, response) => {

nyt riittää määritellä

notesRouter.delete('/:id', (request, response) => {

Mistä routereissa oikeastaan on kyse? Expressin manuaalin sanoin

A router object is an isolated instance of middleware and routes. You can think of it as a “mini-application,” capable only of performing middleware and routing functions. Every Express application has a built-in app router.

Router on siis middleware, jonka avulla on mahdollista määritellä joukko “toisiinsa liittyviä” routeja yhdessä paikassa, yleensä omassa moduulissaan.

Ohjelman käynnistystiedosto, eli määrittelyt tekevä index.js ottaa määrittelemämme routerin käyttöön seuraavasti:

const notesRouter = require('./controllers/notes')
app.use('/api/notes', notesRouter)

Näin määrittelemäämme routeria käytetään jos polun alkuosa on /api/notes. notesRouter-olion sisällä täytyy tämän takia käyttää ainoastaan polun loppuosia, eli tyhjää polkua / tai pelkkää parametria /:id.

sovelluksen muut osat

Sovelluksen käynnistyspisteenä toimiva index.js näyttää muutosten jälkeen seuraavalta:

const express = require('express')
const app = express()
const bodyParser = require('body-parser')
const cors = require('cors')
const mongoose = require('mongoose')
const middleware = require('./utils/middleware')
const notesRouter = require('./controllers/notes')

if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config()
}

mongoose
  .connect(process.env.MONGODB_URI)
  .then( () => {
    console.log('connected to database', process.env.MONGODB_URI)
  })
  .catch( err => {
    console.log(err)
  })

app.use(cors())
app.use(bodyParser.json())
app.use(express.static('build'))
app.use(middleware.logger)

app.use('/api/notes', notesRouter)

app.use(middleware.error)

const PORT = process.env.PORT || 3001
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Tiedostossa siis otetaan käyttöön joukko middlewareja, näistä yksi on polkuun /api/notes kiinnitettävä notesRouter (tai notes-kontrolleri niin kuin jotkut sitä kutsuisivat).

Tietokannan yhteydenmuodostuksen suorittavaan funktioon on myös lisätty tapahtumankäsittelijä, joka ilmoittaa onko yhteyden muodostus onnistunut vai ei.

Middlewareista kaksi middleware.logger ja middleware.error on määritelty hakemiston utils tiedostossa middleware.js:

const logger = (request, response, next) => {
  console.log('Method:', request.method)
  console.log('Path:  ', request.path)
  console.log('Body:  ', request.body)
  console.log('---')
  next()
}

const error = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

module.exports = {
  logger,
  error
}

Tietokantayhteyden muodostaminen on nyt siirretty konfiguraatiot tekevän index.js:n vastuulle. Hakemistossa models oleva tiedosto note.js sisältää nyt ainoastaan muistiinpanojen skeeman määrittelyn.

const mongoose = require('mongoose')

const Note = mongoose.model('Note', {
  content: String,
  date: Date,
  important: Boolean
})

module.exports = Note

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part4-1:

Jos kloonaat projektin itsellesi, suorita komento npm install ennen käynnistämistä eli komentoa npm start.

Express-sovelluksien rakenteelle, eli hakemistojen ja tiedostojen nimennälle ei ole olemassa mitään yleismaailmallista standardia samaan tapaan kuin esim. Ruby on Railsissa. Tässä käyttämämme malli noudattaa eräitä internetissä vastaan tulevia hyviä käytäntöjä.

Tehtäviä

Tee nyt tehtävät 4.1 ja 4.2

node-sovellusten testaaminen

Olemme laiminlyöneet ikävästi yhtä oleellista ohjelmistokehityksen osa-aluetta, automatisoitua testaamista.

Aloitamme yksikkötestauksesta. Sovelluksemme logiikka on sen verran yksinkertaista, että siinä ei ole juurikaan mielekästä yksikkötestattavaa. Luodaan tiedosto utils/for_testing.js ja määritellään sinne pari yksinkertaista funktiota testattavaksi:

const palindrom = (string) => {
  return string.split('').reverse().join('')
}

const average = (array) => {
  const reducer = (sum, item) => {
    return sum + item
  }

  return array.reduce(reducer, 0) / array.length
}

module.exports = {
  palindrom,
  average
}

Metodi average käyttää taulukoiden metodia reduce. Jos metodi ei ole vieläkään tuttu, on korkea aika katsoa youtubesta Functional Javascript -sarjasta ainakin kolme ensimmäistä videoa.

Javascriptiin on tarjolla runsaasti erilaisia testikirjastoja eli test runnereita. Käytämme tällä kurssilla Facebookin kehittämää ja sisäisesti käyttämää jest:iä, joka on toiminnaltaan ja syntakstiltaankin hyvin samankaltainen kuin tämän hetken eniten käytetty testikirjasto Mocha. Muitakin mahdollisuuksia olisi, esim. eräissä piireissä suosiota nopeasti saavuttanut ava.

Jest on tälle kurssille luonteva valinta, sillä se sopii hyvin backendien testaamiseen, mutta suorastaan loistaa Reactilla tehtyjen frontendien testauksessa.

Huomio Windows-käyttäjille: jest ei välttämättä toimi, jos projektin hakemistopolulla on hakemisto, jonka nimessä on välilyöntejä.

Koska testejä on tarkoitus suorittaa ainoastaan sovellusta kehitettäessä, asennetaan jest kehitysaikaiseksi riippuvuudeksi komennolla

npm install --save-dev jest

määritellään npm skripti test suorittamaan testaus jestillä ja raportoimaan testien suorituksesta verbose-tyylillä:

{
  //...
  "scripts": {
    "start": "node index.js",
    "watch": "nodemon index.js",
    "lint": "eslint .",
    "test": "jest --verbose"
  },
  //...
}

Tehdään testejä varten hakemisto tests ja sinne tiedosto palindrom.test.js, jonka sisältö on seuraava

const palindrom = require('../utils/for_testing').palindrom

test('palindrom of a', () => {
  const result = palindrom('a')

  expect(result).toBe('a')
})

test('palindrom of react', () => {
  const result = palindrom('react')

  expect(result).toBe('tcaer')
})

test('palindrom of saippuakauppias', () => {
  const result = palindrom('saippuakauppias')

  expect(result).toBe('saippuakauppias')
})

Edellisessä osassa käyttöön ottamamme ESlint valittaa testien käyttämistä komennoista test ja expect sillä käyttämämme konfiguraatio kieltää globaalina määriteltyjen asioiden käytön. Poistetaan valitus lisäämällä tiedostoon .eslintrc.js kenttä globals:

module.exports = {
    "env": {
        "es6": true,
        "node": true
    },
    "extends": "eslint:recommended",
    "rules": {
      //...
    },
    "globals": {
        "test": true,
        "expect": true,
        "describe": true
    }
}

Testi ottaa ensimmäisellä rivillä käyttöön testattavan funktion sijoittaen sen muuttujaan palindrom:

const palindrom = require('../utils/for_testing').palindrom

Yksittäiset testitapaukset määritellään funktion test avulla. Ensimmäisenä parametrina on merkkijonomuotoinen testin kuvaus. Toisena parametrina on funktio, joka määrittelee testitapauksen toiminnallisuuden. Esim. toisen testitapauksen toiminnallisuus näyttää seuraavalta:

() => {
  const result = palindrom('react')

  expect(result).toBe('tcaer')
}

Ensin suoritetaan testattava koodi, eli generoidaan merkkijonon react palindromi. Seuraavaksi varmistetaan tulos metodin expect avulla. Expect käärii tuloksena olevan arvon olioon, joka tarjoaa joukon matcher-funktioita, joiden avulla tuloksen oikeellisuutta voidaan tarkastella. Koska kyse on kahden merkkijonon samuuden vertailusta, sopii tilanteeseen matcheri toBe.

Kuten odotettua, testit menevät läpi:

Jest olettaa oletusarvoisesti, että testitiedoston nimessä on merkkijono .test. Käytetään kurssilla konventiota, millä testitiedostojen nimen loppu on .test.js

Jestin antamat virheilmoitukset ovat hyviä, rikotaan testi

test('palindrom of react', () => {
  const result = palindrom('react')

  expect(result).toBe('tkaer')
})

seurauksena on seuraava virheilmotus

Lisätään muutama testi metodille average, tiedostoon tests/average.test.js.

const average = require('../utils/for_testing').average

describe('average', () => {

  test('of one value is the value itself', () => {
    expect(average([1])).toBe(1)
  })

  test('of many is caclulated right', () => {
    expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5)
  })

  test('of empty array is zero', () => {
    expect(average([])).toBe(0)
  })

})

Testi paljastaa, että metodi toimii väärin tyhjällä taulukolla (sillä nollallajaon tulos on Javascriptissä NaN):

Metodi on helppo korjata

const average = (array) => {
  const reducer = (sum, item) => {
    return sum + item
  }
  return array.length === 0 ? 0 : array.reduce(reducer, 0) / array.length
}

Eli jos taulukon pituus on 0, palautetaan 0 ja muussa tapauksessa palautetaan metodin reduce avulla laskettu keskiarvo.

Pari huomiota keskiarvon testeistä. Määrittelimme testien ympärille nimellä average varustetun describe-lohkon.

describe('average', () => {
  // testit
})

Describejen avulla yksittäisessä tiedostossa olevat testit voidaan jaotella loogisiin kokonaisuuksiin. Testituloste hyödyntää myös describe-lohkon nimeä:

Kuten myöhemmin tulemme näkemään, describe-lohkot ovat tarpeellisia siinä vaiheessa, jos haluamme osalle yksittäisen testitiedoston testitapauksista jotain yhteisiä alustus- tai lopetustoimenpiteitä.

Toisena huomiona se, että kirjoitimme testit aavistuksen tiiviimmässä muodossa, ottamatta testattavan metodin tulosta erikseen apumuuttujaan:

  test('of empty array is zero', () => {
    expect(average([])).toBe(0)
  })

Tehtäviä

Tee nyt tehtävät 4.3-4.7

API:n testaaminen

Joissain tilanteissa voisi olla mielekästä suorittaa ainakin osa backendin testauksesta siten, että oikea tietokanta eristettäisiin testeistä ja korvattaisiin “valekomponentilla” eli mockilla. Eräs tähän sopiva ratkaisu olisi mongo-mock.

Koska sovelluksemme backend on koodiltaan kuitenkin suhteellisen yksinkertainen, päätämme testata sitä kokonaisuudessaan, siten että myös testeissä käytetään tietokantaa. Tämän kaltaisia, useita sovelluksen komponentteja yhtäaikaa käyttäviä testejä voi luonnehtia integraatiotesteiksi.

test-ympäristö

Edellisen osan luvussa Sovelluksen vieminen tuotantoon mainitsimme, että kun sovellusta suoritetaan Herokussa, on se production-moodissa.

Noden konventiona on määritellä projektin suoritusmoodi ympäristömuuttujan NODE_ENV avulla. Lataammekin sovelluksen nykyisessä versiossa tiedostossa .env määritellyt ympäristömuuttujat ainoastaan jos sovellus ei ole production moodissa:

if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config()
}

Yleinen käytäntö on määritellä sovelluksille omat moodinsa myös sovelluskehitykseen ja testaukseen.

Määrtellään nyt tiedostossa package.json, että testejä suorittaessa sovelluksen NODE_ENV saa arvokseen test:

{
  // ...
  "scripts": {
    "start": "NODE_ENV=production node index.js",
    "watch": "NODE_ENV=development nodemon index.js",
    "test": "NODE_ENV=test jest --verbose",
    "lint": "eslint ."
  },
  // ...
}

Samalla määriteltiin, että suoritettaessa sovellusta komennolla npm run watch eli nodemonin avulla, on sovelluksen moodi development. Jos sovellusta suoritetaan normaalisti Nodella, on moodiksi määritelty production.

Määrittelyssämme on kuitenkin pieni ongelma, se ei toimi windowsilla. Tilanne korjautuu asentamalla kirjasto cross-env komennolla

npm install --save-dev cross-env

ja muuttamalla package.js kaikilla käyttöjärjestelmillä toimivaan muotoon

{
  // ...
  "scripts": {
    "start": "cross-env NODE_ENV=production node index.js",
    "watch": "cross-env NODE_ENV=development nodemon index.js",
    "test": "cross-env NODE_ENV=test jest --verbose",
    "lint": "eslint ."
  },
  // ...
}

Nyt sovelluksen toimintaa on mahdollista muokata sen suoritusmoodiin perustuen. Eli voimme määritellä, esim. että testejä suoritettaessa ohjelma käyttää erillistä, testejä varten luotua tietokantaa.

Sovelluksen testikanta voidaan luoda tuotantokäytön ja sovelluskehityksen tapaan mlabiin. Ratkaisu ei ole optimaalinen erityisesti, jos sovellusta on tekemässä yhtä aikaa useita henkilöitä. Testien suoritus nimittäin yleensä edellyttää, että samaa tietokantainstanssia ei ole yhtä aikaa käyttämässä useampia testiajoja.

Testaukseen kannattaakin käyttää verkossa olevaa jaettua tietokantaa mielummin esim. sovelluskehittäjän paikallisen koneen tietokantaa. Optimiratkaisu olisi tietysti se, että jokaista testiajoa varten olisi käytettävissä oma tietokanta, sekin periaatteessa onnistuu “suhteellisen helposti” mm. keskusmuistissa toimivan Mongon ja docker-kontainereiden avulla. Etenemme kuitenkin nyt lyhyemmän kaavan mukaan ja käytetään testikantana normaalia Mongoa.

Voisimme kirjoittaa ympäristökohtaiset konfiguraatiot, esim. oikean tietokannan valinnan suoraan tiedostoon index.js, se kuitenkin tekisi tiedoston koodista sekavan. Eristetään sovelluksen ympäristökohtainen konfigurointi omaan tiedostoon utils/config.js sijoitettavaan moduuliin.

Ideana on, että index.js voi käyttää konfiguraatioita seuraavasti:

const config = require('./utils/config')

// ...

mongoose
  .connect(config.mongoUrl)
  .then( () => {
    console.log('connected to database', config.mongoUrl)
  })
  .catch( err => {
    console.log(err)
  })


// ...

const PORT = config.port

Konfiguraation suorittavan moduulin koodi on seuraavassa:

if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config()
}

let port = process.env.PORT
let mongoUrl = process.env.MONGODB_URI

if (process.env.NODE_ENV === 'test') {
  port = process.env.TEST_PORT
  mongoUrl = process.env.TEST_MONGODB_URI
}

module.exports = {
  mongoUrl,
  port
}

Koodi lataa ympäristömuuttujat tiedostosta .env jos se ei ole tuotantomoodissa. Tuotantomoodissa käytetään Herokuun asetettuja ympäristömuuttujia.

Tiedostossa .env on nyt määritelty erikseen sekä sovelluskehitysympäristön ja testausympäristön tietokannan osoite (esimerkissä molemmat ovat sovelluskehityskoneen lokaaleja mongo-kantoja) ja portti:

MONGODB_URI=mongodb://fullstack:sekred@ds111078.mlab.com:11078/fullstact-notes-dev
PORT=3001

TEST_PORT=3002
TEST_MONGODB_URI=mongodb://fullstack:sekred@ds113098.mlab.com:13098/fullstack-notes-test

Eri porttien käyttö mahdollistaa sen, että sovellus voi olla käynnissä testien suorituksen aikana.

Omatekemämme eri ympäristöjen konfiguroinnista huolehtivaa config-moduuli toimii hieman samassa hengessä kuin node-config-kirjasto. Omatekemä konfigurointiympäristö sopii tarkoitukseemme, sillä sovellus on yksinkertainen ja oman konfiguraatio-moduulin tekeminen on myös jossain määrin opettavaista. Isommissa sovelluksissa kannattaa harkita valmiiden kirjastojen, kuten node-config:in käyttöä.

Tiedosto index.js muutetaan nyt muotoon:

const http = require('http')
const express = require('express')
const app = express()
const bodyParser = require('body-parser')
const cors = require('cors')
const mongoose = require('mongoose')
const middleware = require('./utils/middleware')
const notesRouter = require('./controllers/notes')
const config = require('./utils/config')

mongoose
  .connect(config.mongoUrl)
  .then( () => {
    console.log('connected to database', config.mongoUrl)
  })
  .catch( err => {
    console.log(err)
  })

app.use(cors())
app.use(bodyParser.json())
app.use(express.static('build'))
app.use(middleware.logger)

app.use('/api/notes', notesRouter)

app.use(middleware.error)

const server = http.createServer(app)

server.listen(config.port, () => {
  console.log(`Server running on port ${config.port}`)
})

server.on('close', () => {
  mongoose.connection.close()
})

module.exports = {
  app, server
}

HUOM: koska käytämme useimpia kirjastoja koodissa vain kerran, olisi mahdollista tiivistää koodia hiukan kirjoittamalla esim. app.use(cors()) sijaan app.use(require('cors')()) ja jättää apumuuttuja cors kokonaan määrittelemättä. On kuitenkin epäselvää kannattaako tälläiseen koodirivien säästelyyn lähteä. Ei ainakaan silloin jos koodin ymmärrettävyys kärsisi.

Tiedoston lopussa on muutama tärkeä muutos.

Sovelluksen käynnistäminen tapahtuu nyt server-muuttujassa olevan olion kautta. Serverille määritellään tapahtumankäsitteljäfunktio tapahtumalle close eli tilanteeseen, missä sovellus sammutetaan. Tapahtumankäsittelijä sulkee tietokantayhteyden.

Sekä sovellus app että sitä suorittava server-olio määritellään eksportattavaksi tiedostosta. Tämä mahdollistaa sen, että testit voivat käynnistää ja sammuttaa backendin.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part4-2.

supertest

Käytetään API:n testaamiseen Jestin apuna supertest-kirjastoa.

Kirjasto asennetaan kehitysaikaiseksi riippuvuudeksi komennolla

npm install --save-dev supertest

Luodaan heti ensimmäinen testi tiedostoon tests/note_api.test.js:

const supertest = require('supertest')
const { app, server } = require('../index')
const api = supertest(app)

test('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

afterAll(() => {
  server.close()
})

Toisella rivillä testi käynnistää backendin ja käärii sen kolmannella rivillä funktion supertest avulla ns. superagent-olioksi. Tämä olio sijoitetaan muuttujaan api ja sen kautta testit voivat tehdä HTTP-pyyntöjä backendiin.

Testimetodi tekee HTTP GET -pyynnön osoitteeseen api/notes ja varmistaa, että pyyntöön vastataan statuskoodilla 200 ja että data palautetaan oikeassa muodossa, eli että Content-Type:n arvo on application/json.

Testissä on muutama detalji joihin tutustumme vasta hieman myöhemmin tässä osassa. Testikoodin määrittelevä nuolifunktio alkaa sanalla async ja api-oliolle tehtyä metodikutsua edeltää sama await. Teemme ensin muutamia testejä ja tutustumme sen jälkeen async/await-magiaan. Tällä hetkellä niistä ei tarvitse välittää, kaikki toimii kun kirjoitat testimetodit esimerkin mukaan. Async/await-syntaksin käyttö liittyy siihen, että palvelimelle tehtävät pyynnöt ovat asynkronisia operaatioita. Async/await-kikalla saamme pyynnön näyttämään koodin tasolla synkroonisesti toimivalta.

Huom! Jos eslint herjaa async -syntaksista, niin saat ongelman korjattua lisäämällä seuraavan .eslintrc tiedostoon

module.exports = {
  //...
  "parserOptions": {
    "ecmaVersion": 2017
  }
}

Kaikkien testien (joita siis tällä kertaa on vain yksi) päätteeksi on vielä lopputoimenpiteenä pyydettävä backendia suorittava server-olio sammuttamaan itsensä. Tämä onnistuu helposti metodissa afterAll:

afterAll(() => {
  server.close()
})

HTTP-pyyntöjen tiedot loggaava middleware logger häiritsee hiukan testien tulostusta. Jos haluat hiljentää sen testien suorituksen ajaksi, muuta funktiota esim. seuraavasti:

const logger = (request, response, next) => {
  if ( process.env.NODE_ENV === 'test' ) {
    return next()
  }
  console.log('Method:', request.method)
  console.log('Path:  ', request.path)
  console.log('Body:  ', request.body)
  console.log('---')
  next()
}

Tehdään pari testiä lisää:

test('there are five notes', async () => {
  const response = await api
    .get('/api/notes')

  expect(response.body.length).toBe(5)
})

test('the first note is about HTTP methods', async () => {
  const response = await api
    .get('/api/notes')

  expect(response.body[0].content).toBe('HTML on helppoa')
})

Molemmat testit sijoittavat pyynnön vastauksen muuttujaan response ja toisin kuin edellinen testi, joka käytti supertestin mekanismeja statuskoodin ja vastauksen headereiden oikeellisuuden varmistamiseen, tällä kertaa tutkitaan vastauksessa olevan datan, eli response.body:n oikeellisuutta Jestin expect:in avulla.

Async/await-kikan hyödyt tulevat nyt selkeästi esiin. Normaalisti tarvitsisimme asynkronisten pyyntöjen vastauksiin käsille pääsemiseen promiseja ja takaisinkutsuja, mutta nyt kaikki menee mukavasti:

const res = await api
  .get('/api/notes')

// tänne tullaan vasta kun edellinen komento eli HTTP-pyyntö on suoritettu
// muuttujassa res on nyt HTTP-pyynnön tulos
expect(res.body.length).toBe(5)

Testit menevät läpi. Testit ovat kuitenkin huonoja, niiden läpimeno riippuu tietokannan tilasta (joka sattuu omassa testikannassani olemaan sopiva). Jotta saisimme robustimmat testit, tulee tietokannan tila nollata testien alussa ja sen jälkeen laittaa kantaan hallitusti testien tarvitsema data.

Error: listen EADDRINUSE :::3002

Jos jotain patologista tapahtuu voi käydä niin, että testien suorittama palvelin jää päälle. Tällöin uusi testiajo aiheuttaa ongelmia, ja seurauksena on virheilmoitus

Error: listen EADDRINUSE :::3002

Ratkaisu tilanteeseen on tappaa palvelinta suorittava prosessi. Portin 3002 varaava prosessi löytyy OSX:lla ja Linuxilla esim. komennolla lsof -i :3002.

COMMAND  PID     USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
node    8318 mluukkai   14u  IPv6 0x5428af4833b85e8b      0t0  TCP *:redwood-broker (LISTEN)

Windowsissa portin varaavan prosessin näkee resmon.exe:n Verkko-välilehdeltä.

Komennon avulla selviää ikävyyksiä aiheuttavan prosesin PID eli prosessi-id. Prosessin saa tapettua komennolla KILL 8318 olettaen että PID on 8318 niin kuin kuvassa. Joskus prosessi on sitkeä eikä kuole ennen kuin se tapetaan komennolla KILL -9 8318.

Windowsissa vastaava komento on taskkill /f /pid 8318.

Tietokannan alustaminen ennen testejä

Testimme käyttää jo jestin metodia afterAll sulkemaan backendin testien suoritusten jälkeen. Jest tarjoaa joukon muitakin funktioita, joiden avulla voidaan suorittaa operaatioita ennen yhdenkään testin suorittamista tai ennen jokaisen testin suoritusta.

Päätetään alustaa tietokanta ennen kaikkien testin suoritusta, eli funktiossa beforeAll:

const supertest = require('supertest')
const {app, server} = require('../index')
const api = supertest(app)
const Note = require('../models/note')

const initialNotes = [
  {
    content: 'HTML on helppoa',
    important: false
  },
  {
    content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
    important: true
  }
]

beforeAll(async () => {
  await Note.remove({})

  let noteObject = new Note(initialNotes[0])
  await noteObject.save()

  noteObject = new Note(initialNotes[1])
  await noteObject.save()
})

Tietokanta siis tyhjennetään aluksi ja sen jälkeen kantaan lisätään kaksi taulukkoon initialNotes talletettua muistiinpanoa. Näin testien suoritus aloitetaan aina hallitusti samasta tilasta.

Muutetaan kahta jälkimmäistä testiä vielä seuraavasti:

test('all notes are returned', async () => {
  const response = await api
    .get('/api/notes')

  expect(response.body.length).toBe(initialNotes.length)
})

test('a specific note is within the returned notes', async () => {
  const response = await api
    .get('/api/notes')

  const contents = response.body.map(r => r.content)

  expect(contents).toContain('HTTP-protokollan tärkeimmät metodit ovat GET ja POST')
})

Huomaa jälkimmäisen testin ekspektaatio. Komennolla response.body.map(r => r.content) muodostetaan taulukko API:n palauttamien muistiinpanojen sisällöistä. Jestin toContain-ekspektaatiometodilla tarkistetaan että parametrina oleva muistiinpano on kaikkien API:n palauttamien muistiinpanojen joukossa.

Ennen kun teemme lisää testejä, tarkastellaan tarkemmin mitä async ja await tarkoittavat.

async-await

Async- ja await ovat ES7:n mukanaan tuoma uusi syntaksi, joka mahdollistaa promisen palauttavien asynkronisten funktioiden kutsumisen siten, että kirjoitettava koodi näyttää synkroniselta.

Esim. muistiinpanojen hakeminen tietokannasta hoidetaan promisejen avulla seuraavasti:

Note
  .find({})
  .then(notes => {
    console.log('operaatio palautti seuraavat muistiinpanot', notes)
  })

Metodikutsu Note.find() palauttaa promisen, ja saamme itse operaation tuloksen rekisteröimällä promiselle tapahtumankäsittelijän metodilla then.

Kaikki operaation suorituksen jälkeinen koodi kirjoitetaan tapahtumankäsittelijään. Jos haluaisimme tehdä peräkkäin useita asynkronisia funktiokutsuja, menisi tilanne ikävämmäksi. Joutuisimme tekemään kutsut tapahtumankäsittelijästä. Näin syntyisi potentiaalisesti monimutkaista koodia, pahimmassa tapauksessa jopa niin sanottu callback-helvetti.

Ketjuttamalla promiseja tilanne pysyy jollain tavalla hallinnassa, callback-helvetin eli monien sisäkkäisten callbackien sijaan saadaan aikaan siistihkö then-kutsujen ketju. Olemmekin nähneet jo kurssin aikana muutaman sellaisen. Seuraavassa vielä erittäin keinotekoinen esimerkki, joka hakee ensin kaikki muistiinpanot ja sitten tuhoaa niistä ensimmäisen:

Note
  .find({})
  .then(notes => {
    return notes[0].remove()
  })
  .then(response => {
    console.log('the first note is removed')
    // more code here
  })

Then-ketju on ok, mutta parempaankin pystytään. Jo ES6:ssa esitellyt generaattorifunktiot mahdollistivat ovelan tavan määritellä asynkronista koodia siten että se “näyttää synkroniselta”. Syntaksi ei kuitenkaan ole täysin luonteva ja sitä ei käytetä kovin yleisesti.

ES7:ssa async ja await tuovat generaattoreiden tarjoaman toiminnallisuuden ymmärrettävästi ja syntaksin puolesta selkeällä tavalla koko Javascript-kansan ulottuville.

Voisimme hakea tietokannasta kaikki muistiinpanot await-operaattoria hyödyntäen seuraavasti:

const notes = await Note.find({})

console.log('operaatio palautti seuraavat muistiinpanot ', notes)

Koodi siis näyttää täsmälleen synkroniselta koodilta. Suoritettavan koodinpätkän suhteen tilanne on se, että suoritus pysähtyy komentoon const notes = await Note.find({}) ja jatkuu kyselyä vastaavan promisen fulfillmentin eli onnistuneen suorituksen jälkeen seuraavalta riviltä. Kun suoritus jatkuu, promisea vastaavan operaation tulos on muuttujassa notes.

Ylempänä oleva monimutkaisempi esimerkki suoritettaisiin awaitin avulla seuraavasti:

const notes = await Note.find({})
const response = await notes[0].remove()

console.log('the first note is removed')

Koodi siis yksinkertaistuu huomattavasti verrattuna promiseja käyttävään then-ketjuun.

Awaitin käyttöön liittyy parikin tärkeää seikkaa. Jotta asynkronisia operaatioita voi kutsua awaitin avulla, niiden täytyy olla promiseja. Tämä ei sinänsä ole ongelma, sillä myös “normaaleja” callbackeja käyttävä asynkroninen koodi on helppo kääriä promiseksi.

Mistä tahansa kohtaa Javascript-koodia ei awaitia kuitenkaan pysty käyttämään. Awaitin käyttö onnistuu ainoastaan jos ollaan async-funktiossa.

Eli jotta edelliset esimerkit toimisivat, on ne suoritettava async-funktioiden sisällä, huomaa funktion määrittelevä rivi:

const main = async () => {
  const notes = await Note.find({})
  console.log('operaatio palautti seuraavat muistiinpanot', notes)

  const notes = await Note.find({})
  const response = await notes[0].remove()

  console.log('the first note is removed')
}

main()

Koodi määrittelee ensin asynkronisen funktion, joka sijoitetaan muuttujaan main. Määrittelyn jälkeen koodi kutsuu metodia komennolla main()

testin beforeAll-metodin optimointi

Palataan takaisin testien pariin, ja tarkastellaan määrittelemäämme testit alustavaa funktiota beforeAll:

const initialNotes = [
  {
    content: 'HTML on helppoa',
    important: false
  },
  {
    content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
    important: true
  }
]

beforeAll(async () => {
  await Note.remove({})

  let noteObject = new Note(initialNotes[0])
  await noteObject.save()

  noteObject = new Note(initialNotes[1])
  await noteObject.save()
})

Funktio tallettaa tietokantaan taulukon initialNotes nollannen ja ensimmäisen alkion, kummankin erikseen taulukon alkioita indeksöiden. Ratkaisu on ok, mutta jos haluaisimme tallettaa alustuksen yhteydessä kantaan useampia alkioita, olisi toisto parempi ratkaisu:

beforeAll(async () => {
  await Note.remove({})
  console.log('cleared')

  initialNotes.forEach(async (note) => {
    let noteObject = new Note(note)
    await noteObject.save()
    console.log('saved')
  })
  console.log('done')
})

test('notes are returned as json', async () => {
  console.log('entered test')
  // ...
}

Talletamme siis taulukossa initialNotes määritellyt muistiinpanot tietokantaan forEach-loopissa. Testeissä kuitenkin ilmenee jotain häikkää, ja sitä varten koodin sisään on lisätty aputulosteita.

Konsoliin tulostuu

cleared
done
entered test
saved
saved

Yllättäen ratkaisu ei async/awaitista huolimatta toimi niin kuin oletamme, testin suoritus aloitetaan ennen kun tietokannan tila on saatu alustettua!

Ongelma on siinä, että jokainen forEach-loopin läpikäynti generoi oman asynkronisen operaation ja beforeAll ei odota näiden suoritusta. Eli forEach:in sisällä olevat await-komennot eivät ole funktiossa beforeAll vaan erillisissä funktioissa joiden päättymistä beforeAll ei odota.

Koska testien suoritus alkaa heti beforeAll metodin suorituksen jälkeen, testien suoritus ehditään jo aloittaa ennen kuin tietokanta on alustettu toivottuun alkutilaan.

Toimiva ratkaisu tilanteessa on odottaa asynkronisten talletusoperaatioiden valmistumista beforeAll-funktiossa, esim. metodin Promise.all avulla:

beforeAll(async () => {
  await Note.remove({})

  const noteObjects = initialNotes.map(note => new Note(note))
  const promiseArray = noteObjects.map(note => note.save())
  await Promise.all(promiseArray)
})

Ratkaisu on varmasti aloittelijalle tiiviydestään huolimatta hieman haastava. Taulukkoon noteObjects talletetaan taulukkoon initialNotes talletettuja Javascript-oliota vastaavat Note-konstruktorifunktiolla generoidut Mongoose-oliot. Seuraavalla rivillä luodaan uusi taulukko, joka muodostuu promiseista, jotka saadaan kun jokaiselle noteObjects taulukon alkiolle kutsutaan metodia save, eli ne talletetaan kantaan.

Metodin Promise.all avulla saadaan koostettua taulukollinen promiseja yhdeksi promiseksi, joka valmistuu, eli menee tilaan fulfilled kun kaikki sen parametrina olevan taulukon promiset ovat valmistuneet. Siispä viimeinen rivi, await Promise.all(promiseArray) odottaa, että kaikki tietokantaan talletetusta vastaavat promiset ovat valmiina, eli alkiot on talletettu tietokantaan.

Promise.all-metodia käyttäessä päästään tarvittaessa käsiksi sen parametrina olevien yksittäisten promisejen arvoihin, eli promiseja vastaavien operaatioiden tuloksiin. Jos odotetaan promisejen valmistumista await-syntaksilla const results = await Promise.all(promiseArray) palauttaa operaatio taulukon, jonka alkioina on promiseArray:n promiseja vastaavat arvot samassa järjestyksessä kuin promiset ovat taulukossa.

Promise.all suorittaa kaikkia syötteenä saamiaan promiseja rinnakkain. Jos operaatioiden suoritusjärjestyksellä on merkitystä, voi tämä aiheuttaa ongelmia. Tällöin asynkroniset operaatiot on mahdollista määrittää for…of lohkon sisällä, jonka suoritusjärjestys on taattu.

beforeAll(async () => {
  await Note.remove({})

  for (let note of initialNotes) {
    let noteObject = new Note(note)
    await noteObject.save()
  }
})

Javascriptin asynkroninen suoritusmalli aiheuttaakin siis helposti yllätyksiä ja myös async/await-syntaksin kanssa pitää olla koko ajan tarkkana. Vaikka async/await peittää monia promisejen käsittelyyn liittyviä seikkoja, promisejen toiminta on syytä tuntea mahdollisimman hyvin!

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part4-3.

async/await backendissä

Muutetaan nyt backend käyttämään asyncia ja awaitia. Koska kaikki asynkroniset operaatiot tehdään joka tapauksessa funktioiden sisällä, awaitin käyttämiseen riittää, että muutamme routejen käsittelijät async-funktioiksi.

Kaikkien muistiinpanojen hakemisesta vastaava route muuttuu seuraavasti:

notesRouter.get('/', async (request, response) => {
  const notes = await Note.find({})
  response.json(notes.map(formatNote))
})

Voimme varmistaa refaktoroinnin onnistumisen selaimella, sekä suorittamalla juuri määrittelemämme testit.

ESlint ja async/await nuolifunktioissa

Ennen testejä tehdään pieni täsmennyt ESlint-konfiguraatioon. Tällä hetkellä ESlint valittaa async-määreellä varustetuista nuolifunktioista:

kyse on siitä, että ESlint ei vielä osaa tulkita uutta syntaksia kunnolla. Pääsemme valituksesta eroon asentamalla babel-eslint-pluginin:

npm install babel-eslint --save-dev

Pluginin käyttöönotto tulee määritellä tiedostossa .eslintrc.js :

module.exports = {
  "env": {
    "node": true,
    "es6": true
  },
  "parser": "babel-eslint",
  // ...
}

Aiheeton valitus poistuu.

Testejä ja backendin refaktorointia

Koodia refaktoroidessa vaanii aina regression vaara, eli on olemassa riski, että jo toimineet ominaisuudet hajoavat. Tehdäänkin muiden operaatioiden refaktorointi siten, että ennen koodin muutosta tehdään jokaiselle API:n routelle sen toiminnallisuuden varmistavat testit.

Aloitetaan lisäysoperaatiosta. Tehdään testi, joka lisää uuden muistiinpanon ja tarkistaa, että API:n palauttamien muistiinpanojen määrä kasvaa, ja että lisätty muistiinpano on palautettujen joukossa:

test('a valid note can be added ', async () => {
  const newNote = {
    content: 'async/await yksinkertaistaa asynkronisten funktioiden kutsua',
    important: true
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(200)
    .expect('Content-Type', /application\/json/)

  const response = await api
    .get('/api/notes')

  const contents = response.body.map(r => r.content)

  expect(response.body.length).toBe(initialNotes.length + 1)
  expect(contents).toContain('async/await yksinkertaistaa asynkronisten funktioiden kutsua')
})

Kuten odotimme ja toivoimme, menee testi läpi.

Tehdään myös testi, joka varmistaa, että muistiinpanoa, jolle ei ole asetettu sisältöä, ei talleteta

test('note without content is not added ', async () => {
  const newNote = {
    important: true
  }

  const intialNotes = await api
    .get('/api/notes')

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(400)

  const response = await api
    .get('/api/notes')

  expect(response.body.length).toBe(intialNotes.body.length)
})

Testi ei mene läpi.

Käy ilmi, että myös operaation suoritus postman tai Visual Studio Coden REST clientillä johtaa virhetilanteeseen. Koodissa on siis bugi.

Huom: testejä tehdessä täytyy aina varmistua siitä, että testi testaa oikeaa asiaa, ja usein ensimmäistä kertaa testiä tehdessä se että testi ei mene läpi tarkoittaa sitä, että testi on tehty väärin. Myös päinvastaista tapahtuu, eli testi menee läpi mutta koodissa onkin virhe, eli testi ei testaa sitä mitä sen piti testata. Tämän takia testit kannattaa aina “testata” rikkomalla koodi ja varmistamalla, että testi huomaa koodiin tehdyt virheet.

Kun suoritamme operaation postmanilla konsoli paljastaa, että kyseessä on Unhandled promise rejection, eli koodi ei käsittele promisen virhetilannetta:

Server running on port 3001
Method: POST
Path:   /api/notes/
Body:   { important: true }
---
(node:28657) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: Can't set headers after they are sent.
(node:28657) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

Kuten jo edellisessä osassa mainittiin, tämä ei ole hyvä idea. Kannattaakin aloittaa lisäämällä promise-ketjuun metodilla catch virheenkäisttelijä, joka tulostaa konsoliin virheen syyn:

notesRouter.post('/', (request, response) => {
  // ...

  note
    .save()
    .then(note => {
      return formatNote(note)
    })
    .then(formattedNote => {
      response.json(formattedNote)
    })
    .catch(error => {
      console.log(error)
      response.status(500).json({ error: 'something went wrong...' })
    })

Konsoliin tulostuu seuraava virheilmoitus

Error: Can't set headers after they are sent.
    at validateHeader (_http_outgoing.js:489:11)
    at ServerResponse.setHeader (_http_outgoing.js:496:3)

Aloittelijalle virheilmoitus ei välttämättä kerro paljoa, mutta googlaamalla virheilmoituksella, pieni etsiminen tuottaisi jo tuloksen.

Kyse on siitä, että koodi kutsuu response-olion metodia send kaksi kertaa, tai oikeastaan koodi kutsuu metodia json, joka kutsuu edelleen metodia send.

Kaksi kertaa tapahtuva send-kutsu johtuu siitä, että koodin alun if-lauseessa on ongelma:

notesRouter.post('/', (request, response) => {
  const body = request.body

  if (body.content === undefined) {
    response.status(400).json({ error: 'content missing' })
    // suoritus jatkuu!
  }

  //...
}

kun koodi kutsuu response.status(400).json(...) suoritus jatkaa koodin alla olevaa osaan ja se taas aiheuttaa uuden response.json()-kutsun.

Korjataan ongelma lisäämällä if-lauseeseen return:

notesRouter.post('/', (request, response) => {
  const body = request.body

  if (body.content === undefined) {
    return response.status(400).json({ error: 'content missing' })
  }

  //...
}

Edellisen osan lopussa koodi oli vielä oikein, mutta siirtäessämme osan alussa koodia tiedostosta index.js uuteen paikkaan, on return kadonnut matkalta.

Promiseja käyttävä koodi toimii nyt ja testitkin menevät läpi. Olemme valmiit muuttamaan koodin käyttämään async/await-syntaksia.

Koodi muuttuu seuraavasti (huomaa, että käsittelijän alkuun on laitettava määre async):

notesRouter.post('/', async (request, response) => {
  const body = request.body

  if (body.content === undefined) {
    return response.status(400).json({ error: 'content missing' })
  }

  const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    date: new Date()
  })

  const savedNote = await note.save()
  response.json(formatNote(savedNote))
})

Koodiin jää kuitenkin pieni ongelma: virhetilanteita ei nyt käsitellä ollenkaan. Miten niiden suhteen tulisi toimia?

virheiden käsittely ja async/await

Jos sovellus POST-pyyntöä käsitellessään aiheuttaa jonkinlaisen ajonaikaisen virheen, syntyy jälleen tuttu tilanne:

(node:30644) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): TypeError: formattedNote.nonexistingMethod is not a function

eli käsittelemätön promisen rejektoituminen. Pyyntöön ei vastata tilanteessa mitenkään.

Async/awaitia käyttäessä kannattaa käyttää vanhaa kunnon try/catch-mekanismia virheiden käsittelyyn:

notesRouter.post('/', async (request, response) => {
  try {
    const body = request.body

    if (body.content === undefined) {
      return response.status(400).json({ error: 'content missing' })
    }

    const note = new Note({
      content: body.content,
      important: body.important === undefined ? false : body.important,
      date: new Date()
    })

    const savedNote = await note.save()
    response.json(formatNote(note))
  } catch (exception) {
    console.log(exception)
    response.status(500).json({ error: 'something went wrong...' })
  }
})

Iso try/catch tuo koodiin hieman ikävän vivahteen, mutta mikään ei ole ilmaista.

Tehdään sitten testit yksittäisen muistiinpanon tietojen katsomiselle ja muistiinpanon poistolle:

test('a specific note can be viewed', async () => {
  const resultAll = await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)

  const aNoteFromAll = resultAll.body[0]

  const resultNote = await api
    .get(`/api/notes/${aNoteFromAll.id}`)

  const noteObject = resultNote.body

  expect(noteObject).toEqual(aNoteFromAll)
})

test('a note can be deleted', async () => {
  const newNote = {
    content: 'HTTP DELETE poistaa resurssin',
    important: true
  }

  const addedNote = await api
    .post('/api/notes')
    .send(newNote)

  const notesAtBeginningOfOperation = await api
    .get('/api/notes')

  await api
    .delete(`/api/notes/${addedNote.body.id}`)
    .expect(204)

  const notesAfterDelete = await api
    .get('/api/notes')

  const contents = notesAfterDelete.body.map(r => r.content)

  expect(contents).not.toContain('HTTP DELETE poistaa resurssin')
  expect(notesAfterDelete.body.length).toBe(notesAtBeginningOfOperation.body.length - 1)
})

Testit eivät tässä vaiheessa ole optimaaliset, parannetaan niitä kohta. Ensin kuitenkin refaktoroidaan backend käyttämään async/awaitia.

notesRouter.get('/:id', async (request, response) => {
  try {
    const note = await Note.findById(request.params.id)

    if (note) {
      response.json(formatNote(note))
    } else {
      response.status(404).end()
    }

  } catch (exception) {
    console.log(exception)
    response.status(400).send({ error: 'malformatted id' })
  }
})

notesRouter.delete('/:id', async (request, response) => {
  try {
    await Note.findByIdAndRemove(request.params.id)

    response.status(204).end()
  } catch (exception) {
    console.log(exception)
    response.status(400).send({ error: 'malformatted id' })
  }
})

Async/await ehkä selkeyttää koodia jossain määrin, mutta saavutettava hyöty ei ole sovelluksessamme vielä niin iso mitä se tulee olemaan jos asynkronisia kutsuja on tehtävä useampia.

Kaikki eivät kuitenkaan ole vakuuttuneita siitä, että async/await on hyvä lisä Javascriptiin, lue esim. ES7 async functions - a step in the wrong direction

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part4-4. Samassa on “vahingossa” mukana testeistä seuraavan luvun jälkeinen paranneltu versio.

Varoitus

Jos huomaat kirjoittavasi sekaisin async/awaitia ja then-kutsuja, on 99% varmaa, että teet jotain väärin. Käytä siis jompaa kumpaa tapaa, älä missään tapauksessa “varalta” molempia.

Tehtäviä

Tee nyt tehtävät 4.8-4.11

Testien refaktorointi

Testimme sisältävät tällä hetkellä jossain määrin toisteisuutta ja niiden rakenne ei ole optimaalinen. Testit ovat myös osittain epätäydelliset, esim. reittejä GET /api/notes/:id ja DELETE /api/notes/:id ei tällä hetkellä testata epävalidien id:iden osalta.

Testeissä on myös eräs hieman ikävä ja jopa riskialtis piirre. Testit luottavat siihen, että ne suoritetaan siinä järjestyksessä, missä ne on kirjoitettu testitiedostoon. Tämä pitää kyllä paikkansa, vaikkakin se ei ole kovin selkeästi määritelty ominaisuus eli siihen ei ole hyvä luottaa. Testit tuleekin kirjoittaa siten, että yksittäiset testit ovat riippumattoimia toistensa suorituksesta.

Parannellaan testejä hiukan.

Tehdään testejä varten muutama apufunktio moduuliin tests/test_helper.js

const Note = require('../models/note')

const initialNotes = [
  {
    content: 'HTML on helppoa',
    important: false
  },
  {
    content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
    important: true
  }
]

const format = (note) => {
  return {
    content: note.content,
    important: note.important,
    id: note._id
  }
}

const nonExistingId = async () => {
  const note = new Note()
  await note.save()
  await note.remove()

  return note._id.toString()
}

const notesInDb = async () => {
  const notes = await Note.find({})
  return notes.map(format)
}

module.exports = {
  initialNotes, format, nonExistingId, notesInDb
}

Tärkein apufunktioista on notesInDb joka palauttaa kaikki tietokannassa kutsuhetkellä olevat oliot.

Jossain määrin parannellut testit seuraavassa:

const supertest = require('supertest')
const { app, server } = require('../index')
const api = supertest(app)
const Note = require('../models/note')
const { format, initialNotes, nonExistingId, notesInDb } = require('./test_helper')

describe('when there is initially some notes saved', async () => {
  beforeAll(async () => {
    await Note.remove({})

    const noteObjects = initialNotes.map(n => new Note(n))
    await Promise.all(noteObjects.map(n => n.save()))
  })

  test('all notes are returned as json by GET /api/notes', async () => {
    const notesInDatabase = await notesInDb()

    const response = await api
      .get('/api/notes')
      .expect(200)
      .expect('Content-Type', /application\/json/)

    expect(response.body.length).toBe(notesInDatabase.length)

    const returnedContents = response.body.map(n => n.content)
    notesInDatabase.forEach(note => {
      expect(returnedContents).toContain(note.content)
    })
  })

  test('individual notes are returned as json by GET /api/notes/:id', async () => {
    const notesInDatabase = await notesInDb()
    const aNote = notesInDatabase[0]

    const response = await api
      .get(`/api/notes/${aNote.id}`)
      .expect(200)
      .expect('Content-Type', /application\/json/)

    expect(response.body.content).toBe(aNote.content)
  })

  test('404 returned by GET /api/notes/:id with nonexisting valid id', async () => {
    const validNonexistingId = await nonExistingId()

    const response = await api
      .get(`/api/notes/${validNonexistingId}`)
      .expect(404)
  })

  test('400 is returned by GET /api/notes/:id with invalid id', async () => {
    const invalidId = "5a3d5da59070081a82a3445"

    const response = await api
      .get(`/api/notes/${invalidId}`)
      .expect(400)
  })

  describe('addition of a new note', async () => {

    test('POST /api/notes succeeds with valid data', async () => {
      const notesAtStart = await notesInDb()

      const newNote = {
        content: 'async/await yksinkertaistaa asynkronisten funktioiden kutsua',
        important: true
      }

      await api
        .post('/api/notes')
        .send(newNote)
        .expect(200)
        .expect('Content-Type', /application\/json/)

      const notesAfterOperation = await notesInDb()

      expect(notesAfterOperation.length).toBe(notesAtStart.length + 1)

      const contents = notesAfterOperation.map(r => r.content)
      expect(contents).toContain('async/await yksinkertaistaa asynkronisten funktioiden kutsua')
    })

    test('POST /api/notes fails with proper statuscode if content is missing', async () => {
      const newNote = {
        important: true
      }

      const notesAtStart = await notesInDb()

      await api
        .post('/api/notes')
        .send(newNote)
        .expect(400)

      const notesAfterOperation = await notesInDb()

      const contents = notesAfterOperation.map(r => r.content)

      expect(notesAfterOperation.length).toBe(notesAtStart.length)
    })
  })

  describe('deletion of a note', async () => {
    let addedNote

    beforeAll(async () => {
      addedNote = new Note({
        content: 'poisto pyynnöllä HTTP DELETE',
        important: false
      })
      await addedNote.save()
    })

    test('DELETE /api/notes/:id succeeds with proper statuscode', async () => {
      const notesAtStart = await notesInDb()

      await api
        .delete(`/api/notes/${addedNote._id}`)
        .expect(204)

      const notesAfterOperation = await notesInDb()

      const contents = notesAfterOperation.map(r => r.content)

      expect(contents).not.toContain(addedNote.content)
      expect(notesAfterOperation.length).toBe(notesAtStart.length - 1)
    })
  })

  afterAll(() => {
    server.close()
  })

})

Muutama huomio testeistä. Olemme jaotelleet testejä desribe-lohkojen avulla ja muutamissa lohkoissa on oma beforeAll-funktiolla suoritettava alustuskoodi.

Joissain tapauksissa tämä olisi parempi tehdä operaatioilla beforeEach, joka suoritetaan ennen jokaista testiä, näin testeistä saisi varmemmin toisistaan riippumattomia. Esimerkissä beforeEachia ei kuitenkaan ole käytetty.

Testien raportointi tapahtuu describe-lohkojen ryhmittelyn mukaan:

Backendin tietokannan tilaa muuttavat testit, esim. uuden muistiinpanon lisäämistä testaava testi ‘addition of a new note’, on tehty siten, että ne ensin aluksi selvittävät tietokannan tilan apufunktiolla notesInDb()

const notesAtBeginningOfOperation = await notesInDb()

suorittavat testattavan operaation:

const newNote = {
  content: 'async/await yksinkertaistaa asynkronisten funktioiden kutsua',
  important: true
}

await api
  .post('/api/notes')
  .send(newNote)
  .expect(200)
  .expect('Content-Type', /application\/json/)

selvittävät tietokannan tilan operaation jälkeen

const notesAfterOperation = await notesInDb()

ja varmentavat, että operaation suoritus vaikutti tietokantaan halutulla tavalla

expect(notesAfterOperation.length).toBe(notesAtBeginningOfOperation.length + 1)

const contents = notesAfterOperation.map(r => r.content)
expect(contents).toContain('async/await yksinkertaistaa asynkronisten funktioiden kutsua')

Testeihin jää vielä paljon parannettavaa mutta on jo aika siirtyä eteenpäin.

Käytetty tapa API:n testaamiseen, eli HTTP-pyyntöinä tehtävät operaatiot ja tietokannan tilan tarkastelu Mongoosen kautta ei ole suinkaan ainoa tai välttämättä edes paras tapa tehdä API-tason integraatiotestausta. Universaalisti parasta tapaa testien tekoon ei ole, vaan kaikki on aina suhteessa käytettäviin resursseihin ja testattavaan ohjelmistoon.

Tehtäviä

Tee nyt tehtävät 4.12-4.14

Käyttäjien hallinta ja monimutkaisempi tietokantaskeema

Haluamme toteuttaa sovellukseemme käyttäjien hallinnan. Käyttäjät tulee tallettaa tietokantaan ja jokaisesta muistiinpanosta tulee tietää sen luonut käyttäjä. Muistiinpanojen poisto ja editointi tulee olla sallittua ainoastaan muistiinpanot tehneelle käyttäjälle.

Aloitetaan lisäämällä tietokantaan tieto käyttäjistä. Käyttäjän (User) ja muistiinpanojen (Note) välillä on yhden suhde moneen -yhteys:

Relaatiotietokantoja käytettäessä ratkaisua ei tarvitsisi juuri miettiä. Molemmille olisi oma taulunsa ja muistiinpanoihin liitettäisiin sen luonutta käyttäjää vastaava id vierasavaimeksi (foreign key).

Dokumenttitietokantoja käytettäessä tilanne on kuitenkin toinen, erilaisia tapoja mallintaa tilanne on useita.

Olemassaoleva ratkaisumme tallentaa jokaisen luodun muistiinpanon tietokantaan notes-kokoelmaan eli collectioniin. Jos emme halua muuttaa tätä, lienee luontevinta tallettaa käyttäjät omaan kokoelmaansa, esim. nimeltään users.

Mongossa voidaan kaikkien dokumenttitietokantojen tapaan käyttää olioiden id:itä viittaamaan muissa kokoelmissa talletettaviin dokumentteihin, vastaavasti kuten viiteavaimia käytetään relaatiotietokannoissa.

Dokumenttitietokannat kuten Mongo eivät kuitenkaan tue relaatiotietokantojen liitoskyselyitä vastaavaa toiminnallisuutta, joka mahdollistaisi useaan kokoelmaan kohdistuvan tietokantahaun (tämä ei ole tarkalleen ottaen enää välttämättä pidä paikkaansa, sillä versiosta 3.2. alkaen Mongo on tukenut useampaan kokoelmaan kohdistuvia lookup-aggregaattikyselyitä, emme kuitenkaan käsittele niitä kurssilla).

Jos tarvitsemme liitoskyselyitä vastaavaa toiminnallisuutta, tulee se toteuttaa sovelluksen tasolla, eli käytännössä tekemällä tietokantaan useita kyselyitä. Tietyissä tilanteissa mongoose-kirjasto osaa hoitaa liitosten tekemisen, jolloin kysely näyttää mongoosen käyttäjälle toimivan liitoskyselyn tapaan. Mongoose tekee kuitenkin näissä tapauksissa taustalla useamman kyselyn tietokantaan.

Viitteet kokoelmien välillä

Jos käyttäisimme relaatiotietokantaa, muistiinpano sisältäisi viiteavaimen sen tehneeseen käyttäjään. Dokumenttitietokannassa voidaan toimia samoin.

Oletetaan että kokoelmassa users on kaksi käyttäjää:

[
  {
    username: 'mluukkai',
    _id: 123456
  },
  {
    username: 'hellas',
    _id: 141414
  }
]

Kokoelmassa notes on kolme muistiinpanoa, kaikkien kenttä user viittaa users-kentässä olevaan käyttäjään:

[
  {
    content: 'HTML on helppoa',
    important: false,
    _id: 221212,
    user: 123456
  },
  {
    content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
    important: true,
    _id: 221255,
    user: 123456
  },
  {
    content: 'Java on kieli, jota käytetään siihen asti kunnes aurinko sammuu',
    important: false,
    _id: 221244,
    user: 141414
  }
]

Mikään ei kuitenkaan määrää dokumenttitietokannoissa, että viitteet on talletettava muistiinpanoihin, ne voivat olla myös (tai ainoastaan) käyttäjien yhteydessä:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [221212, 221255]
  },
  {
    content: 'hellas',
    _id: 141414,
    notes: [141414]
  }
]

Koska käyttäjiin liittyy potentiaalisesti useita muistiinpanoja, niiden id:t talletetaan käyttäjän kentässä notes olevaan taulukkoon.

Dokumenttitietokannat tarjoavat myös radikaalisti erilaisen tavan datan organisointiin, joissain tilanteissa saattaisi olla mielekästä tallettaa muistiinpanot kokonaisuudessa käyttäjien sisälle:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [
      {
        content: 'HTML on helppoa',
        important: false
      },
      {
        content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
        important: true
      }
    ]
  },
  {
    content: 'hellas',
    _id: 141414,
    notes: [
      {
        content: 'Java on kieli, jota käytetään siihen asti kunnes aurinko sammuu',
        important: false
      }
    ]
  }
]

Muistiinpanot olisivat tässä skeemaratkaisussa siis yhteen käyttäjään alisteisia kenttiä, niillä ei olisi edes omaa identiteettiä, eli id:tä tietokannan tasolla.

Dokumenttitietokantojen yhteydessä skeeman rakenne ei siis ole ollenkaan samalla tavalla ilmeinen kuin relaatiotietokannoissa, ja valittava ratkaisu kannattaa määritellä siten että se tukee parhaalla tavalla sovelluksen käyttötapauksia. Tämä ei luonnollisestikaan ole helppoa, sillä järjestelmän kaikki käyttötapaukset eivät yleensä ole selvillä kun projektin alkuvaiheissa mietitään datan organisointitapaa.

Hieman paradoksaalisesti tietokannan tasolla skeematon Mongo edellyttääkin projektin alkuvaiheissa jopa radikaalimpien datan organisoimiseen liittyvien ratkaisujen tekemistä kuin tietokannan tasolla skeemalliset relaatiotietokannat, jotka tarjoavat keskimäärin kaikkiin tilanteisiin melko hyvin sopivan tavan organisoida dataa.

Käyttäjien mongoose-skeema

Päätetään tallettaa käyttäjän yhteyteen myös tieto käyttäjän luomista muistiinpanoista, eli käytännössä muistiinpanojen id:t. Määritellään käyttäjää edustava model tiedostoon models/user:

const mongoose = require('mongoose')

const User = mongoose.model('User', {
  username: String,
  name: String,
  passwordHash: String,
  notes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Note' }]
})

module.exports = User

Muistiinpanojen id:t on talletettu käyttäjien sisälle taulukkona mongo-id:itä. Määrittely on seuraava

{ type: mongoose.Schema.Types.ObjectId, ref: 'Note' }

kentän tyyppi on ObjectId joka viittaa Note-tyyppisiin dokumentteihin. Mongo ei itsessään tiedä mitään siitä, että kyse on kentästä joka viittaa nimenomaan muistiinpanoihin, kyseessä onkin puhtaasti mongoosen syntaksi.

Laajennetaan tiedostossa model/note.js olevaa muistiinpanon skeemaa siten, että myös muistiinpanossa on tieto sen luoneesta käyttäjästä

const Note = mongoose.model('Note', {
  content: String,
  date: Date,
  important: Boolean,
  user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
})

Relaatiotietokantojen käytänteistä poiketen viitteet on nyt talletettu molempiin dokumentteihin, muistiinpano viittaa sen luoneeseen käyttäjään ja käyttäjä sisältää taulukollisen viitteitä sen luomiin muistiinpanoihin.

Käyttäjien luominen

Toteutetaan seuraavaksi route käyttäjien luomista varten. Käyttäjällä on siis username jonka täytyy olla järjestelmässä yksikäsitteinen, nimi eli name sekä passwordHash, eli salasanasta yksisuuntaisen funktion perusteella laskettu tunniste. Salasanojahan ei ole koskaan viisasta tallentaa tietokantaan selväsanaisena!

Asennetaan salasanojen hashaamiseen käyttämämme bcrypt-kirjasto:

npm install bcrypt --save

Käyttäjien luominen tapahtuu osassa 3 läpikäytyjä RESTful-periaatteita seuraten tekemällä HTTP POST -pyyntö polkuun users.

Määritellään käyttäjienhallintaa varten oma router tiedostoon controllers/users.js, ja liitetään se index.js-tiedostossa huolehtimaan polulle /api/users/ tulevista pyynnöistä:

const usersRouter = require('./controllers/users')

// ...

app.use('/api/users', usersRouter)

Routerin alustava sisältö on seuraava:

const bcrypt = require('bcrypt')
const usersRouter = require('express').Router()
const User = require('../models/user')

usersRouter.post('/', async (request, response) => {
  try {
    const body = request.body

    const saltRounds = 10
    const passwordHash = await bcrypt.hash(body.password, saltRounds)

    const user = new User({
      username: body.username,
      name: body.name,
      passwordHash
    })

    const savedUser = await user.save()

    response.json(savedUser)
  } catch (exception) {
    console.log(exception)
    response.status(500).json({ error: 'something went wrong...' })
  }
})

module.exports = usersRouter

Tietokantaan siis ei talleteta pyynnön mukana tulevaa salasanaa, vaan funktion bcrypt.hash avulla laskettu hash.

Materiaalin tilamäärä ei valitettavasti riitä käsittelemään sen tarkemmin salasanojen tallennuksen perusteita, esim. mitä maaginen luku 10 muuttujan saltRounds arvona tarkoittaa. Lue linkkien takaa lisää.

Koodissa ei tällä hetkellä ole mitään virheidenkäsittelyä eikä validointeja, eli esim. käyttäjätunnuksen ja salasanan halutun muodon tarkastuksia.

Uutta ominaisuutta voidaan ja kannattaakin joskus testailla käsin esim. postmanilla. Käsin tapahtuva testailu muuttuu kuitenkin nopeasti työlääksi, etenkin kun tulemme pian vaatimaan, että samaa käyttäjätunnusta ei saa tallettaa kantaan kahteen kertaan.

Pienellä vaivalla voimme tehdä automaattisesti suoritettavat testit, jotka helpottavat sovelluksen kehittämistä merkittävästi.

Alustava testi näyttää seuraavalta:

const User = require('../models/user')
const { format, initialNotes, nonExistingId, notesInDb, usersInDb } = require('./test_helper')

//...

describe.only('when there is initially one user at db', async () => {
  beforeAll(async () => {
    await User.remove({})
    const user = new User({ username: 'root', password: 'sekret' })
    await user.save()
  })

  test('POST /api/users succeeds with a fresh username', async () => {
    const usersBeforeOperation = await usersInDb()

    const newUser = {
      username: 'mluukkai',
      name: 'Matti Luukkainen',
      password: 'salainen'
    }

    await api
      .post('/api/users')
      .send(newUser)
      .expect(200)
      .expect('Content-Type', /application\/json/)

    const usersAfterOperation = await usersInDb()
    expect(usersAfterOperation.length).toBe(usersBeforeOperation.length+1)
    const usernames = usersAfterOperation.map(u=>u.username)
    expect(usernames).toContain(newUser.username)
  })
})

Koska testi on määritelty describe.only-lohkoksi, suorittaa Jest ainoastaan lohkon sisälle määritellyt testit. Tämä on alkuvaiheessa hyödyllistä, sillä ennen kuin uusia käyttäjiä lisäävä toiminnallisuus on valmis, kannattaa suorittaa testeistä ainoastaan kyseistä toiminnallisuutta tutkivat testitapaukset.

Testit käyttävät myös tiedostossa tests/test_helper.js määriteltyä apufunktiota usersInDb() tarkastamaan lisäysoperaation jälkeisen tietokannan tilan:

const User = require('../models/user')

// ...

const usersInDb = async () => {
  const users = await User.find({})
  return users
}

module.exports = {
  initialNotes, format, nonExistingId, notesInDb, usersInDb
}

Lohkon beforeAll lisää kantaan käyttäjän, jonka username on root. Voimmekin tehdä uuden testin, jolla varmistetaan, että samalla käyttäjätunnuksella ei voi luoda uutta käyttäjää:

test('POST /api/users fails with proper statuscode and message if username already taken', async () => {
  const usersBeforeOperation = await usersInDb()

  const newUser = {
    username: 'root',
    name: 'Superuser',
    password: 'salainen'
  }

  const result = await api
    .post('/api/users')
    .send(newUser)
    .expect(400)
    .expect('Content-Type', /application\/json/)

  expect(result.body).toEqual({ error: 'username must be unique'})

  const usersAfterOperation = await usersInDb()
  expect(usersAfterOperation.length).toBe(usersBeforeOperation.length)
})

Testi ei tietenkään mene läpi tässä vaiheessa. Toimimme nyt oleellisesti TDD:n eli test driven developmentin hengessä, uuden ominaisuuden testi on kirjoitettu ennen ominaisuuden ohjelmointia.

Koodi laajenee seuraavasti:

usersRouter.post('/', async (request, response) => {
  try {
    const body = request.body

    const existingUser = await User.find({username: body.username})
    if (existingUser.length>0) {
      return response.status(400).json({ error: 'username must be unique' })
    }

    //...

  }
})

Eli haetaan tietokannasta ne user-dokumentit, joiden username-kentän arvo on sama kuin pyynnössä oleva. Jos sellainen user-dokumentti löytyy, vastataan pyyntöön statuskoodilla 400 bad request ja kerrotaan syy ongelmaan.

Voisimme toteuttaa käyttäjien luomisen yhteyteen myös muita tarkistuksia, esim. onko käyttäjätunnus tarpeeksi pitkä, koostuuko se sallituista merkeistä ja onko salasana tarpeeksi hyvä. Jätämme ne kuitenkin harjoitustehtäväksi.

Ennen kuin menemme eteenpäin, lisätään alustava versio joka palauttaa kaikki käyttäjät palauttavasta käsittelijäfunktiosta:

const formatUser = (user) => {
  return {
    id: user.id,
    username: user.username,
    name: user.name,
    notes: user.notes
  }
}

usersRouter.get('/', async (request, response) => {
  const users = await User.find({})
  response.json(users.map(formatUser))
})

Lista näyttää seuraavalta

Formatointifunktioiden siirto modelien märittelyn yhteyteen

Kuten muistinpanojenkin tapauksessa, olemme myös nyt määritellet apufunktion formatUser, joka muodostaa tietokannan palauttamista user-olioista selaimelle lähetettävän muodon, joista on mm. poistettu kenttä passwordHash.

Formatointifunktio on nyt sijoitettu routejen määrittelyn yhteyteen. Paikka ei välttämättä ole optimaalinen ja päätetäänkin viedä formatointi User-skeeman vastuulle, sen staattiseksi metodiksi.

Tehdään seuraava muutos tiedostoon models/user.js:

const mongoose = require('mongoose')

const userSchema = new mongoose.Schema({
  username: String,
  name: String,
  passwordHash: String,
  notes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Note' }]
})

userSchema.statics.format = (user) => {
  return {
    id: user.id,
    username: user.username,
    name: user.name,
    notes: user.notes
  }
}

const User = mongoose.model('User', userSchema)

module.exports = User

Näin määriteltyä metodia kutsutaan User.format(user). Voimme muuttaa tiedostosta controllesr/users.js olevat routet seuraavaan muotoon:

usersRouter.get('/', async (request, response) => {
  const users = await User.find({})
  response.json(users.map(User.format))
})

usersRouter.post('/', async (request, response) => {
  try {
    // ...
    const savedUser = await user.save()

    response.json(User.format(savedUser))
  } catch (exception) {
    // ...
  }
})

Formatointifunktion määritteleminen skeeman määrittelyn yhteydessä on sikäli luontevaa, että jos skeemaan tulee muutoksia, on formatointifunktio samassa tiedostossa ja todennäköisyys sen päivittämisen unohtamiselle pienenee.

Tehdään sama muutos muistiinpanojen formatointiin, eli muutetaan models/note.js muotoon

const mongoose = require('mongoose')

const noteSchema = new mongoose.Schema({
  content: String,
  date: Date,
  important: Boolean,
  user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
})

noteSchema.statics.format = (note) => {
  return {
    id: note._id,
    content: note.content,
    date: note.date,
    important: note.important
  }
}

const Note = mongoose.model('Note', noteSchema)

module.exports = Note

ja muutetaan tiedostosta controllers/notes.js metotodikutsut formatNote(note) muotoon Note.format(note) ja kutsu notes.map(formatNote) muotoon notes.map(Note.format)

Testien suoritus varmistaa, että sovelluksemme ei hajonnut refaktoroinnin myötä.

Pääsemme nyt eroon myös testien yhteyteen määritellystä muistiinpanoja formatoivasta apumetodista format, sillä myös testeissä kannattaa hyödyntää funktiota Note.format.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part4-5.

Muistiinpanon luominen

Muistiinpanot luovaa koodia on nyt mukautettava siten, että uusi muistiinpano tulee liitetyksi sen luoneeseen käyttäjään.

Laajennetaan ensin olemassaolevaa toteutusta siten, että tieto muistiinpanon luovan käyttäjän id:stä lähetetään pyynnön rungossa kentän userId arvona:

const User = require('../models/user')

//...

notesRouter.post('/', async (request, response) => {
  try {
    const body = request.body

    if (body.content === undefined) {
      return response.status(400).json({ error: 'content missing' })
    }

    const user = await User.findById(body.userId)

    const note = new Note({
      content: body.content,
      important: body.important === undefined ? false : body.important,
      date: new Date(),
      user: user._id
    })

    const savedNote = await note.save()

    user.notes = user.notes.concat(savedNote._id)
    await user.save()

    response.json(Note.format(note))
  } catch(exception) {
    console.log(exception)
    response.status(500).json({ error: 'something went wrong...' })
  }
})

Huomionarvoista on nyt se, että myös user-olio muuttuu. Sen kenttään notes talletetaan luodun muistiinpanon id:

const user = User.findById(userId)

user.notes = user.notes.concat(savedNote._id)
await user.save()

Kokeillaan nyt lisätä uusi muistiinpano

Operaatio vaikuttaa toimivan. Lisätään vielä yksi muistiinpano ja mennään kaikkien käyttäjien sivulle:

Huomaamme siis, että käyttäjällä on kaksi muistiinpanoa.

Jos laajennamme muistiinpanojen JSON:in muotoileman koodin näyttämään muistiinpanoon liittyvän käyttäjän

noteSchema.statics.format = (note) => {
  return {
    id: note._id,
    content: note.content,
    date: note.date,
    important: note.important,
    user: note.user
  }
}

tulee muistiinpanon luoneen käyttäjän id näkyviin muistiinpanon yhteyteen.

populate

Haluaisimme API:n toimivan siten, että haettaessa esim. käyttäjien tiedot polulle /api/users tehtävällä HTTP GET -pyynnöllä tulisi käyttäjien tekemien muistiinpanojen id:iden lisäksi näyttää niiden sisältö. Relaatiotietokannoilla toiminnallisuus toteutettaisiin liitoskyselyn avulla.

Kuten aiemmin mainittiin, eivät dokumenttitietokannat tue (kunnolla) eri kokoelmien välisiä liitoskyselyitä. Mongoose-kirjasto osaa kuitenkin tehdä liitoksen puolestamme. Mongoose toteuttaa liitoksen tekemällä useampia tietokantakyselyitä, joten siinä mielessä kyseessä on täysin erilainen tapa kuin relaatiotietokantojen liitoskyselyt, jotka ovat transaktionaalisia, eli liitoskyselyä tehdessä tietokannan tila ei muutu. Mongoosella tehtävä liitos taas on sellainen, että mikään ei takaa sitä, että liitettävien kokoelmien tila on konsistentti, toisin sanoen jos tehdään users- ja notes-kokoelmat liittävä kysely, kokoelmien tila saattaa muuttua kesken mongoosen liitosoperaation.

Liitoksen tekeminen suoritetaan mongoosen komennolla populate. Päivitetään ensin kaikkien käyttäjien tiedot palauttava route:

usersRouter.get('/', async (request, response) => {
  const users = await User
    .find({})
    .populate('notes')

  response.json(users.map(User.format))
})

Funktion populate kutsu siis ketjutetaan kyselyä vastaavan metodikutsun (tässä tapauksessa find) perään. Populaten parametri määrittelee, että user-dokumenttien notes-kentässä olevat note-olioihin viittaavat id:t korvataan niitä vastaavilla dokumenteilla.

Lopputulos on jo melkein haluamamme kaltainen:

Populaten yhteydessä on myös mahdollista rajata mitä kenttiä sisällytettävistä dokumenteista otetaan mukaan. Rajaus tapahtuu Mongon syntaksilla:

usersRouter.get('/', async (request, response) => {
  const users = await User
    .find({})
    .populate('notes', { content: 1, date: 1 } )

  response.json(users.map(User.format))
})

Tulos on nyt halutun kaltainen (:

Lisätään sopiva käyttäjän tietojen populointi, muistiinpanojen yhteyteen:

notesRouter.get('/', async (request, response) => {
  const notes = await Note
    .find({})
    .populate('user', { username: 1, name: 1 } )

  response.json(notes.map(Note.format))
})

Nyt käyttäjän tiedot tulevat muistiinpanon kenttään user.

Korostetaan vielä, että tietokannan tasolla ei siis ole mitään määrittelyä siitä, että esim. muistiinpanojen kenttään user talletetut id:t viittaavat käyttäjä-kokoelman dokumentteihin.

Mongoosen populate-funktion toiminnallisuus perustuu siihen, että olemme määritelleet viitteiden “tyypit” olioiden mongoose-skeemaan ref-kentän avulla:

const Note = mongoose.model('Note', {
  content: String,
  date: Date,
  important: Boolean,
  user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
})

Kirjautuminen

Käyttäjien tulee pystyä kirjautumaan sovellukseemme ja muistiinpanot pitää automaattisesti liittää kirjautuneen käyttäjän tekemiksi.

Toteutamme nyt backendiin tuen token-perustaiselle autentikoinnille.

Token-autentikaation periaatetta kuvaa seuraava sekvenssikaavio:

  • Alussa käyttäjä kirjaantuu Reactilla toteutettua kirjautumislomaketta käyttäen
    • lisäämme kirjautumislomakkeen frontendiin osassa 5
  • Tämän seurauksena selaimen React-koodi lähettää käyttäjätunnuksen ja salasanan HTTP POST -pyynnöllä palvelimen osoitteeseen /api/login
  • Jos käyttäjätunnus ja salasana ovat oikein, generoi palvelin Tokenin, jonka yksilöi jollain tavalla kirjautumisen tehneen käyttäjän
    • token on kryptattu, joten sen väärentäminen on (kryptografisesti) mahdotonta
  • backend vastaa selaimelle onnistumisesta kertovalla statuskoodilla ja palauttaa Tokenin vastauksen mukana
  • Selain tallentaa tokenin esimerkiksi React-sovelluksen tilaan
  • Kun käyttäjä luo uuden muistiinpanon (tai tekee jonkin operaation, joka edellyttää tunnistautumista), lähettää React-koodi Tokenin pyynnön mukana palvelimelle
  • Palvelin tunnistaa pyynnön tekijän tokenin perusteella

Tehdään ensin kirjautumistoiminto. Asennetaan jsonwebtoken-kirjasto, jonka avulla koodimme pystyy generoimaan JSON web token -muotoisia tokeneja.

npm install jsonwebtoken --save

Tehdään kirjautumisesta vastaava koodi tiedostoon controllers/login.js

const jwt = require('jsonwebtoken')
const bcrypt = require('bcrypt')
const loginRouter = require('express').Router()
const User = require('../models/user')

loginRouter.post('/', async (request, response) => {
  const body = request.body

  const user = await User.findOne({ username: body.username })
  const passwordCorrect = user === null ?
    false :
    await bcrypt.compare(body.password, user.passwordHash)

  if ( !(user && passwordCorrect) ) {
    return response.status(401).send({ error: 'invalid username or password' })
  }

  const userForToken = {
    username: user.username,
    id: user._id
  }

  const token = jwt.sign(userForToken, process.env.SECRET)

  response.status(200).send({ token, username: user.username, name: user.name })
})

module.exports = loginRouter

Koodi aloittaa etsimällä pyynnön mukana olevaa username:a vastaavan käyttäjän tietokannasta. Seuraavaksi katsotaan onko pyynnön mukana oleva password oikea. Koska tietokantaan ei ole talletettu salasanaa, vaan salasanasta laskettu hash, tehdään vertailu metodilla bcrypt.compare:

await bcrypt.compare(body.password, user.passwordHash)

Jos käyttäjää ei ole olemassa tai salasana on väärä, vastataan kyselyyn statuskoodilla 401 unauthorized ja kerrotaan syy vastauksen bodyssä.

Jos salasana on oikein, luodaan metodin jwt.sign avulla token, joka sisältää kryptatussa muodossa käyttäjätunnuksen ja käyttäjän id:

const userForToken = {
  username: user.username,
  id: user._id
}

const token = jwt.sign(userForToken, process.env.SECRET)

Token on digitaalisesti allekirjoitettu käyttämällä salaisuutena ympäristömuuttujassa SECRET olevaa merkkijonoa. Digitaalinen allekirjoitus varmistaa sen, että ainoastaan salaisuuden tuntevilla on mahdollisuus generoida validi token. Ympäristömuuttujalle pitää muistaa asettaa arvo tiedostoon .env.

Onnistuneeseen pyyntöön vastataan statuskoodilla 200 ok ja generoitu token sekä kirjautuneen käyttäjän käyttäjätunnus ja nimi lähetetään vastauksen bodyssä pyynnön tekijälle.

Kirjautumisesta huolehtiva koodi on vielä liitettävä sovellukseen lisäämällä tiedostoon index.js muiden routejen käyttöönoton yhteyteen

const loginRouter = require('./controllers/login')

//...

app.use('/api/login', loginRouter)

Kokeillaan kirjautumista, käytetään VS Coden REST-clientiä:

Kirjautuminen ei kuitenkaan toimi, konsoli näyttää seuraavalta:

Method: POST
Path:   /api/login
Body:   { username: 'mluukkai', password: 'salainen' }
---
(node:17486) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 2): Error: secretOrPrivateKey must have a value

Ongelman aiheuttaa komento jwt.sign(userForToken, process.env.SECRET) sillä ympäristömuuttujalle SECRET on unohtunut määritellä arvo. Kun arvo määritellään tiedostoon .env, alkaa kirjautuminen toimia.

Onnistunut kirjautuminen palauttaa kirjautuneen käyttäjän tiedot ja tokenin:

Virheellisellä käyttäjätunnuksella tai salasanalla kirjautuessa annetaan asianmukaisella statuskoodilla varustettu virheilmoitus

Muistiinpanojen luominen vain kirjautuneille

Muutetaan vielä muistiinpanojen luomista, siten että luominen onnistuu ainoastaan jos luomista vastaavan pyynnön mukana on validi token. Muistiinpano talletetaan tokenin identifioiman käyttäjän tekemien muistiinpanojen listaan.

Tapoja tokenin välittämiseen selaimesta backendiin on useita. Käytämme ratkaisussamme Authorization-headeria. Tokenin lisäksi headerin avulla kerrotaan mistä autentikointiskeemasta on kyse. Tämä voi olla tarpeen, jos palvelin tarjoaa useita eri tapoja autentikointiin. Skeeman ilmaiseminen kertoo näissä tapauksissa palvelimelle, miten mukana olevat kredentiaalit tulee tulkita. Meidän käyttöömme sopii Bearer-skeema.

Käytännössä tämä tarkoittaa, että jos token on esimerkiksi merkkijono eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, laitetaan pyynnöissä headerin Authorization arvoksi merkkijono

Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW

Modifioitu muistiinpanojen luomisesta huolehtiva koodi seuraavassa:

const jwt = require('jsonwebtoken')

// ...

const getTokenFrom = (request) => {
  const authorization = request.get('authorization')
  if (authorization && authorization.toLowerCase().startsWith('bearer ')) {
    return authorization.substring(7)
  }
  return null
}

notesRouter.post('/', async (request, response) => {
  const body = request.body

  try {
    const token = getTokenFrom(request)
    const decodedToken = jwt.verify(token, process.env.SECRET)

    if (!token || !decodedToken.id) {
      return response.status(401).json({ error: 'token missing or invalid' })
    }

    if (body.content === undefined) {
      return response.status(400).json({ error: 'content missing' })
    }

    const user = await User.findById(decodedToken.id)

    const note = new Note({
      content: body.content,
      important: body.important === undefined ? false : body.important,
      date: new Date(),
      user: user._id
    })

    const savedNote = await note.save()

    user.notes = user.notes.concat(savedNote._id)
    await user.save()

    response.json(Note.format(note))
  } catch(exception) {
    if (exception.name === 'JsonWebTokenError' ) {
      response.status(401).json({ error: exception.message })
    } else {
      console.log(exception)
      response.status(500).json({ error: 'something went wrong...' })
    }
  }
})

Apufunktio getTokenFrom eristää tokenin headerista authorization. Tokenin oikeellisuus varmistetaan metodilla jwt.verify. Metodi myös dekoodaa tokenin, eli palauttaa olion, jonka perusteella token on laadittu:

const decodedToken = jwt.verify(token, process.env.SECRET)

Tokenista dekoodatun olion sisällä on kentät username ja id eli se kertoo palvelimelle kuka pyynnön on tehnyt.

Jos tokenia ei ole tai tokenista dekoodattu olio ei sisällä käyttäjän identiteettiä (eli decodedToken.id ei ole määritelty), palautetaan virheestä kertova statuskoodi 401 unauthorized ja kerrotaan syy vastauksen bodyssä:

if (!token || !decodedToken.id) {
  return response.status(401).json({ error: 'token missing or invalid' })
}

Kun pyynnön tekijän identiteetti on selvillä, jatkuu suoritus entiseen tapaan.

Tokenin verifiointi voi myös aiheuttaa poikkeuksen JsonWebTokenError. Syynä tälle voi olla viallinen, väärennetty tai eliniältään vanhentunut token. Poikkeusten käsittelyssä haaraudutaan virheen tyypin perusteella ja vastataan 401 jos poikkeus johtuu tokenista, ja muuten vastataan 500 internal server error.

Uuden muistiinpanon luominen onnistuu nyt postmanilla jos authorization-headerille asetetaan oikeanlainen arvo, eli merkkijono bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, missä osa on login-operaation palauttama token.

Postmanilla luominen näyttää seuraavalta

ja Visual Studio Coden REST clientillä

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part4-6.

Jos sovelluksessa on useampia rajapintoja jotka vaativat kirjautumisen kannattaa JWT:n validointi eriyttää omaksi middlewarekseen, tai käyttää jotain jo olemassa olevaa kirjastoa kuten express-jwt.

Loppuhuomioita

Koodissa on tapahtunut paljon muutoksia ja matkan varrella on tapahtunut tyypillinen kiivaasti etenevän ohjelmistoprojektin ilmiö: suuri osa testeistä on hajonnut. Koska kurssin tämä osa on jo muutenkin täynnä uutta asiaa, jätämme testien korjailun harjoitustehtäväksi.

Käyttäjätunnuksia, salasanoja ja tokenautentikaatiota hyödyntäviä sovelluksia tulee aina käyttää salatun HTTPS-yhteyden yli. Voimme käyttää sovelluksissamme Noden HTTP-serverin sijaan HTTPS-serveriä. Toisaalta koska sovelluksemme tuotantoversio on Herokussa, sovelluksemme pysyy käyttäjien kannalta suojattuna sen ansiosta, että Heroku reitittää kaiken liikenteen selaimen ja Herokun palvelimien välillä HTTPS:n yli.

Toteutamme kirjautumisen frontendin puolelle kurssin seuraavassa osassa.

Tehtäviä

Tee nyt tehtävät 4.15-4.21