Skip to content

Commit cd82eb2

Browse files
nivedinCopilotjamesgeorge007
authored
fix: remove ref_id field before collection exports and address race conditions (hoppscotch#5626)
Co-authored-by: Copilot <[email protected]> Co-authored-by: James George <[email protected]>
1 parent 4efe86f commit cd82eb2

File tree

12 files changed

+167
-35
lines changed

12 files changed

+167
-35
lines changed

packages/hoppscotch-common/src/components/collections/ImportExport.vue

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { GistSource } from "~/helpers/import-export/import/import-sources/GistSo
6060
import { TeamWorkspace } from "~/services/workspace.service"
6161
import { invokeAction } from "~/helpers/actions"
6262
import { ReqType } from "~/helpers/backend/graphql"
63+
import { sanitizeCollection } from "~/helpers/import-export/import"
6364
6465
const isInsomniaImporterInProgress = ref(false)
6566
const isOpenAPIImporterInProgress = ref(false)
@@ -113,21 +114,35 @@ const handleImportToStore = async (collections: HoppCollection[]) => {
113114
}
114115
}
115116
117+
/**
118+
* Import collections to personal workspace
119+
* We sanitize the collections before importing to remove old id from the imported collection and folders and transform it to new collection format
120+
* @param collections Collections to import
121+
*/
116122
const importToPersonalWorkspace = (collections: HoppCollection[]) => {
123+
// Remove old id from the imported collection and folders and transform it to new collection format
124+
const sanitizedCollections = collections.map(sanitizeCollection)
125+
117126
if (
118127
platform.sync.collections.importToPersonalWorkspace &&
119128
currentUser.value
120129
) {
130+
// The SH adds the id to the collection and folders but for safety we remove it by sanitizeCollection
121131
return platform.sync.collections.importToPersonalWorkspace(
122-
collections,
132+
sanitizedCollections,
123133
ReqType.Rest
124134
)
125135
}
126136
127-
appendRESTCollections(collections)
137+
appendRESTCollections(sanitizedCollections)
128138
return E.right({ success: true })
129139
}
130140
141+
/**
142+
* Import collections to teams workspace
143+
* No need to sanitize the collections before importing to teams workspace because the BE handles this and add the new id to the collection and folders
144+
* @param collections Collections to import
145+
*/
131146
const importToTeamsWorkspace = async (collections: HoppCollection[]) => {
132147
if (!hasTeamWriteAccess.value || !selectedTeamID.value) {
133148
return E.left({

packages/hoppscotch-common/src/components/collections/index.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ import {
313313
resolveSaveContextOnRequestReorder,
314314
} from "~/helpers/collection/request"
315315
import { TeamCollection } from "~/helpers/teams/TeamCollection"
316+
import { stripRefIdReplacer } from "~/helpers/import-export/export"
316317
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
317318
import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service"
318319
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
@@ -2871,7 +2872,7 @@ const initializeDownloadCollection = async (
28712872
*/
28722873
const exportData = async (collection: HoppCollection | TeamCollection) => {
28732874
if (collectionsType.value.type === "my-collections") {
2874-
const collectionJSON = JSON.stringify(collection, null, 2)
2875+
const collectionJSON = JSON.stringify(collection, stripRefIdReplacer, 2)
28752876
28762877
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
28772878
const cleanedCollectionJSON = collectionJSON.replace(
@@ -2896,7 +2897,11 @@ const exportData = async (collection: HoppCollection | TeamCollection) => {
28962897
},
28972898
async (coll) => {
28982899
const hoppColl = teamCollToHoppRESTColl(coll)
2899-
const collectionJSONString = JSON.stringify(hoppColl, null, 2)
2900+
const collectionJSONString = JSON.stringify(
2901+
hoppColl,
2902+
stripRefIdReplacer,
2903+
2
2904+
)
29002905
29012906
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
29022907
const cleanedCollectionJSON = collectionJSONString.replace(

packages/hoppscotch-common/src/helpers/backend/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
GetCollectionRequestsDocument,
2626
GetCollectionTitleAndDataDocument,
2727
} from "./graphql"
28+
import { stripRefIdReplacer } from "../import-export/export"
2829

2930
type TeamCollectionJSON = {
3031
id: string
@@ -286,7 +287,7 @@ export const getTeamCollectionJSON = async (teamID: string) => {
286287
}
287288

288289
const hoppCollections = collections.map(teamCollectionJSONToHoppRESTColl)
289-
return E.right(JSON.stringify(hoppCollections, null, 2))
290+
return E.right(JSON.stringify(hoppCollections, stripRefIdReplacer, 2))
290291
}
291292

292293
/**

packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export type PublishedDocQuery = {
5454
publishedDoc: PublishedDoc
5555
}
5656

57-
type CollectionFolder = {
57+
export type CollectionFolder = {
5858
id?: string
5959
folders: CollectionFolder[]
6060
// Backend stores this as any, we translate it to HoppRESTRequest via translateToNewRequest

packages/hoppscotch-common/src/helpers/collection/collection.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { RESTTabService } from "~/services/tab/rest"
88
import { GQLTabService } from "~/services/tab/graphql"
99
import { TeamCollectionsService } from "~/services/team-collection.service"
1010
import { cascadeParentCollectionForProperties } from "~/newstore/collections"
11+
import { CollectionDataProps } from "../backend/helpers"
12+
import { CollectionFolder } from "../backend/queries/PublishedDocs"
1113

1214
/**
1315
* Resolve save context on reorder
@@ -289,25 +291,27 @@ export function getFoldersByPath(
289291

290292
/**
291293
* Transforms a collection to the format expected by team or personal collections.
292-
* Extracts auth, headers, and variables into a data object and recursively processes folders.
294+
* BE expects CollectionFolder format with a data field containing auth, headers, variables, and description.
293295
* @param collection The collection to transform
294296
* @returns The transformed collection
295297
*/
296-
export function transformCollectionForImport(collection: any): any {
297-
const folders: any[] = (collection.folders ?? []).map(
298-
transformCollectionForImport
299-
)
298+
export function transformCollectionForImport(
299+
collection: HoppCollection
300+
): CollectionFolder {
301+
const folders = (collection.folders ?? []).map(transformCollectionForImport)
300302

301-
const data = {
303+
const data: CollectionDataProps = {
302304
auth: collection.auth,
303305
headers: collection.headers,
304306
variables: collection.variables,
307+
description: collection.description,
305308
}
306309

307-
const obj = {
308-
...collection,
309-
folders,
310-
data,
310+
const obj: CollectionFolder = {
311+
name: collection.name,
312+
folders: folders,
313+
requests: collection.requests,
314+
data: JSON.stringify(data),
311315
}
312316

313317
if (collection.id) obj.id = collection.id
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { HoppCollection } from "@hoppscotch/data"
2+
import { stripRefIdReplacer } from "."
23

34
export const gqlCollectionsExporter = (gqlCollections: HoppCollection[]) => {
4-
return JSON.stringify(gqlCollections, null, 2)
5+
return JSON.stringify(gqlCollections, stripRefIdReplacer, 2)
56
}

packages/hoppscotch-common/src/helpers/import-export/export/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,10 @@ export const initializeDownloadFile = async (
3434

3535
return E.left("state.download_failed")
3636
}
37+
38+
/**
39+
* JSON replacer to remove `_ref_id` from the exported JSON
40+
*/
41+
export const stripRefIdReplacer = (key: string, value: any) => {
42+
return key === "_ref_id" ? undefined : value
43+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { HoppCollection } from "@hoppscotch/data"
2+
import { stripRefIdReplacer } from "."
23

34
export const myCollectionsExporter = (myCollections: HoppCollection[]) => {
4-
return JSON.stringify(myCollections, null, 2)
5+
return JSON.stringify(myCollections, stripRefIdReplacer, 2)
56
}

packages/hoppscotch-common/src/helpers/import-export/import/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import * as TE from "fp-ts/TaskEither"
22
import type { Component } from "vue"
33
import { StepsOutputList } from "../steps"
4+
import {
5+
HoppCollection,
6+
makeCollection,
7+
translateToNewRESTCollection,
8+
} from "@hoppscotch/data"
49

510
/**
611
* A common error state to be used when the file formats are not expected
@@ -67,3 +72,25 @@ export const defineImporter = <ReturnType, StepType, Errors>(input: {
6772
...input,
6873
}
6974
}
75+
76+
/**
77+
* Sanitize collection for import, removes old id and ref_id from collection and folders, and transforms it to
78+
* new collection format with a newly generated ref_id.
79+
* @param collection The collection to sanitize
80+
* @returns The sanitized collection with new ref_id
81+
*/
82+
export const sanitizeCollection = (
83+
collection: HoppCollection
84+
): HoppCollection => {
85+
const {
86+
id: _id,
87+
_ref_id: _refId,
88+
v: _v,
89+
...rest
90+
} = translateToNewRESTCollection(collection)
91+
92+
return makeCollection({
93+
...rest,
94+
folders: rest.folders.map(sanitizeCollection),
95+
})
96+
}

packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ describe("WorkspaceService", () => {
5454
auth: {
5555
getCurrentUserStream: vi.fn(),
5656
getCurrentUser: vi.fn(),
57+
waitProbableLoginToConfirm: vi.fn().mockResolvedValue(undefined),
5758
},
5859
}
5960

@@ -267,6 +268,13 @@ describe("WorkspaceService", () => {
267268
describe("Workspace Synchronization", () => {
268269
it("should call changeTeamID and fetchTeamPublishedDocs when workspace changes to a team workspace", async () => {
269270
const container = new TestContainer()
271+
272+
// Mock user for this test
273+
platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" })
274+
platformMock.auth.getCurrentUserStream.mockReturnValue(
275+
new BehaviorSubject({ uid: "test-user" })
276+
)
277+
270278
const service = container.bind(WorkspaceService)
271279

272280
// Access the mocks
@@ -293,6 +301,13 @@ describe("WorkspaceService", () => {
293301

294302
it("should call clearCollections and fetchUserPublishedDocs when workspace changes to personal workspace", async () => {
295303
const container = new TestContainer()
304+
305+
// Mock user for this test
306+
platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" })
307+
platformMock.auth.getCurrentUserStream.mockReturnValue(
308+
new BehaviorSubject({ uid: "test-user" })
309+
)
310+
296311
const service = container.bind(WorkspaceService)
297312

298313
// Start with a team workspace
@@ -324,6 +339,13 @@ describe("WorkspaceService", () => {
324339

325340
it("should call clearCollections and fetchUserPublishedDocs when workspace changes to team workspace without teamID", async () => {
326341
const container = new TestContainer()
342+
343+
// Mock user for this test
344+
platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" })
345+
platformMock.auth.getCurrentUserStream.mockReturnValue(
346+
new BehaviorSubject({ uid: "test-user" })
347+
)
348+
327349
const service = container.bind(WorkspaceService)
328350

329351
const teamCollectionServiceMock = (service as any).teamCollectionService
@@ -404,12 +426,41 @@ describe("WorkspaceService", () => {
404426
await nextTick()
405427

406428
expect(consoleSpy).toHaveBeenCalledWith(
407-
"Failed to sync team collections and published docs:",
429+
"Failed to sync workspace data:",
408430
expect.any(Error)
409431
)
410432

411433
consoleSpy.mockRestore()
412434
})
435+
436+
it("should fetch user published docs only when user is authenticated", async () => {
437+
// Case 1: No user
438+
platformMock.auth.getCurrentUser.mockReturnValue(null)
439+
platformMock.auth.getCurrentUserStream.mockReturnValue(
440+
new BehaviorSubject(null)
441+
)
442+
const container1 = new TestContainer()
443+
const service1 = container1.bind(WorkspaceService)
444+
const docMock1 = (service1 as any).documentationService
445+
docMock1.fetchUserPublishedDocs.mockClear()
446+
447+
service1.changeWorkspace({ type: "personal" })
448+
await nextTick()
449+
expect(docMock1.fetchUserPublishedDocs).not.toHaveBeenCalled()
450+
451+
// Case 2: With user
452+
platformMock.auth.getCurrentUser.mockReturnValue({ uid: "test-user" })
453+
platformMock.auth.getCurrentUserStream.mockReturnValue(
454+
new BehaviorSubject({ uid: "test-user" })
455+
)
456+
const container2 = new TestContainer()
457+
const service2 = container2.bind(WorkspaceService)
458+
const docMock2 = (service2 as any).documentationService
459+
460+
// We check if it was called on initialization
461+
await nextTick()
462+
expect(docMock2.fetchUserPublishedDocs).toHaveBeenCalled()
463+
})
413464
})
414465

415466
describe("areWorkspacesEqual", () => {

0 commit comments

Comments
 (0)