20 files modified
2 files added
1 files deleted
| New file |
| | |
| | | // Mocha config: keep manual test runs quiet unless LOG_LEVEL is set explicitly. |
| | | process.env.LOG_LEVEL ||= "error" |
| | | |
| | | module.exports = { |
| | | exit: true, |
| | | timeout: 20000, |
| | | } |
| | |
| | | |
| | | ### 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. |
| New file |
| | |
| | | # 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`. |
| | |
| | | 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) { |
| | |
| | | const db = require("./db") |
| | | const {url, iliastoken} = settings.ilias |
| | | const search = require("./search") |
| | | const log = require("../logger") |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | | |
| | |
| | | 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() // |
| | | } |
| | |
| | | |
| | | 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 { |
| | |
| | | token: iliastoken, |
| | | }) |
| | | let url2 = `${url}?${sp.toString()}` |
| | | console.log(url2) |
| | | log.debug(url2) |
| | | const res = await fetch(url2, {method: "POST"}) |
| | | return await res.json() |
| | | } |
| | |
| | | token: iliastoken, |
| | | }) |
| | | let url2 = `${url}?${sp.toString()}` |
| | | console.log(url2) |
| | | log.debug(url2) |
| | | const res = await fetch(url2, {method: "DELETE"}) |
| | | return await res.json() |
| | | } |
| | |
| | | const _ = require("lodash") |
| | | const dayjs = require("dayjs") |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | | |
| | | module.exports = { |
| | | alleAuswerten, |
| | | alleAuswerten, |
| | | auswerten, |
| | | partitionDates, |
| | | } |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | |
| | | /** |
| | | * 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) |
| | | } |
| | |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | | |
| | | 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 |
| | |
| | | { |
| | | "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", |
| | |
| | | { |
| | | "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", |
| | |
| | | |
| | | // 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); |
| | | } |
| | |
| | | "authtoken": "jiuGfr432898D90290kjfsldkfn3hh8F", |
| | | "port": 4101, |
| | | "restUrl": "http://localhost:4101", |
| | | "log": { |
| | | "level": "info" |
| | | }, |
| | | "db": { |
| | | "host": "localhost", |
| | | "port": 33009, |
| | |
| | | |
| | | 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 |
| | |
| | | "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, |
| | | } |
| | | |
| | |
| | | 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) |
| | | }) |
| | | |
| | | }) |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | | |
| | |
| | | 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) |
| | | }) |
| | | |
| | | }) |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | | |
| | |
| | | |
| | | 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), |
| | |
| | | '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") |
| | | }) |
| | |
| | | |
| | | 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", |
| | |
| | | '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") |
| | | }) |
| | | |
| | |
| | | |
| | | 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") |
| | |
| | | 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") |
| | |
| | | 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") |
| | |
| | | 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) |
| | | }) |
| | | |
| | | }) |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | | |
| | |
| | | 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("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 () { |
| | |
| | | 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) |
| | | } |
| | | }) |
| | | |
| | | }) |
| | |
| | | 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"); |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |
| | | |
| | |
| | | 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) |
| | | }) |
| | | |
| | | }) |
| | | |
| | | }) |
| | | |
| | |
| | | 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") |
| | | }) |
| | | |
| | | }) |
| | | |
| | | ///////////////////////////////////////////////////////////////////////// |