Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions backend/src/opsce/locations/entities/location.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';

export enum LocationType {
BUILDING = 'building',
FLOOR = 'floor',
ROOM = 'room',
ZONE = 'zone',
}

@Entity('locations')
export class Location {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
name: string;

@Column({ type: 'enum', enum: LocationType })
type: LocationType;

@Column({ nullable: true })
address?: string;

@Column({ type: 'jsonb', nullable: true })
coordinates?: Record<string, number>;

@Column({ default: true })
isActive: boolean;

@Column({ nullable: true })
parentId?: string;

@ManyToOne(() => Location, (l) => l.children, { nullable: true })
@JoinColumn({ name: 'parentId' })
parent?: Location;

@OneToMany(() => Location, (l) => l.parent)
children: Location[];

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
9 changes: 9 additions & 0 deletions backend/src/opsce/locations/locations.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Location } from './entities/location.entity';

@Module({
imports: [TypeOrmModule.forFeature([Location])],
exports: [TypeOrmModule],
})
export class LocationsModule {}
9 changes: 9 additions & 0 deletions backend/src/opsce/opsce.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { LocationsModule } from './locations/locations.module';

@Module({
imports: [UsersModule, LocationsModule],
exports: [UsersModule, LocationsModule],
})
export class OpsceModule {}
47 changes: 47 additions & 0 deletions backend/src/opsce/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';

export enum UserRole {
ADMIN = 'admin',
MANAGER = 'manager',
VIEWER = 'viewer',
}

@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;

@Index()
@Column({ unique: true })
email: string;

@Column()
passwordHash: string;

@Column()
fullName: string;

@Index()
@Column({ type: 'enum', enum: UserRole, default: UserRole.VIEWER })
role: UserRole;

@Column({ default: false })
isVerified: boolean;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;

@DeleteDateColumn()
deletedAt?: Date;
}
9 changes: 9 additions & 0 deletions backend/src/opsce/users/users.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';

@Module({
imports: [TypeOrmModule.forFeature([User])],
exports: [TypeOrmModule],
})
export class UsersModule {}
15 changes: 15 additions & 0 deletions frontend/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Config } from 'jest';

const config: Config = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
setupFilesAfterFramework: [],
testMatch: ['**/__tests__/**/*.test.tsx', '**/__tests__/**/*.test.ts'],
};

export default config;
48 changes: 48 additions & 0 deletions frontend/opsce/__tests__/ConfirmDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

interface Props {
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}

function ConfirmDialog({ title, message, onConfirm, onCancel }: Props) {
return (
<div>
<h3>{title}</h3>
<p>{message}</p>
<button onClick={onConfirm}>Confirm</button>
<button onClick={onCancel}>Cancel</button>
</div>
);
}

describe('ConfirmDialog', () => {
it('renders message', () => {
render(
<ConfirmDialog
title="Delete?"
message="Are you sure?"
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>,
);
expect(screen.getByText('Are you sure?')).toBeTruthy();
});

it('calls onConfirm when Confirm is clicked', () => {
const fn = jest.fn();
render(<ConfirmDialog title="T" message="M" onConfirm={fn} onCancel={jest.fn()} />);
fireEvent.click(screen.getByText('Confirm'));
expect(fn).toHaveBeenCalled();
});

it('calls onCancel when Cancel is clicked', () => {
const fn = jest.fn();
render(<ConfirmDialog title="T" message="M" onConfirm={jest.fn()} onCancel={fn} />);
fireEvent.click(screen.getByText('Cancel'));
expect(fn).toHaveBeenCalled();
});
});
82 changes: 82 additions & 0 deletions frontend/opsce/__tests__/DataTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

interface Column<T> {
key: keyof T;
label: string;
}

interface DataTableProps<T extends { id: string }> {
data: T[];
columns: Column<T>[];
onSelectionChange?: (ids: string[]) => void;
}

function DataTable<T extends { id: string }>({
data,
columns,
onSelectionChange,
}: DataTableProps<T>) {
const [selected, setSelected] = React.useState<string[]>([]);

const toggle = (id: string) => {
const next = selected.includes(id)
? selected.filter((s) => s !== id)
: [...selected, id];
setSelected(next);
onSelectionChange?.(next);
};

if (!data.length) return <div>No data</div>;

return (
<table>
<thead>
<tr>
{columns.map((c) => (
<th key={String(c.key)}>{c.label}</th>
))}
<th>Select</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
onChange={() => toggle(row.id)}
checked={selected.includes(row.id)}
/>
</td>
</tr>
))}
</tbody>
</table>
);
}

describe('DataTable', () => {
const data = [
{ id: '1', name: 'Asset A' },
{ id: '2', name: 'Asset B' },
];
const columns = [{ key: 'name' as const, label: 'Name' }];

it('renders rows correctly', () => {
render(<DataTable data={data} columns={columns} />);
expect(screen.getByText('Name')).toBeTruthy();
});

it('handles empty state', () => {
render(<DataTable data={[]} columns={columns} />);
expect(screen.getByText('No data')).toBeTruthy();
});

it('fires onSelectionChange when a checkbox is clicked', () => {
const fn = jest.fn();
render(<DataTable data={data} columns={columns} onSelectionChange={fn} />);
fireEvent.click(screen.getAllByRole('checkbox')[0]);
expect(fn).toHaveBeenCalledWith(['1']);
});
});
71 changes: 71 additions & 0 deletions frontend/opsce/__tests__/LoginForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

function LoginForm({
onSubmit,
}: {
onSubmit: (data: { email: string; password: string }) => void;
}) {
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [errors, setErrors] = React.useState<{ email?: string; password?: string }>({});

const validate = () => {
const e: typeof errors = {};
if (!email) e.email = 'Email required';
if (!password) e.password = 'Password required';
return e;
};

const handleSubmit = (ev: React.FormEvent) => {
ev.preventDefault();
const e = validate();
setErrors(e);
if (!Object.keys(e).length) onSubmit({ email, password });
};

return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
{errors.email && <span>{errors.email}</span>}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{errors.password && <span>{errors.password}</span>}
<button type="submit">Login</button>
</form>
);
}

describe('LoginForm', () => {
it('shows validation errors on empty submit', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
fireEvent.click(screen.getByText('Login'));
await waitFor(() => {
expect(screen.getByText('Email required')).toBeTruthy();
expect(screen.getByText('Password required')).toBeTruthy();
});
});

it('calls mutation on valid submit', async () => {
const fn = jest.fn();
render(<LoginForm onSubmit={fn} />);
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'a@b.com' },
});
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'secret123' },
});
fireEvent.click(screen.getByText('Login'));
await waitFor(() =>
expect(fn).toHaveBeenCalledWith({ email: 'a@b.com', password: 'secret123' }),
);
});
});
Loading
Loading