diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..24c0e22 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,191 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "test/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + {Credo.Check.Design.TagTODO, [exit_status: 0]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + ] + } + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5afa737 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Build artifacts +/_build +/cover +/deps +/doc +*.ez +*.beam + +# Chat data +.chat_data*.etf + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +.elixir_ls + +# Documentation +*.md +!README.md + +# CI/CD +.github + +# OS +.DS_Store +Thumbs.db + +# Tests +/test + +# Development +.formatter.exs +.credo.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..5f0366d --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + line_length: 98 +] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b37ac35 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Bug Description +A clear and concise description of what the bug is. + +## To Reproduce +Steps to reproduce the behavior: +1. Run command '...' +2. Enter input '...' +3. See error + +## Expected Behavior +A clear and concise description of what you expected to happen. + +## Actual Behavior +What actually happened. + +## Environment +- OS: [e.g. Ubuntu 22.04, macOS 13.0] +- Elixir version: [e.g. 1.15.0] +- Erlang/OTP version: [e.g. 26.0] +- Chat Simulator version: [e.g. 0.1.0] + +## Error Messages +``` +Paste any error messages or logs here +``` + +## Additional Context +Add any other context about the problem here. + +## Possible Solution +If you have suggestions on how to fix the bug, please describe them here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0e06470 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Question or Discussion + url: https://github.com/codeforgood-org/elixir-chat-sim/discussions + about: Ask questions or discuss ideas with the community diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..149222c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,28 @@ +--- +name: Documentation +about: Report missing, unclear, or incorrect documentation +title: '[DOCS] ' +labels: documentation +assignees: '' +--- + +## Documentation Issue +Describe what documentation is missing, unclear, or incorrect. + +## Location +Where did you encounter this issue? +- [ ] README.md +- [ ] Module documentation +- [ ] Function documentation +- [ ] CONTRIBUTING.md +- [ ] Code comments +- [ ] Other: [specify] + +## Current State +What is currently written (if applicable)? + +## Suggested Improvement +What should the documentation say instead? How could it be clearer? + +## Additional Context +Add any other context, examples, or screenshots about the documentation issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b7c0d83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,37 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Feature Description +A clear and concise description of the feature you'd like to see. + +## Problem Statement +Describe the problem this feature would solve. Ex. I'm always frustrated when [...] + +## Proposed Solution +Describe the solution you'd like to see implemented. + +## Alternatives Considered +Describe any alternative solutions or features you've considered. + +## Use Cases +Describe specific use cases for this feature: +1. As a [user type], I want to [action] so that [benefit] +2. ... + +## Implementation Ideas +If you have ideas about how this could be implemented, share them here. + +## Additional Context +Add any other context, screenshots, or examples about the feature request here. + +## Acceptance Criteria +What would make this feature complete? +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Documentation updated +- [ ] Tests added diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..958e872 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,54 @@ +# Pull Request + +## Description +A clear and concise description of what this PR does. + +Fixes #(issue number) + +## Type of Change +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code refactoring +- [ ] Performance improvement +- [ ] Test coverage improvement +- [ ] Dependency update + +## Changes Made +List the specific changes made in this PR: +- +- +- + +## How Has This Been Tested? +Describe the tests that you ran to verify your changes: + +- [ ] Unit tests pass (`mix test`) +- [ ] Code formatted (`mix format`) +- [ ] Credo checks pass (`mix credo`) +- [ ] Manual testing performed + +## Testing Details +Provide details about your test configuration: +- Elixir version: +- OTP version: +- OS: + +## Checklist +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Screenshots (if applicable) +Add screenshots to help explain your changes. + +## Additional Notes +Add any other context about the pull request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c1cd5b4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,52 @@ +version: 2 +updates: + # Maintain dependencies for Elixir/Mix + - package-ecosystem: "mix" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 5 + reviewers: + - "codeforgood-org" + assignees: + - "codeforgood-org" + commit-message: + prefix: "deps" + include: "scope" + labels: + - "dependencies" + - "elixir" + + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 3 + reviewers: + - "codeforgood-org" + assignees: + - "codeforgood-org" + commit-message: + prefix: "ci" + labels: + - "dependencies" + - "github-actions" + + # Maintain dependencies for Docker + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 3 + commit-message: + prefix: "docker" + labels: + - "dependencies" + - "docker" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ae95cfe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,135 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + MIX_ENV: test + ELIXIR_VERSION: '1.15' + OTP_VERSION: '26.0' + +jobs: + test: + name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }}) + runs-on: ubuntu-latest + + strategy: + matrix: + elixir: ['1.14', '1.15', '1.16'] + otp: ['25.0', '26.0'] + exclude: + - elixir: '1.14' + otp: '26.0' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}- + + - name: Install dependencies + run: mix deps.get + + - name: Compile dependencies + run: mix deps.compile + + - name: Compile application + run: mix compile --warnings-as-errors + + - name: Run tests + run: mix test + + - name: Run tests with coverage + run: mix test --cover + if: matrix.elixir == '1.15' && matrix.otp == '26.0' + + code_quality: + name: Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}- + + - name: Install dependencies + run: mix deps.get + + - name: Check formatting + run: mix format --check-formatted + + - name: Run Credo + run: mix credo --strict + + build: + name: Build Escript + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}- + + - name: Install dependencies + run: mix deps.get + + - name: Build escript + run: mix escript.build + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: chat_simulator + path: chat_simulator + + - name: Test escript + run: | + chmod +x chat_simulator + echo "quit" | ./chat_simulator || true diff --git a/.gitignore b/.gitignore index b263cd1..1afe531 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# Build artifacts /_build /cover /deps @@ -6,5 +7,32 @@ erl_crash.dump *.ez *.beam + +# Escript build +/chat_simulator + +# Configuration /config/*.secret.exs + +# IDE and Editor .elixir_ls/ +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Data files +.chat_data.etf + +# Test coverage +/cover/ +coveralls.json + +# Dialyzer +/priv/plts/*.plt +/priv/plts/*.plt.hash diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d63740b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Complete project reorganization from single file to modular structure +- Configuration management for different environments (dev, test, prod) +- Comprehensive logging throughout the application +- Docker support with multi-stage builds +- GitHub issue and PR templates +- Security policy +- Dependabot configuration +- Example usage scripts +- Integration tests +- Helper development scripts + +## [0.1.0] - 2025-11-13 + +### Added +- Initial Mix project structure with proper organization +- Modular architecture with 6 core modules: + - `ChatSimulator.User` - User management and validation + - `ChatSimulator.Message` - Message handling and formatting + - `ChatSimulator.Auth` - Authentication and registration + - `ChatSimulator.Storage` - Agent-based data persistence + - `ChatSimulator.CLI` - Interactive command-line interface + - `ChatSimulator` - Main module with convenience functions +- Password hashing using SHA256 for security +- User registration with username and password validation +- Message sending between users +- Inbox viewing with unread message indicators +- Conversation history viewing +- List all registered users +- Message read/unread tracking +- File-based data persistence +- Comprehensive ExUnit test suite +- Module and function documentation with @doc and @spec +- Code formatting configuration (.formatter.exs) +- Credo static analysis configuration +- GitHub Actions CI/CD workflow +- Multi-version testing (Elixir 1.14-1.16, OTP 25-26) +- Escript build support for standalone executable +- README with installation and usage instructions +- CONTRIBUTING.md with development guidelines +- LICENSE (MIT) + +### Security +- Implemented password hashing instead of plain text storage +- Added input validation for usernames and passwords +- Message content length limits + +## [0.0.1] - Initial Release + +### Added +- Basic single-file chat simulator +- User registration and login +- Simple message sending +- Basic inbox viewing +- Plain text password storage (insecure) + +[Unreleased]: https://github.com/codeforgood-org/elixir-chat-sim/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/codeforgood-org/elixir-chat-sim/releases/tag/v0.1.0 +[0.0.1]: https://github.com/codeforgood-org/elixir-chat-sim/releases/tag/v0.0.1 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e1b42c0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via GitHub issues. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..01dc67b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,267 @@ +# Contributing to Chat Simulator + +Thank you for your interest in contributing to Chat Simulator! This document provides guidelines and instructions for contributing to the project. + +## Code of Conduct + +By participating in this project, you agree to maintain a respectful and inclusive environment for all contributors. + +## How to Contribute + +### Reporting Bugs + +If you find a bug, please open an issue on GitHub with: + +1. A clear, descriptive title +2. Steps to reproduce the issue +3. Expected behavior +4. Actual behavior +5. Your environment (Elixir version, OS, etc.) +6. Any relevant error messages or logs + +### Suggesting Enhancements + +We welcome enhancement suggestions! Please open an issue with: + +1. A clear description of the enhancement +2. Use cases and benefits +3. Potential implementation approach (optional) +4. Any relevant examples or mockups + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` +2. **Make your changes** following our coding standards +3. **Add tests** for any new functionality +4. **Update documentation** as needed +5. **Run the test suite** to ensure all tests pass +6. **Run code quality tools** (formatter, credo) +7. **Submit a pull request** with a clear description + +## Development Setup + +1. Clone your fork: +```bash +git clone https://github.com/YOUR_USERNAME/elixir-chat-sim.git +cd elixir-chat-sim +``` + +2. Install dependencies: +```bash +mix deps.get +``` + +3. Run tests: +```bash +mix test +``` + +## Coding Standards + +### Style Guide + +- Follow the [Elixir Style Guide](https://github.com/christopheradams/elixir_style_guide) +- Use `mix format` to format your code +- Run `mix credo` to check for code quality issues +- Keep functions small and focused +- Use descriptive variable and function names + +### Documentation + +- Add `@moduledoc` for all modules +- Add `@doc` for all public functions +- Include `@spec` type specifications +- Provide examples in documentation +- Update README.md for user-facing changes + +### Testing + +- Write tests for all new functionality +- Maintain or improve test coverage +- Use descriptive test names +- Follow the AAA pattern (Arrange, Act, Assert) +- Keep tests focused and independent + +Example test structure: +```elixir +describe "function_name/arity" do + test "describes what it should do" do + # Arrange + input = setup_test_data() + + # Act + result = MyModule.function_name(input) + + # Assert + assert result == expected_output + end +end +``` + +## Code Quality Checks + +Before submitting a PR, run: + +```bash +# Format code +mix format + +# Run tests +mix test + +# Check code quality +mix credo + +# Run dialyzer (optional but recommended) +mix dialyzer +``` + +## Commit Messages + +Write clear, concise commit messages: + +- Use present tense ("Add feature" not "Added feature") +- Use imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit first line to 72 characters +- Reference issues and PRs when relevant + +Good examples: +``` +Add user blocking feature + +Implement message search functionality + +Fix password validation bug (#123) + +Update README with new installation instructions +``` + +## Branch Naming + +Use descriptive branch names: + +- `feature/add-group-chat` - New features +- `fix/login-validation` - Bug fixes +- `docs/update-readme` - Documentation updates +- `refactor/storage-module` - Code refactoring +- `test/auth-coverage` - Test improvements + +## Project Structure + +``` +lib/chat_simulator/ +├── auth.ex # Authentication logic +├── cli.ex # Command-line interface +├── message.ex # Message data structure +├── storage.ex # Data persistence +└── user.ex # User data structure +``` + +When adding new features: +- Place new modules in `lib/chat_simulator/` +- Add corresponding tests in `test/chat_simulator/` +- Update main module documentation if needed + +## Testing Guidelines + +### Unit Tests + +- Test individual functions in isolation +- Mock external dependencies +- Cover edge cases and error conditions + +### Integration Tests + +- Test module interactions +- Verify data flow between components +- Test the full user workflow where applicable + +### Running Tests + +```bash +# Run all tests +mix test + +# Run specific test file +mix test test/chat_simulator/user_test.exs + +# Run tests with coverage +mix test --cover + +# Run tests matching a pattern +mix test --only auth +``` + +## Documentation + +### Generating Documentation + +```bash +mix docs +``` + +Then open `doc/index.html` in your browser. + +### Documentation Style + +```elixir +@doc """ +Brief one-line description. + +Longer description providing context and details about +the function's behavior, edge cases, and considerations. + +## Parameters + + - param1: Description of first parameter + - param2: Description of second parameter + +## Returns + +Description of return value(s) + +## Examples + + iex> MyModule.my_function("input") + "expected output" + + iex> MyModule.my_function("") + {:error, "validation error"} +""" +@spec my_function(String.t()) :: String.t() | {:error, String.t()} +def my_function(param1) do + # implementation +end +``` + +## Feature Development Workflow + +1. **Discuss**: Open an issue to discuss the feature +2. **Design**: Plan the implementation approach +3. **Implement**: Write code following standards +4. **Test**: Add comprehensive tests +5. **Document**: Update relevant documentation +6. **Review**: Submit PR and address feedback +7. **Merge**: Maintainers will merge when ready + +## Getting Help + +- Open an issue for questions +- Check existing issues and PRs +- Review the README and documentation +- Reach out to maintainers + +## Recognition + +Contributors will be recognized in: +- GitHub contributors list +- Release notes for significant contributions +- README acknowledgments section (for major features) + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +--- + +Thank you for contributing to Chat Simulator! Your efforts help make this project better for everyone. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23fc9ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Multi-stage Dockerfile for Chat Simulator +# Build stage +FROM hexpm/elixir:1.15.7-erlang-26.1.2-alpine-3.18.4 AS build + +# Install build dependencies +RUN apk add --no-cache build-base git + +# Set working directory +WORKDIR /app + +# Install hex and rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# Set build ENV +ENV MIX_ENV=prod + +# Copy mix files +COPY mix.exs mix.lock ./ + +# Install dependencies +RUN mix deps.get --only prod && \ + mix deps.compile + +# Copy application files +COPY config ./config +COPY lib ./lib + +# Compile the project +RUN mix compile + +# Build the escript +RUN mix escript.build + +# Runtime stage +FROM alpine:3.18.4 + +# Install runtime dependencies +RUN apk add --no-cache \ + ncurses-libs \ + libstdc++ \ + libgcc + +# Create non-root user +RUN addgroup -g 1000 chat && \ + adduser -D -u 1000 -G chat chat + +# Set working directory +WORKDIR /app + +# Copy the escript from build stage +COPY --from=build --chown=chat:chat /app/chat_simulator . + +# Switch to non-root user +USER chat + +# Set the entrypoint +ENTRYPOINT ["./chat_simulator"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..79d48a6 --- /dev/null +++ b/README.md @@ -0,0 +1,364 @@ +# Chat Simulator + +[![CI](https://github.com/codeforgood-org/elixir-chat-sim/workflows/CI/badge.svg)](https://github.com/codeforgood-org/elixir-chat-sim/actions) +[![Elixir](https://img.shields.io/badge/Elixir-1.14%20to%201.16-blueviolet.svg)](https://elixir-lang.org/) +[![Erlang/OTP](https://img.shields.io/badge/Erlang%2FOTP-25%20to%2026-red.svg)](https://www.erlang.org/) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +A terminal-based chat application written in Elixir for learning and demonstration purposes. + +## Features + +- **User Management** + - Secure registration with username validation + - Password hashing using SHA256 + - Session management + +- **Messaging** + - Send messages to other users + - View inbox with unread message indicators + - View conversation history between users + - Message timestamps + +- **Data Persistence** + - Automatic saving to disk + - State maintained between sessions + - Agent-based in-memory storage + +- **User Experience** + - Interactive CLI with clear prompts + - List all registered users + - Unread message counter + - Command help system + +## Installation + +### Prerequisites + +- Elixir 1.14 or higher +- Erlang/OTP 24 or higher + +### Quick Setup + +Use the automated setup script: +```bash +./scripts/setup.sh +``` + +Or manually: + +1. Clone the repository: +```bash +git clone https://github.com/codeforgood-org/elixir-chat-sim.git +cd elixir-chat-sim +``` + +2. Install dependencies: +```bash +mix deps.get +``` + +3. Run tests to verify installation: +```bash +mix test +``` + +## Usage + +### Running the Application + +#### Option 1: Using Mix (Development) + +```bash +mix run -e "ChatSimulator.CLI.main()" +``` + +#### Option 2: Build Executable (Production) + +```bash +mix escript.build +./chat_simulator +``` + +#### Option 3: Docker + +```bash +docker-compose up +``` + +Or build manually: +```bash +./scripts/docker-build.sh +docker run -it --rm chat-simulator:latest +``` + +### Commands + +When not logged in: +- `register` - Create a new account +- `login` - Login to existing account +- `help` - Show available commands +- `quit` or `exit` - Exit the application + +When logged in: +- `send` - Send a message to another user +- `inbox` - View your messages +- `chat` - View conversation with a specific user +- `users` - List all registered users +- `logout` - Logout from your account +- `help` - Show available commands +- `quit` or `exit` - Exit the application + +### Example Session + +``` +╔═══════════════════════════════════════╗ +║ CHAT SIMULATOR v0.1.0 ║ +║ Terminal-based Chat System ║ +╚═══════════════════════════════════════╝ +Welcome to Terminal Chat! +Type 'help' for available commands + +[Not logged in] +> register +Choose a username: alice +Choose a password: secret123 +✓ Registration successful! You are now logged in as alice. + +[alice] +> users +--- Registered Users --- + • alice +1 user(s) registered + +[alice] +> send +--- Send Message --- +To (username): bob +✗ User 'bob' not found. + +[alice] +> logout +Logged out successfully. + +[Not logged in] +> quit +Thank you for using Chat Simulator. Goodbye! +``` + +## Architecture + +The application is organized into modular components: + +``` +lib/ +├── chat_simulator.ex # Main module with convenience functions +└── chat_simulator/ + ├── auth.ex # Authentication and registration + ├── cli.ex # Command-line interface + ├── message.ex # Message struct and operations + ├── storage.ex # Data persistence with Agent + └── user.ex # User struct and validation +``` + +### Module Overview + +- **ChatSimulator** - Main module providing high-level API +- **ChatSimulator.User** - User data structure with password hashing +- **ChatSimulator.Message** - Message data structure with formatting +- **ChatSimulator.Auth** - Registration and login logic +- **ChatSimulator.Storage** - Agent-based storage with file persistence +- **ChatSimulator.CLI** - Interactive command-line interface + +## Development + +### Running Tests + +Run all tests: +```bash +mix test +``` + +Run tests with coverage: +```bash +mix test --cover +``` + +Run specific test file: +```bash +mix test test/chat_simulator/user_test.exs +``` + +### Code Quality + +Run all checks at once: +```bash +./scripts/test.sh +``` + +Or individually: + +Format code: +```bash +mix format +``` + +Run static analysis: +```bash +mix credo +``` + +Run dialyzer (type checking): +```bash +mix dialyzer +``` + +Clean build artifacts: +```bash +./scripts/clean.sh +``` + +### Generating Documentation + +```bash +mix docs +``` + +Then open `doc/index.html` in your browser. + +## Project Structure + +``` +elixir-chat-sim/ +├── lib/ # Application code +│ ├── chat_simulator.ex +│ └── chat_simulator/ +│ ├── auth.ex # Authentication +│ ├── cli.ex # Command-line interface +│ ├── message.ex # Message handling +│ ├── storage.ex # Data persistence +│ └── user.ex # User management +├── test/ # Test suite +│ ├── test_helper.exs +│ ├── integration_test.exs +│ └── chat_simulator/ +│ ├── auth_test.exs +│ ├── message_test.exs +│ ├── storage_test.exs +│ └── user_test.exs +├── config/ # Configuration +│ ├── config.exs +│ ├── dev.exs +│ ├── test.exs +│ ├── prod.exs +│ └── runtime.exs +├── scripts/ # Helper scripts +│ ├── setup.sh +│ ├── test.sh +│ ├── clean.sh +│ └── docker-build.sh +├── examples/ # Usage examples +│ ├── api_usage.exs +│ └── automated_chat.exs +├── .github/ # GitHub configs +│ ├── workflows/ +│ │ └── ci.yml +│ ├── ISSUE_TEMPLATE/ +│ ├── PULL_REQUEST_TEMPLATE.md +│ └── dependabot.yml +├── Dockerfile +├── docker-compose.yml +├── .formatter.exs +├── .credo.exs +├── CHANGELOG.md +├── CODE_OF_CONDUCT.md +├── CONTRIBUTING.md +├── SECURITY.md +├── LICENSE +├── mix.exs +└── README.md +``` + +## Security Considerations + +- Passwords are hashed using SHA256 before storage +- No plain-text passwords are stored +- User input is validated before processing +- Message content is limited to 500 characters + +**Note:** This is an educational project. For production use, consider: +- Using a more robust password hashing algorithm (bcrypt, argon2) +- Adding rate limiting +- Implementing proper session tokens +- Adding encryption for stored data +- Input sanitization for XSS prevention + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to this project. + +## Examples + +Check out the `examples/` directory for: + +- **api_usage.exs** - Using the API programmatically +- **automated_chat.exs** - Simulated multi-user conversations + +Run examples: +```bash +mix compile +elixir examples/api_usage.exs +elixir examples/automated_chat.exs +``` + +## Learning Resources + +This project demonstrates several Elixir concepts: + +1. **Structs** - Data structures for Users and Messages +2. **Pattern Matching** - Command parsing and data extraction +3. **Guards** - Function clause selection based on conditions +4. **Agents** - State management for storage +5. **Configuration** - Environment-specific settings +6. **Logging** - Application logging with Logger +7. **File I/O** - Data persistence +8. **Documentation** - Module and function documentation +9. **Testing** - Comprehensive test suite with ExUnit +10. **CI/CD** - Automated testing and deployment + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- Built with [Elixir](https://elixir-lang.org/) +- Created for educational purposes by codeforgood-org +- Inspired by classic terminal chat applications + +## Roadmap + +Potential future enhancements: + +- [ ] Group chat functionality +- [ ] Message encryption +- [ ] User blocking/privacy features +- [ ] Message editing and deletion +- [ ] File attachments +- [ ] User profiles and status +- [ ] Network-based client-server architecture +- [ ] Web interface with Phoenix +- [ ] Message search functionality +- [ ] Emoji support + +## Support + +For issues, questions, or contributions: +- Open an issue on GitHub +- Submit a pull request +- Contact: codeforgood-org + +--- + +**Happy Chatting!** 💬 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9b7ba35 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,113 @@ +# Security Policy + +## Supported Versions + +We release patches for security vulnerabilities. Currently supported versions: + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | +| < 0.1 | :x: | + +## Reporting a Vulnerability + +We take the security of Chat Simulator seriously. If you believe you have found a security vulnerability, please report it to us as described below. + +### How to Report + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them via: +1. GitHub Security Advisories (preferred) +2. Opening a private security issue on GitHub + +Include the following information: +- Type of vulnerability +- Full paths of source file(s) related to the vulnerability +- Location of the affected source code (tag/branch/commit or direct URL) +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the vulnerability, including how an attacker might exploit it + +### What to Expect + +- You should receive an acknowledgment within 48 hours +- We will send a more detailed response within 7 days indicating next steps +- We will keep you informed about the progress toward a fix +- We may ask for additional information or guidance + +## Security Best Practices + +### For Users + +1. **Password Security** + - Use strong passwords (at least 6 characters, but longer is better) + - Don't reuse passwords from other services + - This is a demonstration project - don't use real passwords + +2. **Data Storage** + - The `.chat_data.etf` file contains hashed passwords + - Keep this file secure and don't share it + - In production, consider encrypting the entire data file + +3. **Network Security** + - Currently, Chat Simulator is local-only + - If extending for network use, implement TLS/SSL + - Add rate limiting to prevent abuse + +### For Developers + +1. **Password Hashing** + - Currently uses SHA256 + - For production use, migrate to bcrypt, argon2, or pbkdf2 + - Never store plain text passwords + +2. **Input Validation** + - All user input is validated + - Message content is limited to 500 characters + - Usernames follow strict patterns + +3. **Dependencies** + - Keep dependencies up to date + - Monitor security advisories + - Use Dependabot for automated updates + +4. **Code Review** + - All changes should be reviewed + - Security-sensitive changes require extra scrutiny + - Run static analysis tools (Credo, Dialyzer) + +## Known Limitations + +This is an educational project with the following security limitations: + +1. **Password Hashing**: Uses SHA256 instead of bcrypt/argon2 +2. **Session Management**: Basic in-memory session handling +3. **Data Encryption**: Data stored unencrypted on disk +4. **Rate Limiting**: No protection against brute force attacks +5. **Input Sanitization**: Basic validation only + +These limitations are documented for educational purposes. For production use, these should be addressed. + +## Security Enhancements (Roadmap) + +Planned security improvements: + +- [ ] Implement bcrypt or argon2 for password hashing +- [ ] Add data encryption at rest +- [ ] Implement rate limiting +- [ ] Add session timeout mechanism +- [ ] Implement account lockout after failed attempts +- [ ] Add audit logging for security events +- [ ] Implement secure password reset mechanism +- [ ] Add two-factor authentication option + +## Attribution + +We appreciate the security research community and will acknowledge researchers who responsibly disclose vulnerabilities (with their permission). + +## Policy Updates + +This security policy may be updated from time to time. Please check back regularly for updates. + +Last updated: 2025-11-13 diff --git a/chat.exs b/chat.exs deleted file mode 100644 index d06059b..0000000 --- a/chat.exs +++ /dev/null @@ -1,121 +0,0 @@ -defmodule ChatSimulator do - defmodule User do - defstruct [:username, :password] - end - - defmodule Message do - defstruct [:from, :to, :content, :timestamp] - end - - def start do - IO.puts("Welcome to Terminal Chat!") - loop([], [], nil) - end - - # Main loop - defp loop(users, messages, current_user) do - if current_user do - IO.puts("\nLogged in as: #{current_user.username}") - IO.puts("Commands: send | inbox | logout | quit") - else - IO.puts("\nCommands: register | login | quit") - end - - case IO.gets("> ") |> String.trim() do - "register" -> - {users, current_user} = register_user(users) - loop(users, messages, current_user) - - "login" -> - case login_user(users) do - {:ok, user} -> loop(users, messages, user) - :error -> loop(users, messages, nil) - end - - "send" when current_user != nil -> - messages = send_message(current_user, users, messages) - loop(users, messages, current_user) - - "inbox" when current_user != nil -> - view_inbox(current_user, messages) - loop(users, messages, current_user) - - "logout" when current_user != nil -> - IO.puts("Logged out.") - loop(users, messages, nil) - - "quit" -> - IO.puts("Exiting chat.") - :ok - - _ -> - IO.puts("Invalid command.") - loop(users, messages, current_user) - end - end - - # User registration - defp register_user(users) do - username = IO.gets("Choose a username: ") |> String.trim() - if Enum.any?(users, &(&1.username == username)) do - IO.puts("Username already taken.") - {users, nil} - else - password = IO.gets("Choose a password: ") |> String.trim() - user = %User{username: username, password: password} - IO.puts("Registered successfully and logged in.") - {[user | users], user} - end - end - - # User login - defp login_user(users) do - username = IO.gets("Username: ") |> String.trim() - password = IO.gets("Password: ") |> String.trim() - - case Enum.find(users, &(&1.username == username && &1.password == password)) do - nil -> - IO.puts("Login failed.") - :error - user -> - IO.puts("Login successful.") - {:ok, user} - end - end - - # Sending a message - defp send_message(from_user, users, messages) do - to_user = IO.gets("To: ") |> String.trim() - if Enum.any?(users, &(&1.username == to_user)) do - content = IO.gets("Message: ") |> String.trim() - msg = %Message{ - from: from_user.username, - to: to_user, - content: content, - timestamp: DateTime.utc_now() |> DateTime.to_string() - } - IO.puts("Message sent.") - [msg | messages] - else - IO.puts("User not found.") - messages - end - end - - # Viewing inbox - defp view_inbox(user, messages) do - user_messages = - Enum.filter(messages, fn m -> m.to == user.username end) - - if user_messages == [] do - IO.puts("No messages.") - else - IO.puts("\nYour messages:") - Enum.each(Enum.reverse(user_messages), fn m -> - IO.puts("[#{m.timestamp}] #{m.from}: #{m.content}") - end) - end - end -end - -ChatSimulator.start() diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..9ef355a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,26 @@ +import Config + +# Configuration for Chat Simulator + +# Storage configuration +config :chat_simulator, :storage, + file: ".chat_data.etf", + auto_save: true + +# User validation rules +config :chat_simulator, :user, + min_username_length: 3, + max_username_length: 20, + min_password_length: 6 + +# Message validation rules +config :chat_simulator, :message, + max_content_length: 500 + +# Logging configuration +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Import environment specific config +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..954f22b --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,10 @@ +import Config + +# Development environment configuration + +config :chat_simulator, :storage, + file: ".chat_data_dev.etf", + auto_save: true + +config :logger, :console, + level: :debug diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..80b0187 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,10 @@ +import Config + +# Production environment configuration + +config :chat_simulator, :storage, + file: System.get_env("CHAT_DATA_FILE", ".chat_data.etf"), + auto_save: true + +config :logger, :console, + level: :info diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..c7fe74e --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,9 @@ +import Config + +# Runtime configuration (loaded at application startup) + +if config_env() == :prod do + # Production runtime configuration + config :chat_simulator, :storage, + file: System.get_env("CHAT_DATA_FILE") || ".chat_data.etf" +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..93f3636 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,10 @@ +import Config + +# Test environment configuration + +config :chat_simulator, :storage, + file: ".chat_data_test.etf", + auto_save: false + +config :logger, :console, + level: :warn diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd4f897 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + chat-simulator: + build: + context: . + dockerfile: Dockerfile + image: chat-simulator:latest + container_name: chat-simulator + stdin_open: true + tty: true + volumes: + # Persist chat data + - chat-data:/app + environment: + - CHAT_DATA_FILE=/app/.chat_data.etf + restart: unless-stopped + +volumes: + chat-data: + driver: local diff --git a/examples/api_usage.exs b/examples/api_usage.exs new file mode 100755 index 0000000..0edf417 --- /dev/null +++ b/examples/api_usage.exs @@ -0,0 +1,112 @@ +#!/usr/bin/env elixir + +# Example: Using the Chat Simulator API programmatically +# +# This script demonstrates how to use the Chat Simulator modules +# without the interactive CLI. +# +# Run with: elixir examples/api_usage.exs + +# Add the lib directory to the code path +Code.prepend_path("_build/dev/lib/chat_simulator/ebin") + +alias ChatSimulator.{Storage, Auth, Message} + +IO.puts("🚀 Chat Simulator API Usage Example\n") + +# Start the storage +IO.puts("Starting storage...") +{:ok, _pid} = Storage.start_link() + +# Register some users +IO.puts("\n📝 Registering users...") + +case Auth.register("alice", "password123") do + {:ok, _user} -> IO.puts("✓ Registered alice") + {:error, reason} -> IO.puts("✗ Failed to register alice: #{reason}") +end + +case Auth.register("bob", "secret456") do + {:ok, _user} -> IO.puts("✓ Registered bob") + {:error, reason} -> IO.puts("✗ Failed to register bob: #{reason}") +end + +case Auth.register("charlie", "pass789") do + {:ok, _user} -> IO.puts("✓ Registered charlie") + {:error, reason} -> IO.puts("✗ Failed to register charlie: #{reason}") +end + +# List all users +IO.puts("\n👥 Registered users:") +Storage.list_usernames() +|> Enum.each(fn username -> + IO.puts(" • #{username}") +end) + +# Send some messages +IO.puts("\n💬 Sending messages...") + +msg1 = Message.new("alice", "bob", "Hey Bob! How are you?") +Storage.add_message(msg1) +IO.puts("✓ Alice → Bob: #{msg1.content}") + +msg2 = Message.new("bob", "alice", "Hi Alice! I'm doing great, thanks!") +Storage.add_message(msg2) +IO.puts("✓ Bob → Alice: #{msg2.content}") + +msg3 = Message.new("charlie", "alice", "Hey Alice, want to grab coffee?") +Storage.add_message(msg3) +IO.puts("✓ Charlie → Alice: #{msg3.content}") + +msg4 = Message.new("alice", "charlie", "Sure! When works for you?") +Storage.add_message(msg4) +IO.puts("✓ Alice → Charlie: #{msg4.content}") + +# View Alice's inbox +IO.puts("\n📬 Alice's inbox:") +alice_messages = Storage.get_messages_for("alice") + +if Enum.empty?(alice_messages) do + IO.puts(" (empty)") +else + Enum.each(alice_messages, fn msg -> + IO.puts(" #{Message.format(msg)}") + end) +end + +IO.puts("\n Total: #{length(alice_messages)} message(s)") +IO.puts(" Unread: #{Storage.count_unread("alice")} message(s)") + +# View conversation between Alice and Bob +IO.puts("\n💬 Conversation between Alice and Bob:") +conversation = Storage.get_conversation("alice", "bob") + +Enum.each(conversation, fn msg -> + direction = if msg.from == "alice", do: "Alice → Bob", else: "Bob → Alice" + IO.puts(" [#{Calendar.strftime(msg.timestamp, "%H:%M:%S")}] #{direction}: #{msg.content}") +end) + +# Login test +IO.puts("\n🔐 Testing authentication...") + +case Auth.login("alice", "password123") do + {:ok, _user} -> IO.puts("✓ Alice logged in successfully") + {:error, reason} -> IO.puts("✗ Login failed: #{reason}") +end + +case Auth.login("alice", "wrongpassword") do + {:ok, _user} -> IO.puts("✗ Should have failed with wrong password") + {:error, _reason} -> IO.puts("✓ Correctly rejected wrong password") +end + +# Statistics +IO.puts("\n📊 Statistics:") +IO.puts(" Total users: #{length(Storage.list_users())}") +IO.puts(" Total messages: #{length(Storage.get_messages_for("alice")) + length(Storage.get_messages_for("bob")) + length(Storage.get_messages_for("charlie"))}") + +# Clean up +IO.puts("\n🧹 Cleaning up...") +Storage.stop() +Storage.clear() + +IO.puts("\n✅ Example complete!") diff --git a/examples/automated_chat.exs b/examples/automated_chat.exs new file mode 100755 index 0000000..f452143 --- /dev/null +++ b/examples/automated_chat.exs @@ -0,0 +1,99 @@ +#!/usr/bin/env elixir + +# Example: Automated chat simulation +# +# This script simulates a conversation between multiple users +# programmatically, useful for testing and demonstration. +# +# Run with: elixir examples/automated_chat.exs + +Code.prepend_path("_build/dev/lib/chat_simulator/ebin") + +alias ChatSimulator.{Storage, Auth, Message} + +defmodule ChatBot do + @moduledoc """ + Simple bot that generates automated responses + """ + + @responses [ + "That's interesting!", + "Tell me more!", + "I see what you mean.", + "That makes sense.", + "Great point!", + "I agree!", + "Hmm, let me think about that...", + "That's a good question." + ] + + def random_response do + Enum.random(@responses) + end +end + +IO.puts("🤖 Automated Chat Simulation\n") + +# Start storage +{:ok, _pid} = Storage.start_link() + +# Register bot users +users = ["Alice", "Bob", "Charlie", "Diana"] + +IO.puts("Creating users...") +Enum.each(users, fn username -> + {:ok, _} = Auth.register(String.downcase(username), "password123") + IO.puts(" ✓ #{username} joined the chat") +end) + +IO.puts("\n💬 Starting conversation...\n") + +# Simulate a conversation +conversations = [ + {"alice", "bob", "Hey Bob, how's the Elixir project going?"}, + {"bob", "alice", "It's going great! Just finished implementing the storage layer."}, + {"charlie", "alice", "Did someone mention Elixir? I love functional programming!"}, + {"alice", "charlie", "Yes! We're building a chat simulator. Want to help?"}, + {"charlie", "alice", ChatBot.random_response()}, + {"diana", "bob", "Bob, have you tried using Agents for state management?"}, + {"bob", "diana", "Yes! That's exactly what we're using. Great suggestion!"}, + {"alice", "diana", "Diana, you should join our code review tomorrow."}, + {"diana", "alice", "Count me in! What time?"}, + {"alice", "diana", "2 PM works for everyone."} +] + +Enum.each(conversations, fn {from, to, content} -> + msg = Message.new(from, to, content) + Storage.add_message(msg) + + IO.puts("[#{String.capitalize(from)} → #{String.capitalize(to)}]") + IO.puts(" #{content}\n") + + # Simulate typing delay + Process.sleep(500) +end) + +# Show statistics +IO.puts("\n📊 Conversation Statistics:") +IO.puts(" Total users: #{length(Storage.list_users())}") + +Enum.each(users, fn username -> + user_lower = String.downcase(username) + inbox_count = length(Storage.get_messages_for(user_lower)) + unread_count = Storage.count_unread(user_lower) + + IO.puts(" #{username}: #{inbox_count} messages (#{unread_count} unread)") +end) + +# Show a specific user's inbox +IO.puts("\n📬 Alice's full inbox:") +Storage.get_messages_for("alice") +|> Enum.each(fn msg -> + IO.puts(" #{Message.format(msg)}") +end) + +# Clean up +Storage.stop() +File.rm(".chat_data.etf") + +IO.puts("\n✅ Simulation complete!") diff --git a/lib/chat_simulator.ex b/lib/chat_simulator.ex new file mode 100644 index 0000000..d187d30 --- /dev/null +++ b/lib/chat_simulator.ex @@ -0,0 +1,87 @@ +defmodule ChatSimulator do + @moduledoc """ + A terminal-based chat simulator for learning Elixir. + + ChatSimulator provides a simple but complete chat application that runs in + the terminal. It demonstrates key Elixir concepts including: + + - Pattern matching and guards + - Structs and protocols + - GenServer/Agent for state management + - File I/O for data persistence + - Module design and organization + - Documentation and testing + + ## Features + + - User registration and authentication + - Password hashing for security + - Send and receive messages + - View inbox and conversation history + - List registered users + - Persistent storage + + ## Getting Started + + To run the chat simulator: + + $ mix deps.get + $ mix escript.build + $ ./chat_simulator + + Or run directly with Mix: + + $ mix run -e "ChatSimulator.CLI.main()" + + ## Architecture + + The application is organized into several modules: + + - `ChatSimulator.User` - User data structure and validation + - `ChatSimulator.Message` - Message data structure and formatting + - `ChatSimulator.Auth` - Authentication and registration logic + - `ChatSimulator.Storage` - Data persistence using Agent + - `ChatSimulator.CLI` - Command-line interface + + ## Examples + + # Start the application + iex> ChatSimulator.CLI.main() + + # Or use the API directly + iex> {:ok, _} = ChatSimulator.Storage.start_link() + iex> {:ok, user} = ChatSimulator.Auth.register("alice", "password123") + iex> {:ok, user} = ChatSimulator.Auth.login("alice", "password123") + """ + + alias ChatSimulator.{Auth, Message, Storage, User} + + @doc """ + Convenience function to start the chat simulator. + """ + def start do + ChatSimulator.CLI.main() + end + + # Re-export commonly used functions for convenience + + @doc """ + Registers a new user. See `ChatSimulator.Auth.register/2`. + """ + defdelegate register(username, password), to: Auth + + @doc """ + Logs in a user. See `ChatSimulator.Auth.login/2`. + """ + defdelegate login(username, password), to: Auth + + @doc """ + Creates a new message. See `ChatSimulator.Message.new/3`. + """ + defdelegate new_message(from, to, content), to: Message, as: :new + + @doc """ + Creates a new user. See `ChatSimulator.User.new/2`. + """ + defdelegate new_user(username, password), to: User, as: :new +end diff --git a/lib/chat_simulator/auth.ex b/lib/chat_simulator/auth.ex new file mode 100644 index 0000000..f62a055 --- /dev/null +++ b/lib/chat_simulator/auth.ex @@ -0,0 +1,98 @@ +defmodule ChatSimulator.Auth do + @moduledoc """ + Handles user authentication and registration. + + This module provides functions for user registration, login, and session management. + """ + + alias ChatSimulator.User + alias ChatSimulator.Storage + + @doc """ + Registers a new user. + + ## Parameters + + - username: The desired username + - password: The password for the account + + ## Returns + + - `{:ok, user}` if registration is successful + - `{:error, reason}` if registration fails + + ## Examples + + iex> ChatSimulator.Auth.register("alice", "secret123") + {:ok, %ChatSimulator.User{username: "alice"}} + """ + @spec register(String.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()} + def register(username, password) do + with true <- User.valid_username?(username), + true <- User.valid_password?(password), + false <- user_exists?(username) do + user = User.new(username, password) + Storage.add_user(user) + {:ok, user} + else + false when not User.valid_username?(username) -> + {:error, "Invalid username. Must be 3-20 characters, alphanumeric, start with a letter."} + + false when not User.valid_password?(password) -> + {:error, "Invalid password. Must be at least 6 characters."} + + true -> + {:error, "Username already taken."} + end + end + + @doc """ + Logs in a user. + + ## Parameters + + - username: The username + - password: The password + + ## Returns + + - `{:ok, user}` if login is successful + - `{:error, reason}` if login fails + + ## Examples + + iex> ChatSimulator.Auth.login("alice", "secret123") + {:ok, %ChatSimulator.User{username: "alice"}} + """ + @spec login(String.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()} + def login(username, password) do + case Storage.get_user(username) do + nil -> + {:error, "Invalid username or password."} + + user -> + if User.verify_password(user, password) do + {:ok, user} + else + {:error, "Invalid username or password."} + end + end + end + + @doc """ + Checks if a user exists. + + ## Parameters + + - username: The username to check + + ## Examples + + iex> ChatSimulator.Auth.user_exists?("alice") + true + """ + @spec user_exists?(String.t()) :: boolean() + def user_exists?(username) do + Storage.get_user(username) != nil + end +end diff --git a/lib/chat_simulator/cli.ex b/lib/chat_simulator/cli.ex new file mode 100644 index 0000000..c395122 --- /dev/null +++ b/lib/chat_simulator/cli.ex @@ -0,0 +1,250 @@ +defmodule ChatSimulator.CLI do + @moduledoc """ + Command-line interface for the Chat Simulator. + + Provides an interactive terminal-based chat application with user + registration, authentication, and messaging capabilities. + """ + + alias ChatSimulator.{Auth, Message, Storage} + + @doc """ + Main entry point for the CLI application. + """ + def main(_args \\ []) do + {:ok, _pid} = Storage.start_link() + + IO.puts("\n" <> banner()) + IO.puts("Welcome to Terminal Chat!") + IO.puts("Type 'help' for available commands\n") + + loop(nil) + + Storage.stop() + end + + defp banner do + """ + ╔═══════════════════════════════════════╗ + ║ CHAT SIMULATOR v0.1.0 ║ + ║ Terminal-based Chat System ║ + ╚═══════════════════════════════════════╝ + """ + end + + # Main command loop + defp loop(current_user) do + display_prompt(current_user) + + case IO.gets("> ") |> String.trim() |> String.downcase() do + "help" -> + display_help(current_user) + loop(current_user) + + "register" when is_nil(current_user) -> + new_user = handle_register() + loop(new_user) + + "login" when is_nil(current_user) -> + logged_in_user = handle_login() + loop(logged_in_user) + + "logout" when not is_nil(current_user) -> + IO.puts("Logged out successfully.") + loop(nil) + + "send" when not is_nil(current_user) -> + handle_send_message(current_user) + loop(current_user) + + "inbox" when not is_nil(current_user) -> + handle_inbox(current_user) + loop(current_user) + + "users" when not is_nil(current_user) -> + handle_list_users() + loop(current_user) + + "chat" when not is_nil(current_user) -> + handle_chat(current_user) + loop(current_user) + + "quit" -> + IO.puts("Thank you for using Chat Simulator. Goodbye!") + :ok + + "exit" -> + IO.puts("Thank you for using Chat Simulator. Goodbye!") + :ok + + "" -> + loop(current_user) + + command when not is_nil(current_user) and command in ["register", "login"] -> + IO.puts("You are already logged in. Please logout first.") + loop(current_user) + + command when is_nil(current_user) and command in ["send", "inbox", "users", "chat"] -> + IO.puts("Please login or register first.") + loop(current_user) + + _unknown -> + IO.puts("Invalid command. Type 'help' for available commands.") + loop(current_user) + end + end + + defp display_prompt(nil) do + IO.puts("\n[Not logged in]") + end + + defp display_prompt(user) do + unread_count = Storage.count_unread(user.username) + unread_str = if unread_count > 0, do: " (#{unread_count} unread)", else: "" + IO.puts("\n[#{user.username}#{unread_str}]") + end + + defp display_help(nil) do + IO.puts(""" + + Available commands: + register - Create a new account + login - Login to existing account + help - Show this help message + quit/exit - Exit the application + """) + end + + defp display_help(_user) do + IO.puts(""" + + Available commands: + send - Send a message to another user + inbox - View your messages + chat - View conversation with a specific user + users - List all registered users + logout - Logout from your account + help - Show this help message + quit/exit - Exit the application + """) + end + + defp handle_register do + IO.puts("\n--- User Registration ---") + username = IO.gets("Choose a username: ") |> String.trim() + password = IO.gets("Choose a password: ") |> String.trim() + + case Auth.register(username, password) do + {:ok, user} -> + IO.puts("✓ Registration successful! You are now logged in as #{username}.") + user + + {:error, reason} -> + IO.puts("✗ Registration failed: #{reason}") + nil + end + end + + defp handle_login do + IO.puts("\n--- User Login ---") + username = IO.gets("Username: ") |> String.trim() + password = IO.gets("Password: ") |> String.trim() + + case Auth.login(username, password) do + {:ok, user} -> + IO.puts("✓ Login successful! Welcome back, #{username}.") + user + + {:error, reason} -> + IO.puts("✗ Login failed: #{reason}") + nil + end + end + + defp handle_send_message(from_user) do + IO.puts("\n--- Send Message ---") + to_username = IO.gets("To (username): ") |> String.trim() + + if to_username == from_user.username do + IO.puts("✗ You cannot send a message to yourself.") + else + case Storage.get_user(to_username) do + nil -> + IO.puts("✗ User '#{to_username}' not found.") + + _user -> + content = IO.gets("Message: ") |> String.trim() + + if Message.valid_content?(content) do + message = Message.new(from_user.username, to_username, content) + Storage.add_message(message) + IO.puts("✓ Message sent to #{to_username}.") + else + IO.puts("✗ Invalid message. Must be 1-500 characters.") + end + end + end + end + + defp handle_inbox(user) do + IO.puts("\n--- Your Inbox ---") + messages = Storage.get_messages_for(user.username) + + if Enum.empty?(messages) do + IO.puts("No messages in your inbox.") + else + Enum.each(messages, fn msg -> + IO.puts(Message.format(msg)) + end) + + IO.puts("\n#{length(messages)} message(s) total") + end + end + + defp handle_list_users do + IO.puts("\n--- Registered Users ---") + usernames = Storage.list_usernames() + + if Enum.empty?(usernames) do + IO.puts("No users registered yet.") + else + Enum.each(usernames, fn username -> + IO.puts(" • #{username}") + end) + + IO.puts("\n#{length(usernames)} user(s) registered") + end + end + + defp handle_chat(current_user) do + IO.puts("\n--- Chat History ---") + other_user = IO.gets("View conversation with (username): ") |> String.trim() + + if other_user == current_user.username do + IO.puts("✗ You cannot view a chat with yourself.") + else + case Storage.get_user(other_user) do + nil -> + IO.puts("✗ User '#{other_user}' not found.") + + _user -> + messages = Storage.get_conversation(current_user.username, other_user) + + if Enum.empty?(messages) do + IO.puts("No messages in this conversation.") + else + IO.puts("Conversation with #{other_user}:") + IO.puts(String.duplicate("-", 50)) + + Enum.each(messages, fn msg -> + direction = if msg.from == current_user.username, do: "You", else: other_user + time_str = Calendar.strftime(msg.timestamp, "%Y-%m-%d %H:%M:%S") + IO.puts("[#{time_str}] #{direction}: #{msg.content}") + end) + + IO.puts("\n#{length(messages)} message(s) in conversation") + end + end + end + end +end diff --git a/lib/chat_simulator/message.ex b/lib/chat_simulator/message.ex new file mode 100644 index 0000000..5f715d0 --- /dev/null +++ b/lib/chat_simulator/message.ex @@ -0,0 +1,109 @@ +defmodule ChatSimulator.Message do + @moduledoc """ + Represents a message in the chat system. + + Messages are sent from one user to another with content and a timestamp. + """ + + @enforce_keys [:from, :to, :content, :timestamp] + defstruct [:id, :from, :to, :content, :timestamp, :read] + + @type t :: %__MODULE__{ + id: String.t(), + from: String.t(), + to: String.t(), + content: String.t(), + timestamp: DateTime.t(), + read: boolean() + } + + @doc """ + Creates a new message. + + ## Parameters + + - from: Username of the sender + - to: Username of the recipient + - content: Message content + + ## Examples + + iex> ChatSimulator.Message.new("alice", "bob", "Hello!") + %ChatSimulator.Message{from: "alice", to: "bob", content: "Hello!", ...} + """ + @spec new(String.t(), String.t(), String.t()) :: t() + def new(from, to, content) do + %__MODULE__{ + id: generate_id(), + from: from, + to: to, + content: content, + timestamp: DateTime.utc_now(), + read: false + } + end + + @doc """ + Marks a message as read. + + ## Parameters + + - message: The message to mark as read + + ## Examples + + iex> message = ChatSimulator.Message.new("alice", "bob", "Hello!") + iex> ChatSimulator.Message.mark_read(message) + %ChatSimulator.Message{read: true, ...} + """ + @spec mark_read(t()) :: t() + def mark_read(message) do + %{message | read: true} + end + + @doc """ + Formats a message for display. + + ## Parameters + + - message: The message to format + + ## Examples + + iex> message = ChatSimulator.Message.new("alice", "bob", "Hello!") + iex> ChatSimulator.Message.format(message) + "[2025-11-13 10:30:45] alice: Hello!" + """ + @spec format(t()) :: String.t() + def format(%__MODULE__{from: from, content: content, timestamp: timestamp, read: read}) do + time_str = Calendar.strftime(timestamp, "%Y-%m-%d %H:%M:%S") + read_indicator = if read, do: "", else: " [NEW]" + "[#{time_str}] #{from}: #{content}#{read_indicator}" + end + + @doc """ + Validates message content. + + Content must be: + - Non-empty + - Less than 500 characters + + ## Examples + + iex> ChatSimulator.Message.valid_content?("Hello!") + true + + iex> ChatSimulator.Message.valid_content?("") + false + """ + @spec valid_content?(String.t()) :: boolean() + def valid_content?(content) do + String.length(content) > 0 and String.length(content) <= 500 + end + + # Generates a unique ID for messages + defp generate_id do + :crypto.strong_rand_bytes(16) + |> Base.url_encode64() + end +end diff --git a/lib/chat_simulator/storage.ex b/lib/chat_simulator/storage.ex new file mode 100644 index 0000000..faf588d --- /dev/null +++ b/lib/chat_simulator/storage.ex @@ -0,0 +1,215 @@ +defmodule ChatSimulator.Storage do + @moduledoc """ + Handles data persistence for users and messages. + + This module uses an Agent to maintain state and provides optional + file-based persistence for data durability. + """ + + use Agent + require Logger + + alias ChatSimulator.User + alias ChatSimulator.Message + + @doc """ + Starts the storage agent. + + Loads data from disk if available, otherwise starts with empty state. + """ + @spec start_link(keyword()) :: Agent.on_start() + def start_link(_opts \\ []) do + initial_state = load_from_disk() || %{users: [], messages: []} + Logger.info("Starting Chat Simulator storage with #{length(initial_state.users)} users") + Agent.start_link(fn -> initial_state end, name: __MODULE__) + end + + @doc """ + Stops the storage agent and saves data to disk. + """ + @spec stop() :: :ok + def stop do + save_to_disk() + Agent.stop(__MODULE__) + end + + # User operations + + @doc """ + Adds a new user to storage. + """ + @spec add_user(User.t()) :: :ok + def add_user(user) do + Logger.debug("Adding user: #{user.username}") + + Agent.update(__MODULE__, fn state -> + %{state | users: [user | state.users]} + end) + + save_to_disk() + :ok + end + + @doc """ + Retrieves a user by username. + """ + @spec get_user(String.t()) :: User.t() | nil + def get_user(username) do + Agent.get(__MODULE__, fn state -> + Enum.find(state.users, &(&1.username == username)) + end) + end + + @doc """ + Lists all registered users. + """ + @spec list_users() :: [User.t()] + def list_users do + Agent.get(__MODULE__, fn state -> state.users end) + end + + @doc """ + Gets usernames of all registered users. + """ + @spec list_usernames() :: [String.t()] + def list_usernames do + Agent.get(__MODULE__, fn state -> + Enum.map(state.users, & &1.username) + end) + end + + # Message operations + + @doc """ + Adds a new message to storage. + """ + @spec add_message(Message.t()) :: :ok + def add_message(message) do + Logger.debug("Adding message from #{message.from} to #{message.to}") + + Agent.update(__MODULE__, fn state -> + %{state | messages: [message | state.messages]} + end) + + save_to_disk() + :ok + end + + @doc """ + Retrieves all messages for a specific recipient. + """ + @spec get_messages_for(String.t()) :: [Message.t()] + def get_messages_for(username) do + Agent.get(__MODULE__, fn state -> + state.messages + |> Enum.filter(&(&1.to == username)) + |> Enum.reverse() + end) + end + + @doc """ + Retrieves conversation between two users. + """ + @spec get_conversation(String.t(), String.t()) :: [Message.t()] + def get_conversation(user1, user2) do + Agent.get(__MODULE__, fn state -> + state.messages + |> Enum.filter(fn msg -> + (msg.from == user1 and msg.to == user2) or + (msg.from == user2 and msg.to == user1) + end) + |> Enum.reverse() + end) + end + + @doc """ + Marks a message as read. + """ + @spec mark_message_read(String.t()) :: :ok + def mark_message_read(message_id) do + Agent.update(__MODULE__, fn state -> + messages = + Enum.map(state.messages, fn msg -> + if msg.id == message_id, do: Message.mark_read(msg), else: msg + end) + + %{state | messages: messages} + end) + + save_to_disk() + :ok + end + + @doc """ + Counts unread messages for a user. + """ + @spec count_unread(String.t()) :: non_neg_integer() + def count_unread(username) do + Agent.get(__MODULE__, fn state -> + state.messages + |> Enum.filter(&(&1.to == username and not &1.read)) + |> length() + end) + end + + # Persistence operations + + defp storage_file do + Application.get_env(:chat_simulator, :storage, []) + |> Keyword.get(:file, ".chat_data.etf") + end + + defp auto_save? do + Application.get_env(:chat_simulator, :storage, []) + |> Keyword.get(:auto_save, true) + end + + defp save_to_disk do + if auto_save?() do + Agent.get(__MODULE__, fn state -> + case File.write(storage_file(), :erlang.term_to_binary(state)) do + :ok -> + Logger.debug("Data saved to #{storage_file()}") + + {:error, reason} -> + Logger.error("Failed to save data: #{inspect(reason)}") + end + + state + end) + end + end + + defp load_from_disk do + file = storage_file() + + case File.read(file) do + {:ok, binary} -> + Logger.info("Loaded data from #{file}") + :erlang.binary_to_term(binary) + + {:error, :enoent} -> + Logger.info("No existing data file found, starting fresh") + nil + + {:error, reason} -> + Logger.error("Failed to load data: #{inspect(reason)}") + nil + end + end + + @doc """ + Clears all data from storage (useful for testing). + """ + @spec clear() :: :ok + def clear do + Logger.debug("Clearing all storage data") + + Agent.update(__MODULE__, fn _state -> + %{users: [], messages: []} + end) + + File.rm(storage_file()) + :ok + end +end diff --git a/lib/chat_simulator/user.ex b/lib/chat_simulator/user.ex new file mode 100644 index 0000000..92db818 --- /dev/null +++ b/lib/chat_simulator/user.ex @@ -0,0 +1,104 @@ +defmodule ChatSimulator.User do + @moduledoc """ + Represents a user in the chat system. + + Users have a username and a hashed password for authentication. + """ + + @enforce_keys [:username, :password_hash] + defstruct [:username, :password_hash, :created_at] + + @type t :: %__MODULE__{ + username: String.t(), + password_hash: String.t(), + created_at: DateTime.t() + } + + @doc """ + Creates a new user with a hashed password. + + ## Parameters + + - username: The username for the new user + - password: The plain text password (will be hashed) + + ## Examples + + iex> ChatSimulator.User.new("alice", "secret123") + %ChatSimulator.User{username: "alice", ...} + """ + @spec new(String.t(), String.t()) :: t() + def new(username, password) do + %__MODULE__{ + username: username, + password_hash: hash_password(password), + created_at: DateTime.utc_now() + } + end + + @doc """ + Verifies if a password matches the user's stored password hash. + + ## Parameters + + - user: The user struct + - password: The plain text password to verify + + ## Examples + + iex> user = ChatSimulator.User.new("alice", "secret123") + iex> ChatSimulator.User.verify_password(user, "secret123") + true + """ + @spec verify_password(t(), String.t()) :: boolean() + def verify_password(%__MODULE__{password_hash: hash}, password) do + hash_password(password) == hash + end + + @doc """ + Validates a username according to chat system rules. + + Usernames must be: + - Between 3 and 20 characters + - Alphanumeric with underscores allowed + - Start with a letter + + ## Examples + + iex> ChatSimulator.User.valid_username?("alice") + true + + iex> ChatSimulator.User.valid_username?("ab") + false + """ + @spec valid_username?(String.t()) :: boolean() + def valid_username?(username) do + String.length(username) >= 3 and + String.length(username) <= 20 and + Regex.match?(~r/^[a-zA-Z][a-zA-Z0-9_]*$/, username) + end + + @doc """ + Validates a password according to security requirements. + + Passwords must be at least 6 characters long. + + ## Examples + + iex> ChatSimulator.User.valid_password?("secret123") + true + + iex> ChatSimulator.User.valid_password?("abc") + false + """ + @spec valid_password?(String.t()) :: boolean() + def valid_password?(password) do + String.length(password) >= 6 + end + + # Private function to hash passwords using SHA256 + defp hash_password(password) do + :crypto.hash(:sha256, password) + |> Base.encode16() + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..afb3294 --- /dev/null +++ b/mix.exs @@ -0,0 +1,42 @@ +defmodule ChatSimulator.MixProject do + use Mix.Project + + def project do + [ + app: :chat_simulator, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + escript: escript(), + + # Docs + name: "Chat Simulator", + source_url: "https://github.com/codeforgood-org/elixir-chat-sim", + docs: [ + main: "ChatSimulator", + extras: ["README.md"] + ] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger, :crypto] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + ] + end + + defp escript do + [main_module: ChatSimulator.CLI] + end +end diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..b72bf46 --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Clean build artifacts and generated files + +set -e + +echo "🧹 Cleaning Chat Simulator project..." + +# Remove build artifacts +echo "Removing build artifacts..." +rm -rf _build +rm -rf deps +rm -rf doc +rm -rf cover + +# Remove escript +if [ -f chat_simulator ]; then + echo "Removing escript..." + rm chat_simulator +fi + +# Remove data files +if [ -f .chat_data.etf ]; then + echo "Removing data file..." + rm .chat_data.etf +fi + +if [ -f .chat_data_dev.etf ]; then + echo "Removing dev data file..." + rm .chat_data_dev.etf +fi + +if [ -f .chat_data_test.etf ]; then + echo "Removing test data file..." + rm .chat_data_test.etf +fi + +# Remove crash dumps +if [ -f erl_crash.dump ]; then + echo "Removing crash dump..." + rm erl_crash.dump +fi + +echo "✅ Clean complete!" diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100755 index 0000000..f5ab64c --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build Docker image for Chat Simulator + +set -e + +echo "🐳 Building Docker image for Chat Simulator..." + +# Build the image +docker build -t chat-simulator:latest . + +echo "" +echo "✅ Docker image built successfully!" +echo "" +echo "To run the container:" +echo " docker-compose up" +echo "" +echo "Or manually:" +echo " docker run -it --rm chat-simulator:latest" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..e4cda09 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Setup script for Chat Simulator development + +set -e + +echo "🚀 Setting up Chat Simulator development environment..." + +# Check if Elixir is installed +if ! command -v elixir &> /dev/null; then + echo "❌ Elixir is not installed. Please install Elixir first." + echo " Visit: https://elixir-lang.org/install.html" + exit 1 +fi + +echo "✓ Elixir $(elixir --version | head -n 1)" + +# Check if Mix is available +if ! command -v mix &> /dev/null; then + echo "❌ Mix is not available." + exit 1 +fi + +echo "✓ Mix available" + +# Install Hex +echo "📦 Installing Hex..." +mix local.hex --force + +# Install Rebar +echo "📦 Installing Rebar..." +mix local.rebar --force + +# Install dependencies +echo "📦 Installing dependencies..." +mix deps.get + +# Compile dependencies +echo "🔨 Compiling dependencies..." +mix deps.compile + +# Run tests +echo "🧪 Running tests..." +mix test + +# Check code formatting +echo "💅 Checking code formatting..." +mix format --check-formatted || { + echo "⚠️ Code is not formatted. Run 'mix format' to fix." +} + +# Build escript +echo "🔨 Building escript..." +mix escript.build + +echo "" +echo "✅ Setup complete!" +echo "" +echo "To run the application:" +echo " ./chat_simulator" +echo "" +echo "Or with Mix:" +echo " mix run -e \"ChatSimulator.CLI.main()\"" +echo "" +echo "To run tests:" +echo " mix test" +echo "" +echo "Happy coding! 🎉" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..aaffe0a --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Comprehensive test script for Chat Simulator + +set -e + +echo "🧪 Running Chat Simulator test suite..." +echo "" + +# Run unit tests +echo "📋 Running unit tests..." +mix test + +# Run tests with coverage +echo "" +echo "📊 Running tests with coverage..." +mix test --cover + +# Check code formatting +echo "" +echo "💅 Checking code formatting..." +if mix format --check-formatted; then + echo "✓ Code is properly formatted" +else + echo "❌ Code formatting issues found. Run 'mix format' to fix." + exit 1 +fi + +# Run Credo +echo "" +echo "🔍 Running static analysis (Credo)..." +if mix credo --strict; then + echo "✓ No issues found" +else + echo "⚠️ Credo found issues" +fi + +# Check for compilation warnings +echo "" +echo "🔨 Checking for compilation warnings..." +if mix compile --warnings-as-errors --force; then + echo "✓ No compilation warnings" +else + echo "❌ Compilation warnings found" + exit 1 +fi + +echo "" +echo "✅ All checks passed!" diff --git a/test/chat_simulator/auth_test.exs b/test/chat_simulator/auth_test.exs new file mode 100644 index 0000000..cd3ee96 --- /dev/null +++ b/test/chat_simulator/auth_test.exs @@ -0,0 +1,71 @@ +defmodule ChatSimulator.AuthTest do + use ExUnit.Case, async: false + + alias ChatSimulator.{Auth, Storage} + + setup do + {:ok, _pid} = Storage.start_link() + Storage.clear() + + on_exit(fn -> + Storage.stop() + end) + + :ok + end + + describe "register/2" do + test "successfully registers a new user" do + assert {:ok, user} = Auth.register("alice", "password123") + assert user.username == "alice" + end + + test "returns error for invalid username" do + assert {:error, reason} = Auth.register("ab", "password123") + assert reason =~ "Invalid username" + end + + test "returns error for invalid password" do + assert {:error, reason} = Auth.register("alice", "short") + assert reason =~ "Invalid password" + end + + test "returns error for duplicate username" do + assert {:ok, _} = Auth.register("alice", "password123") + assert {:error, reason} = Auth.register("alice", "password456") + assert reason =~ "already taken" + end + end + + describe "login/2" do + test "successfully logs in with correct credentials" do + {:ok, _} = Auth.register("alice", "password123") + + assert {:ok, user} = Auth.login("alice", "password123") + assert user.username == "alice" + end + + test "returns error for non-existent user" do + assert {:error, reason} = Auth.login("nonexistent", "password") + assert reason =~ "Invalid username or password" + end + + test "returns error for incorrect password" do + {:ok, _} = Auth.register("alice", "password123") + + assert {:error, reason} = Auth.login("alice", "wrongpassword") + assert reason =~ "Invalid username or password" + end + end + + describe "user_exists?/1" do + test "returns true for existing user" do + {:ok, _} = Auth.register("alice", "password123") + assert Auth.user_exists?("alice") + end + + test "returns false for non-existent user" do + refute Auth.user_exists?("nonexistent") + end + end +end diff --git a/test/chat_simulator/message_test.exs b/test/chat_simulator/message_test.exs new file mode 100644 index 0000000..3320adf --- /dev/null +++ b/test/chat_simulator/message_test.exs @@ -0,0 +1,67 @@ +defmodule ChatSimulator.MessageTest do + use ExUnit.Case, async: true + doctest ChatSimulator.Message + + alias ChatSimulator.Message + + describe "new/3" do + test "creates a message with all required fields" do + message = Message.new("alice", "bob", "Hello!") + + assert message.from == "alice" + assert message.to == "bob" + assert message.content == "Hello!" + assert message.id != nil + assert message.timestamp != nil + assert message.read == false + end + + test "generates unique IDs for different messages" do + msg1 = Message.new("alice", "bob", "Hello!") + msg2 = Message.new("alice", "bob", "Hello!") + + assert msg1.id != msg2.id + end + end + + describe "mark_read/1" do + test "marks message as read" do + message = Message.new("alice", "bob", "Hello!") + refute message.read + + read_message = Message.mark_read(message) + assert read_message.read + end + end + + describe "format/1" do + test "formats message with timestamp and sender" do + message = Message.new("alice", "bob", "Hello!") + formatted = Message.format(message) + + assert formatted =~ "alice:" + assert formatted =~ "Hello!" + assert formatted =~ "[NEW]" + end + + test "does not show [NEW] indicator for read messages" do + message = Message.new("alice", "bob", "Hello!") |> Message.mark_read() + formatted = Message.format(message) + + refute formatted =~ "[NEW]" + end + end + + describe "valid_content?/1" do + test "accepts valid content" do + assert Message.valid_content?("Hello") + assert Message.valid_content?("a") + assert Message.valid_content?(String.duplicate("a", 500)) + end + + test "rejects invalid content" do + refute Message.valid_content?("") + refute Message.valid_content?(String.duplicate("a", 501)) + end + end +end diff --git a/test/chat_simulator/storage_test.exs b/test/chat_simulator/storage_test.exs new file mode 100644 index 0000000..e08227c --- /dev/null +++ b/test/chat_simulator/storage_test.exs @@ -0,0 +1,136 @@ +defmodule ChatSimulator.StorageTest do + use ExUnit.Case, async: false + + alias ChatSimulator.{Storage, User, Message} + + setup do + {:ok, _pid} = Storage.start_link() + Storage.clear() + + on_exit(fn -> + Storage.stop() + end) + + :ok + end + + describe "user operations" do + test "add_user/1 adds a user to storage" do + user = User.new("alice", "password123") + assert :ok = Storage.add_user(user) + + retrieved = Storage.get_user("alice") + assert retrieved.username == "alice" + end + + test "get_user/1 returns nil for non-existent user" do + assert Storage.get_user("nonexistent") == nil + end + + test "list_users/0 returns all users" do + user1 = User.new("alice", "password123") + user2 = User.new("bob", "password456") + + Storage.add_user(user1) + Storage.add_user(user2) + + users = Storage.list_users() + assert length(users) == 2 + assert Enum.any?(users, &(&1.username == "alice")) + assert Enum.any?(users, &(&1.username == "bob")) + end + + test "list_usernames/0 returns all usernames" do + user1 = User.new("alice", "password123") + user2 = User.new("bob", "password456") + + Storage.add_user(user1) + Storage.add_user(user2) + + usernames = Storage.list_usernames() + assert length(usernames) == 2 + assert "alice" in usernames + assert "bob" in usernames + end + end + + describe "message operations" do + test "add_message/1 adds a message to storage" do + message = Message.new("alice", "bob", "Hello!") + assert :ok = Storage.add_message(message) + + messages = Storage.get_messages_for("bob") + assert length(messages) == 1 + assert hd(messages).content == "Hello!" + end + + test "get_messages_for/1 filters by recipient" do + msg1 = Message.new("alice", "bob", "Hello Bob!") + msg2 = Message.new("charlie", "alice", "Hello Alice!") + msg3 = Message.new("bob", "alice", "Hi Alice!") + + Storage.add_message(msg1) + Storage.add_message(msg2) + Storage.add_message(msg3) + + alice_messages = Storage.get_messages_for("alice") + assert length(alice_messages) == 2 + assert Enum.all?(alice_messages, &(&1.to == "alice")) + end + + test "get_conversation/2 returns messages between two users" do + msg1 = Message.new("alice", "bob", "Hi Bob!") + msg2 = Message.new("bob", "alice", "Hi Alice!") + msg3 = Message.new("charlie", "alice", "Hi Alice!") + + Storage.add_message(msg1) + Storage.add_message(msg2) + Storage.add_message(msg3) + + conversation = Storage.get_conversation("alice", "bob") + assert length(conversation) == 2 + assert Enum.all?(conversation, fn msg -> + (msg.from == "alice" and msg.to == "bob") or + (msg.from == "bob" and msg.to == "alice") + end) + end + + test "mark_message_read/1 marks a message as read" do + message = Message.new("alice", "bob", "Hello!") + Storage.add_message(message) + + Storage.mark_message_read(message.id) + + messages = Storage.get_messages_for("bob") + assert hd(messages).read == true + end + + test "count_unread/1 counts unread messages" do + msg1 = Message.new("alice", "bob", "Hello!") + msg2 = Message.new("charlie", "bob", "Hi!") + + Storage.add_message(msg1) + Storage.add_message(msg2) + + assert Storage.count_unread("bob") == 2 + + Storage.mark_message_read(msg1.id) + assert Storage.count_unread("bob") == 1 + end + end + + describe "clear/0" do + test "removes all data" do + user = User.new("alice", "password123") + message = Message.new("alice", "bob", "Hello!") + + Storage.add_user(user) + Storage.add_message(message) + + Storage.clear() + + assert Storage.list_users() == [] + assert Storage.get_messages_for("bob") == [] + end + end +end diff --git a/test/chat_simulator/user_test.exs b/test/chat_simulator/user_test.exs new file mode 100644 index 0000000..6e72bee --- /dev/null +++ b/test/chat_simulator/user_test.exs @@ -0,0 +1,69 @@ +defmodule ChatSimulator.UserTest do + use ExUnit.Case, async: true + doctest ChatSimulator.User + + alias ChatSimulator.User + + describe "new/2" do + test "creates a user with hashed password" do + user = User.new("alice", "password123") + + assert user.username == "alice" + assert user.password_hash != "password123" + assert is_binary(user.password_hash) + assert user.created_at != nil + end + + test "different passwords produce different hashes" do + user1 = User.new("alice", "password1") + user2 = User.new("alice", "password2") + + assert user1.password_hash != user2.password_hash + end + end + + describe "verify_password/2" do + test "returns true for correct password" do + user = User.new("alice", "password123") + + assert User.verify_password(user, "password123") + end + + test "returns false for incorrect password" do + user = User.new("alice", "password123") + + refute User.verify_password(user, "wrongpassword") + end + end + + describe "valid_username?/1" do + test "accepts valid usernames" do + assert User.valid_username?("alice") + assert User.valid_username?("bob123") + assert User.valid_username?("user_name") + assert User.valid_username?("abc") + end + + test "rejects invalid usernames" do + refute User.valid_username?("ab") + refute User.valid_username?("a" <> String.duplicate("b", 20)) + refute User.valid_username?("123user") + refute User.valid_username?("user-name") + refute User.valid_username?("user name") + end + end + + describe "valid_password?/1" do + test "accepts valid passwords" do + assert User.valid_password?("password") + assert User.valid_password?("pass123") + assert User.valid_password?("a1b2c3") + end + + test "rejects invalid passwords" do + refute User.valid_password?("short") + refute User.valid_password?("abc") + refute User.valid_password?("") + end + end +end diff --git a/test/integration_test.exs b/test/integration_test.exs new file mode 100644 index 0000000..029a8ec --- /dev/null +++ b/test/integration_test.exs @@ -0,0 +1,227 @@ +defmodule ChatSimulator.IntegrationTest do + use ExUnit.Case, async: false + + alias ChatSimulator.{Storage, Auth, Message} + + setup do + {:ok, _pid} = Storage.start_link() + Storage.clear() + + on_exit(fn -> + Storage.stop() + end) + + :ok + end + + describe "full user workflow" do + test "complete chat session flow" do + # Register two users + assert {:ok, alice} = Auth.register("alice", "password123") + assert {:ok, bob} = Auth.register("bob", "secret456") + + # Verify users exist + assert Auth.user_exists?("alice") + assert Auth.user_exists?("bob") + refute Auth.user_exists?("nonexistent") + + # Login with correct credentials + assert {:ok, _} = Auth.login("alice", "password123") + assert {:ok, _} = Auth.login("bob", "secret456") + + # Login with wrong credentials fails + assert {:error, _} = Auth.login("alice", "wrongpassword") + + # Send messages + msg1 = Message.new("alice", "bob", "Hello Bob!") + Storage.add_message(msg1) + + msg2 = Message.new("bob", "alice", "Hi Alice!") + Storage.add_message(msg2) + + msg3 = Message.new("alice", "bob", "How are you?") + Storage.add_message(msg3) + + # Check inboxes + alice_inbox = Storage.get_messages_for("alice") + bob_inbox = Storage.get_messages_for("bob") + + assert length(alice_inbox) == 1 + assert length(bob_inbox) == 2 + + # Check unread counts + assert Storage.count_unread("alice") == 1 + assert Storage.count_unread("bob") == 2 + + # Check conversation + conversation = Storage.get_conversation("alice", "bob") + assert length(conversation) == 3 + + # Verify message order + [first, second, third] = conversation + assert first.from == "alice" + assert first.to == "bob" + assert second.from == "bob" + assert second.to == "alice" + assert third.from == "alice" + assert third.to == "bob" + end + + test "persistence across restarts" do + # Register users and send messages + {:ok, _} = Auth.register("alice", "password123") + {:ok, _} = Auth.register("bob", "password456") + + msg = Message.new("alice", "bob", "Hello!") + Storage.add_message(msg) + + # Stop and restart storage + Storage.stop() + {:ok, _pid} = Storage.start_link() + + # Verify data persisted + assert Auth.user_exists?("alice") + assert Auth.user_exists?("bob") + + bob_messages = Storage.get_messages_for("bob") + assert length(bob_messages) == 1 + assert hd(bob_messages).content == "Hello!" + + # Cleanup + Storage.clear() + end + + test "multiple users messaging" do + # Register multiple users + users = ["alice", "bob", "charlie", "diana"] + + Enum.each(users, fn username -> + assert {:ok, _} = Auth.register(username, "password123") + end) + + # Everyone sends a message to alice + Enum.each(["bob", "charlie", "diana"], fn sender -> + msg = Message.new(sender, "alice", "Hello from #{sender}!") + Storage.add_message(msg) + end) + + # Check alice's inbox + alice_inbox = Storage.get_messages_for("alice") + assert length(alice_inbox) == 3 + + # Verify all senders + senders = Enum.map(alice_inbox, & &1.from) + assert "bob" in senders + assert "charlie" in senders + assert "diana" in senders + end + end + + describe "error handling" do + test "prevents duplicate usernames" do + {:ok, _} = Auth.register("alice", "password123") + assert {:error, reason} = Auth.register("alice", "different_password") + assert reason =~ "already taken" + end + + test "validates username format" do + assert {:error, reason} = Auth.register("ab", "password123") + assert reason =~ "Invalid username" + + assert {:error, reason} = Auth.register("123invalid", "password123") + assert reason =~ "Invalid username" + end + + test "validates password length" do + assert {:error, reason} = Auth.register("alice", "short") + assert reason =~ "Invalid password" + end + + test "validates message content" do + refute Message.valid_content?("") + refute Message.valid_content?(String.duplicate("a", 501)) + assert Message.valid_content?("Valid message") + end + end + + describe "message operations" do + test "marks messages as read" do + {:ok, _} = Auth.register("alice", "password123") + {:ok, _} = Auth.register("bob", "password456") + + msg = Message.new("alice", "bob", "Hello!") + Storage.add_message(msg) + + # Message should be unread initially + assert Storage.count_unread("bob") == 1 + + # Mark as read + Storage.mark_message_read(msg.id) + + # Should now be read + assert Storage.count_unread("bob") == 0 + + # Verify in inbox + [read_msg] = Storage.get_messages_for("bob") + assert read_msg.read == true + end + + test "conversation filtering works correctly" do + {:ok, _} = Auth.register("alice", "password123") + {:ok, _} = Auth.register("bob", "password456") + {:ok, _} = Auth.register("charlie", "password789") + + # Alice and Bob conversation + Storage.add_message(Message.new("alice", "bob", "Hello Bob")) + Storage.add_message(Message.new("bob", "alice", "Hi Alice")) + + # Alice and Charlie conversation + Storage.add_message(Message.new("alice", "charlie", "Hello Charlie")) + Storage.add_message(Message.new("charlie", "alice", "Hi Alice")) + + # Bob and Charlie conversation + Storage.add_message(Message.new("bob", "charlie", "Hello Charlie")) + + # Check Alice-Bob conversation + alice_bob = Storage.get_conversation("alice", "bob") + assert length(alice_bob) == 2 + assert Enum.all?(alice_bob, fn msg -> + (msg.from == "alice" and msg.to == "bob") or + (msg.from == "bob" and msg.to == "alice") + end) + + # Check Alice-Charlie conversation + alice_charlie = Storage.get_conversation("alice", "charlie") + assert length(alice_charlie) == 2 + + # Check Bob-Charlie conversation + bob_charlie = Storage.get_conversation("bob", "charlie") + assert length(bob_charlie) == 1 + end + end + + describe "user management" do + test "lists all users correctly" do + {:ok, _} = Auth.register("alice", "password123") + {:ok, _} = Auth.register("bob", "password456") + {:ok, _} = Auth.register("charlie", "password789") + + usernames = Storage.list_usernames() + assert length(usernames) == 3 + assert "alice" in usernames + assert "bob" in usernames + assert "charlie" in usernames + end + + test "retrieves user by username" do + {:ok, _} = Auth.register("alice", "password123") + + user = Storage.get_user("alice") + assert user != nil + assert user.username == "alice" + + non_user = Storage.get_user("nonexistent") + assert non_user == nil + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()