REST Service for POPCORN - ILIAS
b8c5990374e828bea9de4b057709a07d230da6ab..6138ffe99d016cc311f250bc1555b5403b8b1216
5 hours ago alex
GS-2555
6138ff diff | tree
6 hours ago alex
GS-2555
2920cc diff | tree
6 hours ago alex
GS-2555
a82027 diff | tree
6 hours ago alex
GS-2555
c41668 diff | tree
7 hours ago alex
GS-2555
07d24f diff | tree
7 hours ago alex
GS-2555
bc2806 diff | tree
7 hours ago alex
GS-2555
a575e8 diff | tree
7 hours ago alex
GS-2555
594912 diff | tree
7 hours ago alex
GS-2555
db2a97 diff | tree
13 hours ago alex
0.4.0
e8b24f diff | tree
13 hours ago alex
version bump
2145a8 diff | tree
13 hours ago alex
cleanup
c783f0 diff | tree
13 hours ago alex
adding doc
98effc diff | tree
13 hours ago alex
GS-2555
35d6c8 diff | tree
14 hours ago alex
cleanup
ebadc1 diff | tree
20 files modified
2 files added
1 files deleted
1727 ■■■■■ changed files
.mocharc.js 7 ●●●●● patch | view | raw | blame | history
README.md 10 ●●●●● patch | view | raw | blame | history
README_alt.md 127 ●●●●● patch | view | raw | blame | history
REST.md 659 ●●●●● patch | view | raw | blame | history
lib/db.js 75 ●●●●● patch | view | raw | blame | history
lib/libIlias.js 15 ●●●●● patch | view | raw | blame | history
lib/libLp.js 120 ●●●●● patch | view | raw | blame | history
logger.js 27 ●●●●● patch | view | raw | blame | history
package-lock.json 4 ●●●●● patch | view | raw | blame | history
package.json 2 ●●●●● patch | view | raw | blame | history
php/globus-ilias-rest/login.php 3 ●●●●● patch | view | raw | blame | history
settings.default.json 3 ●●●●● patch | view | raw | blame | history
settings.js 3 ●●●●● patch | view | raw | blame | history
test/data.js 15 ●●●●● patch | view | raw | blame | history
test/testAbmelden.js 48 ●●●●● patch | view | raw | blame | history
test/testAnmelden.js 42 ●●●●● patch | view | raw | blame | history
test/testApiKursOffline.js 6 ●●●●● patch | view | raw | blame | history
test/testApiPing.js 3 ●●●●● patch | view | raw | blame | history
test/testCourseAdmins.js 4 ●●●●● patch | view | raw | blame | history
test/testCreateKurs.js 28 ●●●●● patch | view | raw | blame | history
test/testImportIliasUser.js 36 ●●●●● patch | view | raw | blame | history
test/testKursLp.js 356 ●●●●● patch | view | raw | blame | history
test/testSetStatus.js 134 ●●●●● patch | view | raw | blame | history
.mocharc.js
New file
@@ -0,0 +1,7 @@
// Mocha config: keep manual test runs quiet unless LOG_LEVEL is set explicitly.
process.env.LOG_LEVEL ||= "error"
module.exports = {
   exit: true,
   timeout: 20000,
}
README.md
@@ -232,6 +232,16 @@
### Logs
The log level is configured in `settings.$NODE_ENV.json` via `log.level`
(default `info`). The `LOG_LEVEL` environment variable overrides it, which is
handy for quiet test runs:
    LOG_LEVEL=error npm test
Valid levels: `debug`, `info`, `warn`, `error`, and `silent` (or `off`) to
disable logging entirely. Messages below the active level are neither printed
nor written to `log.log`.
To prevent logs from growing too big the file `$INSTALLDIR/log.log` should be included into a logrotation job.
Furthermore `pm2` also does some logging.
README_alt.md
File was deleted
REST.md
New file
@@ -0,0 +1,659 @@
# REST API
HTTP API of **globus-ilias-rest** â€” a REST service for POPCORN â†’ ILIAS synchronization.
The service is implemented with [Fastify](https://fastify.dev/) in `app.js` and starts on
`settings.port` (default `4101`). The active config is `settings.$NODE_ENV.json`
(see `settings.js`).
## Base URL
```
http://<host>:4101
```
In production the service is usually served behind a reverse proxy, e.g.
`https://globusfm-dev2.minervis.com/popcorn`.
## Authentication
Every request must pass the auth token as a query parameter:
```
?token=<authtoken>
```
`authtoken` is defined in `settings.$NODE_ENV.json`.
Requests without a valid token are rejected with `403` after a 500 ms delay
(anti brute-force / DoS measure):
```json
{ "status": "error", "error": "access denied" }
```
**Exceptions that do not require a token:**
- `/api/version`
- everything under `/ui/` (static frontend)
## Common error format
Most error responses use a consistent shape:
```json
{ "status": "error", "msg": "not found" }
```
Some routes use `message` or `error` instead of `msg`; this is noted per route.
## Route overview
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/version` | no | Service version |
| GET | `/api/search/user` | yes | Search users (returns usr_ids) |
| POST | `/api/search/reindex` | yes | Rebuild the user search index |
| GET | `/api/user` | yes | List/search users (paged) |
| GET | `/api/user/count` | yes | Total number of (numeric-login) users |
| GET | `/api/user/login/:login` | yes | Get one user by login |
| GET | `/api/user/userid/:userid` | yes | Get one user by usr_id |
| GET | `/api/user/teilnahmen/:userId` | yes | Memberships of a user |
| POST | `/api/user` | yes | Import/create a user in ILIAS |
| DELETE | `/api/user/:usr_id` | yes | Delete a user in ILIAS |
| GET | `/api/ref_id/:ref_id` | yes | Resolve ref_id â†’ obj_id |
| GET | `/api/obj_id/:obj_id` | yes | Resolve obj_id â†’ ref_id |
| GET | `/api/kurs` | yes | List all courses |
| GET | `/api/kurs/:refId` | yes | Get one course |
| GET | `/api/kurs/items/:refId` | yes | Course item tree (recursive) |
| GET | `/api/kurs/:refId/teilnehmer` | yes | Course members |
| GET | `/api/kurs/:refId/teilnehmer/:userId` | yes | Single course member |
| GET | `/api/kurs/:refId/lp` | yes | Learning progress |
| GET | `/api/kurs/:refId/teilnehmerByRole` | yes | Course members via role |
| GET | `/api/kurs/:refId/roles` | yes | Roles assigned in a course |
| POST | `/api/kurs/:refId/status/:usrId` | yes | Set status + passed for a member |
| GET | `/api/kurs/:refId/offline` | yes | Get offline flag of a course |
| POST | `/api/kurs/:refId/offline` | yes | Set offline flag of a course |
| DELETE | `/api/kurs/:refId/teilnehmer/:usrId` | yes | Unenroll a member (abmelden) |
| GET | `/api/kurs/rolle/admin` | yes | Courses with admin role |
| GET | `/api/kurs/rolle/noadmin` | yes | Courses without assigned admin role |
| GET | `/api/ping` | yes | Ping the ILIAS PHP component |
| GET | `/ui/*` | no | Built Vue frontend (SPA) |
---
## System
### GET /api/version
Returns the service version from `package.json`. No token required.
**Response 200**
```json
{ "version": "0.1" }
```
### GET /api/ping
Pings the custom ILIAS PHP component (`lib/libIlias.js` â†’ `ping`).
**Response 200** â€” forwarded from the PHP component, e.g.
```json
{ "method": "GET", "command": "ping", "status": "ok" }
```
**Response 500**
```json
{ "status": "error", "error": "<error>" }
```
---
## Search
### GET /api/search/user
Full-text search over the user index (`lib/search.js`, FlexSearch). The index is
built from `login firstname lastname institution department`.
**Query parameters**
| Name | Required | Description |
|------|----------|-------------|
| `search` | yes | Search term |
**Response 200** â€” array of matching `usr_id`s (numbers):
```json
[ 23300, 23301 ]
```
**Response 422** â€” when `search` is missing:
```json
{ "status": "error", "msg": "no search" }
```
### POST /api/search/reindex
Rebuilds the user search index from the database.
**Response 200**
```json
{ "status": "ok", "msg": "reindexed in 231 ms" }
```
---
## Users
### GET /api/user
Paged list / search of users.
**Query parameters**
| Name | Default | Description |
|------|---------|-------------|
| `offset` | `0` | Offset |
| `limit` | `10` | Max results |
| `search` | â€” | Optional search term (uses the search index) |
**Response 200**
```json
{
  "total": 11066,
  "offset": 0,
  "limit": 10,
  "data": [
    {
      "usr_id": 23300,
      "login": "134942",
      "firstname": "Aksana",
      "lastname": "Donhauser",
      "gender": "f",
      "email": "alex@minervis.com",
      "institution": "Globus Baumarkt St. Wendel",
      "street": "",
      "city": "",
      "zipcode": "",
      "country": "",
      "department": "MITARBEITER | FARBEN/TAPETEN/BODENBEL.",
      "active": 1
    }
  ]
}
```
> Only users whose `login` is numeric (`login REGEXP '^[0-9]+$'`) are returned.
> If the query fails, an empty result is returned:
> `{ "total": 0, "offset": 0, "limit": 0, "data": [] }`.
### GET /api/user/count
Total number of users (numeric login). Uses `db.getUserCount()` without filters.
**Response 200**
```json
11066
```
### GET /api/user/login/:login
Get a single user by login.
**Path parameters**
| Name | Description |
|------|-------------|
| `login` | ILIAS login |
**Response 200** â€” user object (same fields as `/api/user`) plus all user-defined
fields merged in as top-level keys.
**Response 404**
```json
{ "status": "error", "msg": "not found" }
```
### GET /api/user/userid/:userid
Get a single user by `usr_id`.
**Path parameters**
| Name | Description |
|------|-------------|
| `userid` | numeric `usr_id` |
**Response 200** â€” user object incl. user-defined fields.
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
**Response 500** â€” if `userid` is missing or not numeric:
```json
{ "status": "error", "msg": "userid error" }
```
### GET /api/user/teilnahmen/:userId
Memberships of a user (rows from `obj_members` with `member = 1`), enriched with
object title and learning-progress status.
**Path parameters**
| Name | Description |
|------|-------------|
| `userId` | numeric `usr_id` |
**Response 200** â€” array of
```json
[
  {
    "obj_id": 32212,
    "ref_id": 213,
    "usr_id": 6,
    "title": "112 Feuerwehr ist da",
    "status": 2,
    "passed": 1,
    "status_changed": "2025-07-03T11:26:20.000Z"
  }
]
```
**Response 500** â€” invalid `userId`: `{ "status": "error", "msg": "userId error" }`
### POST /api/user
Import (create/update) a user in ILIAS via the custom PHP component.
**Body** (JSON) â€” POPCORN/SOAP-style user object:
```json
{
  "login": "123456789",
  "passwd": "123456789",
  "passwd_type": "plain",
  "firstname": "Adolfo",
  "lastname": "de la Cruz",
  "email": "alex@example.com",
  "gender": "m",
  "department": "Bananenpflücker",
  "institution": "Globus Budapest",
  "role": 4,
  "udf": {
    "Markt": "Markt UDF 2",
    "Marktnummer": "Marktnummer UDF 2",
    "Personalnummer": "Personal UDF 2"
  }
}
```
The keys of `udf` are user-defined-field **names**; they are mapped to field ids
via `udf_definition` before the request is forwarded to ILIAS.
**Response 200** â€” result forwarded from the PHP component.
> If a `udf` key does not match a defined field name, the request fails
> (`udfMap[key]` is `undefined`).
### DELETE /api/user/:usr_id
Delete a user in ILIAS.
**Path parameters**
| Name | Description |
|------|-------------|
| `usr_id` | numeric `usr_id` |
**Response 200** â€” result forwarded from the PHP component.
**Response 500** â€” invalid `usr_id`: `{ "status": "error", "msg": "userId error" }`
---
## ref_id / obj_id
### GET /api/ref_id/:ref_id
Resolve a `ref_id` (tree/reference id) to an `obj_id`.
**Response 200**
```json
{ "ref_id": 213, "obj_id": 32212 }
```
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
### GET /api/obj_id/:obj_id
Resolve an `obj_id` to a `ref_id`.
**Response 200**
```json
{ "ref_id": 213, "obj_id": 32212 }
```
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
---
## Courses (Kurs)
### GET /api/kurs
List all courses (`type = 'crs'`, not deleted).
**Response 200** â€” array of
```json
[
  {
    "ref_id": 213,
    "obj_id": 32212,
    "title": "112 Feuerwehr ist da",
    "description": "...",
    "type": "crs",
    "offline": 0
  }
]
```
### GET /api/kurs/:refId
Get a single course by `ref_id`.
**Response 200** â€” single object
```json
{
  "ref_id": 213,
  "obj_id": 32212,
  "title": "112 Feuerwehr ist da",
  "description": "...",
  "type": "crs",
  "create_date": "2024-01-01 00:00:00",
  "offline": 0
}
```
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
### GET /api/kurs/items/:refId
Recursive tree of items contained in a course (`crs_items`, recursive CTE).
**Response 200** â€” array of
```json
[
  { "parent_id": 213, "obj_id": 36450, "ref_id": 597, "title": "asdf123", "type": "tst" }
]
```
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
### GET /api/kurs/:refId/teilnehmer
Course members.
**Response 200** â€” array of
```json
[
  {
    "parent_id": 213,
    "ref_id": 597,
    "obj_id": 36450,
    "title": "asdf123",
    "type": "tst",
    "usr_id": 6,
    "login": "root",
    "firstname": "root",
    "lastname": "user",
    "active": 1,
    "passed": 1,
    "status": 2,
    "status_changed": "2025-10-22T07:42:36.000Z"
  }
]
```
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
### GET /api/kurs/:refId/teilnehmer/:userId
Single course member.
**Response 200** â€” single member object (same fields as above).
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
### GET /api/kurs/:refId/lp
Learning progress of a course.
**Query parameters**
| Name | Description |
|------|-------------|
| `raw` | If present (any non-empty value, e.g. `?raw=1`), returns raw sub-object rows instead of the aggregated view |
**Response 200 (raw)** â€” one row per tracked sub-object and user:
```json
[
  {
    "usr_id": 6,
    "login": "root",
    "firstname": "root",
    "lastname": "user",
    "obj_id": 32212,
    "item_id": 597,
    "lpmode": 5,
    "item_obj_id": 36450,
    "type": "tst",
    "status": 2,
    "status_changed": "2025-10-22T07:42:36.000Z",
    "percentage": 100,
    "completed": 0
  }
]
```
**Response 200 (aggregated)** â€” one row per user, sub-object statuses combined
via `lib/libLp.js`:
```json
[
  {
    "usr_id": 6,
    "login": "root",
    "firstname": "root",
    "lastname": "user",
    "status": 1,
    "status_changed": "2025-10-22T09:45:56.000Z"
  }
]
```
`status` values:
| Value | Meaning |
|-------|---------|
| `0` | not attempted (noch nicht bearbeitet) |
| `1` | in progress (in Bearbeitung) |
| `2` | passed (bestanden) |
| `3` | failed (nicht bestanden) |
**Response 404** â€” `{ "status": "error", "msg": "not found" }`
### GET /api/kurs/:refId/teilnehmerByRole
Course members resolved through the course's member role
(`rbac_ua` joined via the role whose description matches `Member%<obj_id>`).
**Response 200** â€” array of
```json
[ { "role_id": 12345, "usr_id": 6, "firstname": "root", "lastname": "user" } ]
```
### GET /api/kurs/:refId/roles
Roles assigned in a course (`rbac_pa`).
**Response 200** â€” array of
```json
[
  {
    "rol_id": 12345,
    "ref_id": 213,
    "obj_id": 999,
    "type": "role",
    "title": "il_crs_member_32212",
    "description": "Member of course 32212"
  }
]
```
### POST /api/kurs/:refId/status/:usrId
Set the learning-progress `status` and `passed` flag for a course member. Writes
both `ut_lp_marks` (status + status_changed) and `obj_members` (passed).
**Path parameters**
| Name | Description |
|------|-------------|
| `refId` | course `ref_id` |
| `usrId` | user `usr_id` |
**Body** (JSON) â€” both fields required
```json
{ "passed": 1, "status": 2 }
```
**Response 200**
```json
{ "status": "ok" }
```
**Response 400** â€” missing arguments:
```json
{ "status": "error", "msg": "argument error", "statusCode": 400 }
```
**Response 500** â€” e.g. when the affected rows don't match:
```json
{ "status": "error", "msg": "<message>" }
```
### GET /api/kurs/:refId/offline
Get the `offline` flag of a course.
**Response 200**
```json
{ "offline": 0 }
```
**Response 500**
```json
{ "status": "error", "message": "<message>" }
```
### POST /api/kurs/:refId/offline
Set the `offline` flag of a course.
**Body** (JSON)
```json
{ "offline": 1 }
```
**Response 200**
```json
{ "offline": 1 }
```
**Response 500**
```json
{ "status": "error", "message": "<message>" }
```
### DELETE /api/kurs/:refId/teilnehmer/:usrId
Unenroll a member ("abmelden") via the custom PHP component.
**Path parameters**
| Name | Description |
|------|-------------|
| `refId` | course `ref_id` |
| `usrId` | user `usr_id` |
**Response 200** â€” result forwarded from the PHP component.
**Response 404** â€” `{ "status": "error", "msg": "Teilnahme not found" }`
**Response 500** â€” `{ "status": "error", "msg": "<message>" }`
---
## Course roles / admins
### GET /api/kurs/rolle/admin
Courses that have an admin role assigned (role title contains `admin`).
**Response 200** â€” array of
```json
[
  {
    "crs_obj_id": 32212,
    "crs_ref_id": 213,
    "crs_title": "112 Feuerwehr ist da",
    "rol_id": 12345,
    "role": "il_crs_admin_32212"
  }
]
```
**Response 500** â€” `{ "status": "error", "error": "<error>" }`
### GET /api/kurs/rolle/noadmin
Courses whose admin role exists but has no user assigned (no `rbac_ua` entry).
**Response 200** â€” same shape as `/api/kurs/rolle/admin`.
**Response 500** â€” `{ "status": "error", "error": "<error>" }`
---
## Static frontend
### GET /ui/*
Serves the built Vue single-page app from `vue/dist` (registered with
`@fastify/static`, prefix `/ui/`). No token required.
Any route that does not match an API route or a static file returns the SPA
fallback `vue/dist/index.html`.
lib/db.js
@@ -421,81 +421,6 @@
      return data
   }
   // const teilnehmer = await getKursTeilnehmer(ref_id)
   // const tnUnter = await getKursUnterobjektLp(obj_id)
   // console.table(teilnehmer)
   // console.table(tnUnter)
   //
   // /**
   //  * Die beiden Datensätze mergen
   //  * NEIN - es reicht der
   //  *
   //  */
   //
   // const idx = _.groupBy(tnUnter, "usr_id")
   // for (const tn of teilnehmer) {
   //     delete tn.parent_id
   //     delete tn.type
   //     delete tn.active
   //
   //     /** @type Array */
   //     const unter = idx[tn.usr_id]
   //     if (!unter) continue
   //
   //     /**
   //      * wenn unter.status_changed neuer, wird unter tn.status_changed vorgezogen
   //      * wenn unter.status neuer, Ã¼berscheibt es tn.status
   //      * tn.passed muss zurückgesetzt werden wenn tn.status Ã¼berschrieben wird
   //      */
   //     tn.status_overwrite = false
   //
   //     // max unter status_changed finden
   //     const unterStatusChanged = _.max(unter.map(u => u.status_changed))
   //
   //     // es muss nur Ã¼berschrieben werden wenn das Unterdatum größer ist
   //     if (unterStatusChanged > tn.status_changed) {
   //         // unter Status auswerten
   //         // 0 = noch nicht bearbeitet
   //         // 1 = in Bearbeitung
   //         // 2 = bestanden
   //         // 3 = nicht bestanden
   //
   //         /** @type Array */
   //         const unterStatusse = unter.map(u => u.status)
   //         let newStatus = tn.status
   //         const allSame = function () {
   //             if (!unterStatusse.length) return false
   //             const first = unterStatusse[0]
   //             return unterStatusse.every(it => it === first)
   //         }()
   //         console.log({unterStatusse})
   //         // Fall 1: keine Unterstatussse vorhanden -> status vom Kurs
   //         if (!unterStatusse.length) {
   //             newStatus = tn.status
   //         }
   //         // Fall 2: eines nicht bestanden -> nicht bestanden // 0,1,2,3 -> 3
   //         if (unterStatusse.some(u => u === 3)) {
   //             newStatus = 3
   //         }
   //         // Fall 3: alle statusse gleich -> status // 0,0,0 1,1,1 2,2,2 3,3,3
   //         else if (allSame) {
   //             newStatus = unterStatusse[0]
   //         }
   //         // Fall 4: wenn eines in Bearbeitung -> in Bearbeitung // 0,1,0 2,1,2
   //         else if (unterStatusse.some(u => u === 1)) {
   //             newStatus = 1
   //         }
   //         // Fall 5: sonst in Bearbeitung
   //         else {
   //             // newStatus = Math.max.apply(this, unterStatusse)
   //             newStatus = 1
   //         }
   //         tn.status_changed = unterStatusChanged
   //         tn.status = newStatus
   //         tn.status_overwrite = true
   //     }
   // }
   // return teilnehmer
}
async function getKursUnterobjektLp(obj_id) {
lib/libIlias.js
@@ -5,6 +5,7 @@
const db = require("./db")
const {url, iliastoken} = settings.ilias
const search = require("./search")
const log = require("../logger")
/////////////////////////////////////////////////////////////////////////
@@ -35,7 +36,7 @@
      token: iliastoken,
   })
   let url2 = `${url}?${sp.toString()}`
   console.log("pinging url", url2)
   log.debug("pinging url", url2)
   const res = await fetch(url2, {method: "GET"})
   return await res.json() //
}
@@ -85,14 +86,16 @@
   const udfDef = await db.getUdf()
   const udfMap = _.keyBy(udfDef, "field_name")
   // console.dir(udfMap, {depth: null, colors: true, maxArrayLength: null})
   user.udf = _.mapKeys(user.udf, function (value, key) {
   // do not mutate the caller's user object
   const user2 = _.cloneDeep(user)
   user2.udf = _.mapKeys(user.udf, function (value, key) {
      return udfMap[key].field_id
   })
   const res = await fetch(url2, {
      method: "POST",
      body: JSON.stringify(user)
      body: JSON.stringify(user2)
   })
   const text = await res.text()
   try {
@@ -190,7 +193,7 @@
      token: iliastoken,
   })
   let url2 = `${url}?${sp.toString()}`
   console.log(url2)
   log.debug(url2)
   const res = await fetch(url2, {method: "POST"})
   return await res.json()
}
@@ -203,7 +206,7 @@
      token: iliastoken,
   })
   let url2 = `${url}?${sp.toString()}`
   console.log(url2)
   log.debug(url2)
   const res = await fetch(url2, {method: "DELETE"})
   return await res.json()
}
lib/libLp.js
@@ -1,9 +1,12 @@
const _ = require("lodash")
const dayjs = require("dayjs")
/////////////////////////////////////////////////////////////////////////
module.exports = {
    alleAuswerten,
   alleAuswerten,
   auswerten,
   partitionDates,
}
/////////////////////////////////////////////////////////////////////////
@@ -11,60 +14,89 @@
/**
 * Unterobjekte auswerten und LF kombinieren pro user
 * @param {[usr_id,login,firstname,lastname,obj_id,item_id,item_obj_id,status,status_changed]} data
 * @return {[usr_id,firstname,lastname,status,status_changed]}
 * @return {[usr_id,login,firstname,lastname,status,status_changed]}
 */
function alleAuswerten(data) {
    const grouped = _.groupBy(data, "usr_id")
    const ret = []
    for (const items of Object.values(grouped)) {
        if (!items.length) continue
        const unterStatusse = items.map(it => it.status)
   const grouped = _.groupBy(data, "usr_id")
   const ret = []
   for (const items of Object.values(grouped)) {
      const statusData = items.map(({status_changed, status}) => ({status_changed, status}))
      const newStatus = auswerten(statusData)
      const status_changed = _.max(items.map(u => u.status_changed))
        const newStatus = auswerten(unterStatusse)
        const status_changed = _.max(items.map(u => u.status_changed))
        const {usr_id, login, firstname, lastname,} = items[0]
        const newItem = {
            usr_id, login, firstname, lastname, status: newStatus, status_changed
        }
        ret.push(newItem)
    }
    return ret
      const {usr_id, login, firstname, lastname,} = items[0]
      const newItem = {
         usr_id, login, firstname, lastname, status: newStatus, status_changed
      }
      ret.push(newItem)
   }
   return ret
}
function auswerten(unterStatusse) {
    // unter Status auswerten
    // 0 = noch nicht bearbeitet
    // 1 = in Bearbeitung
    // 2 = bestanden
    // 3 = nicht bestanden
/**
 * Neuen Status berechnen
 * @param {{status,status_changed}[]} statusData
 * @return {number}
 */
function auswerten(statusData) {
   // unter Status auswerten
   // 0 = noch nicht bearbeitet
   // 1 = in Bearbeitung
   // 2 = bestanden
   // 3 = nicht bestanden
    // Fall 1: keine Unterstatussse vorhanden -> 0
    if (!unterStatusse.length) {
        return 0
    }
    // Fall 2: eines nicht bestanden -> alles nicht bestanden // 0,1,2,3 -> 3
    if (unterStatusse.some(u => u === 3)) {
        return 3
    }
   // Fall 1: keine Unterstatussse vorhanden -> 0
   if (!statusData.length) {
      return 0
   }
    // Fall 3: alle statusse gleich -> status // 0,0,0 1,1,1 2,2,2 3,3,3
    if (isAllSame(unterStatusse)) {
        return unterStatusse[0]
    }
   const maxDate = dayjs(_.max(statusData.map(it => it.status_changed)))
   const {inside, outside} = partitionDates(maxDate, statusData)
    // Fall 4: wenn eines in Bearbeitung -> in Bearbeitung // 0,1,0 2,1,2
    if (unterStatusse.some(u => u === 1 || u === 2)) {
        return 1
    }
   // Fall 2: eines nicht bestanden -> alles nicht bestanden // 0,1,2,3 -> 3
   // hier kommt es nicht darauf an ob noch Daten in outside sind
   if (inside.map(it => it.status).some(u => u === 3)) {
      return 3
   }
    // Fall 5: sonst 0
    return 0
   // Fall 3: alle statusse gleich -> status // 0,0,0 1,1,1 2,2,2 3,3,3
   // wenn outside nicht leer ist -> in Bearbeitung
   if (isAllSame(inside.map(it => it.status))) {
      // wenn keine Einträge in outside vorhanden sind einfach den Status zurückgeben
      if(!outside.length) return statusData[0].status
      // ansonsten in Bearbeitung
      return 1
   }
   // Fall 4: wenn eines in Bearbeitung -> in Bearbeitung // 0,1,0 2,1,2
   if (inside.map(it=>it.status).some(u => u === 1 || u === 2)) {
      return 1
   }
   // Fall 5: sonst 0
   return 0
}
/**
 * @param  {Date} maxDate
 * @param {{status, status_changed}[]} items
 */
function partitionDates(maxDate, items) {
   const [inside, outside] = _.partition(items, function (it) {
      return isInBallpark(maxDate, it.status_changed)
   })
   return {inside, outside}
}
function isInBallpark(maxDate, date) {
   const ballpark = {value: 1, unit: "month"}
   const diff = maxDate.diff(date, ballpark.unit, true)
   return diff <= ballpark.value
}
function isAllSame(items) {
    if (!items.length) return false
    const first = items[0]
    return items.every(it => it === first)
   if (!items.length) return false
   const first = items[0]
   return items.every(it => it === first)
}
logger.js
@@ -5,16 +5,43 @@
/////////////////////////////////////////////////////////////////////////
const LEVELS = {debug: 10, info: 20, warn: 30, error: 40}
const DEFAULT_LEVEL = "info"
// level configured from settings (see settings.js); env LOG_LEVEL overrides it
let settingsLevel = null
module.exports = {
   debug: msg => log("DEBUG", msg),
   info: msg => log("INFO", msg),
   warn: msg => log("WARN", msg),
   error: msg => log("ERROR", msg),
   setLevel,
   level: currentLevel,
}
/////////////////////////////////////////////////////////////////////////
/**
 * Set the level from settings. The LOG_LEVEL env var still takes precedence.
 */
function setLevel (level) {
   settingsLevel = level ? level.toString().trim().toLowerCase() : null
}
/**
 * Resolve the active log level: LOG_LEVEL env var > settings level > "info".
 */
function currentLevel () {
   const level = process.env.LOG_LEVEL || settingsLevel || DEFAULT_LEVEL
   return level.toString().trim().toLowerCase()
}
function log (level, msg) {
   const threshold = currentLevel()
   if (threshold === "silent" || threshold === "off") return
   if (LEVELS[level.toLowerCase()] < (LEVELS[threshold] ?? LEVELS[DEFAULT_LEVEL])) return
   if (!_.isString(msg)) {
      if (msg.message) {
         msg = msg.message
package-lock.json
@@ -1,12 +1,12 @@
{
   "name": "globus-ilias-rest",
   "version": "0.1",
   "version": "0.4.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
      "": {
         "name": "globus-ilias-rest",
         "version": "0.1",
         "version": "0.4.0",
         "dependencies": {
            "@fastify/compress": "^9.2.0",
            "@fastify/static": "^10.1.5",
package.json
@@ -1,6 +1,6 @@
{
   "name": "globus-ilias-rest",
   "version": "0.1",
   "version": "0.4.0",
   "private": true,
   "author": "Alexander Löhr",
   "description": "REST API for ILIAS used by POPCORN",
php/globus-ilias-rest/login.php
@@ -167,10 +167,11 @@
// ACHTUNG course_id muss obj_id sein!
// die Rolle muss aus dem Kurs gelesen werden
// ACHTUNG ilParticipants::readParticipants() ist protected (ILIAS 9) und wird
// bereits im Konstruktor von getMembersObject() aufgerufen - nicht erneut aufrufen!
function anmelden($usr_id, $course_id) {
    $course = ilObjectFactory::getInstanceByObjId($course_id);
    $membersObject = $course->getMembersObject();
    $membersObject->readParticipants();
    $role = $membersObject::IL_CRS_MEMBER;
    return $membersObject->add($usr_id, $role);
}
settings.default.json
@@ -2,6 +2,9 @@
   "authtoken": "jiuGfr432898D90290kjfsldkfn3hh8F",
   "port": 4101,
   "restUrl": "http://localhost:4101",
   "log": {
      "level": "info"
   },
   "db": {
      "host": "localhost",
      "port": 33009,
settings.js
@@ -12,4 +12,7 @@
var settings = nconf.get()
// configure the logger level (LOG_LEVEL env var still takes precedence)
log.setLevel(settings.log && settings.log.level)
module.exports = settings
test/data.js
@@ -32,8 +32,23 @@
   "registerNo": true
}
// real existing (usr_id, course obj_id) pair used by setStatus tests
const setStatus = {
   ref_id: 88,
   obj_id: 9689,   // setStatus() expects the course obj_id, not the ref_id
   usr_id: 24038,
}
// real existing small course used by the anmelden/abmelden tests
const anmelden = {
   ref_id: 595,
   obj_id: 36428,   // anmelden()/abmelden() expect the course obj_id, not the ref_id
}
module.exports = {
   user,
   kurs,
   setStatus,
   anmelden,
}
test/testAbmelden.js
@@ -1,46 +1,48 @@
const expect = require("chai").expect
const _ = require("lodash")
const settings = require("../settings")
const libIlias = require("../lib/libIlias")
const db = require("../lib/db")
const testData = require("./data")
/////////////////////////////////////////////////////////////////////////
describe.skip("the function anmelden", function () {
// Integration test against a real ILIAS instance.
// Creates a temporary user, enrolls it via libIlias.anmelden() into an
// existing course, verifies that libIlias.abmelden() removes the
// membership, then cleans up.
describe("the function abmelden", function () {
   const kurs = testData.kurs // TODO
   const user = testData.user
   let kursId = null
   const courseId = testData.anmelden.obj_id
   let user = null
   let userId = null
   beforeEach(async function () {
      // const {usr_id:userId} = await libIlias.importIliasUser(user)
      const userRes  = await libIlias.importIliasUser(user)
      // const kursRes  = await libIlias.
      console.log(data)
      user = _.cloneDeep(testData.user)
      const res = await libIlias.importIliasUser(user)
      userId = res.usr_id
      // precondition: the user must be enrolled
      await libIlias.anmelden(userId, courseId)
   })
   afterEach(async function () {
      await libIlias.deleteUser(userId)
      // TODO delete kurs
      if (!userId) return
      // best effort cleanup (abmelden is idempotent)
      await libIlias.abmelden(userId, courseId).catch(console.error)
      await libIlias.deleteUser(userId).catch(console.error)
      userId = null
   })
   it("should import a new user to ILIAS", async function () {
      console.log("!111")
      console.error("finish test")
      return
      console.log("++abmelden", userId, kursId)
      const res = await libIlias.abmelden(userId, kursId)
      console.dir(res, {depth: null})
   it("should remove the Teilnahme of a user", async function () {
      const res = await libIlias.abmelden(userId, courseId)
      expect(res).to.have.property("status").and.to.equal("ok")
      expect(res.command).to.equal("abmelden")
      expect(res.method).to.equal("DELETE")
      expect(res).to.have.property("command").and.to.equal("abmelden")
      expect(res).to.have.property("method").and.to.equal("DELETE")
      const members = await db.getKursTeilnehmerByRole(courseId)
      expect(members.map(m => m.usr_id)).to.not.include(userId)
   })
})
/////////////////////////////////////////////////////////////////////////
test/testAnmelden.js
@@ -1,41 +1,45 @@
const expect = require("chai").expect
const _ = require("lodash")
const settings = require("../settings")
const libIlias = require("../lib/libIlias")
const db = require("../lib/db")
const testData = require("./data")
/////////////////////////////////////////////////////////////////////////
describe.skip("the function anmelden", function () {
// Integration test against a real ILIAS instance.
// Creates a temporary user, enrolls it via libIlias.anmelden() into an
// existing course and verifies the membership, then cleans up.
describe("the function anmelden", function () {
   const kurs = testData.kurs // TODO
   const user = testData.user
   let kursId = 31938
   let userId = 31954
   const courseId = testData.anmelden.obj_id
   let user = null
   let userId = null
   beforeEach(async function () {
      // TODO add kurs
      // const res = await libIlias.importIliasUser(user)
      // userId = res.usr_id
      user = _.cloneDeep(testData.user)
      const res = await libIlias.importIliasUser(user)
      userId = res.usr_id
   })
   afterEach(async function () {
      // await libIlias.deleteUser(userId)
      // TODO delete kurs
      if (!userId) return
      // best effort cleanup
      await libIlias.abmelden(userId, courseId).catch(console.error)
      await libIlias.deleteUser(userId).catch(console.error)
      userId = null
   })
   it("should import a new user to ILIAS", async function () {
      console.log("++anmelden", userId, kursId)
      const res = await libIlias.anmelden(userId, kursId)
      console.dir(res, {depth: null})
   it("should enroll a user into a course", async function () {
      const res = await libIlias.anmelden(userId, courseId)
      expect(res).to.have.property("status").and.to.equal("ok")
      expect(res.command).to.equal("anmelden")
      expect(res.method).to.equal("POST")
      expect(res).to.have.property("command").and.to.equal("anmelden")
      expect(res).to.have.property("method").and.to.equal("POST")
      const members = await db.getKursTeilnehmerByRole(courseId)
      expect(members.map(m => m.usr_id)).to.include(userId)
   })
})
/////////////////////////////////////////////////////////////////////////
test/testApiKursOffline.js
@@ -19,7 +19,6 @@
        it("should return the offline or online status of a kurs", async function () {
            const url = getUrl(ref_id)
            console.log(url)
            const res = await fetch(url, {
                method: "GET",
                // body: JSON.stringify(body),
@@ -27,9 +26,7 @@
                    'Content-Type': 'application/json', // Indicate JSON data
                },
            })
            // console.log(res)
            const data = await res.json()
            console.log(data)
            expect(data).to.be.a("object").and.to.have.property("offline")
        })
@@ -40,7 +37,6 @@
        it("should set the offline or online status of a kurs", async function () {
            const url = getUrl(ref_id)
            console.log(url)
            const body = {offline: 0}
            const res = await fetch(url, {
                method: "POST",
@@ -49,9 +45,7 @@
                    'Content-Type': 'application/json', // Indicate JSON data
                },
            })
            // console.log(res)
            const data = await res.json()
            console.log(data)
            expect(data).to.be.a("object").and.to.have.property("offline")
        })
test/testApiPing.js
@@ -16,16 +16,13 @@
        it("should return an answer from php component", async function () {
            const url = getUrl(ref_id)
            console.log(url)
            const res = await fetch(url, {
                method: "GET",
                // body: JSON.stringify({}),
                headers: {
                    'Content-Type': 'application/json', // Indicate JSON data
                },
            })
            const data = await res.json()
            // console.log(data)
            expect(data).to.be.a("object")
            expect(data.status).to.equal("ok")
test/testCourseAdmins.js
@@ -19,8 +19,6 @@
   describe("the function getCourseAdminRoles()", function () {
      it("should return all admin roles for all courses", async function () {
         const res = await db.getCourseAdminRoles()
         // console.table(res)
         // console.log(res)
         expect(res).to.be.a("array")
         for (const item of res) {
            expect(item).to.have.property("crs_obj_id").and.to.be.a("number")
@@ -35,8 +33,6 @@
   describe("the function getCoursesWithoutAdminRoles()", function () {
      it("should return all admin roles for all courses", async function () {
         const res = await db.getCourseWithoutAdminRoles()
         // console.table(res)
         console.log(res)
         expect(res).to.be.a("array")
         for (const item of res) {
            expect(item).to.have.property("crs_obj_id").and.to.be.a("number")
test/testCreateKurs.js
@@ -1,33 +1,25 @@
const expect = require("chai").expect
const settings = require("../settings")
const libIlias = require("../lib/libIlias")
const db = require("../lib/db")
const testData = require("./data")
/////////////////////////////////////////////////////////////////////////
describe("the function anmelden", function () {
// Skipped: the ILIAS PHP component does not implement course creation yet
// (php/globus-ilias-rest/login.php -> createKurs() throws "not implemented").
// There is also no deleteKurs() for cleanup, so running this test would leave
// a real course behind. Enable once both exist.
describe("the function createKurs", function () {
   const kurs = testData.kurs // TODO
   let kursId = 31938
   const kurs = testData.kurs
   beforeEach(async function () {
   })
   afterEach(async function () {
   })
   it("should create a new Kurs in ILIAS", async function () {
      console.log("++createKurs", kurs)
   it.skip("should create a new Kurs in ILIAS", async function () {
      const res = await libIlias.createKurs(kurs)
      console.dir(res, {depth: null})
      // expect(res).to.have.property("status").and.to.equal("ok")
      // expect(res.command).to.equal("anmelden")
      // expect(res.method).to.equal("POST")
      expect(res).to.have.property("status").and.to.equal("ok")
      expect(res.command).to.equal("createKurs")
      expect(res).to.have.property("ref_id").and.to.be.a("number").above(0)
   })
})
/////////////////////////////////////////////////////////////////////////
test/testImportIliasUser.js
@@ -1,6 +1,6 @@
const expect = require("chai").expect
const _ = require("lodash")
const settings = require("../settings")
const libIlias = require("../lib/libIlias")
const db = require("../lib/db")
const testData = require("./data")
@@ -10,14 +10,16 @@
describe("the function importIliasUser", function () {
   let lastUserId = null
   const user = testData.user
   let user = null
   beforeEach(async function () {
      // deep clone so importIliasUser can not mutate the shared fixture
      user = _.cloneDeep(testData.user)
      lastUserId = null
   })
   afterEach(async function () {
      await deleteUser(lastUserId)
      if (lastUserId) await deleteUser(lastUserId).catch(console.error)
   })
   it("should import a new user to ILIAS", async function () {
@@ -33,11 +35,29 @@
      console.log(user2)
      expect(user2).to.have.property("login").and.to.equal(user.login)
      expect(user2).to.have.property("usr_id").and.to.equal(res.usr_id)
      expect(user2).to.have.property("firstname").and.to.equal(user.firstname)
      expect(user2).to.have.property("lastname").and.to.equal(user.lastname)
      expect(user2).to.have.property("email").and.to.equal(user.email)
      expect(user2).to.have.property("institution").and.to.equal(user.institution)
   })
      // const res2 = await libIlias.deleteUser(usr_id)
      // console.log(res2)
      // expect(res2).to.have.property("status").and.to.equal("ok")
      // expect(res2).to.have.property("command").and.to.equal("deleteUser")
   it("should not mutate the given user object", async function () {
      const before = _.cloneDeep(user)
      const res = await libIlias.importIliasUser(user)
      lastUserId = res.usr_id
      expect(user).to.deep.equal(before)
   })
   it("should resolve user defined field names to ids", async function () {
      const res = await libIlias.importIliasUser(user)
      lastUserId = res.usr_id
      const fields = await db.getUserDefinedField(lastUserId)
      const byName = _.keyBy(fields, "field_name")
      for (const [name, value] of Object.entries(user.udf)) {
         expect(byName).to.have.property(name)
         expect(byName[name].value).to.equal(value)
      }
   })
})
test/testKursLp.js
@@ -1,9 +1,9 @@
const expect = require("chai").expect
const settings = require("../settings")
const libIlias = require("../lib/libIlias")
const db = require("../lib/db")
const testData = require("./data")
const dayjs = require("dayjs");
const {alleAuswerten, auswerten, partitionDates} = require("../lib/libLp");
/////////////////////////////////////////////////////////////////////////
@@ -18,30 +18,338 @@
   afterEach(async function () {
   })
    describe("the function getKursUnterobjektLp", function () {
        it("should deliver the LP for the Kurs Unterobjekte", async function () {
            const res = await db.getKursUnterobjektLp(kursId)
            // console.table(res)
            expect(res).to.be.a("array")
            for(const item of res) {
                expect(item).to.have.property("obj_id").and.to.be.a("number")
                expect(item).to.have.property("item_id").and.to.be.a("number")
                expect(item).to.have.property("lpmode").and.to.be.a("number")
                expect(item).to.have.property("item_obj_id").and.to.be.a("number")
                expect(item).to.have.property("usr_id").and.to.be.a("number")
                expect(item).to.have.property("status").and.to.be.a("number")
                expect(item).to.have.property("status_changed").and.to.be.a("date")
            }
        })
    })
   describe("the function getKursUnterobjektLp", function () {
      it("should deliver the LP for the Kurs Unterobjekte", async function () {
         const res = await db.getKursUnterobjektLp(kursId)
         // console.table(res)
         expect(res).to.be.a("array")
         for (const item of res) {
            expect(item).to.have.property("obj_id").and.to.be.a("number")
            expect(item).to.have.property("item_id").and.to.be.a("number")
            expect(item).to.have.property("lpmode").and.to.be.a("number")
            expect(item).to.have.property("item_obj_id").and.to.be.a("number")
            expect(item).to.have.property("usr_id").and.to.be.a("number")
            expect(item).to.have.property("status").and.to.be.a("number")
            expect(item).to.have.property("status_changed").and.to.be.a("date")
         }
      })
   })
    describe("the function getKursLp", function () {
        it("should return the unified LP of a Kurs", async function () {
            const res = await db.getKursLp(kursId)
            console.table(res)
        })
    })
   describe("the function getKursLp", function () {
      it("should return the unified LP of a Kurs", async function () {
         const res = await db.getKursLp(kursId)
         console.table(res)
      })
   })
   describe('the function partitionDates()', function () {
      const maxDate = dayjs("2025-06-15T12:00:00")
      const item = (status, date) => ({status, status_changed: date})
      it("should return an object with inside and outside arrays", function () {
         const res = partitionDates(maxDate, [])
         expect(res).to.be.an("object")
         expect(res.inside).to.be.an("array")
         expect(res.outside).to.be.an("array")
      })
      it("should put an empty item list into empty arrays", function () {
         const {inside, outside} = partitionDates(maxDate, [])
         expect(inside).to.have.lengthOf(0)
         expect(outside).to.have.lengthOf(0)
      })
      it("should put all items inside when all are within the last month", function () {
         const items = [
            item(0, dayjs(maxDate).subtract(1, "day").toDate()),
            item(1, dayjs(maxDate).subtract(2, "week").toDate()),
            item(2, dayjs(maxDate).subtract(3, "week").toDate()),
         ]
         const {inside, outside} = partitionDates(maxDate, items)
         expect(inside).to.have.lengthOf(3)
         expect(outside).to.have.lengthOf(0)
      })
      it("should put all items outside when all are older than a month", function () {
         const items = [
            item(0, dayjs(maxDate).subtract(2, "month").toDate()),
            item(1, dayjs(maxDate).subtract(1, "year").toDate()),
         ]
         const {inside, outside} = partitionDates(maxDate, items)
         expect(inside).to.have.lengthOf(0)
         expect(outside).to.have.lengthOf(2)
      })
      it("should treat an item exactly one month before maxDate as inside", function () {
         const it0 = item(0, dayjs(maxDate).subtract(1, "month").toDate())
         const {inside, outside} = partitionDates(maxDate, [it0])
         expect(inside).to.deep.equal([it0])
         expect(outside).to.have.lengthOf(0)
      })
      it("should treat an item slightly more than one month before maxDate as outside", function () {
         const it0 = item(0, dayjs(maxDate).subtract(1, "month").subtract(1, "second").toDate())
         const {inside, outside} = partitionDates(maxDate, [it0])
         expect(inside).to.have.lengthOf(0)
         expect(outside).to.deep.equal([it0])
      })
      it("should treat an item at the same instant as maxDate as inside", function () {
         const it0 = item(2, maxDate.toDate())
         const {inside, outside} = partitionDates(maxDate, [it0])
         expect(inside).to.deep.equal([it0])
         expect(outside).to.have.lengthOf(0)
      })
      it("should treat an item after maxDate as inside", function () {
         const it0 = item(1, dayjs(maxDate).add(1, "day").toDate())
         const {inside, outside} = partitionDates(maxDate, [it0])
         expect(inside).to.deep.equal([it0])
         expect(outside).to.have.lengthOf(0)
      })
      it("should split a mixed list correctly", function () {
         const recent = item(2, dayjs(maxDate).subtract(10, "day").toDate())
         const old = item(3, dayjs(maxDate).subtract(6, "month").toDate())
         const veryOld = item(1, dayjs(maxDate).subtract(1, "year").toDate())
         const {inside, outside} = partitionDates(maxDate, [recent, old, veryOld])
         expect(inside).to.deep.equal([recent])
         expect(outside).to.deep.equal([old, veryOld])
      })
      it("should preserve the original item objects", function () {
         const recent = item(2, dayjs(maxDate).subtract(1, "day").toDate())
         const old = item(3, dayjs(maxDate).subtract(1, "year").toDate())
         const {inside, outside} = partitionDates(maxDate, [recent, old])
         expect(inside[0]).to.equal(recent)
         expect(outside[0]).to.equal(old)
      })
      it("should not mutate the input array", function () {
         const items = [
            item(0, dayjs(maxDate).subtract(1, "day").toDate()),
            item(2, dayjs(maxDate).subtract(2, "year").toDate()),
         ]
         const copy = items.slice()
         partitionDates(maxDate, items)
         expect(items).to.deep.equal(copy)
      })
   })
   describe('the function auswerten()', function () {
      const recent = () => dayjs().subtract(1, "day").toDate()
      const stale = () => dayjs().subtract(2, "month").toDate()
      const item = (status, date) => ({status, status_changed: date})
      // an "outside" element used to trigger the outside-handling branches
      const outsideItem = () => item(0, stale())
      /////// Fall 1: no sub statuses
      it("Fall 1: should return 0 for an empty list", function () {
         expect(auswerten([])).to.equal(0)
      })
      /////// Fall 2: any 'not passed' (3) -> 3
      it("Fall 2: should return 3 when a 3 is present", function () {
         expect(auswerten([item(3, recent()), item(2, recent())])).to.equal(3)
      })
      it("Fall 2: should return 3 when all statuses are 3", function () {
         expect(auswerten([item(3, recent()), item(3, recent())])).to.equal(3)
      })
      it("Fall 2: should return 3 for mixed 0,1,2,3", function () {
         expect(auswerten([item(0, recent()), item(1, recent()), item(2, recent()), item(3, recent())])).to.equal(3)
      })
      /////// Fall 3: all statuses equal
      it("Fall 3: should return 0 when all are 0 (no outside)", function () {
         expect(auswerten([item(0, recent()), item(0, recent())])).to.equal(0)
      })
      it("Fall 3: should return 1 when all are 1 (no outside)", function () {
         expect(auswerten([item(1, recent()), item(1, recent())])).to.equal(1)
      })
      it("Fall 3: should return 2 when all are 2 (no outside)", function () {
         expect(auswerten([item(2, recent()), item(2, recent())])).to.equal(2)
      })
      it("Fall 3: should return 3 when all are 3 (no outside)", function () {
         expect(auswerten([item(3, recent()), item(3, recent())])).to.equal(3)
      })
      it("Fall 3: should return the status of a single status (no outside)", function () {
         expect(auswerten([item(0, recent())])).to.equal(0)
         expect(auswerten([item(1, recent())])).to.equal(1)
         expect(auswerten([item(2, recent())])).to.equal(2)
         expect(auswerten([item(3, recent())])).to.equal(3)
      })
      it("Fall 3: should return 1 when all are equal but an outside element exists", function () {
         expect(auswerten([item(2, recent()), item(2, recent()), outsideItem()])).to.equal(1)
      })
      /////// Fall 4: something is in progress (1 or 2)
      it("Fall 4: should return 1 for 0,1,0", function () {
         expect(auswerten([item(0, recent()), item(1, recent()), item(0, recent())])).to.equal(1)
      })
      it("Fall 4: should return 1 for 2,1,2", function () {
         expect(auswerten([item(2, recent()), item(1, recent()), item(2, recent())])).to.equal(1)
      })
      it("Fall 4: should return 1 for 0,2", function () {
         expect(auswerten([item(0, recent()), item(2, recent())])).to.equal(1)
      })
      it("Fall 4: should return 1 for 0,1,2", function () {
         expect(auswerten([item(0, recent()), item(1, recent()), item(2, recent())])).to.equal(1)
      })
      /////// Fall 5: everything else
      it("Fall 5: should return 0 when only 0 and no 3/1/2 mixed in", function () {
         expect(auswerten([item(0, recent()), item(0, recent()), item(0, recent())])).to.equal(0)
      })
      /////// stale dates (outside the one-month ballpark)
      it("stale: should return the status when all items are stale and equal", function () {
         expect(auswerten([item(2, stale()), item(2, stale())])).to.equal(2)
      })
      it("stale: should return 1 when all stale items are mixed (0,2)", function () {
         expect(auswerten([item(0, stale()), item(2, stale())])).to.equal(1)
      })
      it("stale: should return 3 when all stale items are 3", function () {
         expect(auswerten([item(3, stale()), item(3, stale())])).to.equal(3)
      })
      it("stale: should return the status for a single stale item", function () {
         expect(auswerten([item(2, stale())])).to.equal(2)
         expect(auswerten([item(3, stale())])).to.equal(3)
      })
      it("stale: a stale 3 should not force 3 when a recent item exists", function () {
         expect(auswerten([item(3, stale()), item(2, recent())])).to.equal(1)
      })
      it("stale: a stale 3 should still force 3 when a recent 3 exists", function () {
         expect(auswerten([item(3, stale()), item(3, recent())])).to.equal(3)
      })
      it("stale: should return 1 when all recent statuses are equal but a stale item exists", function () {
         expect(auswerten([item(0, recent()), item(0, recent()), item(0, stale())])).to.equal(1)
      })
      it("stale: should return 1 for a recent 2 plus a stale 2", function () {
         expect(auswerten([item(2, recent()), item(2, stale())])).to.equal(1)
      })
      it("stale: should ignore a stale item when recent statuses are already mixed", function () {
         expect(auswerten([item(0, recent()), item(2, recent()), item(0, stale())])).to.equal(1)
      })
      it("stale: boundary - item just inside the month counts as recent", function () {
         const now = dayjs()
         const inside = now.subtract(1, "month").add(1, "day").toDate()
         expect(auswerten([item(0, now.toDate()), item(0, inside)])).to.equal(0)
      })
      it("stale: boundary - item just outside the month counts as stale", function () {
         const now = dayjs()
         const outside = now.subtract(1, "month").subtract(1, "day").toDate()
         expect(auswerten([item(0, now.toDate()), item(0, outside)])).to.equal(1)
      })
   })
   describe('the function alleAuswerten()', function () {
      const recent = () => dayjs().subtract(1, "day").toDate()
      const stale = () => dayjs().subtract(2, "month").toDate()
      // build a raw row like the ones coming from getKursUnterobjektLp()
      const row = (usr_id, status, date, extra = {}) => Object.assign({
         usr_id,
         login: `login${usr_id}`,
         firstname: `First${usr_id}`,
         lastname: `Last${usr_id}`,
         obj_id: 32212,
         item_id: 1,
         item_obj_id: 100,
         status,
         status_changed: date,
      }, extra)
      it("should return an empty array for empty input", function () {
         expect(alleAuswerten([])).to.be.an("array").and.to.have.lengthOf(0)
      })
      it("should return one row per user", function () {
         const res = alleAuswerten([
            row(1, 0, recent()), row(1, 2, recent()), row(1, 1, recent()),
            row(2, 2, recent()),
         ])
         expect(res).to.be.an("array").and.to.have.lengthOf(2)
         expect(res.map(r => r.usr_id).sort()).to.deep.equal([1, 2])
      })
      it("should group all items of a user into a single result row", function () {
         const res = alleAuswerten([
            row(1, 0, recent()), row(1, 0, recent()), row(1, 0, recent()),
         ])
         expect(res).to.have.lengthOf(1)
         expect(res[0].usr_id).to.equal(1)
      })
      it("should keep users separated", function () {
         const res = alleAuswerten([
            row(1, 0, recent()), row(1, 2, recent()),  // user 1 -> mixed -> 1
            row(2, 2, recent()), row(2, 2, recent()),  // user 2 -> all 2 -> 2
         ])
         const byId = Object.fromEntries(res.map(r => [r.usr_id, r.status]))
         expect(byId[1]).to.equal(1)
         expect(byId[2]).to.equal(2)
      })
      it("should order the result by usr_id ascending", function () {
         const res = alleAuswerten([
            row(7, 0, recent()), row(3, 0, recent()), row(7, 1, recent()),
         ])
         expect(res.map(r => r.usr_id)).to.deep.equal([3, 7])
      })
      /////// stale dates (outside the one-month ballpark)
      it("stale: should return the status when all of a user's items are stale and equal", function () {
         const res = alleAuswerten([row(1, 2, stale()), row(1, 2, stale())])
         expect(res[0].status).to.equal(2)
      })
      it("stale: should return 1 when a user's stale items are mixed", function () {
         const res = alleAuswerten([row(1, 0, stale()), row(1, 2, stale())])
         expect(res[0].status).to.equal(1)
      })
      it("stale: a stale item should turn equal recent statuses into 1", function () {
         const res = alleAuswerten([row(1, 0, recent()), row(1, 0, stale())])
         expect(res[0].status).to.equal(1)
      })
      it("stale: should still return 3 when a recent item of the user is 3", function () {
         const res = alleAuswerten([row(1, 3, recent()), row(1, 3, stale())])
         expect(res[0].status).to.equal(3)
      })
   })
})
test/testSetStatus.js
@@ -1,34 +1,142 @@
const expect = require("chai").expect
const mysql = require("mysql2/promise")
const db = require("../lib/db")
const testData = require("./data")
const {host, port, user: dbUser, database, password} = require("../settings").db
/////////////////////////////////////////////////////////////////////////
const {obj_id: courseId, usr_id: userId} = testData.setStatus
describe("the function setStatus", function () {
   const kurs = testData.kurs // TODO
   const user = testData.user
   // TODO make generic
   let kursId = 32212
   let userId = 31793
   const status = 2
   const passed = 1
   let conn = null
   let original = null
   let testValues = null
   beforeEach(async function () {
      // TODO add kurs, add User
      conn = await mysql.createConnection({host, port, database, user: dbUser, password})
      // snapshot the current values (source of truth)
      const [lp] = await conn.query(
         `SELECT status, status_changed FROM ${database}.ut_lp_marks WHERE usr_id = ? AND obj_id = ?`,
         [userId, courseId]
      )
      const [om] = await conn.query(
         `SELECT passed FROM ${database}.obj_members WHERE usr_id = ? AND obj_id = ?`,
         [userId, courseId]
      )
      expect(lp, "ut_lp_marks row must exist").to.have.lengthOf(1)
      expect(om, "obj_members row must exist").to.have.lengthOf(1)
      original = {
         status: lp[0].status,
         status_changed: lp[0].status_changed,
         passed: om[0].passed,
      }
      // pick values that are guaranteed to differ from the original ones
      testValues = {
         status: original.status === 2 ? 1 : 2,
         passed: original.passed === 1 ? 0 : 1,
      }
      this.testStart = new Date()
   })
   afterEach(async function () {
      // TODO delete kurs, delete user
      // restore the exact original values, including status_changed
      await conn.query(
         `UPDATE ${database}.ut_lp_marks SET status = ?, status_changed = ? WHERE usr_id = ? AND obj_id = ?`,
         [original.status, original.status_changed, userId, courseId]
      )
      await conn.query(
         `UPDATE ${database}.obj_members SET passed = ? WHERE usr_id = ? AND obj_id = ?`,
         [original.passed, userId, courseId]
      )
      await conn.end()
   })
   it("should set status and passed for a kurs TN", async function () {
      const res = await db.setStatus(kursId, userId, passed, status)
      console.dir(res, {depth: null})
   it("should set status, passed and status_changed for a kurs TN", async function () {
      const {status, passed} = testValues
      const res = await db.setStatus(courseId, userId, passed, status)
      expect(res).to.have.property("status").and.to.equal("ok")
      // read back directly from the DB
      const [[lp]] = await conn.query(
         `SELECT status, status_changed FROM ${database}.ut_lp_marks WHERE usr_id = ? AND obj_id = ?`,
         [userId, courseId]
      )
      const [[om]] = await conn.query(
         `SELECT passed FROM ${database}.obj_members WHERE usr_id = ? AND obj_id = ?`,
         [userId, courseId]
      )
      expect(lp.status).to.equal(status)
      expect(om.passed).to.equal(passed)
      const changed = new Date(lp.status_changed)
      expect(changed.getTime()).to.be.at.least(this.testStart.getTime() - 1000)
      expect(changed.getTime()).to.be.above(new Date(original.status_changed).getTime())
   })
   it("should be idempotent when called twice with the same values", async function () {
      const {status, passed} = testValues
      const res1 = await db.setStatus(courseId, userId, passed, status)
      const res2 = await db.setStatus(courseId, userId, passed, status)
      expect(res1).to.have.property("status").and.to.equal("ok")
      expect(res2).to.have.property("status").and.to.equal("ok")
      const [[lp]] = await conn.query(
         `SELECT status FROM ${database}.ut_lp_marks WHERE usr_id = ? AND obj_id = ?`,
         [userId, courseId]
      )
      const [[om]] = await conn.query(
         `SELECT passed FROM ${database}.obj_members WHERE usr_id = ? AND obj_id = ?`,
         [userId, courseId]
      )
      expect(lp.status).to.equal(status)
      expect(om.passed).to.equal(passed)
   })
   it("should reject for a non-existent (user, course) pair", async function () {
      const missingUserId = 999999999
      let err = null
      try {
         await db.setStatus(courseId, missingUserId, 1, 2)
      } catch (ex) {
         err = ex
      }
      expect(err, "expected setStatus to throw").to.not.equal(null)
      expect(err).to.have.property("status").and.to.equal("error")
      expect(err).to.have.nested.property("reason.affectedRows1").and.to.equal(0)
      expect(err).to.have.nested.property("reason.affectedRows2").and.to.equal(0)
   })
   it("should reject for a non-existent course", async function () {
      const missingCourseId = 999999999
      let err = null
      try {
         await db.setStatus(missingCourseId, userId, 1, 2)
      } catch (ex) {
         err = ex
      }
      expect(err, "expected setStatus to throw").to.not.equal(null)
      expect(err).to.have.property("status").and.to.equal("error")
      expect(err).to.have.nested.property("reason.affectedRows1").and.to.equal(0)
      expect(err).to.have.nested.property("reason.affectedRows2").and.to.equal(0)
   })
   it("should succeed even when passed keeps its current value", async function () {
      // status is changed (status_changed always updates), passed stays the same;
      // this DB counts matched rows, so the update still reports affectedRows = 1
      const status = testValues.status
      const passed = original.passed
      const res = await db.setStatus(courseId, userId, passed, status)
      expect(res).to.have.property("status").and.to.equal("ok")
   })
})
/////////////////////////////////////////////////////////////////////////