Changes
9 changed files (+575/-7)
-
-
@@ -21,7 +21,7 @@ }export async function idbBackend(): Promise<IDBBackend> { return new IDBBackend({ db: await openDB("yamori", 4, { db: await openDB("yamori", 5, { async upgrade(db, oldVersion, _newVersion, transaction, _event) { // v1 if (oldVersion < 1) {
-
@@ -89,6 +89,42 @@ capabilities: {...cursor.value.capabilities, createLeaveDefinitionKey, }, }); } } // v5 if (oldVersion < 5) { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, // 労働基準法 第三十九条 第十項 で定義されている「出勤したものとみなす」休業に // ついてはどんな会社でも必ず必須となるため一律で登録する。 leaveDefinitions: [ { id: `lv-${self.crypto.randomUUID()}`, displayName: "育児休業", isWorkerDeemedToBeWorked: true, updateKey: null, createdBy: "system", }, { id: `lv-${self.crypto.randomUUID()}`, displayName: "介護休業", isWorkerDeemedToBeWorked: true, updateKey: null, createdBy: "system", }, { id: `lv-${self.crypto.randomUUID()}`, displayName: "産前産後休業", isWorkerDeemedToBeWorked: true, updateKey: null, createdBy: "system", }, ], }); } }
-
-
-
@@ -17,6 +17,13 @@ updateKey: Uint8Array;workerAddKey: Uint8Array; createLeaveDefinitionKey: Uint8Array; }; leaveDefinitions: { id: string; isWorkerDeemedToBeWorked: boolean; displayName: string; updateKey: Uint8Array | null; createdBy: "user" | "system"; }[]; updatedAt: number; }; indexes: { updatedAt: number };
-
-
-
@@ -1,16 +1,28 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, type MessageInitShape } from "@bufbuild/protobuf"; import { LeaveSchema, type Leave } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { create, type MessageShape, type MessageInitShape } from "@bufbuild/protobuf"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type LeaveReadMask } from "@yamori/proto/yamori/work_record/v1/leave_read_mask_pb.js"; import { maskMessage } from "../../../helpers"; import { type YamoriDB } from "../../../types"; export function maskLeave( export function fromDBEntry( entry: YamoriDB["workspaces"]["value"]["leaveDefinitions"][number], ): MessageShape<typeof LeaveSchema> { return create(LeaveSchema, { id: { value: entry.id }, isWorkerDeemedToBeWorked: entry.isWorkerDeemedToBeWorked, displayName: entry.displayName, updateKey: entry.updateKey ? { key: entry.updateKey } : undefined, }); } export function mask( mask: LeaveReadMask | undefined, leave: MessageInitShape<typeof LeaveSchema>, ): Leave { ): MessageShape<typeof LeaveSchema> { if (!mask) { return create(LeaveSchema, leave); }
-
-
-
@@ -10,7 +10,7 @@ import { type WorkspaceReadMask } from "@yamori/proto/yamori/workspace/v1/workspace_read_mask_pb.js";import { maskMessage } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import { maskLeave } from "../../work_record/v1/leave"; import * as leave from "../../work_record/v1/leave"; export function fromDBEntry(entry: YamoriDB["workspaces"]["value"]): Workspace { return create(WorkspaceSchema, {
-
@@ -20,6 +20,9 @@ deletionKey: { key: entry.capabilities.deletionKey },updateKey: { key: entry.capabilities.updateKey }, workerAddKey: { key: entry.capabilities.workerAddKey }, createLeaveDefinitionKey: { key: entry.capabilities.createLeaveDefinitionKey }, leaveDefinitions: entry.leaveDefinitions.map((childEntry) => leave.fromDBEntry(childEntry), ), }); }
-
@@ -37,6 +40,6 @@ return create(WorkspaceSchema, {...masked, leaveDefinitions: masked.leaveDefinitions && masked.leaveDefinitions.map((leave) => maskLeave(mask.leaveDefinitionsMask, leave)), masked.leaveDefinitions.map((def) => leave.mask(mask.leaveDefinitionsMask, def)), } as MessageInitShape<typeof WorkspaceSchema>); }
-
-
-
@@ -201,3 +201,51 @@ expect(resp.result.value.workspace?.displayName).toBe("Foo");expect(resp.result.value.workspace?.id).toBeEmpty(); expect(resp.result.value.workspace?.updateKey).toBeEmpty(); }); test("Should generate system leave definitions", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [WorkspaceSchema.field.leaveDefinitions.number], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.leaveDefinitions).toContainEqual({ $typeName: expect.anything(), id: expect.anything(), displayName: "育児休業", isWorkerDeemedToBeWorked: true, updateKey: undefined, }); expect(resp.result.value.workspace?.leaveDefinitions).toContainEqual({ $typeName: expect.anything(), id: expect.anything(), displayName: "介護休業", isWorkerDeemedToBeWorked: true, updateKey: undefined, }); expect(resp.result.value.workspace?.leaveDefinitions).toContainEqual({ $typeName: expect.anything(), id: expect.anything(), displayName: "産前産後休業", isWorkerDeemedToBeWorked: true, updateKey: undefined, }); });
-
-
-
@@ -77,6 +77,31 @@ updateKey,workerAddKey, createLeaveDefinitionKey, }, // 労働基準法 第三十九条 第十項 で定義されている「出勤したものとみなす」休業に // ついてはどんな会社でも必ず必須となるため一律で登録する。 leaveDefinitions: [ { id: `lv-${self.crypto.randomUUID()}`, displayName: "育児休業", isWorkerDeemedToBeWorked: true, updateKey: null, createdBy: "system", }, { id: `lv-${self.crypto.randomUUID()}`, displayName: "介護休業", isWorkerDeemedToBeWorked: true, updateKey: null, createdBy: "system", }, { id: `lv-${self.crypto.randomUUID()}`, displayName: "産前産後休業", isWorkerDeemedToBeWorked: true, updateKey: null, createdBy: "system", }, ], updatedAt: Date.now(), }); } catch (error) {
-
-
packages/idb_backend/src/yamori/workspace/v1/workspace_service/create_leave_definition.test.ts (new)
-
@@ -0,0 +1,271 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { idbBackend } from "../../../../lib"; import { type Context } from "../../../../types"; import { CONTEXT } from "../../../../symbols"; import { workspaceService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function createWorkspace( ctx: Context, ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( CreateResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Create", data: toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.leaveDefinitions.number, WorkspaceSchema.field.createLeaveDefinitionKey.number, ], }, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for workspace creation, found ${JSON.stringify(resp.result.case)}`, ); } if (!resp.result.value.workspace) { expect.unreachable( `Expected Create method to return a created workspace, got empty value`, ); } return resp.result.value.workspace; } async function createLeaveDefinition( ctx: Context, request: MessageInitShape<typeof CreateLeaveDefinitionRequestSchema>, ): Promise<MessageShape<typeof CreateLeaveDefinitionResponseSchema>> { return fromBinary( CreateLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", data: toBinary( CreateLeaveDefinitionRequestSchema, create(CreateLeaveDefinitionRequestSchema, request), ), }, ctx, ), ); } async function getWorkspace( ctx: Context, id: MessageInitShape<typeof WorkspaceSchema>["id"], ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( GetResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Get", data: toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: id, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for getting workspace op, found ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; } test("Should return missing_field if workspace_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { leaveDefinition: { displayName: "Bar", }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("workspace_id"); }); test("Should return missing_field if create_leave_definition_key is absent", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { displayName: "Bar", }, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("create_leave_definition_key"); }); test("Should return missing_field if leave_definition.display_name is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { displayName: "", }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("leave_definition.display_name"); }); test("Should reject manipulated capability key", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { displayName: "Bar", }, createLeaveDefinitionKey: { key: new Uint8Array([0, 0, 0]) }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("create_leave_definition_key"); }); test("Should return not_found", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: { value: `${created.id?.value}-FOO`, }, leaveDefinition: { displayName: "Bar", }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should create a leave definition", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { isWorkerDeemedToBeWorked: true, displayName: "Bar", }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.displayName).toBe("Bar"); const createdDefinition = resp.result.value; if (!createdDefinition.id) { expect.unreachable(`Created leave definition has empty ID`); } const updated = await getWorkspace(ctx, created.id); const def = updated.leaveDefinitions.find( (def) => def.id && def.id.value === createdDefinition.id!.value, ); if (!def) { expect.unreachable(`Created definition does not exist in workspace`); } expect(def.updateKey).toHaveProperty("key"); expect(def.displayName).toBe("Bar"); expect(def.isWorkerDeemedToBeWorked).toBe(true); });
-
-
-
@@ -0,0 +1,163 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape } from "@bufbuild/protobuf"; import { CreateLeaveDefinitionRequestSchema, type CreateLeaveDefinitionRequest, } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as leave from "../../../work_record/v1/leave"; function respond( payload: Extract< MessageInitShape<typeof CreateLeaveDefinitionResponseSchema>["result"], { case: string } >, ): Uint8Array { return toBinary( CreateLeaveDefinitionResponseSchema, create(CreateLeaveDefinitionResponseSchema, { result: payload, }), ); } export async function createLeaveDefinition( data: Uint8Array, { db }: Context, ): Promise<Uint8Array> { let req: CreateLeaveDefinitionRequest; try { req = fromBinary(CreateLeaveDefinitionRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } if (!req.workspaceId?.value) { return respond({ case: "missingField", value: { path: req.workspaceId ? "workspace_id.value" : "workspace_id", }, }); } if (!req.leaveDefinition) { return respond({ case: "missingField", value: { path: "leave_definition", }, }); } if (!req.leaveDefinition.displayName) { return respond({ case: "missingField", value: { path: "leave_definition.display_name", }, }); } if (!req.createLeaveDefinitionKey?.key) { return respond({ case: "missingField", value: { path: req.createLeaveDefinitionKey ? "create_leave_definition_key.key" : "create_leave_definition_key", }, }); } try { const tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); const found = await store.get(req.workspaceId.value); if (!found) { await tx.done; return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } if ( !isSameBytes( req.createLeaveDefinitionKey.key, found.capabilities.createLeaveDefinitionKey, ) ) { return respond({ case: "capabilityError", value: { path: "create_leave_definition_key", }, }); } const updateKey = new Uint8Array(16); self.crypto.getRandomValues(updateKey); const entry: YamoriDB["workspaces"]["value"]["leaveDefinitions"][number] = { id: `lv-${self.crypto.randomUUID()}`, isWorkerDeemedToBeWorked: req.leaveDefinition.isWorkerDeemedToBeWorked, displayName: req.leaveDefinition.displayName, updateKey, createdBy: "user", }; await store.put({ ...found, leaveDefinitions: [...found.leaveDefinitions, entry], }); const updatedWorkspace = await store.get(found.id); const updated = updatedWorkspace?.leaveDefinitions.find((def) => def.id === entry.id); if (!updated) { await tx.done; return respond({ case: "systemError", value: { code: "IDB_ERROR", message: "Unable to find updated data", }, }); } await tx.done; return respond({ case: "ok", value: leave.mask(req.readMask, leave.fromDBEntry(updated)), }); } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to delete a workspace" : `Exception thrown during delete transaction: ${error}`, }, }); } }
-
-
-
@@ -4,6 +4,7 @@import { type Context, type RPCMessage } from "../../../../types"; import { create } from "./create"; import { createLeaveDefinition } from "./create_leave_definition"; import { deleteWorkspace } from "./delete"; import { get } from "./get"; import { list } from "./list";
-
@@ -24,6 +25,8 @@ case "Delete":return deleteWorkspace(request.data, ctx); case "Get": return get(request.data, ctx); case "CreateLeaveDefinition": return createLeaveDefinition(request.data, ctx); default: throw new Error(`Unknown method "${request.method}" for ${request.service}`); }
-