Skip to content

Commit 4c002fd

Browse files
authored
Merge pull request #158 from codex-team/refactor/user-manager
UserManager abstraction added
2 parents ae271b1 + 1230cce commit 4c002fd

File tree

16 files changed

+290
-43
lines changed

16 files changed

+290
-43
lines changed

.github/workflows/main.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ jobs:
2222
CI_JOB_NUMBER: 2
2323
steps:
2424
- uses: actions/checkout@v1
25+
with:
26+
fetch-depth: 0
2527
- name: Use Node.js from .nvmrc
2628
uses: actions/setup-node@v6
2729
with:
2830
node-version-file: '.nvmrc'
2931
- run: corepack enable
3032
- run: yarn install
31-
- run: yarn workspace @hawk.so/javascript test
33+
- run: yarn test:modified origin/${{ github.event.pull_request.base.ref }}
3234

3335
build:
3436
runs-on: ubuntu-latest

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"dev": "yarn workspace @hawk.so/javascript dev",
1515
"build:all": "yarn workspaces foreach -Apt run build",
1616
"build:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run build",
17+
"test:all": "yarn workspaces foreach -Apt run test",
18+
"test:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run test",
1719
"stats": "yarn workspace @hawk.so/javascript stats",
1820
"lint": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js --fix",
1921
"lint-test": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js"

packages/core/package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
}
1818
},
1919
"scripts": {
20-
"build": "vite build"
20+
"build": "vite build",
21+
"test": "vitest run",
22+
"test:coverage": "vitest run --coverage",
23+
"lint": "eslint --fix \"src/**/*.{js,ts}\""
2124
},
2225
"repository": {
2326
"type": "git",
@@ -33,8 +36,13 @@
3336
"url": "https://github.com/codex-team/hawk.javascript/issues"
3437
},
3538
"homepage": "https://github.com/codex-team/hawk.javascript#readme",
39+
"dependencies": {
40+
"@hawk.so/types": "0.5.8"
41+
},
3642
"devDependencies": {
43+
"@vitest/coverage-v8": "^4.0.18",
3744
"vite": "^7.3.1",
38-
"vite-plugin-dts": "^4.2.4"
45+
"vite-plugin-dts": "^4.2.4",
46+
"vitest": "^4.0.18"
3947
}
4048
}

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export type { HawkStorage } from './storages/hawk-storage';
2+
export type { RandomGenerator } from './utils/random';
3+
export { HawkUserManager } from './users/hawk-user-manager';
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { AffectedUser } from '@hawk.so/types';
2+
import type { HawkStorage } from '../storages/hawk-storage';
3+
import { id } from '../utils/id';
4+
import type { RandomGenerator } from '../utils/random';
5+
6+
/**
7+
* Storage key used to persist the auto-generated user ID.
8+
*/
9+
const HAWK_USER_ID_KEY = 'hawk-user-id';
10+
11+
/**
12+
* Manages the affected user identity.
13+
*
14+
* Manually provided users are kept in memory only (they don't change restarts).
15+
* {@link HawkStorage} is used solely to persist the auto-generated ID
16+
* so it survives across sessions.
17+
*
18+
* @remarks changes to user data in storage from outside manager are not tracked;
19+
* for changes to take effect call {@link clear}.
20+
*/
21+
export class HawkUserManager {
22+
/**
23+
* In-memory user set explicitly via {@link setUser}.
24+
*/
25+
private user: AffectedUser | null = null;
26+
27+
/**
28+
* Underlying storage used to persist auto-generated user ID.
29+
*/
30+
private readonly storage: HawkStorage;
31+
32+
/**
33+
* Random generator used to produce anonymous user IDs.
34+
*/
35+
private readonly randomGenerator: RandomGenerator;
36+
37+
/**
38+
* @param storage - storage backend to use for persistence
39+
* @param randomGenerator - utilities related to RandomGenerator generated values
40+
*/
41+
constructor(
42+
storage: HawkStorage,
43+
randomGenerator: RandomGenerator
44+
) {
45+
this.storage = storage;
46+
this.randomGenerator = randomGenerator;
47+
}
48+
49+
/**
50+
* Returns current affected user if set, otherwise generates and persists an anonymous ID.
51+
*
52+
* Priority: in-memory user > persisted user ID.
53+
*
54+
* @returns set affected user or user with generated ID
55+
*/
56+
public getUser(): AffectedUser {
57+
if (this.user) {
58+
return this.user;
59+
}
60+
61+
let storedId = this.storage.getItem(HAWK_USER_ID_KEY);
62+
63+
if (!storedId) {
64+
storedId = id(this.randomGenerator);
65+
this.storage.setItem(HAWK_USER_ID_KEY, storedId);
66+
}
67+
68+
this.user = { id: storedId };
69+
70+
return this.user!;
71+
}
72+
73+
/**
74+
* Sets the user explicitly (in memory only).
75+
*
76+
* @param user - The affected user provided by the application.
77+
*/
78+
public setUser(user: AffectedUser): void {
79+
this.user = user;
80+
}
81+
82+
/**
83+
* Clears the explicitly set user, falling back to the persisted user ID.
84+
*/
85+
public clear(): void {
86+
this.user = null;
87+
}
88+
}

packages/core/src/utils/id.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { RandomGenerator } from './random';
2+
3+
/**
4+
* Returns random string
5+
*
6+
* @param random
7+
*/
8+
export function id(random: RandomGenerator): string {
9+
const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
10+
11+
const randomSequence = random
12+
.getRandomNumbers(40)
13+
.map(x => validChars.charCodeAt(x % validChars.length));
14+
15+
return String.fromCharCode.apply(null, randomSequence);
16+
}

packages/core/src/utils/random.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Abstraction over random value generator.
3+
* Allows platform-specific implementations to be injected wherever random values are needed.
4+
*/
5+
export interface RandomGenerator {
6+
/**
7+
* Generates sequence of random unsigned numbers.
8+
*
9+
* @param length - Length of generated sequence.
10+
* @returns Array filled with random unsigned numbers.
11+
*/
12+
getRandomNumbers(length: number): Uint8Array<ArrayBuffer>;
13+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { HawkUserManager } from '../../src';
3+
import type { HawkStorage, RandomGenerator } from '../../src';
4+
5+
describe('HawkUserManager', () => {
6+
let storage: HawkStorage;
7+
let randomGenerator: RandomGenerator;
8+
let manager: HawkUserManager;
9+
10+
beforeEach(() => {
11+
storage = {
12+
getItem: vi.fn().mockReturnValue(null),
13+
setItem: vi.fn(),
14+
removeItem: vi.fn(),
15+
};
16+
randomGenerator = {
17+
getRandomNumbers: vi.fn().mockReturnValue(new Uint8Array(40).fill(42)),
18+
};
19+
manager = new HawkUserManager(storage, randomGenerator);
20+
});
21+
22+
it('should return anonymous ID when no user is set and no ID is persisted', () => {
23+
const user = manager.getUser();
24+
25+
expect(user.id).toBeTruthy();
26+
expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', user.id);
27+
});
28+
29+
it('should return in-memory user set via setUser()', () => {
30+
const user = { id: 'user-1', name: 'Ryan Gosling', url: 'https://example.com', photo: 'https://example.com/photo.png' };
31+
32+
manager.setUser(user);
33+
34+
expect(manager.getUser()).toEqual(user);
35+
expect(storage.setItem).not.toHaveBeenCalled();
36+
});
37+
38+
it('should not affect storage when setUser() is called', () => {
39+
manager.setUser({ id: 'user-1' });
40+
41+
expect(storage.setItem).not.toHaveBeenCalled();
42+
expect(storage.removeItem).not.toHaveBeenCalled();
43+
});
44+
45+
it('should return anonymous user from storage when no in-memory user is set', () => {
46+
vi.mocked(storage.getItem).mockReturnValue('anon-123');
47+
48+
expect(manager.getUser()).toEqual({ id: 'anon-123' });
49+
expect(storage.getItem).toHaveBeenCalledWith('hawk-user-id');
50+
});
51+
52+
it('should prefer in-memory user over persisted anonymous ID', () => {
53+
vi.mocked(storage.getItem).mockReturnValue('anon-123');
54+
manager.setUser({ id: 'explicit-user' });
55+
56+
expect(manager.getUser()).toEqual({ id: 'explicit-user' });
57+
});
58+
59+
it('should clear in-memory user and fall back to persisted anonymous ID', () => {
60+
vi.mocked(storage.getItem).mockReturnValue('anon-123');
61+
manager.setUser({ id: 'user-1' });
62+
manager.clear();
63+
64+
expect(manager.getUser()).toEqual({ id: 'anon-123' });
65+
});
66+
67+
it('should return new anonymous ID after clear() when no ID is persisted', () => {
68+
manager.setUser({ id: 'user-1' });
69+
manager.clear();
70+
71+
const user = manager.getUser();
72+
73+
expect(user.id).toBeTruthy();
74+
expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', user.id);
75+
});
76+
});

packages/core/tsconfig.test.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": null,
5+
"declaration": false,
6+
"types": ["vitest/globals"]
7+
},
8+
"include": [
9+
"src/**/*",
10+
"tests/**/*",
11+
"vitest.config.ts"
12+
]
13+
}

packages/core/vitest.config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
globals: true,
6+
include: ['tests/**/*.test.ts'],
7+
typecheck: {
8+
tsconfig: './tsconfig.test.json',
9+
},
10+
coverage: {
11+
provider: 'v8',
12+
include: ['src/**/*.ts'],
13+
},
14+
},
15+
});

0 commit comments

Comments
 (0)