Skip to content

Commit

Permalink
Added tests for data migration
Browse files Browse the repository at this point in the history
Added mock for object storage
Added special migration for before 7.7 release
  • Loading branch information
jloleysens committed Feb 7, 2020
1 parent dd93c2d commit a522cdd
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { migrateToTextObjects } from './data_migration';
import { createStorage, createHistory, Storage, History } from '../../../services';
import { objectStorageClientMock } from '../../../mocks';
import * as localObjectStorageLib from '../../../lib/local_storage_object_client';
import { ObjectStorageClient } from '../../../types';

const mockLocalStorage: WindowLocalStorage['localStorage'] = {
clear: jest.fn(),
getItem: jest.fn(),
key: jest.fn(),
length: 0,
removeItem: jest.fn(),
setItem: jest.fn(),
};

describe('Data migration', () => {
let history: History;
let storage: Storage;

beforeEach(() => {
storage = createStorage({ engine: mockLocalStorage });
history = createHistory({ storage });
});

afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});

describe('from legacy localStorage', () => {
describe('with legacy state', () => {
it('reads, migrates and deletes', async () => {
const testLegacyContent = JSON.stringify({ time: 123, content: 'test' });
(mockLocalStorage.getItem as jest.Mock)
.mockReturnValueOnce(testLegacyContent)
.mockReturnValueOnce(testLegacyContent);

await migrateToTextObjects({
history,
objectStorageClient: objectStorageClientMock,
localObjectStorageMigrationClient: objectStorageClientMock,
});

// Assert that object was created.
const [[{ text }], nextCreateCall] = (objectStorageClientMock.text
.create as jest.Mock).mock.calls;
expect(text).toBe('test');
expect(nextCreateCall).toBeUndefined();

// Assert that legacy state was deleted.
const [
[removeItemCalledWith],
nothing,
] = (mockLocalStorage.removeItem as jest.Mock).mock.calls;
expect(nothing).toBeUndefined();
expect(removeItemCalledWith).toBe('sense:editor_state');
});
});
describe('without legacy state', () => {
it('does nothing', async () => {
// Do not set up any legacy state.

await migrateToTextObjects({
history,
objectStorageClient: objectStorageClientMock,
localObjectStorageMigrationClient: objectStorageClientMock,
});

// Assert that nothing was created.
const [newObjectStorageCallArgs] = (objectStorageClientMock.text
.create as jest.Mock).mock.calls;
expect(newObjectStorageCallArgs).toBeUndefined();

// Assert that non-existent state was not deleted.
const [legacyObjectDeleteArgs] = (mockLocalStorage.removeItem as jest.Mock).mock.calls;
expect(legacyObjectDeleteArgs).toBeUndefined();
});
});
});

// TODO: Remove this after 7.7 release because this is just to handle special
// case logic of moving updated, local data to Saved Objects.
describe('from updated localStorage', () => {
let localObjectStorageClient: ObjectStorageClient;

beforeEach(() => {
localObjectStorageClient = localObjectStorageLib.create(storage);
});

describe('with state', () => {
const newLocalStorageKey = 'sense:console_local_text-object';

beforeEach(() => {
// local keys look like: sense:console_local_text-object_12345
(mockLocalStorage as any)[newLocalStorageKey] = {};
});

afterEach(() => {
delete (mockLocalStorage as any)[newLocalStorageKey];
});

it('reads, migrates and deletes', async () => {
const testLegacyContent = JSON.stringify({
createdAt: 123,
updatedAt: 123,
text: 'test',
});
(mockLocalStorage.getItem as jest.Mock)
// Bypass legacy check
.mockReturnValueOnce(undefined)
.mockReturnValue(testLegacyContent);

await migrateToTextObjects({
history,
objectStorageClient: objectStorageClientMock,
localObjectStorageMigrationClient: localObjectStorageClient,
});

const [[{ text }]] = (objectStorageClientMock.text.create as jest.Mock).mock.calls;
expect(text).toBe('test');

const [[deleteItem]] = (mockLocalStorage.removeItem as jest.Mock).mock.calls;
expect(deleteItem).toBe('sense:console_local_text-object');
});
});

describe('without state', () => {
it('does nothing', async () => {
(mockLocalStorage.getItem as jest.Mock)
// Bypass legacy check
.mockReturnValueOnce(undefined);

await migrateToTextObjects({
history,
objectStorageClient: objectStorageClientMock,
localObjectStorageMigrationClient: localObjectStorageClient,
});

const [shouldBeUndefined] = (objectStorageClientMock.text.create as jest.Mock).mock.calls;
expect(shouldBeUndefined).toBeUndefined();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,67 @@
*/

import { History } from '../../../services';
import { LocalObjectStorage } from '../../../lib/local_storage_object_client';
import { ObjectStorageClient } from '../../../types';

export interface Dependencies {
history: History;
objectStorageClient: ObjectStorageClient;
/**
* The ability to store Console text in Saved Objects came after
* first data migration. So we went:
*
* Legacy Data -> New Data Shape -> Wipe old data.
*
* We used the presence of legacy data to kick off this process. However,
* now that we have another adapter that enables storing data in Saved Objects
* potentially (i.e. outside localStorage when security is available) the updated
* migration path we want to enable is:
*
* Legacy Data -> New Data Shape + potentially saved objects (remotely).
*
* There are a subset of users that had the first migration path run who will be running
* Kibana with x-pack security enabled. For these users we specifically check if we should
* migrate them to saved objects.
*
* We do this automatically for now, but probably should remove this behaviour before 7.7 release
*/
localObjectStorageMigrationClient: ObjectStorageClient;
}

/**
* Once off migration to new text object data structure
*/
export async function migrateToTextObjects({
history,
objectStorageClient: objectStorageClient,
localObjectStorageMigrationClient,
}: Dependencies): Promise<void> {
const legacyTextContent = history.getLegacySavedEditorState();

if (!legacyTextContent) return;
if (legacyTextContent) {
await objectStorageClient.text.create({
createdAt: Date.now(),
updatedAt: Date.now(),
text: legacyTextContent.content,
});

history.deleteLegacySavedEditorState();
return;
}

if (!(objectStorageClient.text instanceof LocalObjectStorage)) {
const localObjects = await localObjectStorageMigrationClient.text.findAll();

if (!localObjects?.length) {
return;
}

await objectStorageClient.text.create({
createdAt: Date.now(),
updatedAt: Date.now(),
text: legacyTextContent.content,
});
for (const { createdAt, updatedAt, text } of localObjects) {
await objectStorageClient.text.create({
createdAt,
updatedAt,
text,
});
}

history.deleteLegacySavedEditorState();
(localObjectStorageMigrationClient.text as LocalObjectStorage<any>).deleteLocallyStoredState();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ import { useCallback, useEffect, useState } from 'react';
import { migrateToTextObjects } from './data_migration';
import { useEditorActionContext, useServicesContext } from '../../contexts';

import * as localStorageObjectClient from '../../../lib/local_storage_object_client';

export const useDataInit = () => {
const {
services: { objectStorageClient, history, storage },
} = useServicesContext();

const [error, setError] = useState<Error | null>(null);
const [done, setDone] = useState<boolean>(false);
const [retryToken, setRetryToken] = useState<object>({});
Expand All @@ -32,16 +38,16 @@ export const useDataInit = () => {
setError(null);
}, []);

const {
services: { objectStorageClient, history },
} = useServicesContext();

const dispatch = useEditorActionContext();

useEffect(() => {
const load = async () => {
try {
await migrateToTextObjects({ history, objectStorageClient });
await migrateToTextObjects({
history,
objectStorageClient,
localObjectStorageMigrationClient: localStorageObjectClient.create(storage),
});
const results = await objectStorageClient.text.findAll();
if (!results.length) {
const newObject = await objectStorageClient.text.create({
Expand All @@ -66,7 +72,7 @@ export const useDataInit = () => {
};

load();
}, [dispatch, objectStorageClient, history, retryToken]);
}, [dispatch, objectStorageClient, history, retryToken, storage]);

return {
error,
Expand Down
5 changes: 1 addition & 4 deletions src/plugins/console/public/application/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,7 @@ export function renderApp({
const trackUiMetric = createUsageTracker(usageCollection);
trackUiMetric.load('opened_app');

const storage = createStorage({
engine: window.localStorage,
prefix: 'sense:',
});
const storage = createStorage({ engine: window.localStorage });
const history = createHistory({ storage });
const settings = createSettings({ storage });
const objectStorageClient = getObjectStorageClient() ?? localStorageObjectClient.create(storage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,18 @@ export class LocalObjectStorage<O extends IdObject> implements ObjectStorage<O>
}
return result;
}

/**
* This is a one way operation that removes state from localStorage.
*
* This is only really useful for users who have text object data stored locally,
* but have security available and so need to migrate to SavedObject storage.
*/
public deleteLocallyStoredState() {
const allLocalKeys = this.client.keys().filter(key => {
return key.includes(this.prefix);
});

allLocalKeys.forEach(key => this.client.delete(key));
}
}
28 changes: 28 additions & 0 deletions src/plugins/console/public/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { ObjectStorageClient } from './types';

export const objectStorageClientMock: ObjectStorageClient = {
text: {
findAll: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
10 changes: 7 additions & 3 deletions src/plugins/console/public/services/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@

import { transform, keys, startsWith } from 'lodash';

const STORAGE_PREFIX = 'sense:';

type IStorageEngine = typeof window.localStorage;

export enum StorageKeys {
WIDTH = 'widths',
}

export class Storage {
constructor(private readonly engine: IStorageEngine, private readonly prefix: string) {}
prefix = STORAGE_PREFIX;

constructor(private readonly engine: IStorageEngine) {}

encode(val: any) {
return JSON.stringify(val);
Expand Down Expand Up @@ -77,6 +81,6 @@ export class Storage {
}
}

export function createStorage(deps: { engine: IStorageEngine; prefix: string }) {
return new Storage(deps.engine, deps.prefix);
export function createStorage(deps: { engine: IStorageEngine }) {
return new Storage(deps.engine);
}

0 comments on commit a522cdd

Please sign in to comment.