@opengis/fastify-table 1.1.21 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Changelog.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # fastify-table
2
2
 
3
+ ## 1.1.23 - 03.10.2024
4
+
5
+ - add user API and unit tests
6
+
3
7
  ## 1.1.21 - 03.10.2024
4
8
 
5
9
  - add mention in comment notification hook
package/index.js CHANGED
@@ -15,7 +15,7 @@ import crudPlugin from './crud/index.js';
15
15
  import policyPlugin from './policy/index.js';
16
16
  import utilPlugin from './util/index.js';
17
17
  import cronPlugin from './cron/index.js';
18
- import hookPlugin from './hook/index.js';
18
+ import userPlugin from './user/index.js';
19
19
 
20
20
  import pgClients from './pg/pgClients.js';
21
21
 
@@ -92,7 +92,7 @@ async function plugin(fastify, opt) {
92
92
  widgetPlugin(fastify, opt);
93
93
  utilPlugin(fastify, opt);
94
94
  cronPlugin(fastify, opt);
95
- hookPlugin(fastify, opt);
95
+ userPlugin(fastify, opt);
96
96
 
97
97
  // core templates && cls
98
98
  const filename = fileURLToPath(import.meta.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengis/fastify-table",
3
- "version": "1.1.21",
3
+ "version": "1.1.23",
4
4
  "type": "module",
5
5
  "description": "core-plugins",
6
6
  "main": "index.js",
@@ -41,10 +41,10 @@ COMMENT ON COLUMN admin.user_properties.property_key IS 'Ключ';
41
41
  ALTER TABLE admin.user_properties ADD COLUMN IF NOT EXISTS property_json json;
42
42
  COMMENT ON COLUMN admin.user_properties.property_json IS 'Значення налаштування';
43
43
 
44
- ALTER TABLE admin.properties ADD COLUMN IF NOT EXISTS property_entity text;
45
- COMMENT ON COLUMN admin.properties.property_entity IS 'Сутність';
46
- ALTER TABLE admin.properties ADD COLUMN IF NOT EXISTS property_title text;
47
- COMMENT ON COLUMN admin.properties.property_title IS 'Назва';
44
+ ALTER TABLE admin.user_properties ADD COLUMN IF NOT EXISTS property_entity text;
45
+ COMMENT ON COLUMN admin.user_properties.property_entity IS 'Сутність';
46
+ ALTER TABLE admin.user_properties ADD COLUMN IF NOT EXISTS property_title text;
47
+ COMMENT ON COLUMN admin.user_properties.property_title IS 'Назва';
48
48
 
49
49
  ALTER TABLE admin.user_properties ADD COLUMN IF NOT EXISTS uid text NOT NULL DEFAULT '1'::text;
50
50
  ALTER TABLE admin.user_properties ADD COLUMN IF NOT EXISTS editor_id text;
@@ -53,6 +53,6 @@ ALTER TABLE admin.user_properties ADD COLUMN IF NOT EXISTS cdate timestamp witho
53
53
  ALTER TABLE admin.user_properties ADD COLUMN IF NOT EXISTS files json;
54
54
 
55
55
  ALTER TABLE admin.user_properties ADD CONSTRAINT admin_user_properties_property_id_pkey PRIMARY KEY(property_id);
56
- alter table admin.user_properties add constraint user_properties_key_uid_unique UNIQUE (property_key,uid);
56
+ ALTER TABLE admin.user_properties ADD CONSTRAINT user_properties_key_uid_unique UNIQUE (property_key,property_entity,uid);
57
57
 
58
58
  COMMENT ON TABLE admin.user_properties IS 'Налаштування користувача';
@@ -131,4 +131,38 @@ COMMENT ON COLUMN admin.users_social_auth.social_auth_obj IS 'обьект со
131
131
  COMMENT ON COLUMN admin.users_social_auth.social_auth_date IS 'время получение последнего обьекта соцсети';
132
132
  COMMENT ON COLUMN admin.users_social_auth.social_auth_softpro_code IS 'код обьекта соцсети, создаваемый и используемый сервером авторизации';
133
133
  COMMENT ON COLUMN admin.users_social_auth.enabled IS 'Выключатель';
134
- COMMENT ON COLUMN admin.users_social_auth.social_auth_url IS 'URL для QR code';
134
+ COMMENT ON COLUMN admin.users_social_auth.social_auth_url IS 'URL для QR code';
135
+
136
+ CREATE TABLE IF NOT EXISTS admin.user_cls();
137
+ ALTER TABLE admin.user_cls DROP CONSTRAINT IF EXISTS admin_user_cls_pkey;
138
+ ALTER TABLE admin.user_cls DROP CONSTRAINT IF EXISTS admin_user_unique;
139
+
140
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS user_clsid text;
141
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS code text;
142
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS parent text;
143
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS name text;
144
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS icon text;
145
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS data text;
146
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS type text;
147
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS files json;
148
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS cdate timestamp without time zone;
149
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS editor_id text;
150
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS editor_date timestamp without time zone;
151
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS uid text;
152
+ ALTER TABLE admin.user_cls ADD COLUMN IF NOT EXISTS color text;
153
+
154
+ ALTER TABLE admin.user_cls ALTER COLUMN user_clsid SET NOT NULL;
155
+ ALTER TABLE admin.user_cls ALTER COLUMN user_clsid SET DEFAULT next_id();
156
+ ALTER TABLE admin.user_cls ALTER COLUMN cdate SET DEFAULT (now())::timestamp without time zone;
157
+ ALTER TABLE admin.user_cls ALTER COLUMN uid SET NOT NULL;
158
+ ALTER TABLE admin.user_cls ALTER COLUMN uid SET DEFAULT '1'::text;
159
+
160
+ ALTER TABLE admin.user_cls ADD CONSTRAINT admin_user_cls_pkey PRIMARY KEY(user_clsid);
161
+ ALTER TABLE admin.user_cls ADD CONSTRAINT admin_user_unique UNIQUE(code, parent);
162
+
163
+ COMMENT ON TABLE admin.user_cls IS 'Користувацькі класифікатори';
164
+ COMMENT ON COLUMN admin.user_cls.user_clsid IS 'ID';
165
+ COMMENT ON COLUMN admin.user_cls.code IS 'Код';
166
+ COMMENT ON COLUMN admin.user_cls.name IS 'Назва';
167
+ COMMENT ON COLUMN admin.user_cls.icon IS 'Іконка';
168
+ COMMENT ON COLUMN admin.user_cls.color IS 'Колір';
@@ -1,15 +1,16 @@
1
1
  import getSelect from '../../controllers/utils/getSelect.js';
2
- import pg from '../../../pg/pgClients.js';
2
+ import pgClients from '../../../pg/pgClients.js';
3
3
  import redis from '../../../redis/client.js';
4
4
 
5
- export default async function metaFormat({ name, values }) {
5
+ export default async function getSelectVal({ pg: pg1, name, values }) {
6
+ const pg = pg1 || pgClients.client;
6
7
  const cls = await getSelect(name);
7
8
  if (!cls?.arr && !cls?.sql) return null;
8
9
  const key = `select:${name}`;
9
10
  const cache = !cls.arr ? (await redis.hmget(key, values)).reduce((p, el, i) => ({ ...p, [values[i]]: el }), {}) : {};
10
11
 
11
12
  const data = cls.arr || (values.filter(el => !cache[el]).length
12
- ? await pg.client.query(`with c(id,text) as (${cls.sql}) select * from c where id = any('{${values.filter(el => !cache[el])}}')`).then(el => el.rows)
13
+ ? await pg.query(`with c(id,text) as (${cls.sql}) select * from c where id = any('{${values.filter(el => !cache[el])}}')`).then(el => el.rows)
13
14
  : []);
14
15
 
15
16
  const clsAr = { ...cache, ...data.reduce((p, el) => ({ ...p, [el.id.toString()]: el.color ? el : el.text }), {}) };
@@ -4,34 +4,71 @@ import assert from 'node:assert';
4
4
  import build from '../../helper.js';
5
5
  import config from '../config.js';
6
6
 
7
- const session = { passport: { user: { uid: config.testUser?.uid || '1' } } };
8
-
9
- import userNotifications from '../../notification/controllers/userNotifications.js';
7
+ import { getSelect, initPG, pgClients } from '../../utils.js';
10
8
 
11
- import pgClients from '../../pg/pgClients.js';
9
+ const session = { passport: { user: { uid: config.testUser?.uid || '1' } } };
10
+ const { uid } = session.passport.user;
12
11
 
13
12
  test('api && funcs notification', async (t) => {
14
13
  const app = await build(t);
15
- const pg = pgClients.client;
16
- /*
17
- // require dependency
18
- await t.test('GET /auth', async () => {
14
+ const { client: pg } = pgClients;
15
+ await initPG(pg);
16
+
17
+ app.addHook('onRequest', async (req) => {
18
+ req.session = session;
19
+ });
20
+
21
+ const notificationIds = await pgClients.client.query(`insert into crm.notifications(addressee_id,entity_id,author_id,subject,body,link)
22
+ values($1,$1,$1,$1,$1,$1), ($1,$1,$1,$1,$1,$1) returning notification_id as "notificationId"`, [uid])
23
+ .then((res) => res.rows?.map((el) => el.notificationId) || {});
24
+
25
+ if (!notificationIds?.length) {
26
+ assert.ok(0, 'insert notification error');
27
+ }
28
+ await t.test('GET /notification', async () => {
19
29
  const res = await app.inject({
20
30
  method: 'GET',
21
- url: `/api/login?username=${config.testUser?.username}&password=${config.testUser?.password}`,
31
+ url: '/api/notification',
22
32
  });
23
- assert.ok(res.statusCode);
33
+ const rep = res.json();
34
+ assert.ok(rep.total > 0);
24
35
  });
25
- await t.test('GET /notification', async () => {
36
+ await t.test('GET /notification-read/:id', async () => {
26
37
  const res = await app.inject({
27
38
  method: 'GET',
28
- url: '/api/notification',
39
+ url: `/api/notification-read/${notificationIds[0]}`,
40
+ });
41
+ const rep = res.json();
42
+ assert.ok(!rep.message?.startsWith(0), res.body);
43
+ assert.ok(rep.message?.includes('unread notifications marked'), res.body);
44
+ });
45
+ await t.test('GET /notification-read', async () => {
46
+ const res = await app.inject({
47
+ method: 'GET',
48
+ url: '/api/notification-read',
29
49
  });
30
- const rep = JSON.parse(res?.body);
31
- assert.ok(rep.time);
32
- }); */
33
- /* await t.test('GET /notification', async () => {
34
- const rep = await userNotifications({ pg, session });
35
- assert.ok(rep.time);
36
- }); */
50
+ const rep = res.json();
51
+ assert.ok(!rep.message?.startsWith(0), res.body);
52
+ assert.ok(rep.message?.includes('unread notifications marked'), res.body);
53
+ });
54
+
55
+ const { sql } = await getSelect('core.user_mentioned');
56
+ const { name } = await pg.query(`with data (id,name,email) as (${sql})
57
+ select name from data where id = $1`, [uid]).then((res) => res.rows?.[0] || {});
58
+ if (name) {
59
+ await t.test('GET Email Notification via HOOK at POST /widget/comment/:id', async () => {
60
+ const res = await app.inject({
61
+ method: 'POST',
62
+ url: `/api/widget/comment/${notificationIds[0]}`,
63
+ body: { body: `@${name}`, entity_id: uid },
64
+ });
65
+ assert.equal(res.statusCode, 200);
66
+ assert.equal(res.json().rowCount, 1);
67
+ });
68
+ }
69
+
70
+ await t.test('clean up', async () => {
71
+ const { rowCount = 0 } = await pg.query('delete from crm.notifications where entity_id=$1', [uid]);
72
+ console.log('clean up', rowCount);
73
+ });
37
74
  });
@@ -0,0 +1,84 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import pgClients from '../../pg/pgClients.js';
5
+ import init from '../../pg/funcs/init.js';
6
+
7
+ import build from '../../helper.js';
8
+ import config from '../config.js';
9
+
10
+ const prefix = config.prefix || '/api';
11
+
12
+ const body = {
13
+ name: 'test.user.cls',
14
+ type: 'json',
15
+ children: [
16
+ {
17
+ id: 'get',
18
+ text: 'Отримання',
19
+ },
20
+ ],
21
+ };
22
+
23
+ test('applyHook to API data/table', async (t) => {
24
+ const app = await build(t);
25
+ await init(pgClients.client);
26
+
27
+ app.addHook('onRequest', async (req) => {
28
+ req.session = { passport: { user: { uid: '1' } } };
29
+ });
30
+
31
+ await t.test('GET /user-info', async () => {
32
+ const res = await app.inject({
33
+ method: 'GET',
34
+ url: `${prefix}/user-info`,
35
+ });
36
+ assert.equal(res.statusCode, 200);
37
+ assert.equal(res.json().uid, '1');
38
+ assert.ok(res.json().user_name, 'not enough info: user_name');
39
+ assert.ok(res.json().notifications || res.json().notifications === 0, 'not enough info: notifications');
40
+ });
41
+
42
+ await t.test('GET /user-cls', async () => {
43
+ const res = await app.inject({
44
+ method: 'GET',
45
+ url: `${prefix}/user-cls`,
46
+ });
47
+ const { message = {} } = res.json() || {};
48
+ assert.ok(message.rows?.length, 'empty cls list');
49
+ });
50
+
51
+ await t.test('POST /user-cls', async () => {
52
+ const res = await app.inject({
53
+ method: 'POST',
54
+ url: `${prefix}/user-cls`,
55
+ body,
56
+ });
57
+ assert.equal(res.statusCode, 200);
58
+ assert.ok(res.json().children?.length, 'insert user cls error');
59
+ });
60
+
61
+ await t.test('GET /user-cls/:name', async () => {
62
+ const res = await app.inject({
63
+ method: 'GET',
64
+ url: `${prefix}/user-cls/${body.name}`,
65
+ body,
66
+ });
67
+ assert.equal(res.statusCode, 200);
68
+ assert.equal(res.json().status || 200, 200);
69
+ assert.equal(res.json().message?.children?.length, 1);
70
+ });
71
+
72
+ await t.test('clean up', async () => {
73
+ const { rowCount = 0 } = await pgClients.client.query(`with recursive rows as (
74
+ select user_clsid, name, code, icon, color, parent
75
+ from admin.user_cls a
76
+ where name=$1
77
+ union all
78
+ select a.user_clsid, a.name, a.code, a.icon, a.color, a.parent
79
+ from admin.user_cls a
80
+ join rows b on a.parent=b.name
81
+ ) delete from admin.user_cls where user_clsid in (select user_clsid from rows )`, [body.name]);
82
+ console.log('clean up', rowCount);
83
+ });
84
+ });
@@ -0,0 +1,14 @@
1
+ import userCls from './user.cls.js';
2
+
3
+ export default async function userClsId(req) {
4
+ const { id } = req.params || {};
5
+ const res = await userCls(req);
6
+ if (req.query?.sql || res?.error) {
7
+ return res;
8
+ }
9
+ const { rows = [] } = res?.message || {};
10
+ if (!rows?.length) {
11
+ return { message: `cls not found: ${id}`, status: 404 };
12
+ }
13
+ return { message: rows?.[0], status: 200 };
14
+ }
@@ -0,0 +1,71 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { getSelect, getTemplatePath } from '../../utils.js';
3
+
4
+ export default async function userCls(req) {
5
+ const {
6
+ pg, params = {}, query = {}, session = {},
7
+ } = req;
8
+ const { uid } = session.passport?.user || {};
9
+
10
+ if (!uid) {
11
+ return { message: 'access restricted', status: 403 };
12
+ }
13
+
14
+ const q = `select user_clsid as id, name, type,
15
+ case when type='json' then (
16
+ ${params?.id
17
+ ? `(with recursive rows as (
18
+ select user_clsid, name, code, icon, color, parent
19
+ from admin.user_cls a
20
+ where name=u.name
21
+ union all
22
+ select a.user_clsid, a.name, a.code, a.icon, a.color, a.parent
23
+ from admin.user_cls a
24
+ join rows b on a.parent=b.name
25
+ ) select json_agg(row_to_json(q)) from rows q where name<>u.name
26
+ )`
27
+ : 'select count(*)::int from admin.user_cls where parent=u.name'} ) else null end as children,
28
+ case when type='sql' then data else null end as sql from admin.user_cls u
29
+ where (case when type='json' then parent is null else true end)
30
+ and uid=(select uid from admin.users where $1 in (login,uid) limit 1)
31
+ and ${params?.id ? 'u.name=$2' : '1=1'}`;
32
+
33
+ if (query?.sql) return q;
34
+
35
+ try {
36
+ const { rows = [] } = await pg.query(q, [uid, params.id].filter((el) => el));
37
+
38
+ rows.forEach((row) => {
39
+ if (row.type === 'sql') delete row.children;
40
+ if (row.type === 'json') { delete row.sql; Object.assign(row, { children: row.children || (params?.id ? [] : 0) }); }
41
+ });
42
+
43
+ const clsList = getTemplatePath('cls');
44
+ const selectList = getTemplatePath('select');
45
+
46
+ const userClsNames = rows.map((el) => el.name);
47
+ const selectNames = selectList.map((el) => el[0]);
48
+
49
+ const arr = (clsList || []).concat(selectList || []).map((el) => ({ name: el[0], path: el[1], type: el[2] }))
50
+ ?.filter((el) => (params?.id ? el.name === params?.id : true))
51
+ ?.filter((el) => ['sql', 'json'].includes(el?.type) && !userClsNames.includes(el.name))
52
+ ?.filter((el) => (el?.type === 'json' ? !selectNames?.includes(el?.name) : true));
53
+
54
+ const res = await Promise.all(arr?.map(async (el) => {
55
+ const type = { json: 'cls', sql: 'select' }[el.type] || el.type;
56
+ // const clsData = await getSelect(type, el.name);
57
+ const str = await readFile(el.path, 'utf-8');
58
+ const clsData = type === 'cls' ? JSON.parse(str) : str;
59
+ if (type === 'cls') {
60
+ const children = params?.id ? clsData : clsData?.length || 0;
61
+ return { name: el.name, type: el.type, children };
62
+ }
63
+ return { name: el.name, type: el.type, sql: clsData?.sql || clsData };
64
+ }));
65
+
66
+ return { message: { rows: rows.concat(res) }, status: 200 };
67
+ }
68
+ catch (err) {
69
+ return { error: err.toString(), status: 200 };
70
+ }
71
+ }
@@ -0,0 +1,55 @@
1
+ export default async function userClsPost({
2
+ pg, body = {}, session = {},
3
+ }) {
4
+ const { uid } = session.passport?.user || {};
5
+ const {
6
+ name, type = 'json', children = [], sql,
7
+ } = body;
8
+
9
+ if (!uid) {
10
+ return { message: 'access restricted', status: 403 };
11
+ }
12
+
13
+ if (!name) {
14
+ return { message: 'not enough params: name', status: 400 };
15
+ }
16
+
17
+ if (type === 'json' && (!Array.isArray(children) || !children.length || !children?.[0]?.id)) {
18
+ return { message: 'invalid params: children (array of objects)', status: 400 };
19
+ }
20
+
21
+ if (type === 'sql' && (!sql || typeof sql !== 'string')) {
22
+ return { message: 'invalid params: sql (string)', status: 400 };
23
+ }
24
+
25
+ try {
26
+ const { rowCount = 0 } = await pg.query(`with recursive rows as (
27
+ select user_clsid, name, code, icon, color, parent
28
+ from admin.user_cls a
29
+ where name=$1
30
+ union all
31
+ select a.user_clsid, a.name, a.code, a.icon, a.color, a.parent
32
+ from admin.user_cls a
33
+ join rows b on a.parent=b.name
34
+ ) delete from admin.user_cls where user_clsid in (select user_clsid from rows )`, [name]);
35
+ console.log('delete old user cls', name, rowCount);
36
+
37
+ const { id, data } = await pg.query('insert into admin.user_cls(name,type,data,uid) values($1,$2,$3,$4) returning user_clsid as id, data', [name, type, sql, uid])
38
+ .then((res) => res.rows?.[0] || {});
39
+ if (type === 'json') {
40
+ if (!id) { return { error: 'insert user cls error', status: 500 }; }
41
+ const q1 = `insert into admin.user_cls(code,name,color,icon,parent,uid)
42
+
43
+ select value->>'id',value->>'text',value->>'color',value->>'icon', '${name.replace(/'/g, "''")}', '${uid}'
44
+ from json_array_elements('${JSON.stringify(children).replace(/'/g, "''")}'::json)
45
+
46
+ returning user_clsid as id, code, name, parent`;
47
+ const { rows = [] } = await pg.query(q1);
48
+ return { id, children: rows };
49
+ }
50
+ return { id, data };
51
+ }
52
+ catch (err) {
53
+ return { error: err.toString(), status: 500 };
54
+ }
55
+ }
@@ -0,0 +1,21 @@
1
+ export default async function userInfo({
2
+ pg, session = {},
3
+ }) {
4
+ const { uid } = session.passport?.user || {};
5
+
6
+ if (!uid) {
7
+ return { message: 'access restricted', status: 403 };
8
+ }
9
+
10
+ const data = await pg.query(`select user_name, sur_name, father_name, user_rnokpp, user_type, email, login from admin.users
11
+ where uid=$1`, [uid]).then((res) => res.rows?.[0] || {});
12
+
13
+ try {
14
+ const { notifications = 0 } = await pg.query(`select count(*)::int as notifications from crm.notifications
15
+ where addressee_id=$1 and read is not true`, [uid]).then((res) => res.rows?.[0] || {});
16
+ return { uid, ...data, notifications };
17
+ }
18
+ catch (err) {
19
+ return { error: err.toString(), status: 500 };
20
+ }
21
+ }
package/user/index.js ADDED
@@ -0,0 +1,46 @@
1
+ import userCls from './controllers/user.cls.js';
2
+ import userClsId from './controllers/user.cls.id.js';
3
+ import userInfo from './controllers/user.info.js';
4
+ import userClsPost from './controllers/user.cls.post.js';
5
+
6
+ async function plugin(fastify, opts) {
7
+ const { prefix = '/api' } = opts || {};
8
+ fastify.route({
9
+ method: 'GET',
10
+ url: `${prefix}/user-cls`,
11
+ config: {
12
+ policy: ['user'],
13
+ },
14
+ schema: {},
15
+ handler: userCls,
16
+ });
17
+ fastify.route({
18
+ method: 'GET',
19
+ url: `${prefix}/user-cls/:id`,
20
+ config: {
21
+ policy: ['user'],
22
+ },
23
+ schema: {},
24
+ handler: userClsId,
25
+ });
26
+ fastify.route({
27
+ method: 'POST',
28
+ url: `${prefix}/user-cls`,
29
+ config: {
30
+ policy: ['user'],
31
+ },
32
+ schema: {},
33
+ handler: userClsPost,
34
+ });
35
+ fastify.route({
36
+ method: 'GET',
37
+ url: `${prefix}/user-info`,
38
+ config: {
39
+ policy: ['user'],
40
+ },
41
+ schema: {},
42
+ handler: userInfo,
43
+ });
44
+ }
45
+
46
+ export default plugin;
package/hook/index.js DELETED
@@ -1,6 +0,0 @@
1
- import addHook from './funcs/addHook.js';
2
- import applyHook from './funcs/applyHook.js';
3
-
4
- export default async function plugin(fastify, opts) {
5
- fastify.decorate('hook', { add: addHook, apply: applyHook });
6
- }