-
Notifications
You must be signed in to change notification settings - Fork 113
feat(gear): implement GraphQL API endpoints for user gear #3492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
8bc9ffc
feat(gear): implement GraphQL API endpoints for user gear
bd23ab8
refactor: rename userGear to gear and move autocomplete to autocomple…
github-actions[bot] 5dce5c1
Merge branch 'main' into eng-391-gear-api
rebelchris a72f93f
Merge branch 'main' into eng-391-gear-api
rebelchris 24a5bd9
fix: the dataload
rebelchris 391afdd
Merge branch 'main' into eng-391-gear-api
rebelchris f48992b
fix: lint
rebelchris 5b80769
Merge remote-tracking branch 'origin/eng-391-gear-api' into eng-391-g…
rebelchris File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,329 @@ | ||
| import { DataSource } from 'typeorm'; | ||
| import createOrGetConnection from '../src/db'; | ||
| import { | ||
| disposeGraphQLTesting, | ||
| GraphQLTestClient, | ||
| GraphQLTestingState, | ||
| initializeGraphQLTesting, | ||
| MockContext, | ||
| saveFixtures, | ||
| } from './helpers'; | ||
| import { User } from '../src/entity/user/User'; | ||
| import { usersFixture } from './fixture/user'; | ||
| import { DatasetGear } from '../src/entity/dataset/DatasetGear'; | ||
| import { UserGear } from '../src/entity/user/UserGear'; | ||
|
|
||
| let con: DataSource; | ||
| let state: GraphQLTestingState; | ||
| let client: GraphQLTestClient; | ||
| let loggedUser: string | null = null; | ||
|
|
||
| beforeAll(async () => { | ||
| con = await createOrGetConnection(); | ||
| state = await initializeGraphQLTesting( | ||
| () => new MockContext(con, loggedUser), | ||
| ); | ||
| client = state.client; | ||
| }); | ||
|
|
||
| afterAll(() => disposeGraphQLTesting(state)); | ||
|
|
||
| beforeEach(async () => { | ||
| loggedUser = null; | ||
| await saveFixtures(con, User, usersFixture); | ||
| }); | ||
|
|
||
| describe('query userGear', () => { | ||
| const QUERY = ` | ||
| query UserGear($userId: ID!) { | ||
| userGear(userId: $userId) { | ||
| edges { | ||
| node { | ||
| id | ||
| position | ||
| gear { | ||
| id | ||
| name | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| it('should return empty list for user with no gear', async () => { | ||
| const res = await client.query(QUERY, { variables: { userId: '1' } }); | ||
| expect(res.data.userGear.edges).toEqual([]); | ||
| }); | ||
|
|
||
| it('should return gear ordered by position', async () => { | ||
| const gear1 = await con.getRepository(DatasetGear).save({ | ||
| name: 'MacBook Pro', | ||
| nameNormalized: 'macbookpro', | ||
| }); | ||
| const gear2 = await con.getRepository(DatasetGear).save({ | ||
| name: 'Keyboard', | ||
| nameNormalized: 'keyboard', | ||
| }); | ||
|
|
||
| await con.getRepository(UserGear).save([ | ||
| { userId: '1', gearId: gear1.id, position: 1 }, | ||
| { userId: '1', gearId: gear2.id, position: 0 }, | ||
| ]); | ||
|
|
||
| const res = await client.query(QUERY, { variables: { userId: '1' } }); | ||
| expect(res.data.userGear.edges).toHaveLength(2); | ||
| expect(res.data.userGear.edges[0].node.gear.name).toBe('Keyboard'); | ||
| expect(res.data.userGear.edges[1].node.gear.name).toBe('MacBook Pro'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('query autocompleteGear', () => { | ||
| const QUERY = ` | ||
| query AutocompleteGear($query: String!) { | ||
| autocompleteGear(query: $query) { | ||
| id | ||
| name | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| it('should return matching gear', async () => { | ||
| await con.getRepository(DatasetGear).save([ | ||
| { name: 'MacBook Pro', nameNormalized: 'macbookpro' }, | ||
| { name: 'MacBook Air', nameNormalized: 'macbookair' }, | ||
| { name: 'Keyboard', nameNormalized: 'keyboard' }, | ||
| ]); | ||
|
|
||
| const res = await client.query(QUERY, { variables: { query: 'macbook' } }); | ||
| expect(res.data.autocompleteGear).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('should return empty for no matches', async () => { | ||
| const res = await client.query(QUERY, { variables: { query: 'xyz' } }); | ||
| expect(res.data.autocompleteGear).toEqual([]); | ||
| }); | ||
|
|
||
| it('should return exact match first when searching', async () => { | ||
| await con.getRepository(DatasetGear).save([ | ||
| { name: 'Monitor Stand', nameNormalized: 'monitorstand' }, | ||
| { name: 'Monitor', nameNormalized: 'monitor' }, | ||
| { name: 'Monitor Arm', nameNormalized: 'monitorarm' }, | ||
| ]); | ||
|
|
||
| const res = await client.query(QUERY, { variables: { query: 'monitor' } }); | ||
| const names = res.data.autocompleteGear.map( | ||
| (g: { name: string }) => g.name, | ||
| ); | ||
| expect(names).toContain('Monitor'); | ||
| // Exact match should be first | ||
| expect(names[0]).toBe('Monitor'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('mutation addUserGear', () => { | ||
| const MUTATION = ` | ||
| mutation AddUserGear($input: AddUserGearInput!) { | ||
| addUserGear(input: $input) { | ||
| id | ||
| gear { | ||
| name | ||
| } | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| it('should require authentication', async () => { | ||
| const res = await client.mutate(MUTATION, { | ||
| variables: { input: { name: 'MacBook Pro' } }, | ||
| }); | ||
| expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); | ||
| }); | ||
|
|
||
| it('should create gear and dataset entry', async () => { | ||
| loggedUser = '1'; | ||
| const res = await client.mutate(MUTATION, { | ||
| variables: { | ||
| input: { | ||
| name: 'MacBook Pro', | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| expect(res.data.addUserGear.gear.name).toBe('MacBook Pro'); | ||
|
|
||
| const dataset = await con | ||
| .getRepository(DatasetGear) | ||
| .findOneBy({ nameNormalized: 'macbookpro' }); | ||
| expect(dataset).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('should reuse existing dataset entry', async () => { | ||
| loggedUser = '1'; | ||
| await con.getRepository(DatasetGear).save({ | ||
| name: 'Keyboard', | ||
| nameNormalized: 'keyboard', | ||
| }); | ||
|
|
||
| await client.mutate(MUTATION, { | ||
| variables: { input: { name: 'Keyboard' } }, | ||
| }); | ||
|
|
||
| const count = await con.getRepository(DatasetGear).countBy({ | ||
| nameNormalized: 'keyboard', | ||
| }); | ||
| expect(count).toBe(1); | ||
| }); | ||
|
|
||
| it('should prevent duplicate gear', async () => { | ||
| loggedUser = '1'; | ||
| await client.mutate(MUTATION, { | ||
| variables: { input: { name: 'Monitor' } }, | ||
| }); | ||
|
|
||
| const res = await client.mutate(MUTATION, { | ||
| variables: { input: { name: 'Monitor' } }, | ||
| }); | ||
|
|
||
| expect(res.errors?.[0]?.message).toBe( | ||
| 'Gear already exists in your profile', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('mutation deleteUserGear', () => { | ||
| const MUTATION = ` | ||
| mutation DeleteUserGear($id: ID!) { | ||
| deleteUserGear(id: $id) { | ||
| _ | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| it('should require authentication', async () => { | ||
| const res = await client.mutate(MUTATION, { | ||
| variables: { id: '00000000-0000-0000-0000-000000000000' }, | ||
| }); | ||
| expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); | ||
| }); | ||
|
|
||
| it('should delete gear', async () => { | ||
| loggedUser = '1'; | ||
| const gear = await con.getRepository(DatasetGear).save({ | ||
| name: 'Webcam', | ||
| nameNormalized: 'webcam', | ||
| }); | ||
| const userGear = await con.getRepository(UserGear).save({ | ||
| userId: '1', | ||
| gearId: gear.id, | ||
| position: 0, | ||
| }); | ||
|
|
||
| await client.mutate(MUTATION, { variables: { id: userGear.id } }); | ||
|
|
||
| const deleted = await con | ||
| .getRepository(UserGear) | ||
| .findOneBy({ id: userGear.id }); | ||
| expect(deleted).toBeNull(); | ||
| }); | ||
|
|
||
| it('should not delete another user gear', async () => { | ||
| loggedUser = '1'; | ||
| const gear = await con.getRepository(DatasetGear).save({ | ||
| name: 'Mouse', | ||
| nameNormalized: 'mouse', | ||
| }); | ||
| const userGear = await con.getRepository(UserGear).save({ | ||
| userId: '2', // Different user | ||
| gearId: gear.id, | ||
| position: 0, | ||
| }); | ||
|
|
||
| await client.mutate(MUTATION, { variables: { id: userGear.id } }); | ||
|
|
||
| // Should still exist because it belongs to user 2 | ||
| const notDeleted = await con | ||
| .getRepository(UserGear) | ||
| .findOneBy({ id: userGear.id }); | ||
| expect(notDeleted).not.toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('mutation reorderUserGear', () => { | ||
| const MUTATION = ` | ||
| mutation ReorderUserGear($items: [ReorderUserGearInput!]!) { | ||
| reorderUserGear(items: $items) { | ||
| id | ||
| position | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| it('should require authentication', async () => { | ||
| const res = await client.mutate(MUTATION, { | ||
| variables: { | ||
| items: [{ id: '00000000-0000-0000-0000-000000000000', position: 0 }], | ||
| }, | ||
| }); | ||
| expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); | ||
| }); | ||
|
|
||
| it('should update positions', async () => { | ||
| loggedUser = '1'; | ||
| const gear1 = await con.getRepository(DatasetGear).save({ | ||
| name: 'Desk', | ||
| nameNormalized: 'desk', | ||
| }); | ||
| const gear2 = await con.getRepository(DatasetGear).save({ | ||
| name: 'Chair', | ||
| nameNormalized: 'chair', | ||
| }); | ||
|
|
||
| const [item1, item2] = await con.getRepository(UserGear).save([ | ||
| { userId: '1', gearId: gear1.id, position: 0 }, | ||
| { userId: '1', gearId: gear2.id, position: 1 }, | ||
| ]); | ||
|
|
||
| const res = await client.mutate(MUTATION, { | ||
| variables: { | ||
| items: [ | ||
| { id: item1.id, position: 1 }, | ||
| { id: item2.id, position: 0 }, | ||
| ], | ||
| }, | ||
| }); | ||
|
|
||
| const reordered = res.data.reorderUserGear; | ||
| expect( | ||
| reordered.find((i: { id: string }) => i.id === item1.id).position, | ||
| ).toBe(1); | ||
| expect( | ||
| reordered.find((i: { id: string }) => i.id === item2.id).position, | ||
| ).toBe(0); | ||
| }); | ||
|
|
||
| it('should not reorder another user gear', async () => { | ||
| loggedUser = '1'; | ||
| const gear = await con.getRepository(DatasetGear).save({ | ||
| name: 'Headphones', | ||
| nameNormalized: 'headphones', | ||
| }); | ||
| const userGear = await con.getRepository(UserGear).save({ | ||
| userId: '2', // Different user | ||
| gearId: gear.id, | ||
| position: 0, | ||
| }); | ||
|
|
||
| await client.mutate(MUTATION, { | ||
| variables: { | ||
| items: [{ id: userGear.id, position: 5 }], | ||
| }, | ||
| }); | ||
|
|
||
| // Position should still be 0 because it belongs to user 2 | ||
| const notReordered = await con | ||
| .getRepository(UserGear) | ||
| .findOneBy({ id: userGear.id }); | ||
| expect(notReordered?.position).toBe(0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import type { DataSource } from 'typeorm'; | ||
| import { DatasetGear } from '../entity/dataset/DatasetGear'; | ||
|
|
||
| const normalizeName = (name: string): string => | ||
| name | ||
| .toLowerCase() | ||
| .trim() | ||
| .replace(/\./g, 'dot') | ||
| .replace(/\+/g, 'plus') | ||
| .replace(/#/g, 'sharp') | ||
| .replace(/&/g, 'and') | ||
| .replace(/\s+/g, ''); | ||
|
|
||
| export const findOrCreateDatasetGear = async ( | ||
| con: DataSource, | ||
| name: string, | ||
| ): Promise<DatasetGear> => { | ||
| const nameNormalized = normalizeName(name); | ||
| const repo = con.getRepository(DatasetGear); | ||
|
|
||
| let gear = await repo.findOne({ | ||
| where: { nameNormalized }, | ||
| }); | ||
|
|
||
| if (!gear) { | ||
| gear = repo.create({ | ||
| name: name.trim(), | ||
| nameNormalized, | ||
| }); | ||
| await repo.save(gear); | ||
| } | ||
|
|
||
| return gear; | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import z from 'zod'; | ||
|
|
||
| export const addUserGearSchema = z.object({ | ||
| name: z.string().min(1).max(255), | ||
| }); | ||
|
|
||
| export const reorderUserGearItemSchema = z.object({ | ||
| id: z.uuid(), | ||
| position: z.number().int().min(0), | ||
| }); | ||
|
|
||
| export const reorderUserGearSchema = z.array(reorderUserGearItemSchema).min(1); | ||
|
|
||
| export type AddUserGearInput = z.infer<typeof addUserGearSchema>; | ||
| export type ReorderUserGearInput = z.infer<typeof reorderUserGearItemSchema>; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are not needed for gear